diff --git a/.claude/skills/tag-dev/SKILL.md b/.claude/skills/tag-dev/SKILL.md new file mode 100644 index 000000000..a37bc4613 --- /dev/null +++ b/.claude/skills/tag-dev/SKILL.md @@ -0,0 +1,64 @@ +--- +name: tag-dev +description: Create and push a timestamped dev tag for the Nucleus 2.6 branch in the format v2.6.0-dev-YYYYMMDDHHMM, publishing runtime modules to Maven Central and the plugin to the Gradle Plugin Portal without running preMerge. Use when the user asks to "publish a dev build", "cut a dev", "tag dev", "publier une version dev", or similar on this project. +--- + +# Tag dev — Nucleus 2.6 + +Creates a timestamped dev tag on the current HEAD and pushes it to `origin`. The tag publishes +**unverified** artifacts: `.github/workflows/publish-maven.yaml` and `publish-plugin.yaml` skip +`preMerge` for dev tags, and the desktop / GraalVM release workflows ignore them entirely. + +## Format + +`v2.6.0-dev-YYYYMMDDHHMM` — e.g. `v2.6.0-dev-202609200830` for Sep 20 2026, 08:30 UTC. + +Timestamp components come from `date -u +%Y%m%d%H%M` (UTC, no separators, 12 chars). + +The published Maven version is the tag without the leading `v` (`2.6.0-dev-202609200830`), which +orders below `2.6.0` for Gradle and Maven, so a dev build can never shadow the real release. + +## Procedure + +1. **Verify the branch is a 2.6 line branch** — `nucleus-2.6` or a feature branch cut from it. + A dev tag on `main` or on the 2.5 line would publish a `2.6.0-dev-*` version from the wrong + code; abort and say so. +2. **Verify the working tree is clean** — `git status --porcelain` empty. If dirty, ask the user + whether to commit first or abort. Never tag a dirty tree: the tag is what CI builds. +3. **Verify HEAD is pushed** — `git fetch origin` then check the commit exists on the remote + (`git branch -r --contains HEAD`). A tag on an unpushed commit makes CI check out a commit + nobody else has; push the branch first (ask before pushing). +4. **Generate the timestamp** with `date -u +%Y%m%d%H%M`. +5. **Check the tag doesn't already exist** — `git tag -l v2.6.0-dev-`. If it does, use the + next minute; Maven Central versions are immutable, a retag would publish nothing. +6. **Create an annotated tag**: + ```bash + git tag -a "v2.6.0-dev-" -m "v2.6.0-dev-" + ``` + Annotated (not lightweight) because the published history uses annotated tags. +7. **Push the tag**: + ```bash + git push origin "v2.6.0-dev-" + ``` +8. **Report** the tag name, the commit SHA, the resulting Maven version, and the coordinates a + consumer needs, e.g.: + ```kotlin + implementation("dev.nucleusframework:nucleus.decorated-window-tao:2.6.0-dev-") + ``` + Mention that Central takes ~15 minutes to expose the version after the workflow goes green. + +## Hard rules + +- Never tag `main` or the 2.5 line with this format. +- Never overwrite or force-push a tag — the version is already on Central and cannot be replaced. +- Never add `Co-Authored-By` or AI attribution to the tag message (per the project's CLAUDE.md). +- Tag message body is just the tag name itself — matches the existing convention. +- The tag must stay `v..-dev-`: `.github/actions/release-tag-info` + rejects anything else, because every publish task derives its version by stripping + `refs/tags/v` from `GITHUB_REF`. + +## When NOT to use this skill + +- Stable releases (`v2.6.0`) — those go through the full `preMerge` gate and cut GitHub releases. +- Alpha/beta/rc prereleases — see the `tag-alpha` skill and `.github/actions/validate-release-ref`. +- Backporting onto an old commit — this skill always tags `HEAD`. diff --git a/.github/actions/build-macos-universal/action.yml b/.github/actions/build-macos-universal/action.yml index 0c8b06774..8b017a81d 100644 --- a/.github/actions/build-macos-universal/action.yml +++ b/.github/actions/build-macos-universal/action.yml @@ -52,6 +52,14 @@ inputs: description: 'Path to runtime embedded.provisionprofile for sandboxed app runtime' required: false default: '' + electron-builder-toolchain-dir: + description: 'Directory holding the package.json / package-lock.json of the pinned electron-builder toolchain embedded in the Nucleus plugin' + required: false + default: 'plugin-build/plugin/src/main/resources/nucleus/electron-builder' + node-version: + description: 'Node.js line to provision (a major like 22, or a pinned x.y.z). Keep in sync with the nativeDistributions { nodejs { version } } default.' + required: false + default: '22' outputs: zip: @@ -102,6 +110,22 @@ runs: echo "==> No sandboxed ZIPs found (App Store PKG will use electron-builder fallback)" fi + # Same cache entry as setup-nucleus: the provisioning script uses the + # plugin's install layout, so a Node.js downloaded by either is reused. + - name: Cache Node.js toolchain + uses: actions/cache@v4 + with: + path: ~/.gradle/nucleus/nodejs + key: nodejs-toolchain-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }} + + - name: Provision Node.js and electron-builder + shell: bash + env: + NODE_LINE: ${{ inputs.node-version }} + TOOLCHAIN_DIR: ${{ inputs.electron-builder-toolchain-dir }} + TOOL_DIR: ${{ runner.temp }}/electron-builder-tool + run: bash "${{ github.action_path }}/provision-electron-builder.sh" + - name: Build universal binary id: build shell: bash diff --git a/.github/actions/build-macos-universal/build-universal.sh b/.github/actions/build-macos-universal/build-universal.sh index 445d22801..97e857651 100755 --- a/.github/actions/build-macos-universal/build-universal.sh +++ b/.github/actions/build-macos-universal/build-universal.sh @@ -4,7 +4,7 @@ set -euo pipefail # ── Required env vars ───────────────────────────────────────────────────── -: "${ARM64_ZIP:?}" "${X64_ZIP:?}" "${OUTPUT_DIR:?}" +: "${ARM64_ZIP:?}" "${X64_ZIP:?}" "${OUTPUT_DIR:?}" "${NODE_BIN:?}" "${ELECTRON_BUILDER_CLI:?}" # ── Optional env vars (default to empty) ────────────────────────────────── SIGNING_IDENTITY="${SIGNING_IDENTITY:-}" @@ -499,8 +499,9 @@ run_electron_builder() { codesign --force --deep --sign - "$app_copy" fi + # Provisioned by provision-electron-builder.sh from the plugin's lock file. CSC_IDENTITY_AUTO_DISCOVERY=false \ - npx --yes electron-builder \ + "$NODE_BIN" "$ELECTRON_BUILDER_CLI" \ --prepackaged "$app_copy" \ --config "$eb_dir/electron-builder.yml" \ --config.electronVersion=33.0.0 \ diff --git a/.github/actions/build-macos-universal/provision-electron-builder.sh b/.github/actions/build-macos-universal/provision-electron-builder.sh new file mode 100755 index 000000000..40e5bdc3f --- /dev/null +++ b/.github/actions/build-macos-universal/provision-electron-builder.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# Provisions the same Node.js and the same pinned electron-builder toolchain the Nucleus plugin +# uses, for the universal repack — which runs electron-builder outside the plugin. +# +# - Node.js: the newest release of $NODE_LINE from nodejs.org, verified against the release's +# SHASUMS256.txt, installed with the plugin's layout (NodeToolchainProvisioner): +# /node--darwin-// plus a `.nucleus-provisioned` +# marker naming that top dir. A cache restored from a plugin run is therefore reused as is. +# - electron-builder: `npm ci --ignore-scripts` against the lock file embedded in the plugin, so +# every transitive package is checked against its recorded integrity hash (no `npx --yes`). +# +# Writes NODE_BIN and ELECTRON_BUILDER_CLI to $GITHUB_ENV. +set -euo pipefail + +: "${NODE_LINE:?}" "${TOOLCHAIN_DIR:?}" "${TOOL_DIR:?}" +# The plugin's default cache location (/nucleus/nodejs). +NODE_INSTALL_BASE="${NODE_INSTALL_BASE:-$HOME/.gradle/nucleus/nodejs}" + +case "$(uname -m)" in + arm64 | aarch64) arch="arm64" ;; + x86_64) arch="x64" ;; + *) echo "::error::Unsupported architecture $(uname -m)" >&2; exit 1 ;; +esac + +install_dir="$NODE_INSTALL_BASE/node-$NODE_LINE-darwin-$arch" +marker="$install_dir/.nucleus-provisioned" + +if [[ -f "$marker" && -x "$install_dir/$(cat "$marker")/bin/node" ]]; then + node_home="$install_dir/$(cat "$marker")" + echo "==> Reusing Node.js at $node_home" +else + dist="https://nodejs.org/dist" + # Same rule as the plugin: a pinned x.y.z as is, otherwise the newest release of that major. + if [[ "$NODE_LINE" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + version="v${NODE_LINE#v}" + else + version="$(curl -fsSL "$dist/index.json" | + jq -r --arg major "$NODE_LINE" \ + '[.[] | select(.version | startswith("v" + $major + "."))] + | sort_by(.version | ltrimstr("v") | split(".") | map(tonumber)) | last | .version')" + fi + if [[ -z "$version" || "$version" == "null" ]]; then + echo "::error::No Node.js release matches '$NODE_LINE'" >&2 + exit 1 + fi + + archive="node-$version-darwin-$arch.tar.gz" + work="$(mktemp -d)" + trap 'rm -rf "$work"' EXIT + + echo "==> Downloading Node.js $version from $dist/$version/$archive" + curl -fsSL -o "$work/$archive" "$dist/$version/$archive" + curl -fsSL -o "$work/SHASUMS256.txt" "$dist/$version/SHASUMS256.txt" + (cd "$work" && grep " $archive\$" SHASUMS256.txt | shasum -a 256 -c -) + + tar -xzf "$work/$archive" -C "$work" + top_dir="node-$version-darwin-$arch" + rm -rf "$install_dir" + mkdir -p "$install_dir" + mv "$work/$top_dir" "$install_dir/" + echo "$top_dir" > "$marker" + node_home="$install_dir/$top_dir" + echo "==> Node.js $version installed to $node_home" +fi + +node_bin="$node_home/bin/node" +# npm's launcher resolves `node` through PATH. +export PATH="$node_home/bin:$PATH" + +echo "==> Provisioning electron-builder from the plugin's lock file (npm ci --ignore-scripts)" +rm -rf "$TOOL_DIR" +mkdir -p "$TOOL_DIR" +cp "$TOOLCHAIN_DIR/package.json" "$TOOLCHAIN_DIR/package-lock.json" "$TOOL_DIR/" +(cd "$TOOL_DIR" && npm ci --ignore-scripts --no-audit --no-fund --no-progress --loglevel=error) + +cli="$TOOL_DIR/node_modules/electron-builder/cli.js" +if [[ ! -f "$cli" ]]; then + echo "::error::electron-builder CLI missing at $cli after npm ci" >&2 + exit 1 +fi +echo "==> electron-builder $("$node_bin" "$cli" --version)" + +{ + echo "NODE_BIN=$node_bin" + echo "ELECTRON_BUILDER_CLI=$cli" +} >> "$GITHUB_ENV" diff --git a/.github/actions/release-tag-info/action.yml b/.github/actions/release-tag-info/action.yml new file mode 100644 index 000000000..d285d5fa6 --- /dev/null +++ b/.github/actions/release-tag-info/action.yml @@ -0,0 +1,64 @@ +name: Release tag info +description: Classifies the pushed tag into a release channel and derives the version the build will publish + +outputs: + version: + description: Maven version the publish tasks will derive from the tag (tag name without the leading `v`) + value: ${{ steps.classify.outputs.version }} + channel: + description: '`dev` for a `v-dev-` tag, `release` for anything else' + value: ${{ steps.classify.outputs.channel }} + is-dev: + description: '`true` when the tag is a dev tag (publish without running preMerge)' + value: ${{ steps.classify.outputs.is-dev }} + +runs: + using: composite + steps: + - name: Classify tag + id: classify + shell: bash + run: | + set -euo pipefail + + if [[ "${GITHUB_REF_TYPE:-}" != "tag" ]]; then + echo "::error::release-tag-info only runs on tag refs (got '${GITHUB_REF_TYPE:-none}')." + exit 1 + fi + + tag="${GITHUB_REF_NAME}" + + # Every publish task derives its version with `GITHUB_REF.removePrefix("refs/tags/v")`, + # so a tag that is not `v` would publish a version literally named + # `refs/tags/`. Fail here rather than on Maven Central, where it is permanent. + if [[ ! "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+([.-][0-9A-Za-z]+)*)?$ ]]; then + echo "::error::Tag '$tag' is not a publishable version tag (expected v..[-qualifier])." + exit 1 + fi + + version="${tag#v}" + + # Dev channel: `v2.6.0-dev-202609200830`. Cut from any branch, published without + # preMerge — the usual Kotlin-ecosystem `-dev-` build. + if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-dev([.-][0-9A-Za-z]+)*$ ]]; then + channel=dev + is_dev=true + else + channel=release + is_dev=false + fi + + echo "Tag '$tag' → version '$version', channel '$channel'." + { + echo "version=$version" + echo "channel=$channel" + echo "is-dev=$is_dev" + } >> "$GITHUB_OUTPUT" + + { + echo "### Publishing \`$version\` (\`$channel\` channel)" + if [[ "$is_dev" == "true" ]]; then + echo "" + echo "Dev tag: \`preMerge\` is skipped." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/actions/setup-nucleus/action.yml b/.github/actions/setup-nucleus/action.yml index 242ccabc7..60beda3ef 100644 --- a/.github/actions/setup-nucleus/action.yml +++ b/.github/actions/setup-nucleus/action.yml @@ -31,21 +31,17 @@ inputs: required: false default: 'false' graalvm-version: - description: 'GraalVM toolchain version, used only as a cache-key component. Keep in sync with the graalvm { toolchain { } } DSL (e.g. 25i3, 25).' + description: 'GraalVM toolchain version, used only as a cache-key component. Keep in sync with the graalvm { toolchain { } } DSL (e.g. 25i4, 25).' required: false - default: '25i3' + default: '25i4' graalvm-distribution: description: 'GraalVM distribution, used only as a cache-key component. Keep in sync with graalvm { toolchain { distribution } } (community or oracle). Changing it must not restore a cache holding the other distribution.' required: false default: 'community' - setup-node: - description: 'Setup Node.js' - required: false - default: 'true' node-version: - description: 'Node.js version' + description: 'Node.js line the Nucleus plugin provisions, used only as a cache-key component. Keep in sync with the nativeDistributions { nodejs { version } } DSL.' required: false - default: '24' + default: '22' outputs: java-home: @@ -62,8 +58,7 @@ runs: # Temurin, matching the rest of the workflows. jpackage bundles this JDK into # the distributed app. In GraalVM mode the native image is built entirely by # the plugin-provisioned GraalVM, so the Gradle JDK contributes nothing to - # that output. decorated-window-jbr needs no JBR here — the JBR API comes - # from the org.jetbrains.runtime:jbr-api Maven artifact. + # that output. - name: Set up JDK uses: actions/setup-java@v4 with: @@ -155,8 +150,12 @@ runs: uses: gradle/actions/setup-gradle@v5 # ── Node.js ───────────────────────────────────────────────────────── - - name: Setup Node.js - if: inputs.setup-node == 'true' - uses: actions/setup-node@v6 + # No actions/setup-node: the Nucleus plugin downloads the Node.js it runs + # electron-builder with and caches it under ~/.gradle/nucleus/nodejs, so CI + # packages with the same toolchain a developer machine does. Only the cache + # is CI's business. + - name: Cache Node.js toolchain + uses: actions/cache@v4 with: - node-version: ${{ inputs.node-version }} + path: ~/.gradle/nucleus/nodejs + key: nodejs-toolchain-${{ runner.os }}-${{ runner.arch }}-${{ inputs.node-version }} diff --git a/.github/actions/validate-release-ref/action.yml b/.github/actions/validate-release-ref/action.yml index 55c74d701..4687552b7 100644 --- a/.github/actions/validate-release-ref/action.yml +++ b/.github/actions/validate-release-ref/action.yml @@ -3,13 +3,19 @@ description: Validate release tags that must be cut from a specific branch inputs: prerelease-branch: - description: Branch that owns 2.x prerelease tags + description: > + Branch that owns the prerelease tags. Empty (the default) derives it from the tag itself, + so the check follows the release line instead of going stale every cycle. required: false - default: nucleus-2.0 + default: '' + prerelease-branch-prefix: + description: Prefix of the derived prerelease branch name (`.`) + required: false + default: nucleus- prerelease-tag-regex: - description: Bash regex for prerelease tags that must be on prerelease-branch + description: Bash regex for prerelease tags that must be on the prerelease branch required: false - default: '^v2\.[0-9]+\.[0-9]+-(alpha|beta|rc)([.-][0-9A-Za-z]+)*$' + default: '^v[0-9]+\.[0-9]+\.[0-9]+-(alpha|beta|rc)([.-][0-9A-Za-z]+)*$' runs: using: composite @@ -33,8 +39,23 @@ runs: exit 0 fi + # The branch that owns a prerelease tag is the one named after its release line: + # `v2.6.0-rc.1` belongs on `nucleus-2.6`. Derived rather than hard-coded, because a + # pinned branch name silently protects the *previous* line once the work moves on + # (the default was still `nucleus-2.0` while 2.6 was in development, and that branch + # no longer exists on origin — every rc would have failed to fetch). + if [[ -z "$prerelease_branch" ]]; then + line="${tag#v}" + line="${line%%-*}" + prerelease_branch="${{ inputs.prerelease-branch-prefix }}${line%.*}" + echo "Derived prerelease branch '$prerelease_branch' from tag '$tag'." + fi + echo "Validating prerelease tag '$tag' against origin/$prerelease_branch..." - git fetch --no-tags origin "+refs/heads/$prerelease_branch:refs/remotes/origin/$prerelease_branch" + if ! git fetch --no-tags origin "+refs/heads/$prerelease_branch:refs/remotes/origin/$prerelease_branch"; then + echo "::error::Prerelease branch '$prerelease_branch' does not exist on origin (derived from tag '$tag')." + exit 1 + fi if git merge-base --is-ancestor "$GITHUB_SHA" "refs/remotes/origin/$prerelease_branch"; then echo "Tag '$tag' points to a commit contained in origin/$prerelease_branch." diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..97ec799c0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,81 @@ +version: 2 + +# Grouped deliberately: ungrouped, this repository's dependency count would open +# dozens of pull requests the first week and a steady trickle after. One PR per +# group per week is reviewable; a major version bump still comes on its own, +# because that is the one that needs reading. +updates: + # Runtime libraries and the version catalog, including + # dev.nucleusframework:nucleus.angle-natives -- which is how a new ANGLE + # release branch reaches Nucleus. + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/Paris + open-pull-requests-limit: 5 + groups: + kotlin-and-compose: + patterns: + - "org.jetbrains.kotlin*" + - "org.jetbrains.kotlinx*" + - "org.jetbrains.compose*" + - "org.jetbrains.androidx*" + update-types: [minor, patch] + angle: + patterns: + - "dev.nucleusframework:nucleus.angle-natives" + minor-and-patch: + patterns: ["*"] + exclude-patterns: + - "org.jetbrains.kotlin*" + - "org.jetbrains.kotlinx*" + - "org.jetbrains.compose*" + - "org.jetbrains.androidx*" + - "dev.nucleusframework:nucleus.angle-natives" + update-types: [minor, patch] + commit-message: + prefix: "chore(deps)" + + # The Gradle plugin is an included build with its own dependency graph. + - package-ecosystem: gradle + directory: /plugin-build + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/Paris + open-pull-requests-limit: 3 + groups: + minor-and-patch: + patterns: ["*"] + update-types: [minor, patch] + commit-message: + prefix: "chore(deps)" + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + day: monday + time: "06:00" + timezone: Europe/Paris + open-pull-requests-limit: 3 + groups: + actions: + patterns: ["*"] + commit-message: + prefix: "chore(ci)" + +# Deliberately not covered: +# +# - cargo (decorated-window-tao/src/main/native): the crate graph is pinned +# through [patch.crates-io] onto vendored, patched forks of tao and the +# accesskit crates. Dependabot would propose upgrades that silently drop +# those patches. +# - npm (plugin-build/plugin/src/main/resources/nucleus/electron-builder): the +# package.json / package-lock.json pair is generated by +# scripts/update-electron-builder-lock.sh and pinned on purpose, so the two +# would fight over the lockfile. diff --git a/.github/workflows/build-natives.yaml b/.github/workflows/build-natives.yaml index 40ba12956..02f288f2d 100644 --- a/.github/workflows/build-natives.yaml +++ b/.github/workflows/build-natives.yaml @@ -48,11 +48,6 @@ jobs: shell: cmd run: call native-ssl\src\main\native\windows\build.bat - - name: Build decorated-window-jni Windows DLLs - if: steps.natives-cache.outputs.cache-hit != 'true' - shell: cmd - run: call decorated-window-jni\src\main\native\windows\build.bat - - name: Build system-color Windows DLLs if: steps.natives-cache.outputs.cache-hit != 'true' shell: cmd @@ -130,22 +125,12 @@ jobs: shell: cmd run: call decorated-window-tao\src\main\native\windows\build.bat - # ANGLE (libEGL + libGLESv2) backs the Tao Windows Direct3D-11 render path. - # Fetched from a pinned Electron release with SHA-256 verification (never - # committed — see .gitignore). Lands in the same win32-*/ dirs so it ships - # (and is cached) inside the natives-windows artifact. - - name: Fetch ANGLE runtime DLLs - if: steps.natives-cache.outputs.cache-hit != 'true' - shell: bash - run: bash decorated-window-tao/src/main/native/windows/fetch-angle.sh all - - name: Verify Windows natives shell: bash run: | FILES=( "darkmode-detector/nucleus_windows_theme.dll" "native-ssl/nucleus_ssl.dll" - "decorated-window-jni/nucleus_windows_decoration.dll" "system-color/nucleus_systemcolor.dll" "energy-manager/nucleus_energy_manager.dll" "taskbar-progress/nucleus_taskbar_progress.dll" @@ -164,8 +149,6 @@ jobs: "decorated-window-tao/nucleus_tao_gl.dll" "decorated-window-tao/nucleus_tao_dnd.dll" "decorated-window-tao/nucleus_tao_windows_native_view.dll" - "decorated-window-tao/libEGL.dll" - "decorated-window-tao/libGLESv2.dll" ) MISSING=0 for arch in win32-x64 win32-aarch64; do @@ -217,14 +200,6 @@ jobs: if: steps.natives-cache.outputs.cache-hit != 'true' run: bash native-ssl/src/main/native/macos/build.sh - - name: Build decorated-window-jbr macOS dylibs - if: steps.natives-cache.outputs.cache-hit != 'true' - run: bash decorated-window-jbr/src/main/native/macos/build.sh - - - name: Build decorated-window-jni macOS dylibs - if: steps.natives-cache.outputs.cache-hit != 'true' - run: bash decorated-window-jni/src/main/native/macos/build.sh - - name: Build system-color macOS dylibs if: steps.natives-cache.outputs.cache-hit != 'true' run: bash system-color/src/main/native/macos/build.sh @@ -301,8 +276,6 @@ jobs: FILES=( "darkmode-detector/libnucleus_darkmode.dylib" "native-ssl/libnucleus_ssl.dylib" - "decorated-window-jbr/libnucleus_macos.dylib" - "decorated-window-jni/libnucleus_macos_jni.dylib" "system-color/libnucleus_systemcolor.dylib" "energy-manager/libnucleus_energy_manager.dylib" "taskbar-progress/libnucleus_taskbar_progress.dylib" @@ -391,10 +364,6 @@ jobs: if: steps.natives-cache.outputs.cache-hit != 'true' run: bash darkmode-detector/src/main/native/linux/build.sh - - name: Build decorated-window-jni Linux native shared library - if: steps.natives-cache.outputs.cache-hit != 'true' - run: bash decorated-window-jni/src/main/native/linux/build.sh - - name: Build linux-hidpi native shared library if: steps.natives-cache.outputs.cache-hit != 'true' run: bash linux-hidpi/src/main/native/linux/build.sh @@ -462,7 +431,6 @@ jobs: run: | FILES=( "darkmode-detector/libnucleus_linux_theme.so" - "decorated-window-jni/libnucleus_linux_jni.so" "linux-hidpi/libnucleus_linux_hidpi_jni.so" "spellcheck/libnucleus_spellcheck.so" "system-color/libnucleus_systemcolor.so" diff --git a/.github/workflows/pre-merge.yaml b/.github/workflows/pre-merge.yaml index f4c29e5af..008507af7 100644 --- a/.github/workflows/pre-merge.yaml +++ b/.github/workflows/pre-merge.yaml @@ -54,14 +54,6 @@ jobs: "native-ssl/src/main/resources/nucleus/native/darwin-x64/libnucleus_ssl.dylib" "native-ssl/src/main/resources/nucleus/native/win32-x64/nucleus_ssl.dll" "native-ssl/src/main/resources/nucleus/native/win32-aarch64/nucleus_ssl.dll" - "decorated-window-jbr/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos.dylib" - "decorated-window-jbr/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos.dylib" - "decorated-window-jni/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos_jni.dylib" - "decorated-window-jni/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos_jni.dylib" - "decorated-window-jni/src/main/resources/nucleus/native/linux-x64/libnucleus_linux_jni.so" - "decorated-window-jni/src/main/resources/nucleus/native/linux-aarch64/libnucleus_linux_jni.so" - "decorated-window-jni/src/main/resources/nucleus/native/win32-x64/nucleus_windows_decoration.dll" - "decorated-window-jni/src/main/resources/nucleus/native/win32-aarch64/nucleus_windows_decoration.dll" "linux-hidpi/src/main/resources/nucleus/native/linux-x64/libnucleus_linux_hidpi_jni.so" "linux-hidpi/src/main/resources/nucleus/native/linux-aarch64/libnucleus_linux_hidpi_jni.so" "spellcheck/src/main/resources/nucleus/native/linux-x64/libnucleus_spellcheck.so" @@ -152,10 +144,6 @@ jobs: "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/nucleus_tao_dnd.dll" "decorated-window-tao/src/main/resources/nucleus/native/win32-x64/nucleus_tao_windows_native_view.dll" "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/nucleus_tao_windows_native_view.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-x64/libEGL.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/libEGL.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-x64/libGLESv2.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/libGLESv2.dll" "decorated-window-tao/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_tao.dylib" "decorated-window-tao/src/main/resources/nucleus/native/darwin-x64/libnucleus_tao.dylib" "decorated-window-tao/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_tao_metal.dylib" diff --git a/.github/workflows/publish-maven.yaml b/.github/workflows/publish-maven.yaml index 19a408df1..85d3f6c31 100644 --- a/.github/workflows/publish-maven.yaml +++ b/.github/workflows/publish-maven.yaml @@ -8,12 +8,20 @@ on: jobs: validate-release-ref: runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.tag.outputs.version }} + channel: ${{ steps.tag.outputs.channel }} + is-dev: ${{ steps.tag.outputs.is-dev }} steps: - name: Checkout Repo uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Classify release tag + id: tag + uses: ./.github/actions/release-tag-info + - name: Validate release ref uses: ./.github/actions/validate-release-ref @@ -23,7 +31,7 @@ jobs: uses: ./.github/workflows/build-natives.yaml publish: - needs: build-natives + needs: [validate-release-ref, build-natives] runs-on: ubuntu-22.04 steps: - name: Checkout Repo @@ -48,14 +56,6 @@ jobs: "native-ssl/src/main/resources/nucleus/native/darwin-x64/libnucleus_ssl.dylib" "native-ssl/src/main/resources/nucleus/native/win32-x64/nucleus_ssl.dll" "native-ssl/src/main/resources/nucleus/native/win32-aarch64/nucleus_ssl.dll" - "decorated-window-jbr/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos.dylib" - "decorated-window-jbr/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos.dylib" - "decorated-window-jni/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_macos_jni.dylib" - "decorated-window-jni/src/main/resources/nucleus/native/darwin-x64/libnucleus_macos_jni.dylib" - "decorated-window-jni/src/main/resources/nucleus/native/linux-x64/libnucleus_linux_jni.so" - "decorated-window-jni/src/main/resources/nucleus/native/linux-aarch64/libnucleus_linux_jni.so" - "decorated-window-jni/src/main/resources/nucleus/native/win32-x64/nucleus_windows_decoration.dll" - "decorated-window-jni/src/main/resources/nucleus/native/win32-aarch64/nucleus_windows_decoration.dll" "linux-hidpi/src/main/resources/nucleus/native/linux-x64/libnucleus_linux_hidpi_jni.so" "linux-hidpi/src/main/resources/nucleus/native/linux-aarch64/libnucleus_linux_hidpi_jni.so" "spellcheck/src/main/resources/nucleus/native/linux-x64/libnucleus_spellcheck.so" @@ -146,10 +146,6 @@ jobs: "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/nucleus_tao_dnd.dll" "decorated-window-tao/src/main/resources/nucleus/native/win32-x64/nucleus_tao_windows_native_view.dll" "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/nucleus_tao_windows_native_view.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-x64/libEGL.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/libEGL.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-x64/libGLESv2.dll" - "decorated-window-tao/src/main/resources/nucleus/native/win32-aarch64/libGLESv2.dll" "decorated-window-tao/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_tao.dylib" "decorated-window-tao/src/main/resources/nucleus/native/darwin-x64/libnucleus_tao.dylib" "decorated-window-tao/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_tao_metal.dylib" @@ -201,7 +197,11 @@ jobs: curl -s https://api.ipify.org; echo curl -s https://api64.ipify.org; echo + # Dev builds (`v2.6.0-dev-`) publish straight from the tag: the whole + # point is a throwaway version a downstream app can consume today. Every other tag + # is a real release and still has to pass preMerge. - name: Run pre-merge checks + if: needs.validate-release-ref.outputs.is-dev != 'true' run: ./gradlew preMerge --continue - name: Publish to Maven Central diff --git a/.github/workflows/publish-plugin.yaml b/.github/workflows/publish-plugin.yaml index 3dce77546..b31690326 100644 --- a/.github/workflows/publish-plugin.yaml +++ b/.github/workflows/publish-plugin.yaml @@ -8,12 +8,20 @@ on: jobs: validate-release-ref: runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.tag.outputs.version }} + channel: ${{ steps.tag.outputs.channel }} + is-dev: ${{ steps.tag.outputs.is-dev }} steps: - name: Checkout Repo uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Classify release tag + id: tag + uses: ./.github/actions/release-tag-info + - name: Validate release ref uses: ./.github/actions/validate-release-ref @@ -22,7 +30,7 @@ jobs: uses: ./.github/workflows/build-natives.yaml gradle: - needs: build-natives + needs: [validate-release-ref, build-natives] runs-on: ubuntu-22.04 env: GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }} @@ -41,10 +49,20 @@ jobs: pattern: 'natives-*' merge-multiple: true + # ubuntu-22.04 defaults to JDK 11; Gradle 9 needs 17+. + - name: Setup JDK 21 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + - name: Cache Gradle Caches uses: gradle/actions/setup-gradle@v5 + # Dev tags (`v2.6.0-dev-`) go out unverified on purpose; real + # release tags still have to pass preMerge before reaching the portal. - name: Run Gradle tasks + if: needs.validate-release-ref.outputs.is-dev != 'true' run: ./gradlew preMerge --continue - name: Publish on Plugin Portal diff --git a/.github/workflows/release-desktop.yaml b/.github/workflows/release-desktop.yaml index 98f15f3f6..886ff123e 100644 --- a/.github/workflows/release-desktop.yaml +++ b/.github/workflows/release-desktop.yaml @@ -3,7 +3,10 @@ name: Release Desktop App (All Platforms) on: push: tags: + # Dev builds (`v2.6.0-dev-`) only publish libraries to Maven — + # they must not cut a GitHub release or burn the per-OS packaging matrix. - "v*" + - "!v*-dev*" workflow_dispatch: permissions: @@ -46,7 +49,6 @@ jobs: arch: amd64 - os: windows-11-arm arch: arm64 - node-version: '22' # npm 11 (Node 24) has ECOMPROMISED bugs on Windows ARM64 - os: macos-latest arch: arm64 - os: macos-15-intel @@ -78,8 +80,6 @@ jobs: flatpak: 'true' snap: 'true' setup-gradle: 'true' - setup-node: 'true' - node-version: ${{ matrix.node-version || '24' }} - name: Download native artifacts uses: actions/download-artifact@v4 @@ -186,13 +186,9 @@ jobs: sparse-checkout: | .github/actions examples/nucleus-demo/packaging/macos + plugin-build/plugin/src/main/resources/nucleus/electron-builder fetch-depth: 1 - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: '24' - - name: Setup macOS signing id: signing if: env.HAS_SIGNING_CERTS == 'true' diff --git a/.github/workflows/release-graalvm.yaml b/.github/workflows/release-graalvm.yaml index 43ccfd338..4107a6163 100644 --- a/.github/workflows/release-graalvm.yaml +++ b/.github/workflows/release-graalvm.yaml @@ -3,7 +3,10 @@ name: Release GraalVM Native Image (Jewel Sample) on: push: tags: + # Dev builds (`v2.6.0-dev-`) only publish libraries to Maven — + # they must not cut a GitHub release or burn the per-OS packaging matrix. - "v*" + - "!v*-dev*" workflow_dispatch: permissions: @@ -74,7 +77,6 @@ jobs: with: graalvm: 'true' setup-gradle: 'true' - setup-node: 'true' - name: Configure Linux GPG signing if: runner.os == 'Linux' diff --git a/.github/workflows/test-graalvm.yaml b/.github/workflows/test-graalvm.yaml index 8338bbdda..9c748caad 100644 --- a/.github/workflows/test-graalvm.yaml +++ b/.github/workflows/test-graalvm.yaml @@ -42,7 +42,6 @@ jobs: with: graalvm: 'true' setup-gradle: 'true' - setup-node: 'false' # ── Stage 1: build + run the Tao native test pyramid ──────────────── # Compiles examples/tao-native-test, then executes the packaged binary diff --git a/.github/workflows/test-packaging.yaml b/.github/workflows/test-packaging.yaml index 5335add5e..6136e00ea 100644 --- a/.github/workflows/test-packaging.yaml +++ b/.github/workflows/test-packaging.yaml @@ -28,7 +28,6 @@ jobs: - name: Windows ARM64 os: windows-11-arm arch: arm64 - node-version: '22' # npm 11 (Node 24) has ECOMPROMISED bugs on Windows ARM64 - name: macOS ARM64 os: macos-latest arch: arm64 @@ -47,8 +46,6 @@ jobs: flatpak: 'true' snap: 'true' setup-gradle: 'true' - setup-node: 'true' - node-version: ${{ matrix.node-version || '24' }} - name: Download native artifacts uses: actions/download-artifact@v4 diff --git a/.gitignore b/.gitignore index 907f4767b..3f5f98b4a 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,9 @@ examples/tao-demo/src/main/native/windows/build_log.txt **/graalvm/libraryMetadata/ **/graalvm/metadataRepoDirs.txt +# Compiled Python caches (local helper scripts) +__pycache__/ + # JVM crash logs hs_err_pid*.log replay_pid*.log diff --git a/CLAUDE.md b/CLAUDE.md index 5f8108ad3..40e093663 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,14 +2,14 @@ A multi-module Gradle plugin and runtime library toolkit for shipping production-ready JVM desktop applications on macOS, Windows, and Linux. -Published releases are `2.4.x` (latest tag `v2.4.4`). Do not treat `IDEAL_API.md` as current — that file is gone; the real entry point is `nucleusApplication(args) { }` in `nucleus-application`. Plugin-injected strings are `NucleusApp`, not a generated `NucleusGenerated` object. +Published releases are `2.5.x` (latest tag `v2.5.0`). Do not treat `IDEAL_API.md` as current — that file is gone; the real entry point is `nucleusApplication(args) { }` in `nucleus-application`. Plugin-injected strings are `NucleusApp`, not a generated `NucleusGenerated` object. ## Project Structure -- `nucleus-application` - `nucleusApplication`, backend-agnostic `DecoratedWindow` / `HostedWindow`, `onDeepLink`, `aotTraining` +- `nucleus-application` - `nucleusApplication`, `DecoratedWindow` / `HostedWindow`, `onDeepLink`, `aotTraining` - `core-runtime` - Executable type detection, single instance, deep links, platform detection, app metadata (`NucleusApp`) - `aot-runtime` - AOT cache mode detection for JDK 25+ (Project Leyden) -- `updater-runtime` - Auto-update engine (GitHub/S3), SHA-512, delta/blockmap, progress, update level, post-update events +- `updater-runtime` / `updater-testing` - Auto-update engine (GitHub/S3/local directory), SHA-512, delta/blockmap, progress, update level, post-update events, Windows NSIS hot update (see Development Notes), and update testing without publishing (see Development Notes); `updater-testing` ships `UpdateFeedServer`, the fault-injecting loopback release host - `freedesktop-icons` - Type-safe freedesktop Icon Naming Specification constants (shared by notification-linux and launcher-linux) - `sf-symbols` - Type-safe SF Symbols catalog - `notification-common` - Cross-platform notification DSL with per-platform option blocks @@ -30,22 +30,20 @@ Published releases are `2.4.x` (latest tag `v2.4.4`). Do not treat `IDEAL_API.md - `energy-manager` - Energy efficiency & screen-awake APIs - `autolaunch` - Start at login (Win32/MSIX/SMAppService/systemd/Flatpak portal) - `scheduler` / `scheduler-testing` - OS-scheduled background tasks (Task Scheduler / launchd / systemd) + test doubles -- `fs-watcher` - Native filesystem watcher +- `fs-watcher` - Native filesystem watcher over the Rust `notify` crate. One native watcher per `FsWatcher` (all registrations share it; events are routed back by root in `lib.rs`, so an inotify instance is per watcher, not per path — #571). The backend is handed the canonical root on macOS and Linux (one watch per real directory; inotify keys watches by inode and `notify` keeps one spelling per descriptor, so aliases must share it — Kotlin projects events back onto each registration's spelling), the registered spelling on Windows (`canonicalize()` yields `\\?\` paths there). On macOS the Rust side also feeds FSEvents through `FsEventsNormalizer` before the debouncer: FSEvents reports an inode's *accumulated* flags (a rename of an old file arrives as `Create`+`Rename`+`Modify` on the gone path), which otherwise folds renames into a bare `Created(new)` and deletes into `Modified` (#570). Renames the backend cannot pair are `Removed(old)` + `Created(new)`, never dropped; `Raw` delivery never emits `Moved` - `service-management-macos` - macOS `SMAppService` — login items, launch agents, daemons - `native-ssl` / `native-http` / `native-http-okhttp` / `native-http-ktor` - OS trust store integration - `linux-hidpi` - Native HiDPI scale detection on Linux - `graalvm-runtime` - GraalVM native-image bootstrap - `decorated-window-core` - Shared types, layout, styling (design-system agnostic) -- `decorated-window-tao` - **Default/recommended backend** — no-AWT window shell over the Rust `tao` crate via JNI (Metal on macOS, EGL on Linux, ANGLE/GLES on Windows), single native event-loop thread as `Dispatchers.Main` -- `decorated-window-awt` - AWT chrome shared by the JBR/JNI backends -- `decorated-window-jbr` - JBR-based implementation (requires JetBrains Runtime) — **legacy/maintenance-only** -- `decorated-window-jni` - JNI-based implementation (any JVM, GraalVM compatible) — **legacy/maintenance-only** +- `decorated-window-tao` - **The only window backend** — no-AWT window shell over the Rust `tao` crate via JNI (Metal on macOS, EGL on Linux, ANGLE/GLES on Windows), single native event-loop thread as `Dispatchers.Main`. **Linux/Wayland frame timing (#444)**: Tao's GTK `draw` and `configure-event` handlers only post to its event channel, so `RedrawRequested` / `Resized` reach the host *after* GDK has already committed the toplevel; during a resize burst `TaoComposeSceneHostLinux` therefore renders from a real `draw` hook (`nativeConnectToplevelDraw`, size read with `gtk_window_get_size`), with the content sub-surface in `set_sync` and swap interval 0, and waits for the swap before returning so GTK's commit carries geometry and content together. Mesa applies `wl_egl_window_resize` only while no back buffer is acquired — push it before `eglMakeCurrent`, never after. The in-frame path is **watched**: GTK only paints while the compositor feeds GDK's frame clock, and Mutter sends no frame callback to a toplevel fully covered by its own opaque content sub-surface (maximized / tiled, no shadow ring) — so a `queue_draw` unanswered for 50 ms (`IN_FRAME_DRAW_GRACE_NS`, watchdog redraw via `DelayScheduler`) drops the burst back to the event-loop render path, and the burst's end (`endResizeBurstIfStale`) runs from `onRedrawRequested` too, never only from a render. Two more rules from the same finding: the in-frame path is never armed while the window is maximized / tiled / fullscreen (`parentObscured()`), and the content's opaque region (`nativeSetOpaqueRegion`) always leaves its **bottom row** out — a toplevel fully covered by an opaque subsurface is culled by Mutter, gets no frame callback, GDK's frame clock freezes and with it the flush-events phase that delivers pointer motion (GDK3 holds a lone motion event until that phase): the app renders at full rate but hover and drags only move when another event arrives +- `decorated-window-tao` internals: `window/tao/workspace/` is the shared, `internal` core behind the multi-window archetypes — `WindowGroup` (membership, focus recency, pinning), `RelocatedContentHost` + `RelocatingSaveableStateRegistry` (`rememberSaveable` state that follows content between windows), `HostGeometry` (drop targets in physical screen px), `CrossWindowDrag` (one live drag, screen-space drag handle), `DragGhostWindow`, `ScreenPlacement` (the public capability is `TaoWindow.canPlaceOnScreen` — the native-Wayland gate — GDK reports every toplevel at `(0, 0)` and ignores moves, so anything that treats `outerBoundsPx()`'s origin as a screen coordinate must check it; the size half stays valid there; `warnScreenPlacementUnsupported` logs the gap once per process), `TransferDrag` (the native-Wayland path of every cross-window gesture: the grip starts a platform **drag-and-drop** session carrying an in-process token (`TaoPrivateTransfer`, `SAME_APP` only), the window under the pointer resolves the drop in its *own* coordinates and records it on the session, and the source acts on that record when the session ends — inverted roles versus `ScreenDrag`, because the source is told nothing about where the pointer is; the drag icon is a reduced snapshot of the dragged palette or panel, taken through `TaoWindow.contentSnapshot`). **Tab drag, two paths.** Where the app places its windows the gesture is `screenDragHandle` → `TabWorkspace.beginDrag` (ghost window, screen hit-test, tear-off; the drop resolves through `dropTargetAt(draggedScreenRectPx, pointerScreenPx, …)` — a strip the **card** has reached counts as entered, the pointer's own strip still winning, the same rule as the dock zones, and a single-tab window's drag hands its own strip band as the card), and the strip animates the reorder from `dragPointerScreenPx`. Where it cannot (native Wayland), the grip is `tabStripLocalDragHandle`: a **local** reorder driven by the pointer's travel in window px and resolved by `reorderTarget` (edge-crossing, RTL inferred from the slots), and the moment the pointer leaves the strip the gesture is handed to the platform's drag-and-drop session — `transferDragHandle(gesture = …)` takes a `TransferDragGesture` whose `onDrag` returns `true` to start it mid-gesture, from the *press* position (Compose refuses a point outside the source node). That handover is what gives every *other* window the pointer in its own coordinates, so their strips can preview the drop; nothing else can, since a client hears nothing about a pointer another window holds. `DragGhostWindow(popupFor = source)` is the preview that follows the pointer out of a compositor-placed window (`wl_subsurface`, parent-relative positions). The tab slot carries `noWindowDrag()`: the title bar's move is a compositor grab that swallows the gesture. **`TabStrip` motion** (`TabStripAnimation.kt`, a port of `sh.calvin.reorderable`'s `ReorderableRow` state machine): items are `key`ed on the tab id; a tab dragged along its **own** strip publishes no ghost (`TabTearOffDragSession` clears it while `dropPreview.group === entry.group`) and the strip draws it at the pointer's travel since the grab (`TabWorkspace.dragGrabScreenPx` / `dragPointerScreenPx`), a neighbour slides one tab-width aside (spring `StiffnessMediumLow`) when the carried tab's *edge* crosses its *centre*, and on release the session sets `pendingReorder` instead of reordering — the strip's `TabStripMotion.settle` slides the tab into the target slot, then `reorder()` + `rest()` in the same frame, so nothing jumps. The own-strip drop index is `reorderTarget` (edge-crossing rule, RTL inferred from the slots, same rule as the motion) and `insertionIndex` is direction-aware too (a right-to-left strip used to resolve every drop mirrored); both take the direction from the strip's published `HostGeometry.layoutDirection` and infer it from the slot order only without one — a single tab cannot tell, and a right-to-left strip resolved left to right opens the preview on the wrong side, which moves the tab under the pointer and flips the index with every sample (two cards sliding about; `tab motion a sweep over a single-tab right-to-left strip` guards it). Offsets are draw-time `graphicsLayer` translations, so `tabSlot` geometry is always the settled layout. Tabs open/close by width (`AnimatedVisibility`, 200 ms, `clip = false` so the carried card can leave its slot) and the stock close button delays `workspace.close` by the exit duration; `TabEntry.isEntering` marks a tab the strip has not shown yet. `TabWindows` has two app slots: `windowWrapper` wraps the whole window *including* its strip (per-window locals, background), `windowBodyWrapper` wraps only what is under the strip and is where window-level chrome goes (a `DockLayout`, activity bars) — composed at one call site for every window, so a tab change neither rebuilds it nor moves the body's relocation keys. `SatelliteWorkspace` (docking) and `TabWorkspace` (Chrome-like tabs) are both built on it — put new cross-window gestures there rather than duplicating the geometry or the drag bookkeeping. `DockLayout` (`window/tao/DockLayout.kt` + `DockSplitter.kt` + `DockTransferTarget.kt`) is the dock: sides nest in `sideOrder` (outermost first, default `DefaultDockSideOrder` = top, bottom, left, right — **not** `DockSide.entries`, whose declaration order is left, right, top, bottom), a side is either *split* (panels share its length by `Docked.weight` and its thickness by `dockExtent(side)`) or *layered* (`layeredSides`: each panel a full-length layer of its own `Docked.extent`, the way a nested split-pane tree looks), `splitter` / `panel` slots carry the app's own chrome (`DockSplitterScope.dockSplitterHandle()` is the gesture; an overflowing `requiredWidth` grip on a 1 dp line works), sides are physical and the layout forces LTR internally then restores the caller's direction for content/panels/slots, and every panel and the content are `movableContentOf` so no layout change (extent, weight, order, side, restore, side order, direction) rebuilds a subtree — the layout's inputs live in `DockLayoutState` as snapshot state because the bands are separate composables that strong skipping would otherwise skip. Extents are fitted proportionally when the window is too small (`fit`). Drop feedback lives in `DockZoneHints.kt` and **the rectangles it draws are the target**: it publishes them to `HostGeometry.zoneBoundsInWindowPx`, and `dockTargetAt(draggedScreenRectPx, pointerScreenPx)` → `dockSideEntered` resolves a drop against those, not against the window's edges — on a layered side the strip is inset behind the existing layers, and the window's own edge behind them is nothing. A zone is entered when the dragged **satellite's** edge (its window, or the tear-out ghost) is within one zone thickness of the zone's outer edge and overlaps it across the other axis — edge alignment, not overlap, or a full-height panel could never be torn out; the pointer inside a zone is a second trigger and the tie-break, else the smallest gap wins. The rects come from `DockLayoutState.landingRectPx`: the side's measured band, inside existing layers, counting the dragged panel's own side as already freed; `hintedSides` drops the side the panel is alone on in that window, so it is neither drawn nor droppable. **`dockSides`**: `Satellite(dockSides = …)` (default all four, empty = floating-only) is fixed at declaration and enforced everywhere — `dock()` and `restore()` refuse another side, `hintedSides` and `DockZoneHints` neither draw nor publish it, the drag sessions resolve through `dockTargetFor(entry, …)` and the Wayland target filters on `drag.entry.dockSides`, and the default header hides its Dock action for a floating-only palette. **`floatable = false`** is the opposite knob — a fixed panel: `undock()` refuses it, a `restore()` that floats it is ignored, the docked drag publishes no tear-out ghost and a release off every zone leaves it in place, the default header drops its Float action, and the declaration requires a docked `initialPlacement`. **`minExtent` / `maxExtent`** (`Satellite(...)`, default `MinDockExtent`..∞) bound a panel's docked thickness: `SatelliteEntry.extentRange` clamps its own layer (`setDockedExtent`), `sideExtentRange(side, joining)` (thickest minimum, thinnest maximum, minimum wins) clamps a split side's shared extent (`setDockExtent`, re-applied by `dock()` and `restore()` so a newcomer's limits count), `dockSeedExtent` / `plannedDockExtent` are clamped so the preview is the width the drop produces (the split-side landing rect is the stack at that thickness, fitted like the panels), and `clampThicknessPx(…, panel)` stops the splitters; the floating window is not constrained. **`reorderable = false`** pins the rank: `dock(order)` is ignored for it (it takes the declared rank back), `insertInStack` pushes any other panel past the last pinned one (`pinnedFloor`), `dropSlotsPx` returns nothing for a pinned dragged panel and keeps the forbidden ranks as **empty** slots so a slot's index is still its rank, `hintedSides` drops its own side, `targetFor` strips the rank off a target, and `satelliteDragHandle` is inert when `canBeDragged` says a drag could not end anywhere. **Telling the two gestures apart** (what an app adapts its UI to, #663 review): `TaoWindow.canPlaceOnScreen` is the public capability (branch on it, not on `isNativeWaylandSurface`), `SatelliteScope.isCompositorPlaced` is the same answer for the window the chrome is composed in (the floating scope reads the satellite's own window through a lambda since the scope outlives it; the docked scope reads `entry.dockHost`), `SatelliteCaptionStripWidth` + the `floatingCaption` slot of `Satellite` are the strip the title bar leaves to the compositor's move — reserved and composed **only** where `isCompositorPlaced`, so an app never has to guess a width or accidentally claim the only area that can move the palette — and `SatelliteWorkspace.dragKind` (`Window` / `Transfer`) says how a drag in flight is carried, which is what tells preview code whether `dragGhost` will ever be published. `reader-dock-demo`: the book tree and the contents are `floatable = false` + `reorderable = false` + `dockSides = setOf(Right)` — furniture, and no pane can be dropped in front of them. **Ranks**: `Docked.order` is kept contiguous from 0 per (host, side) by `dock()` / `undock()` (`dock(order)` inserts at that index, `null` = the rank the entry last held on that side, remembered in `SatelliteEntry.dockMemory`, else the end), and a side with panels publishes `DockDropZone.slots` — one rect per rank, cut at the neighbours' centres, the dragged panel excluded — so `DockTarget.order` is the rank under the pointer (`dockSlotAt`), the own rank (`ownTarget`) being no target; a pointer over a stack beats a strip across its corner. `dropAt` converts a shown-rank into the full rank (closed panels keep theirs). The Wayland DnD path (`DockTransferTarget`) hit-tests the same published zones. A hand-driven `beginDrag` session must wait for the zones to be published before its first sample, or it resolves against the bare edges. `dock()` and the preview share one width (`dockSeedExtent`) and one weight (`dockSeedWeight`), so what lights up is what the release produces. **One drop preview everywhere** (`DragPreviewDefaults.kt`): the card that follows the pointer (`SatelliteGhostCard` / `TabGhostCard` on `DragPreviewSurface`) is also drawn on the space the release fills — the dock draws it at `DockLayoutState.dropRectPx(side, dragged, order, extentPx)` (empty side: the edge strip; layered: the layer at that rank; split: the share the re-divided weights give it, dividers counted), the tab strip opens a slot of the dragged tab's width (`TabStripScope.dropGhost` → `TabDropGhost`, `TabDropGhostCard`; `TabWorkspace.draggedTabWidth` reads the source slot) — and the sides merely on offer are the same surface at `hint` intensity. No insertion bars, no drop-indicator lines; a custom strip draws `dropGhost` itself, as `jewel-tabs-demo` does with a placeholder `TabData.Editor`. The card under the pointer is the `dragGhost` slot of `TabWindows` (default `TabDragGhostCard`) and the card in the landing slot is `TabStrip(dropGhostCard)` (default `TabDropGhostCard`; `TabDropGhost.tab` is the entry); both defaults are the public `TabGhostCard(tab, modifier)`, the shape an app's own card takes so one composable serves both slots. `WorkspaceDragKind` (`Window` / `Transfer`) is shared by `SatelliteWorkspace.dragKind` and `TabWorkspace.dragKind`, `Transfer` only once the platform session exists (a tab merely held in its strip on Wayland reports `null`); on native Wayland `dragGhost` is never published and the slot never composes. The ghost is laid out in the **source strip's / dock's** direction, for both archetypes: `publishHostGeometry` records `LocalLayoutDirection` on `HostGeometry.layoutDirection`, the drag session copies it onto `TabDragGhost` / `DragGhost`, and `DragGhostWindow(layoutDirection)` provides it in the ghost scene (a scene of its own re-provides the global direction over the bridged locals). The ghost content has the ghost window's `TaoDecoratedWindowScope`; `nucleus-application` wraps the `dragGhost` slot in `bindNucleusContent` (Nucleus locals, ghost direction) but never in the app's `windowWrapper`, which paints a window background. `DragController.active` is snapshot state so `dragKind` is observable. `TabWindows` and `DragGhostWindow` are `@ComposableOpenTarget(-1)` with `@UiComposable` lambdas (#636 — the inferred target flipped with incremental compilation, so it is pinned). `TabStrip` has per-tab `tabLeading` / `tabTrailing` slots (`Modifier.slotGap`: the 6 dp gap is charged only when the slot measured wider than 0, so the stock chip is unchanged and an empty slot costs nothing), the drop slot is sized by the strip whatever `dropGhostCard` draws, a tab opens/closes clipped to its slot only while `MutableTransitionState.isIdle` is false (AnimatedVisibility's own `clip` stays on for good and would cut a carried card), `TabPreview(tab, modifier)` is the one composable for a tab's thumbnail (the stock hover card is built on it), and `TabEntry.thumbnail` is app-assignable — assign before the tab is shown, from an effect next to the `Tab` declaration, since `restore()` creates no entries and the recorder's capture replaces whatever is there when the tab is shown. Headful coverage: `DockLayoutHeadfulCases` (robot splitter drags) + `DockLayoutMonkeyHeadfulCases` (profiles × seeds, `-Dnucleus.tao.headful.filter="dock layout"`). - `decorated-window-jewel` - Jewel (IntelliJ theme) integration - `decorated-window-material2` - Material 2 color mapping - `decorated-window-material3` - Material 3 color mapping - `plugin-build/plugin` - Gradle plugin for packaging & distribution - `buildSrc` - Build-only convention plugins (`nucleus.native-module`: the shared `buildNative*` wiring for every JNI module) -- `examples/` - Demo & sample applications: `nucleus-demo` (flagship), `compose-demo`, `tao-demo`, `swing-tao-demo`, `jni-demo`, `jewel-demo`, `cmp-demo` (KMP), `window-scaffold-demo`, `zstd-demo`, `scheduler-demo`, `service-management-demo`, `system-info-demo`, `fs-watcher-smoke`, `orphan-reflect-smoke`, `extra-launcher-demo`, `tao-native-test` (GraalVM + SLF4J fixture), `benchmark-demo` (JIT-vs-GraalVM-O3, ports under `ports/`), `gstreamer-demo` / `mediafoundation-demo` / `avfoundation-demo` (platform video into a `TextureView`), plus `shared` (Compose helper used by tao/jni demos). `native-proxy` and `spellcheck` directories on disk are **not** on `main` — ignore them unless the matching feature branch is checked out. +- `examples/` - Demo & sample applications: `nucleus-demo` (flagship), `compose-demo`, `tao-demo`, `swing-tao-demo`, `jewel-demo`, `cmp-demo` (KMP), `window-scaffold-demo`, `satellite-demo` (satellite workspace: floating palettes following the focused document, docking into a `DockLayout`, drag-to-dock, layout snapshots), `tabs-demo` (Chrome-like tabs: tear-off, merge, reorder, state following a tab between windows, layout snapshots), `jewel-tabs-demo` (the same tab workspace wearing Jewel's `TabStrip` / `TabData.Editor` chrome), `tab-satellites-demo` (the two archetypes composed: one `SatelliteWorkspace` per tab window, palettes drawing the window's selected tab), `reader-dock-demo` (a right-to-left book reader composing **both** archetypes: its seforim are tabs and its every pane is a satellite — layered right side with per-pane widths, `sideOrder` putting the right side outside the bottom one, the reader's own 1 dp + 5 dp-grip splitters and hover headers, Classic/Islands styles, one dock per tab window hung on `TabWindows(windowBodyWrapper)` so the strip stays the top of the window and a tab change touches no panel — the target layout of SeforimApp), `zstd-demo`, `scheduler-demo`, `service-management-demo`, `system-info-demo`, `fs-watcher-smoke`, `orphan-reflect-smoke`, `extra-launcher-demo`, `macos-appex-demo` (a macOS Network Extension `.appex` embedded and signed through `macOS { appExtensions { } }`), `tao-native-test` (GraalVM + SLF4J fixture), `benchmark-demo` (JIT-vs-GraalVM-O3, ports under `ports/`), `gstreamer-demo` / `mediafoundation-demo` / `avfoundation-demo` (platform video into a `TextureView`), plus `shared` (Compose helper used by the tao demos). `native-proxy` and `spellcheck` directories on disk are **not** on `main` — ignore them unless the matching feature branch is checked out. ## Build & Run @@ -61,8 +59,7 @@ Published releases are `2.4.x` (latest tag `v2.4.4`). Do not treat `IDEAL_API.md - Kotlin 2.4 with Compose Desktop 1.11 - JNI for all native interop (no JNA in runtime modules) -- JBR (JetBrains Runtime) API for decorated-window-jbr -- Gradle 9.4 with version catalog (`gradle/libs.versions.toml`) +- Gradle 9.8 with version catalog (`gradle/libs.versions.toml`) - Detekt + KtLint for code quality ## Development Notes @@ -72,12 +69,19 @@ Published releases are `2.4.x` (latest tag `v2.4.4`). Do not treat `IDEAL_API.md - Native modules use platform-specific JNI implementations — test on each OS - Plugin is published via included build in `plugin-build/` - Version catalog is the source of truth for all dependency versions -- **Public API freeze**: root `build.gradle.kts` applies kotlinx binary-compatibility-validator + `explicitApi()` to every non-example module. Baselines live in `/api/.api`. After intentional public API changes run `./gradlew apiDump` and commit the dump; `apiCheck` (wired into `check` / `preMerge`) fails on accidental ABI drift. Exception: `decorated-window-jewel` (JVM 25) is ignored by BCV until ASM supports class-file 69 — still uses `explicitApi()`. Helper: `scripts/fix-explicit-api.py` for mechanical visibility/return-type fixes from kotlinc diagnostics. +- **Compose window API v2**: Compose 1.12's `androidx.compose.ui.window.v2` types are hard-wired to AWT (`Screen` wraps a `GraphicsDevice`, `WindowGeometryProviderScope` takes a displayable `java.awt.Window`), so they are **not accepted** by any Nucleus window API — a half-working surface (scoped providers and `requestScreen` inert) is worse than none. The supported v2 surface is `dev.nucleusframework.window.tao.v2`, a member-for-member AWT-free clone backed by `TaoMonitors` + `TaoWindow`: migrating from the Compose package is a single import change, and deleting the clone restores the upstream import if JetBrains decouples its own types. Multi-monitor geometry comes from `TaoMonitors` (`EnumDisplayMonitors` / `NSScreen.screens` / GDK), never `GraphicsEnvironment` +- **No reflection**: runtime modules must stay GraalVM native-image compatible, so reflection is not an acceptable implementation tool — not even with a graceful fallback. Reach for a static bridge instead (e.g. a friend-package Java accessor like `androidx.compose.ui.draganddrop.TaoTransferableAccess`, which reads Kotlin `internal` members through their `$ui`-mangled JVM names), a public API of our own, or a plugin bytecode transform. A feature that can only be built reflectively is a feature we do not ship: document the gap and offer a working alternative +- **Public API freeze**: root `build.gradle.kts` applies kotlinx binary-compatibility-validator + `explicitApi()` to every non-example module. Baselines live in `/api/.api`. After intentional public API changes run `./gradlew apiDump` and commit the dump; `apiCheck` (wired into `check` / `preMerge`) fails on accidental ABI drift. Exception: `decorated-window-jewel` (JVM 25) is ignored by BCV until ASM supports class-file 69 — still uses `explicitApi()`. Helper: `scripts/fix-explicit-api.py` for mechanical visibility/return-type fixes from kotlinc diagnostics. **Experimental surface**: the satellite / dock family (`Satellite`, `SatelliteWindow`, `SatelliteWorkspace`, `SatellitePlacement`, `DockLayout`, `DockSplitterScope`, …) and the Chrome-like tab family (`Tab`, `TabWindows`, `TabWorkspace`, `TabStrip`, `TabStripScope`, …), in both `decorated-window-tao` and `nucleus-application`, are marked `@ExperimentalNucleusApi` (`dev.nucleusframework.window`, lives in `decorated-window-core` so every consumer sees it; opt-in level ERROR). Library modules and the demos opt in module-wide with `compilerOptions { optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") }`; new public satellite/dock/tab declarations must carry the marker. - **KDoc on public API**: `UndocumentedPublicClass` / `UndocumentedPublicFunction` are enforced by detekt (`detekt` is wired into `check` / `preMerge`). Pre-existing gaps are grandfathered in per-module `/detekt-baseline.xml` files — any *new* undocumented public class or function fails the build. Do not regenerate a baseline to silence a new finding; write the KDoc. `UndocumentedPublicProperty` stays off because the generated icon/symbol catalogs (`sf-symbols`, `freedesktop-icons`) would swamp it - **Logging**: `java.util.logging` is the single facade for every runtime module — no SLF4J dependency forced on consumers, no raw `println` / `System.err` in `src/main`. Logger names must be the fully-qualified class name (or an explicit `dev.nucleusframework.*` string) so the whole framework sits under one JUL namespace. `allowNucleusRuntimeLogging = true` is an opt-in convenience that raises the `dev.nucleusframework` logger to `nucleusLoggingLevel` and attaches a colored console handler; apps that configure JUL themselves (`logging.properties`, `jul-to-slf4j`) leave it `false` and Nucleus never touches the JUL configuration -- `decorated-window-tao` is the recommended backend for new projects (no AWT, native event-loop-driven, true Windows fullscreen, GraalVM native-image first-class). `decorated-window-jni` and `decorated-window-jbr` (the AWT-based backends) are legacy/maintenance-only -- **macOS trackpad on Tao** (#652–#654): scroll deltas are AWT-shaped (`preciseWheelRotation`, no display scale). Trackpad gestures reach Compose as `PanStart` / `PanMove` / `PanEnd` (`panOffset` = AWT delta × 10 dp), wheel notches as `Scroll`; foundation's `Modifier.scrollable` handles both. Custom handlers that only listen for `PointerEventType.Scroll` must also handle Pan, or the app can set `-Dnucleus.tao.trackpadPanEvents=false` to get AWT-style `Scroll` for everything. Everything scroll-related enters the scene through `TaoSceneScrollRouter` (window + NSPanel popups); the phase wire (Rust `SCROLL_GESTURE_*`, `popup_panel.m`, `TaoScrollGesturePhase`) is guarded by `TaoScrollWireDriftTest` +- `decorated-window-tao` is the only window backend (no AWT, native event-loop-driven, true Windows fullscreen, GraalVM native-image first-class). The AWT-based backends (`decorated-window-awt` / `-jbr` / `-jni`), `NucleusBackend`, `LocalNucleusBackend`, the `backend =` parameter of `nucleusApplication`, and `NucleusWindowUnsafe.awtWindow` / `awtDialog` were all removed in 2.6. Compose Desktop's AWT `Window` / `Dialog` / `Tray` are unsupported — use `DecoratedWindow`, `HostedWindow` / `HostedDialog`, and an AWT-free tray +- **Event-loop watchdog** (#643): a stalled loop produces no exception — to the JVM the thread is a healthy `RUNNABLE` / `_thread_in_native` — and `TaoApplication.rethrowPendingFatal` sits *after* `nativeRunBlocking`, which a deadlocked loop never leaves, so #640 froze silently. `TaoEventLoopWatchdog` is a min-priority daemon thread that polls `IsHungAppWindow` (`NativeTaoBridge.nativeIsWindowHung`) every 2 s and logs `SEVERE` + a full thread dump once a window has been hung past the grace period on top of Windows' own ~5 s threshold. The probe is a pure OS-state query — it sends nothing to the loop, unlike a `SendMessageTimeout(WM_NULL)` probe, whose inline sent message is exactly the re-entrancy that deadlocked #640. HWNDs are cached on `WINDOW_READY` from the event-loop thread: resolving one later goes through the native `WINDOWS` map, whose lock a stalled loop may hold. **The app-facing shape is Electron's**: the framework logs and raises `onUnresponsive` / `onResponsive` (`NucleusApplicationScope`, `TaoApplication` — `webContents`' `unresponsive` / `responsive`), and ships **no UI of its own**; the "wait or quit" prompt is the app's to build, as it is in Electron, Chromium's HangWatcher, IntelliJ's PerformanceWatcher and Unreal's `FThreadHeartBeat`. Both callbacks run on their own `nucleus-tao-watchdog-events` thread — not the UI thread (the stuck one, so anything posted to `Dispatchers.Main` would only run once the stall ends) and not the sampling thread, so a listener that blocks in a "wait or quit" prompt delays the next callback, never the detection. Off by default under a debug agent (a breakpoint on the UI thread is indistinguishable from a stall — the reason Unreal ships `HangDuration=0`); a poll that overslept by >10 s is read as a system suspend, which drops the episode and ignores the next 30 s (Electron #53529's `base::PowerMonitor` rule). `expectUnresponsive { }` (`NucleusApplicationScope`, `TaoApplication`) declares a long synchronous operation so it is not reported — Chromium's `InvalidateActiveExpectations()`, and the reason the global switch is not the only recourse. The watchdog thread parks while no window is registered (HangWatcher does the same with an empty watch list). `-Dnucleus.tao.watchdog=false` disables it (`=true` forces it on under a debugger), `-Dnucleus.tao.watchdogGraceMs=` retunes it, `-Dnucleus.tao.watchdogDialog=true` also pops the native dialog (from the watchdog thread — the loop thread is the stuck one, #622's constraint; `nucleus.tao.fatalErrorDialog=false` suppresses it too, as it does every native modal). Windows only: macOS has no public "not responding" query and X11's `_NET_WM_PING` perturbs the loop it observes. E2E: `EventLoopWatchdogHeadfulCases` (real window, real freeze), black-box switch smoke `./gradlew :decorated-window-tao:taoWatchdogSmoke` (prints `severe=N unresponsive=N responsive=N`; `-Dnucleus.tao.watchdogDialog=true -Dnucleus.tao.watchdog.smoke.holdMs=20000` to look at the dialog) +- **macOS trackpad on Tao** (#652–#654, #660): scroll deltas are AWT-shaped (`preciseWheelRotation`, no display scale). Trackpad two-finger swipe reaches Compose as `PanStart` / `PanMove` / `PanEnd` (`panOffset` = AWT delta × 10 dp), wheel notches as `Scroll`; foundation's `Modifier.scrollable` handles both. Custom handlers that only listen for `PointerEventType.Scroll` must also handle Pan, or the app can set `-Dnucleus.tao.trackpadPanEvents=false` to get AWT-style `Scroll` for everything. Everything scroll-related enters the scene through `TaoSceneScrollRouter` (window + NSPanel popups); the phase wire (Rust `SCROLL_GESTURE_*`, `popup_panel.m`, `TaoScrollGesturePhase`) is guarded by `TaoScrollWireDriftTest`. Platform-recognized pinch is `ScaleStart` / `ScaleChange` / `ScaleEnd` (`scaleFactor` = per-event ratio) via `dispatchTrackpadScale` — not two synthetic Touch contacts; `Modifier.transformable` and MapLibre consume that path, while `detectTransformGestures` still only sees two-finger rotate (two synthetic Touch contacts). Magnify and rotate interleave on a real trackpad and the two models cannot overlap (a Scale event without the contacts reads as their release → a touch tap per step; one carrying them stamps the factor on every pointer and foundation multiplies it per pointer), so the gesture that begins first owns it: during a pinch rotate steps are dropped, during a rotation magnify widens the contacts (spacing clamped to 0.05–20×, past which `detectZoom` handed the app `Infinity` / `NaN`). The contacts never coexist with **any** mouse-only event: a rotation does not start while a pan is open (`TaoSceneScrollRouter.panOpen`), drops trackpad scroll and smart-magnify while it owns the fingers, and a real cursor move / click / exit / focus loss interrupts it (cancelled, not a tap; the rest of it is ignored until it ends). Headful coverage: `MacOsTrackpadGestureMonkeyHeadfulCases` (trackpad / chaos / burst profiles × seeds against an exact oracle of the host rules, plus degenerate cases: collapsing / exploding contacts, gestures far off-window, a window closed with 200 gestures queued) and `MacOsTrackpadScaleHeadfulCases` (gesture NSEvents via `nativeDiagInjectTrackpadGesture` — a type-29 CGEvent, window set through field 51 + the private `CGEventSetWindowLocation`, **posted** with `postEvent:atStart:`: a synchronous `sendEvent:` from the test body re-enters tao's event callback and deadlocks). Linux/Windows pinch (GDK / Ctrl+wheel) uses the same Scale events. **GDK differs**: it reports pinch and rotation as *one* gesture (every `GdkEventTouchpadPinch` carries a scale and an angle, `touch.rs` forwards a magnify then a rotate step for each), so first-come would make rotation unreachable — a pinch opens as Scale and only accumulates its angle, and the rotation takes over (Scale closes, contacts pressed already turned by that angle) once it has turned 10° while the zoom stays within ±10 %. GDK's `angle_delta` is clockwise-positive on screen, i.e. Compose's sense (no flip, unlike AppKit). The contacts carry `TaoTrackpadRotationContacts` ids, which is how `TitleBar` keeps them from arming a window drag on every platform (a Linux rotation over the bar started a compositor move). An interrupted rotation calls `cancelPointerInput()` **before** sending the contacts' Release — the other order delivers an unconsumed touch-up, i.e. a tap. Linux headful coverage: `LinuxTrackpadPinchHeadfulCases` (synthetic `GdkEventTouchpadPinch` through the GtkWindow's `event` signal via `nativeLinuxInjectGdkTouchpadPinch`; coordinates are toplevel-relative, so add `nativeLinuxContentOrigin`) and `TrackpadScaleHeadfulCases` (real Ctrl+wheel through the AWT Robot — X11 leg only, the Robot cannot inject on Wayland). - macOS Liquid Glass enabled by default via `macOsSdkVersion = "26.0"` (vtool SDK patching) +- **Windows NSIS hot update** (every NSIS installer of a JVM app, no DSL switch; per-user installs only — a non-writable `Program Files` install falls back to the classic update): the app never leaves the screen while it updates. `WindowsHotUpdateLayout` lays the jpackage image out as `.exe` + `app\.cfg` at the root and `versions\\{app,runtime}` — the `.cfg` names the runtime with `app.runtime=$ROOTDIR\versions\\runtime` and every `$APPDIR` becomes `$ROOTDIR\versions\\app` (the jpackage launcher reads nothing else, from JDK 21 at least). `installAndRestart` (`WindowsHotUpdate`) then returns immediately: it renames the running launcher(s) to `*.nucleus-old` (a running exe can be renamed, not overwritten) and copies each back, writable (jpackage ships it read-only) — the copy is mapped by nobody, so the installer can replace it while shortcuts, the Run key and protocol handlers keep working — then runs the installer **while the app runs** with `NUCLEUS_HOT_UPDATE=1` — `WindowsHotUpdateNsis`'s `customCheckAppRunning` skips electron-builder's kill and the old version's `customRemoveFiles` keeps its files (both reproduce the 26.x template bodies otherwise; the env reaches the old uninstaller because the installer's `ExecWait` inherits it) — reads the installed version back from `app.runtime`, releases the single-instance lock (`SingleInstanceManager.releaseForHandoff`), launches the new version with `NUCLEUS_UPDATE_READY_FILE` / `NUCLEUS_UPDATE_PREVIOUS_PID`, and exits once the file appears. `UpdateHandoff.signalReady()` writes it from `TaoWindow`'s first presented frame after `show()`, then deletes retired versions (rename-then-delete: a version still in use cannot be renamed) and launchers — **jpackage ships the launcher read-only**, clear the flag before deleting. The previous PIDs include the launcher parent: jpackage's Windows launcher restarts itself as a child (skipped when inherited env says it already did). If the hot path cannot start it falls back to the classic update; if the **installer** fails the app just keeps running (the classic path would rerun the same failing installer and close/reopen the app at every check). **Multi-instance (Chromium's model)**: installs are serialized by an exclusive lock on `versions\.nucleus-install.lock` (Chromium's single machine-wide updater); an instance that waited, or finds the `.cfg` already starting a newer version, only hands off. Other instances learn about it locally — `NucleusUpdater.pendingRestartVersion` (`InstalledVersionWatcher`: `WatchService` on `app\` + 30 min poll, read under the **shared** lock because the `.cfg` is written before its version finishes extracting; Chromium's `InstalledVersionMonitor` + `InstalledVersionPoller`) — and `checkForUpdates` returns `NotAvailable` for a version already on disk, so nothing is downloaded twice. Nothing restarts on its own (unsaved work): the app offers it and calls `restartToInstalledVersion(relaunchArguments)`. `relaunchArguments` (`installAndRestart(file, args)`, Windows only) is explicit and empty by default — replaying the original command line would resend the autostart marker (the new version would think it started at login) or a deep link; Chromium drops positional args too. A same-JVM lock through another channel is an `OverlappingFileLockException`, not a wait: `withInstallLock` retries it. A PowerShell guard relaunches the app if a non-hot installer closed it anyway — unless the user quit it (a shutdown hook drops `app-exited`; a killed process runs none). PowerShell scripts are written with a UTF-8 **BOM** (`writePowerShellScript`): Windows PowerShell 5.1 reads BOM-less scripts as ANSI, which broke every update — classic included — for accented profile paths (`C:\Users\Hélène\…`). The "just updated" marker is written before the install, so `consumeUpdateEvent` / `wasJustUpdated` only report it when its target is the running version (a failed install used to announce an update that never happened). `-Dnucleus.updater.hotUpdate.disabled=true` forces classic. GraalVM native images have no `.cfg` indirection and stay classic (would need a stub launcher). E2E: `scripts/windows-hot-update-e2e.ps1` + `examples/hot-update-demo` (samples visible windows and the screen pixel every ~18 ms; measured 0 ms gap hot vs ~13-15 s classic). `-Scenario` covers `update`, `relaunch-during-install`, `close-during-install`, `failing-installer`, `stale-target-dir`, `two-instances`, `notify-other-instance`; `-NewVersion a,b` chains updates; `-InstallDir` with spaces/apostrophe/accents; a flat (pre-hot) old installer checks the migration (first hop classic, then hot). The window manager cross-fades windows, so blends of the two versions' colours are not gaps. The screen check is meaningless while the display is off — the capture freezes and nothing composes +- **Testing updates without publishing** (electron-updater's `dev-app-update.yml` + Squirrel/Velopack local sources, plus a simulation and a fault-injecting host none of them ship): three switches, all read as a system property *or* the matching environment variable (`UpdaterSettings`: `nucleus.updater.feedUrl` ↔ `NUCLEUS_UPDATER_FEED_URL`, camel humps become `_`), which `./gradlew run -Pnucleus.updater.…` forwards as `-D` and `runDistributable` as env. (1) **Feed redirect** `nucleus.updater.feedUrl` (`FeedOverride`) replaces the configured provider with `LocalFileProvider` (a path or `file:` URL — `FeedFetcher` reads `file:` URLs; no differential, ranges need HTTP) or `GenericProvider` (`https`, loopback `http` only — `[::1]` is `URI.host` *with* brackets). (2) **Simulation** `nucleus.updater.simulate=>` (+ `.version`, `.duration`, `.size`, `.differential`, `.justUpdatedFrom`), or `UpdaterConfig.simulation` in code (wins over both switches): `SimulatedUpdate` answers the check, times the progress, throws the real exceptions, and the install is skipped. (3) **Installed apps honour (1) and the launch-time (2) only with `UpdaterConfig.allowLaunchOverrides`** — whoever sets the variable would choose what the app installs, or silence its updates; an unpackaged run (`ExecutableType.DEV`) always does. Ignored switches log a warning, applied ones too. `NucleusUpdater.feedOverride` / `.simulation` expose what applies. **An unpackaged run never installs**: `installAndRestart` / `installAndQuit` log and return (the installer would install a copy beside the IDE run and exit it), and its format is `null` (auto) — `FileSelector` has no `dev` format. Plugin: `serveUpdateFeed` (`AbstractServeUpdateFeedTask`, per build type, registered when the current OS has an auto-updatable format) depends on the packaging tasks and serves `UpdateYmlPublish.discoverAndMerge` of their outputs + the artifacts with ranges on `127.0.0.1:8421` (`-Pnucleus.updater.serve.{port,throttle,latency,timeout}`). **The packaging output is a feed on its own**: electron-builder writes no `latest*.yml` without a `publish` provider, so the plugin now writes it for every self-contained updatable format (`TargetFormat.updateArtifactExtension`, not NSIS-Web), listing only artifacts named with *this* version (electron-builder never cleans the output dir: a bumped build used to list the previous installer first, i.e. the one every client downloads), and every packaging run first deletes the old manifests (`UpdateYmlPublish.deleteManifests`: a kept `latest.yml` described the previous artifact, SHA-512 included). `updater-testing` (`UpdateFeedServer`: `publish()` writes the manifest, `fault(FeedFault.Status/Delay/Throttle/Truncate/Corrupt/IgnoreRange, path glob, times)`, `requests`) is the host `UpdaterTortureTest` (updater-testing) and `DifferentialTortureTest` (updater-runtime, real electron-builder block maps) run against. A `Truncate` must flush before it drops the connection: bytes left in the server's buffer make the drop look like a stale pooled connection, which the JDK `HttpClient` silently retries. E2E on a real installed NSIS app: `scripts/updater-dev-testing-e2e.ps1` + `examples/hot-update-demo` (production `GitHubProvider`, `allowLaunchOverrides` unless `HOT_UPDATE_DEMO_ALLOW_OVERRIDES=0`) — `file-feed`, `file-url-feed` (spaces + non-ASCII), `http-feed` (throttled `serveUpdateFeed`), `http-feed-cached` (differential over the task), `locked`, `simulate`, `simulate-error`, `simulate-updated`, `run-simulate`, `run-feed` +- **PKG has two channels**, chosen by `macOS { pkg { appStore } }` (default `true`); whether `TargetFormat.Pkg` is a store format is `JvmApplicationDistributions.isSandboxed(format)`, not an enum property. App Store PKG = sandboxed pipeline, "3rd Party Mac Developer" certificates, `productsign` after the build, never notarized (Transporter upload). `appStore = false` = Developer ID PKG on the DMG pipeline (#249): electron-builder signs the installer itself from `pkg.identity` = the **bare** `NAME (TEAMID)` (it prepends "Developer ID Installer" and rejects a prefixed qualifier; `CSC_IDENTITY_AUTO_DISCOVERY=false` means no identity ⇒ silently unsigned, which the task catches with `pkgutil --check-signature`), a DSL keychain travels as `CSC_KEYCHAIN`, and `notarizePkg` notarizes the `.pkg`. `pkg { preInstall / postInstall }` are staged in `/build/pkg-scripts` for `pkgbuild --scripts` (shebang required); the App Store rejects install scripts (error 90254), so they require `appStore = false`. **The staged `preinstall` / `postinstall` are Nucleus shims, not the app's script**: electron-builder sets `BundlePre/PostInstallScriptPath` *and* passes `--scripts`, so `PackageInfo` declares each script twice and Installer runs it twice (confirmed on a real install). The shim skips the per-bundle pass (`$2` is the `.app`) and execs the app's copy, staged as `nucleus-app-pre` / `nucleus-app-post` — names electron-builder's `name.includes("preinstall")` scan must not match. Runtime: gate sandbox-sensitive features on `ExecutableRuntime.isSandboxed()` (`APP_SANDBOX_CONTAINER_ID`), never on `isPkg()` — that is also what makes a Developer ID PKG self-updatable (`NucleusUpdater.isUpdateSupported`) while the App Store build stays excluded +- **Node.js is provisioned, not required**: every format except `TargetFormat.RawAppImage` is built by electron-builder, which the plugin installs with `npm ci --ignore-scripts` against an embedded lock file — so packaging needs a Node.js. It downloads one from `nodejs.org` (verified against the release's `SHASUMS256.txt`) into `/nucleus/nodejs`, exactly like the GraalVM and packaging JDK toolchains, and the three share `ToolchainDownloads`. Configure with `nativeDistributions { nodejs { autoDownload / version / installDir } }`; `version` is a major line (`"22"`, the default), `"lts"`, or a pinned release, and a floating line is sticky once downloaded. Precedence: the `compose.electronBuilder.nodePath` Gradle property, then `NUCLEUS_NODE_HOME`, then the provisioned install, then `PATH` (also the fallback when the download fails). CI therefore runs **no** `actions/setup-node` — only a cache of `~/.gradle/nucleus/nodejs`. `release-desktop`'s `universal-macos` job runs electron-builder outside the plugin, so `build-macos-universal/provision-electron-builder.sh` mirrors it: same Node resolution rule, same install layout and marker (the cache entry is shared), and `npm ci --ignore-scripts` against the plugin's embedded lock file — never `npx --yes` - The HotSpot GC is selected type-safely with `application { garbageCollector = GarbageCollector.Z }` (unset = JVM ergonomics). The flags are prepended to the launcher `.cfg` java-options and to the `run` task — before `jvmArgs`, so an explicit `-XX:+Use…GC` there still wins — and the AOT training run inherits them from the `.cfg` ## Adding a Native JNI Module @@ -94,12 +98,12 @@ When creating a new module with platform-specific JNI libraries, all steps below windows("nucleus_feature") // → nucleus_feature.dll } ``` -4. **Kotlin JNI bridge** — `internal object` using `NativeLibraryLoader.load()` with `@JvmStatic external` methods. Always provide a Kotlin fallback when native lib is unavailable. +4. **Kotlin JNI bridge** — `internal object` using `NativeLibraryLoader.load()` with `@JvmStatic external` methods. Always provide a Kotlin fallback when native lib is unavailable. Native code that must drop a pending JNI exception (a Kotlin callback that threw) calls `nucleus_jni_clear_exception(env)` from `native-common/nucleus_jni.h` — never a bare `ExceptionClear`. 5. **GraalVM reachability metadata** — create `/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus./reachability-metadata.json` declaring all JNI-accessible classes/methods. Without this, native-image silently eliminates the bridge. 6. **CI build** (`build-natives.yaml`) — add one build step per platform job, gated with `if: steps.natives-cache.outputs.cache-hit != 'true'`, plus the library entries in that platform's `Verify ... natives` FILES list. Native outputs are cached keyed on `hashFiles('**/src/main/native/**', ...)`, so the new sources invalidate the cache automatically. Each platform job publishes a single merged artifact (`natives-windows`, `natives-macos`, `natives-linux-{x64,aarch64}`); consumer workflows fetch them all with one `pattern: 'natives-*'` download step and need **no changes** for a new module. 7. **CI verify lists** — add the 6 arch paths to the EXPECTED arrays of the "Verify all natives present" steps in `pre-merge.yaml` and `publish-maven.yaml`. -Common pitfalls: forgetting Linux `.so` in verify lists, missing `reachability-metadata.json`, forgetting the `cache-hit` guard on new build steps in `build-natives.yaml`. +Common pitfalls: forgetting Linux `.so` in verify lists, missing `reachability-metadata.json`, forgetting the `cache-hit` guard on new build steps in `build-natives.yaml`, swallowing JNI exceptions with a silent `ExceptionClear`. Existing `build.sh`/`build.bat` scripts also clear the `NativeLibraryLoader` cache themselves so a bare `./build.sh` (outside Gradle) is safe; new scripts don't have to, since `nucleus.native-module` does it after every run. @@ -127,6 +131,26 @@ GITHUB_REF=refs/tags/v2.4.4 JAVA_HOME=/usr/lib/jvm/java-1.21.0-openjdk-amd64 \ Published tags are `v2.4.x`. The `v` prefix is stripped for the Maven version. +## Dev releases (unverified) + +A tag `v..-dev-` (convention: `v2.6.0-dev-YYYYMMDDHHMM`, UTC, the +`tag-dev` skill cuts it) publishes the runtime modules to Maven Central and the plugin to the +Gradle Plugin Portal **without running `preMerge`** — no tests, no `apiCheck`, no detekt; only +the compile/javadoc/sign graph the publish tasks themselves pull in. Natives are still built and +verified, since the JARs would be unusable otherwise. Dev tags are also excluded from +`release-desktop` / `release-graalvm`, so they cut no GitHub release and burn no packaging matrix. + +`.github/actions/release-tag-info` is the single place that classifies a tag: it rejects anything +that is not `v` (every publish task derives its version with +`GITHUB_REF.removePrefix("refs/tags/v")`, so a `dev-2026…` tag would have published a version +literally named `refs/tags/dev-2026…`) and exposes `is-dev`, which gates the `preMerge` step in +both publish workflows. Dev tags can be cut from any branch — `validate-release-ref` only +constrains `alpha`/`beta`/`rc`, and it derives the branch they must live on from the tag itself +(`v2.6.0-rc.1` → `nucleus-2.6`) rather than pinning one that goes stale each release line. + +`2.6.0-dev-` orders below `2.6.0` for Gradle and Maven, so a dev build never shadows the real +release. The versions are immutable on Central: never retag, bump the timestamp. + ## GraalVM Native Image - Reflection metadata is centralized in 3 levels — users no longer copy hundreds of entries: @@ -136,6 +160,7 @@ Published tags are `v2.4.x`. The `v` prefix is stripped for the Maven version. - GraalVM Deb/Rpm/Pacman packages honor `linux { afterInstall / afterRemove / beforeInstall / beforeRemove }` the same as JVM jpackage/electron-builder. User scripts are concatenated after Nucleus templates. electron-builder substitutes `${sanitizedProductName}` and `${executable}` only when those tokens are single-quoted (`'${sanitizedProductName}-daemon.service'`); double-quoted `"${sanitizedProductName}"` is left unsubstituted and systemd hooks silently no-op. Pacman `.INSTALL` `pre_remove` does not get deb-style `$1=upgrade`, so stop/disable the unit unconditionally and let after-install re-enable on upgrade. - `graalvm { headless = true }` is for daemons/CLIs: skips L3 AWT/Java2D platform metadata, skips always-on L1 packs (`jdk-awt`, `jdk-fonts`, `jdk-graphics2d`, Skiko/Compose/tray), skips copying companion GUI native libs (`libawt`, `libfontmanager`, Skiko, …), and bakes `-Djava.awt.headless=true`. Default `false` (GUI). Without this, JNI registration of AWT types makes `native-image` pull `libawt`/`libawt_xawt` even when app code never references `java.awt`. - `graalvm-runtime` auto-includes `.svg`, `.ttf`, `.otf`, `composeResources/*`, `nucleus/native/*`, and `META-INF/services/*` via `reachability-metadata.json` resource globs (the deprecated `-H:IncludeResources` option was dropped). The blanket `**/*.{svg,ttf,otf}` globs are a required catch-all for fonts/icons bundled inside **library** JARs (e.g. Jewel SVG icons) — those are not the app's own resources so `autoIncludeResources` doesn't cover them. They knowingly trigger native-image's advisory "pattern too generic" warning; do not remove them (it breaks Jewel icons in native image) +- **Nucleus JNI libraries ship loose, never extracted at run time**, in both pipelines. Only **Nucleus** libraries move: every `nucleus.native-module` module ships `META-INF/nucleus/native-libraries/nucleus.` (generated by `generateNativeLibrariesManifest`, one `nucleus/native//` entry per line, sidecars included, plus the dependency libraries it declares with `nucleusNative { dependencyLibraries(NativeTarget.WINDOWS, "libEGL.dll", "libGLESv2.dll") }` — ANGLE lives in the external `nucleus.angle-natives` JAR), and the plugin moves exactly the union of those entries across the classpath; every other entry under `nucleus/native/` (an app's own dylib read through `getResourceAsStream`, as `tao-demo`'s SwiftUI bridge does, or a third-party library such as `composewebview`) stays in its JAR untouched. jpackage (`AbstractJPackageTask.prepareWorkingDir`): the target platform's listed libraries move into `$APPDIR` next to Skiko, other platforms' listed copies are dropped, and the launcher gets `-Dnucleus.native.libraryPath=$APPDIR`, which `NativeLibraryLoader` tries first — only when `core-runtime`'s `META-INF/nucleus/bundled-native-libraries` marker is on the classpath (an older loader would not look there, so the libs stay in the JARs), and never in the sandboxed pipeline, which has its own layout. GraalVM: `unpackGraalvmNucleusNatives` compiles the image from a copy of the uber JAR without the listed libraries (so the `nucleus/**` glob embeds none of them) and `copyGraalvmNucleusNatives` puts the libs next to the executable, where `GraalVmInitializer`'s `java.library.path` resolves them (macOS: `Contents/MacOS`, stripped/patched/signed with the other dylibs). Measured on Windows (`tao-demo`, 9 DLLs): extraction cost ~40–90 ms on the first launch after an install or update, the warm-cache path ~10 ms - The tracing agent (`runWithNativeAgent`) is only needed for app-specific reflection, uncommon libraries, and resource bundles - PGO (Oracle GraalVM): `runWithPgoInstrument` builds + runs an instrumented image and records `graalvm/pgo/default.iprof` on exit; later native-image builds apply the profile automatically. Opt out with `-Pnucleus.graalvm.pgo=off`; customize via `graalvm { pgo { enabled / profile } }` - Agent output is automatically deduplicated against library metadata on the classpath @@ -145,8 +170,8 @@ Published tags are `v2.4.x`. The `v` prefix is stripped for the Maven version. - SLF4J is **not** initialized at build time — the API and the app-selected backend both initialize at run time, so the app keeps control of its provider, levels and environment-dependent config. Forcing `--initialize-at-build-time=org.slf4j` from a shared module breaks any run-time-initialized backend (SLF4J 2.x provider discovery parks Logback's `LogbackMDCAdapter`/`LoggerContext` in the image heap → build failure; adding backend classes one by one only exposes the next object). Apps with a fixed backend can opt in via `graalvm { buildArgs.add("--initialize-at-build-time=org.slf4j") }` — it trades a frozen provider and build-machine-captured config for a cheaper first log call. `examples/tao-native-test` bundles Logback + an `MDC` round-trip as the regression fixture - GraalVM task surface mirrors the JVM one: `runGraalvmNative` is the fast dev loop (forces quick-build `-Ob`, ignoring the configured `optimization`), while `createGraalvmNativeDistributable` / `runGraalvmNativeDistributable` / `packageGraalvmNativeDistributionForCurrentOS` build & run the full app folder with the configured optimization (mirror `createDistributable` / `runDistributable` / `packageDistributionForCurrentOS`). Quick vs distributable is detected from the invoked task name and tracked as a compile input, so switching re-compiles - Native images bake a default max heap of 25% of RAM (`-R:MaximumHeapSizePercent=25`, JVM/HotSpot parity) instead of native-image's Serial-GC default of 80%; configurable via `graalvm { maxHeapSizePercent = N }` or an absolute `graalvm { maxHeapSize = "2g" }`, and always overridable at runtime with `-Xmx` -- The image's GC is baked at build time via `graalvm { garbageCollector = NativeImageGarbageCollector.G1 }` (`--gc=`, unset = native-image's Serial GC). `G1` is Oracle GraalVM + Linux only and degrades to a warning plus the Serial GC anywhere else; the baked heap percentage follows the collector (`-R:MaximumHeapSizePercent` for Serial/Epsilon, `-R:MaxRAMPercentage` for G1, which does not know the former) +- The image's GC is baked at build time via `graalvm { garbageCollector = NativeImageGarbageCollector.G1 }` (`--gc=`, unset = native-image's Serial GC). `G1` requires Oracle GraalVM (CE/NIK/Mandrel ship no G1 at all) and, outside Linux, GraalVM **25.4+** — 25.3 advertises `--gc=G1` and ships `g1GCStructs.h` but not `g1gc-cr.lib`, so the build dies at link time; anything unsupported degrades to a warning plus the Serial GC; the baked heap percentage follows the collector (`-R:MaximumHeapSizePercent` for Serial/Epsilon, `-R:MaxRAMPercentage` for G1, which does not know the former) - The GraalVM toolchain is auto-downloaded by default (`graalvm { toolchain { } }` DSL), but only when `graalvm { isEnabled = true }` and only when a native-image task actually runs — every provider is resolved in `doFirst`, so an IDE sync or `gradlew tasks` never pulls a JDK. Cached under `~/.gradle/nucleus/graalvm/`. - **Distribution defaults to GraalVM Community Edition** (`toolchain { distribution }`, GPLv2+CE, resolved from the `graalvm/graalvm-ce-builds` GitHub releases). `GraalvmDistribution.ORACLE` opts into Oracle GraalVM and logs a GFTC licensing warning — the GFTC forbids charging any fee associated with redistributing the Program, and the plugin ships GraalVM runtime libs (`libjvm`, `libawt`, …) next to the executable. In community mode the Oracle-only `runWithPgoInstrument` task is **not registered at all**; `-O3`, `--pgo` and `-H:AdvancedObfuscation` degrade to a warning. `examples/benchmark-demo` opts into ORACLE because `-O3`/PGO are its whole point. - Install dirs embed the distribution (`graalvm-community-jdk-*` vs `graalvm-jdk-*`), so a pre-existing Oracle download is never silently reused after the default flipped; a `GRAALVM_HOME` whose distribution disagrees with the DSL is ignored with a warning. The CI cache key includes the distribution too. -- Channel/version: innovation by default (`25i3` / GraalVM 25.3.4.1), `channel = GraalvmChannel.LTS` or an explicit `version` ("25", "25.0.1"). On Intel macs (dropped by both distributions after 25.0.1) it falls back to Liberica NIK via the BellSoft API — only the JDK feature version carries over there (BellSoft ships the LTS line only, so Intel macs get NIK 25.0.x even on the innovation channel). `toolchain { autoDownload = false }` restores Gradle toolchain resolution via `javaLanguageVersion`/`jvmVendor`. +- Channel/version: innovation by default (`25i4` / GraalVM 25.4.4.1.1), `channel = GraalvmChannel.LTS` or an explicit `version` ("25", "25.0.1"). On Intel macs (dropped by both distributions after 25.0.1) it falls back to Liberica NIK via the BellSoft API — only the JDK feature version carries over there (BellSoft ships the LTS line only, so Intel macs get NIK 25.0.x even on the innovation channel). `toolchain { autoDownload = false }` restores Gradle toolchain resolution via `javaLanguageVersion`/`jvmVendor`. diff --git a/README.md b/README.md index 716aad114..30aa7b36c 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,15 @@ their public surface locked by a binary-compatibility dump (`api/*.api`, checked `apiCheck` via kotlinx binary-compatibility-validator). Breaking changes to a public FQN or signature fail CI. The one exception is `decorated-window-jewel` (JVM 25 bytecode), which still uses `explicitApi()` but is not dumped until BCV can read class-file major -version 69. The Tao backend is the recommended one for new projects — -`decorated-window-jni` and `decorated-window-jbr` are deprecated and receive fixes only. +version 69. + +Windowing runs on a single backend: the no-AWT Tao one. The legacy AWT-based backends +(`decorated-window-jni`, `decorated-window-jbr`, and the shared `decorated-window-awt` +chrome) are removed in 2.6. To migrate: depend on `nucleus.decorated-window-tao`, drop the +`backend = NucleusBackend.…` argument (`NucleusBackend` and `LocalNucleusBackend` are gone), +and replace AWT-typed window access (`window.unsafe.awtWindow`, Compose Desktop's `Window` / +`Dialog` / `Tray`) with `nucleusWindow`, `HostedWindow` / `HostedDialog`, and an AWT-free +tray. ## Used by @@ -97,10 +104,10 @@ Nucleus builds on Compose Multiplatform and requires: | Requirement | Version | Note | |-------------|---------|------| -| JDK | 17+ (25+ for AOT cache) | JBR 25 recommended | +| JDK | 17+ (25+ for AOT cache) | Any vendor — no JetBrains Runtime needed | | Kotlin | 2.4.10+ | This repo builds with Kotlin 2.4.10 | | Compose Multiplatform | 1.12.0 | Required by the 2.5 line; will not run on 1.11.x | -| Gradle | 9.0+ | Bundled wrapper is Gradle 9.4.0 | +| Gradle | 9.0+ | Bundled wrapper is Gradle 9.8.0-rc-3 | ## Platform support @@ -141,10 +148,10 @@ fun main(args: Array) = nucleusApplication(args) { single-instance lock, and primes autolaunch / Windows AUMID when those modules are on the classpath. Pass the process `args` so deep links, file associations, and "started at login" see the original command line. -The default backend is `Auto` (Tao if `decorated-window-tao` is present, -otherwise AWT). Inside the block you can call `onDeepLink { }` and -`aotTraining()`; plugin-injected metadata is `NucleusApp`, not a generated -constants object. +Windows are Tao-backed: the native event loop owns the main thread and doubles +as `Dispatchers.Main`, with no AWT in the process. Inside the block you can call +`onDeepLink { }` and `aotTraining()`; plugin-injected metadata is `NucleusApp`, +not a generated constants object. On macOS the Tao backend delivers trackpad gestures to Compose as pan events (`PointerEventType.PanStart` / `PanMove` / `PanEnd`, with `panOffset` in @@ -206,18 +213,15 @@ Each module is published independently to Maven Central — use them together or | Module | Description | |--------|-------------| -| `nucleus.nucleus-application` | `nucleusApplication`, backend-agnostic `DecoratedWindow` / `HostedWindow` | +| `nucleus.nucleus-application` | `nucleusApplication`, `DecoratedWindow` / `HostedWindow` | | `nucleus.core-runtime` | Platform detection, single instance, deep links, `NucleusApp` metadata | | `nucleus.aot-runtime` | AOT cache mode detection | | `nucleus.updater-runtime` | Auto-update (GitHub/S3), SHA-512, delta/blockmap, progress | | `nucleus.darkmode-detector` | Reactive OS dark mode detection | | `nucleus.system-color` | Reactive accent color & high contrast detection | | `nucleus.system-info` | CPU, memory, GPU (NVIDIA/AMD/Intel), temperature, network, processes | -| `nucleus.decorated-window-tao` | Recommended windowing backend (Rust `tao`, no AWT) | +| `nucleus.decorated-window-tao` | Windowing backend (Rust `tao`, no AWT) | | `nucleus.decorated-window-core` | Shared window types, layout, chrome (design-system agnostic) | -| `nucleus.decorated-window-awt` | AWT chrome shared by the JBR/JNI backends | -| `nucleus.decorated-window-jbr` | Legacy JBR backend (maintenance only) | -| `nucleus.decorated-window-jni` | Legacy JNI/AWT backend (maintenance only) | | `nucleus.decorated-window-jewel` | Jewel (IntelliJ theme) integration | | `nucleus.decorated-window-material2` | Material 2 integration | | `nucleus.decorated-window-material3` | Material 3 integration | diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a365e586c..b901b6b56 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -72,18 +72,27 @@ Three AccessKit crates are vendored and patched to project the accessibility tre ## 4. ANGLE (libEGL.dll, libGLESv2.dll) — shipped binary (BSD 3-Clause) -The Tao Windows backend (`decorated-window-tao`) ships the ANGLE runtime libraries `libEGL.dll` and -`libGLESv2.dll` to provide a Direct3D 11 render path (OpenGL ES translated to D3D11, with a WARP -software fallback for RDP / VM / driverless environments). +The Tao Windows backend (`decorated-window-tao`) depends on the ANGLE runtime libraries `libEGL.dll` +and `libGLESv2.dll` to provide a Direct3D 11 render path (OpenGL ES translated to D3D11, with a WARP +software fallback for RDP / VM / driverless environments), so they reach every application built on +it. - Project: The ANGLE Project — https://chromium.googlesource.com/angle/angle - License: BSD 3-Clause — [`licenses/LICENSE-BSD-3-Clause-angle.txt`](licenses/LICENSE-BSD-3-Clause-angle.txt) - Copyright 2018 The ANGLE Project Authors. All rights reserved. -The binaries are not committed to this repository; they are fetched at build time from a pinned -[Electron](https://github.com/electron/electron) release (SHA-256 verified) by -`decorated-window-tao/src/main/native/windows/fetch-angle.sh`. The same BSD 3-Clause text also -covers the vendored Khronos/ANGLE EGL headers used at build time +The binaries are not committed to this repository, nor built by it. `decorated-window-tao` declares +a dependency on `dev.nucleusframework:nucleus.angle-natives`, published from +[NucleusFramework/angle](https://github.com/NucleusFramework/angle) — a fork of +[google/angle](https://github.com/google/angle) that builds the unmodified upstream sources of the +ANGLE release branch stable Chrome ships, with everything Nucleus cannot reach disabled at build +configuration level: the Vulkan, desktop-GL/WGL, SwiftShader, WebGPU and OpenCL backends. The +artifact version is the Chromium branch number, so it tracks ANGLE's own cadence. + +That artifact redistributes the upstream BSD 3-Clause `LICENSE` as `META-INF/LICENSE.angle`, +alongside a per-architecture `META-INF/nucleus/angle-build-win32-*.json` recording the exact ANGLE +commit and the full build configuration. The same BSD 3-Clause text also covers the vendored +Khronos/ANGLE EGL headers used at build time (`decorated-window-tao/src/main/native/vendor/angle-headers/LICENSE.angle`). --- diff --git a/build.gradle.kts b/build.gradle.kts index 3ef9b2bc6..9cc68ccf7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,32 +21,17 @@ plugins { } apiValidation { - // Demo / sample apps are not published; skip ABI dumps for them. - // Names match the last segment of include(":examples:...") in settings. + // Demo / sample apps are not published; skip ABI dumps for them. Derived from the project + // tree rather than hand-listed: a hand-maintained list silently goes stale every time a + // sample is added, and twice did (macos-appex-demo, reader-dock-demo failed apiCheck with + // "Expected file with API declarations ... does not exist"). + ignoredProjects.addAll( + subprojects + .filter { it.path.startsWith(":examples:") } + .map { it.name }, + ) ignoredProjects.addAll( listOf( - "nucleus-demo", - "compose-demo", - "tao-demo", - "swing-tao-demo", - "zstd-demo", - "jni-demo", - "shared", - "jewel-demo", - "cmp-demo", - "scheduler-demo", - "service-management-demo", - "system-info-demo", - "fs-watcher-smoke", - "orphan-reflect-smoke", - "extra-launcher-demo", - "benchmark-demo", - "gstreamer-demo", - "mediafoundation-demo", - "avfoundation-demo", - "tao-native-test", - "window-scaffold-demo", - "watermark-demo", // BCV 0.18.1's bundled ASM cannot read JVM 25 class files (major 69). // Module still uses explicitApi(); re-enable once BCV/KGP ABI supports it. "decorated-window-jewel", @@ -56,11 +41,16 @@ apiValidation { // reach Compose's internal AwtDragAndDropTransferable (Java friend-package // access). Implementation detail of decorated-window-tao, not public ABI. ignoredPackages.add("androidx.compose.ui.draganddrop") + // ComposeWindowV2Access lives in androidx.compose.ui.window.v2 to reach + // Compose 1.12's internal WindowState/DialogState request channels. Nothing + // user-facing lives there — inspectableWindowBounds is in + // dev.nucleusframework.window.tao precisely so apiCheck still covers it. + ignoredPackages.add("androidx.compose.ui.window.v2") } // The per-module `buildNative*` tasks themselves are wired by the // `nucleus.native-module` convention plugin (see buildSrc). -val buildNative by tasks.registering { +val buildNative = tasks.register("buildNative") { group = "build" description = "Builds native libraries for the current host platform." } @@ -107,12 +97,22 @@ subprojects { // Library modules only. Examples stay out of the aggregated report so // demo UI does not dilute (or inflate) published-runtime coverage. pluginManager.withPlugin("org.jetbrains.kotlin.jvm") { - apply(plugin = rootProject.libs.plugins.kover.get().pluginId) - rootProject.dependencies.add("kover", project(path)) + apply( + plugin = + rootProject.libs.plugins.kover + .get() + .pluginId, + ) + rootProject.dependencies.add("kover", dependencyFactory.create(path)) } pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { - apply(plugin = rootProject.libs.plugins.kover.get().pluginId) - rootProject.dependencies.add("kover", project(path)) + apply( + plugin = + rootProject.libs.plugins.kover + .get() + .pluginId, + ) + rootProject.dependencies.add("kover", dependencyFactory.create(path)) } } @@ -224,7 +224,7 @@ tasks.register("reformatAll") { dependsOn(gradle.includedBuild("plugin-build").task(":plugin:ktlintFormat")) } -val publishAllToMavenLocal by tasks.registering { +val publishAllToMavenLocal = tasks.register("publishAllToMavenLocal") { group = "publishing" description = "Publishes all runtime libraries and the Gradle plugin to Maven Local." diff --git a/buildSrc/src/main/kotlin/dev/nucleusframework/gradle/NativeModulePlugin.kt b/buildSrc/src/main/kotlin/dev/nucleusframework/gradle/NativeModulePlugin.kt index 2b9d317c9..333b22380 100644 --- a/buildSrc/src/main/kotlin/dev/nucleusframework/gradle/NativeModulePlugin.kt +++ b/buildSrc/src/main/kotlin/dev/nucleusframework/gradle/NativeModulePlugin.kt @@ -1,14 +1,27 @@ package dev.nucleusframework.gradle import org.apache.tools.ant.taskdefs.condition.Os +import org.gradle.api.DefaultTask import org.gradle.api.Plugin import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty import org.gradle.api.plugins.JavaPlugin +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.SourceSet +import org.gradle.api.tasks.TaskAction import org.gradle.api.tasks.TaskProvider import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.getByType import org.gradle.kotlin.dsl.named import org.gradle.kotlin.dsl.register import org.gradle.kotlin.dsl.withType @@ -75,6 +88,23 @@ open class NativeModuleExtension( description: String = NativeTarget.LINUX.defaultDescription, ): TaskProvider = register(NativeTarget.LINUX, library, description) + /** + * Declares libraries a dependency of this module ships under `nucleus/native/` and this + * module loads through `NativeLibraryLoader`, so the Nucleus Gradle plugin moves them out of + * that dependency's JAR together with the module's own. + * + * @param library library file name, e.g. `libGLESv2.dll` + * @param target the platform the dependency ships it for + */ + fun dependencyLibraries( + target: NativeTarget, + vararg library: String, + ) { + nativeLibrariesManifest.configure { + dependencyEntries.addAll(target.resourceDirs.flatMap { dir -> library.map { "nucleus/native/$dir/$it" } }) + } + } + private fun register( target: NativeTarget, library: String, @@ -103,7 +133,12 @@ open class NativeModuleExtension( // there). Other vendor trees (accesskit forks, ANGLE headers) // are large and rarely change independently of `src/**`. include("vendor/tao/**") - exclude("target/**", "vendor/accesskit_*/**", "vendor/angle-headers/**") + exclude("**/target/**", "vendor/accesskit_*/**", "vendor/angle-headers/**") + // The build scripts drop their intermediates next to the sources + // (cl.exe writes .obj into the working directory, cargo leaves + // marker files in the vendor trees). Tracking them as inputs made + // every native task out-of-date on the run right after it built. + exclude(GENERATED_ARTIFACTS) } val task = @@ -116,6 +151,10 @@ open class NativeModuleExtension( .files(nativeSources) .withPropertyName("nativeSources") .withPathSensitivity(PathSensitivity.RELATIVE) + inputs + .file(project.rootProject.layout.projectDirectory.file("native-common/nucleus_jni.h")) + .withPropertyName("nucleusJniHeader") + .optional() outputs.dir(resourceDir).withPropertyName("nativeLibraries") onlyIf("native build task matches the current host OS") { target.isHost } if (skipWhenPrebuilt) { @@ -134,29 +173,66 @@ open class NativeModuleExtension( } // Registered by the publishing plugin, which may not be applied yet. project.tasks.matching { it.name == "sourcesJar" }.configureEach { dependsOn(task) } + nativeLibrariesManifest.configure { dependsOn(task) } return task } - /** Mirrors `NativeLibraryLoader.resolveCacheDir()` in `core-runtime`. */ + /** + * Lists the module's libraries under `META-INF/nucleus/native-libraries/`, so the Nucleus + * Gradle plugin moves those — and only those — out of the JARs of a packaged application. + * The file name is unique per module so the list survives the GraalVM uber JAR's merge. + */ + private val nativeLibrariesManifest: TaskProvider by lazy { + val manifest = + project.tasks.register("generateNativeLibrariesManifest") { + nativeLibraries.from( + project.fileTree(project.layout.projectDirectory.dir(NATIVE_RESOURCE_PATH)) { + include("*/*") + exclude("**/.*") + }, + ) + manifestName.set("nucleus.${project.name}") + outputDir.set(project.layout.buildDirectory.dir("generated/nucleus-native-libraries")) + } + project.plugins.withType().configureEach { + project.extensions + .getByType() + .sourceSets + .named(SourceSet.MAIN_SOURCE_SET_NAME) + .configure { resources.srcDir(manifest) } + } + manifest + } + + /** + * Mirrors `NativeLibraryLoader.defaultCacheDir()` in `core-runtime`. + * + * Deliberately only the platform default: an application that relocates its + * cache (`NativeLibraryLoader.CACHE_DIR_PROPERTY` / `cacheDirectory`) does so + * in its own JVM, which this build never sees, so guessing an override here + * would evict a directory nothing reads and leave the real one untouched. + * Developers running with a relocated cache clear it themselves. + */ private fun loaderCacheDir(): File { val os = System.getProperty("os.name", "").lowercase() val userHome = System.getProperty("user.home") + + // Blank or relative values are ignored, exactly as the loader does: + // evicting a relative directory would miss the cache actually in use. + fun envDir(name: String): File? = + project.providers + .environmentVariable(name) + .orNull + ?.takeIf { it.isNotBlank() } + ?.let(::File) + ?.takeIf { it.isAbsolute } + val base = when { - os.contains("win") -> - project.providers - .environmentVariable("LOCALAPPDATA") - .orNull - ?.let(::File) - ?: File(userHome, "AppData/Local") + os.contains("win") -> envDir("LOCALAPPDATA") ?: File(userHome, "AppData/Local") os.contains("mac") -> File(userHome, "Library/Caches") - else -> - project.providers - .environmentVariable("XDG_CACHE_HOME") - .orNull - ?.let(::File) - ?: File(userHome, ".cache") + else -> envDir("XDG_CACHE_HOME") ?: File(userHome, ".cache") } return File(base, "nucleus/native") } @@ -227,6 +303,66 @@ enum class NativeTarget( private const val NATIVE_RESOURCE_PATH = "src/main/resources/nucleus/native" +/** + * Writes `META-INF/nucleus/native-libraries/`: one `nucleus/native//` + * JAR entry per line, for every library the module ships (sidecars included) plus the + * [dependencyEntries] it loads from a dependency's JAR. + */ +abstract class NativeLibrariesManifestTask : DefaultTask() { + @get:InputFiles + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val nativeLibraries: ConfigurableFileCollection + + /** `nucleus/native//` entries shipped by a dependency, see `dependencyLibraries`. */ + @get:Input + abstract val dependencyEntries: ListProperty + + @get:Input + abstract val manifestName: Property + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + /** Rewrites the manifest from the libraries currently in the module resources. */ + @TaskAction + fun generate() { + val entries = + nativeLibraries.asFileTree.files + .map { "nucleus/native/${it.parentFile.name}/${it.name}" } + .plus(dependencyEntries.get()) + .distinct() + .sorted() + val root = outputDir.get().asFile + root.deleteRecursively() + File(root, "META-INF/nucleus/native-libraries/${manifestName.get()}").apply { + parentFile.mkdirs() + writeText(entries.joinToString(separator = "\n", postfix = if (entries.isEmpty()) "" else "\n")) + } + } +} + +/** + * Build by-products the native scripts leave inside `src/main/native`. They are + * derived from the sources, never edited, and must not take part in the + * up-to-date check. + */ +private val GENERATED_ARTIFACTS = + listOf( + "**/*.obj", + "**/*.o", + "**/*.lib", + "**/*.exp", + "**/*.pdb", + "**/*.ilk", + "**/*.d", + "**/*.dll", + "**/*.so", + "**/*.dylib", + "**/build_log.txt", + "**/.cargo-ok", + "**/.cargo_vcs_info.json", + ) + private fun evictFromLoaderCache( cacheDir: File, libraryFileName: String, diff --git a/core-runtime/api/core-runtime.api b/core-runtime/api/core-runtime.api index f173f5440..14f8c4780 100644 --- a/core-runtime/api/core-runtime.api +++ b/core-runtime/api/core-runtime.api @@ -34,6 +34,7 @@ public final class dev/nucleusframework/core/runtime/ExecutableRuntime { public static final fun isPkg ()Z public static final fun isPortable ()Z public static final fun isRpm ()Z + public static final fun isSandboxed ()Z public static final fun isSevenZ ()Z public static final fun isSnap ()Z public static final fun isTar ()Z @@ -99,9 +100,12 @@ public final class dev/nucleusframework/core/runtime/LinuxUiToolkit$Companion { } public final class dev/nucleusframework/core/runtime/NativeLibraryLoader { + public static final field CACHE_DIR_PROPERTY Ljava/lang/String; public static final field INSTANCE Ldev/nucleusframework/core/runtime/NativeLibraryLoader; + public final fun getCacheDirectory ()Ljava/nio/file/Path; public final fun load (Ljava/lang/String;Ljava/lang/Class;Ljava/lang/String;Ljava/util/List;)Z public static synthetic fun load$default (Ldev/nucleusframework/core/runtime/NativeLibraryLoader;Ljava/lang/String;Ljava/lang/Class;Ljava/lang/String;Ljava/util/List;ILjava/lang/Object;)Z + public final fun setCacheDirectory (Ljava/nio/file/Path;)V } public final class dev/nucleusframework/core/runtime/NucleusApp { @@ -116,6 +120,13 @@ public final class dev/nucleusframework/core/runtime/NucleusApp { public static final fun isConfigured ()Z } +public final class dev/nucleusframework/core/runtime/NucleusUiThread { + public static final field INSTANCE Ldev/nucleusframework/core/runtime/NucleusUiThread; + public static final fun isRegistered ()Z + public static final fun post (Lkotlin/jvm/functions/Function0;)V + public static final fun setExecutor (Ljava/util/concurrent/Executor;)V +} + public final class dev/nucleusframework/core/runtime/Platform : java/lang/Enum { public static final field Companion Ldev/nucleusframework/core/runtime/Platform$Companion; public static final field Linux Ldev/nucleusframework/core/runtime/Platform; @@ -137,6 +148,7 @@ public final class dev/nucleusframework/core/runtime/SingleInstanceManager { public final fun getConfiguration ()Ldev/nucleusframework/core/runtime/SingleInstanceManager$Configuration; public final fun isSingleInstance (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Z public static synthetic fun isSingleInstance$default (Ldev/nucleusframework/core/runtime/SingleInstanceManager;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Z + public final fun releaseForHandoff ()V public final fun setConfiguration (Ldev/nucleusframework/core/runtime/SingleInstanceManager$Configuration;)V } @@ -159,6 +171,27 @@ public final class dev/nucleusframework/core/runtime/SingleInstanceManager$Confi public fun toString ()Ljava/lang/String; } +public final class dev/nucleusframework/core/runtime/UpdateHandoff { + public static final field ENV_HOT_INSTALL Ljava/lang/String; + public static final field ENV_PREVIOUS_PID Ljava/lang/String; + public static final field ENV_READY_FILE Ljava/lang/String; + public static final field INSTANCE Ldev/nucleusframework/core/runtime/UpdateHandoff; + public static final field RETIRED_LAUNCHER_SUFFIX Ljava/lang/String; + public static final field VERSIONS_DIR_NAME Ljava/lang/String; + public static final fun cleanupRetiredVersions ()V + public static final fun getVersionedInstall ()Ldev/nucleusframework/core/runtime/VersionedInstall; + public static final fun isHandoffLaunch ()Z + public static final fun signalReady ()V +} + +public final class dev/nucleusframework/core/runtime/VersionedInstall { + public fun (Ljava/io/File;Ljava/io/File;Ljava/io/File;)V + public final fun getLauncher ()Ljava/io/File; + public final fun getRoot ()Ljava/io/File; + public final fun getVersionDir ()Ljava/io/File; + public final fun getVersionsDir ()Ljava/io/File; +} + public final class dev/nucleusframework/core/runtime/WindowBackend : java/lang/Enum { public static final field Awt Ldev/nucleusframework/core/runtime/WindowBackend; public static final field Companion Ldev/nucleusframework/core/runtime/WindowBackend$Companion; diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/ExecutableRuntime.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/ExecutableRuntime.kt index fe2bdd62f..d2444b3c6 100644 --- a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/ExecutableRuntime.kt +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/ExecutableRuntime.kt @@ -36,6 +36,7 @@ public enum class ExecutableType { public object ExecutableRuntime { public const val TYPE_PROPERTY: String = "nucleus.executable.type" private const val TYPE_MARKER_FILE: String = ".nucleus-executable-type" + private const val APP_SANDBOX_CONTAINER_ID_ENV: String = "APP_SANDBOX_CONTAINER_ID" @JvmStatic public fun type(): ExecutableType { @@ -105,6 +106,26 @@ public object ExecutableRuntime { public val isGraalVmNativeImage: Boolean = System.getProperty("org.graalvm.nativeimage.imagecode") != null + /** + * Whether the process runs inside an OS application sandbox: the macOS App Sandbox, an AppX + * container or a Flatpak. + * + * The App Sandbox is detected through the `APP_SANDBOX_CONTAINER_ID` environment variable the + * sandbox runtime sets in every sandboxed process, whatever the installer format. Prefer this + * over [isPkg] to gate features the sandbox forbids: a PKG built with + * `macOS { pkg { appStore = false } }` installs an ordinary, unsandboxed app. + */ + @JvmStatic + public fun isSandboxed(): Boolean = isSandboxed(type(), System.getenv(APP_SANDBOX_CONTAINER_ID_ENV)) + + internal fun isSandboxed( + type: ExecutableType, + appSandboxContainerId: String?, + ): Boolean = + !appSandboxContainerId.isNullOrEmpty() || + type == ExecutableType.APPX || + type == ExecutableType.FLATPAK + public fun parseType(rawValue: String?): ExecutableType = when (rawValue?.trim()?.lowercase()) { // Windows diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/JniExceptionReporter.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/JniExceptionReporter.kt new file mode 100644 index 000000000..b4bf064ef --- /dev/null +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/JniExceptionReporter.kt @@ -0,0 +1,24 @@ +package dev.nucleusframework.core.runtime + +import java.util.logging.Level +import java.util.logging.Logger + +/** + * JUL sink for pending JNI exceptions that native code has to clear before it + * can continue. Native bridges call this through `nucleus_jni_clear_exception` + * in `native-common/nucleus_jni.h`; without it a Kotlin listener that throws + * from a JNI upcall vanishes with no log line. + */ +internal object JniExceptionReporter { + private val logger = Logger.getLogger(JniExceptionReporter::class.java.name) + + @JvmStatic + fun report(thrown: Throwable?) { + if (thrown == null) return + logger.log( + Level.WARNING, + "Native JNI callback cleared a pending Kotlin exception", + thrown, + ) + } +} diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoader.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoader.kt index 3dac9ad4d..bbf3244ab 100644 --- a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoader.kt +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoader.kt @@ -3,6 +3,7 @@ package dev.nucleusframework.core.runtime import java.net.JarURLConnection import java.net.URL import java.nio.file.Files +import java.nio.file.InvalidPathException import java.nio.file.Path import java.nio.file.StandardCopyOption import java.util.logging.Level @@ -12,8 +13,34 @@ import java.util.logging.Logger * Centralized native library loader with persistent caching. * * Extracts native libraries from JAR resources into a stable cache directory - * (`~/.cache/nucleus/native/` on macOS/Linux, `%LOCALAPPDATA%/nucleus/native/` on Windows) - * so that subsequent launches skip the extraction I/O entirely. + * so that subsequent launches skip the extraction I/O entirely. The default is + * `~/Library/Caches/nucleus/native/` on macOS, `$XDG_CACHE_HOME/nucleus/native/` + * (`~/.cache/...`) on Linux and `%LOCALAPPDATA%\nucleus\native\` on Windows. + * + * Applications that keep all their data under one directory can relocate the + * cache (issue #303). Each candidate below is tried in turn, the next one taking + * over when the previous cannot be created or written to: + * 1. the `nucleus.native.cacheDir` system property ([CACHE_DIR_PROPERTY]), + * e.g. `-Dnucleus.native.cacheDir=/var/lib/acme/native` in the launcher's JVM + * options. The value is used verbatim — neither the JVM nor the jpackage + * launcher expands `${user.home}`, so a path computed at run time goes through + * [cacheDirectory] instead. It must name a per-user, writable location: an + * install directory (`$APPDIR`, `/opt/...`, `C:\Program Files\...`) is + * read-only for a standard user, and writing inside a macOS `.app` bundle + * breaks its signature; + * 2. [cacheDirectory], set from `main()` before the first native library loads; + * 3. the platform default above. + * + * The directory is resolved once, at the first extraction, and the + * content-addressed layout described below is kept under it. A configured + * directory that cannot be created or written to is logged and replaced by + * the platform default rather than failing the load. + * + * Packaged applications built by the Nucleus Gradle plugin never extract + * anything: the plugin moves the libraries out of the JARs into the directory + * named by the `nucleus.native.libraryPath` system property (sandboxed store + * builds put them on `java.library.path` instead). This setting only matters + * for fat JARs, IDE runs and distributions that bypass the plugin. * * The cache is content-addressed: a fingerprint derived from the JAR entry * CRC-32 and size (read from ZIP headers — zero I/O cost) is part of the @@ -22,11 +49,62 @@ import java.util.logging.Logger * application using another version can never swap the library between * validation and load (issue #304). */ +@Suppress("TooManyFunctions") public object NativeLibraryLoader { + /** + * System property naming the directory native libraries are extracted to. + * Takes precedence over [cacheDirectory]. A relative path is resolved + * against the working directory; a blank value is ignored. + */ + public const val CACHE_DIR_PROPERTY: String = "nucleus.native.cacheDir" + private val logger = Logger.getLogger(NativeLibraryLoader::class.java.name) private val loadedLibraries = mutableSetOf() private val lock = Any() + /** Programmatic override, see [cacheDirectory]. Guarded by [lock]. */ + private var configuredCacheDir: Path? = null + + /** The directory in use once the first extraction happened. Guarded by [lock]. */ + private var resolvedCacheDir: Path? = null + + /** + * Directory native libraries are extracted to, overriding the platform + * default. The [CACHE_DIR_PROPERTY] system property, when set, still wins. + * + * Must be set before the first native library is extracted — typically the + * first statement of `main()`. Later assignments cannot move libraries the + * process already loaded, so they are ignored with a warning. + * `null` restores the platform default. + * + * The getter echoes this override only. It reports `null` when the cache was + * relocated through [CACHE_DIR_PROPERTY], and still reports the requested + * path when that path turned out to be unusable and the platform default was + * used instead. + */ + public var cacheDirectory: Path? + get() = synchronized(lock) { configuredCacheDir } + set(value) { + synchronized(lock) { + if (resolvedCacheDir != null) { + logger.warning( + "Ignoring cacheDirectory=$value: native libraries were already " + + "extracted to $resolvedCacheDir. Set it before the first native load.", + ) + return + } + configuredCacheDir = value + } + } + + /** + * Directory the Nucleus Gradle plugin moved the packaged application's + * libraries to. The plugin only moves them when it finds + * `META-INF/nucleus/bundled-native-libraries` (shipped by this module) on + * the classpath, since an older loader would not look here. + */ + private const val LIBRARY_PATH_PROPERTY = "nucleus.native.libraryPath" + /** * Loads a native library by name. * @@ -49,7 +127,10 @@ public object NativeLibraryLoader { synchronized(lock) { if (libraryName in loadedLibraries) return true - // Try system library path first (packaged app with native libs on java.library.path) + // Packaged app: the plugin moved the library out of its JAR + if (tryBundledLoad(libraryName)) return true + + // Sandboxed packaged app: native libs on java.library.path if (trySystemLoad(libraryName)) return true // Fallback: extract from JAR with persistent cache @@ -57,6 +138,30 @@ public object NativeLibraryLoader { } } + /** + * Loads [libraryName] from [LIBRARY_PATH_PROPERTY]. Sidecars need no + * handling: the plugin moved them to the same directory. + */ + @Suppress("SwallowedException") + private fun tryBundledLoad(libraryName: String): Boolean { + val dir = System.getProperty(LIBRARY_PATH_PROPERTY)?.takeIf { it.isNotBlank() } ?: return false + val file = + try { + Path.of(dir, mapLibraryFileName(libraryName, resolvePlatform())) + } catch (_: InvalidPathException) { + return false + } + if (!Files.isRegularFile(file)) return false + return try { + System.load(file.toAbsolutePath().toString()) + loadedLibraries += libraryName + true + } catch (e: UnsatisfiedLinkError) { + logger.log(Level.WARNING, "Failed to load bundled $file, falling back to the JAR", e) + false + } + } + private fun trySystemLoad(libraryName: String): Boolean = try { System.loadLibrary(libraryName) @@ -99,7 +204,7 @@ public object NativeLibraryLoader { val fingerprint = (listOf(resourceUrl) + sidecarUrls.map { it.second }) .joinToString("_") { resolveFingerprint(it) } - val cacheDir = resolveCacheDir().resolve(platform.resourceDir).resolve(fingerprint) + val cacheDir = cacheRoot().resolve(platform.resourceDir).resolve(fingerprint) Files.createDirectories(cacheDir) for ((sidecar, url) in sidecarUrls) { @@ -173,29 +278,98 @@ public object NativeLibraryLoader { return "${connection.contentLengthLong}-${connection.lastModified}" } - private fun resolveCacheDir(): Path { - val os = System.getProperty("os.name", "").lowercase() + /** + * The extraction root for this process: the first directory the application + * asked for that proves usable, else the platform default. Fixed at the + * first call, so every library of a run shares one root. + */ + @Suppress("TooGenericExceptionCaught") + internal fun cacheRoot(): Path = + synchronized(lock) { + resolvedCacheDir?.let { return@synchronized it } + + fun usable(dir: Path): Path? = + try { + Files.createDirectories(dir) + // Files.isWritable is advisory on Windows, where an + // install-directory ACL can still reject the write. Probe + // for real, since this decision is fixed for the process. + Files.delete(Files.createTempFile(dir, "nucleus", ".probe")) + dir + } catch (e: Exception) { + logger.log( + Level.WARNING, + "Native library cache directory $dir is unusable, trying the next candidate", + e, + ) + null + } + + // Each candidate is tried in turn: a property naming a read-only + // directory must not discard the one the application set itself. + val root = + requestedCacheDirs(System.getProperty(CACHE_DIR_PROPERTY), configuredCacheDir) + .firstNotNullOfOrNull(::usable) + ?: defaultCacheDir() + resolvedCacheDir = root + root + } + + /** Test seam: clears [cacheDirectory] and the resolved root, as at startup. */ + internal fun resetCacheDirForTesting() { + synchronized(lock) { + configuredCacheDir = null + resolvedCacheDir = null + } + } + + /** + * The directories an application asked for, most preferred first: + * [property] ([CACHE_DIR_PROPERTY]) then [override] ([cacheDirectory]). + * Relative paths are made absolute; a value the platform cannot parse as a + * path is dropped rather than failing every load. + */ + @Suppress("SwallowedException") + internal fun requestedCacheDirs( + property: String?, + override: Path?, + ): List { + val fromProperty = + try { + property?.takeIf { it.isNotBlank() }?.let { Path.of(it) } + } catch (_: java.nio.file.InvalidPathException) { + null + } + return listOfNotNull(fromProperty, override).map { it.toAbsolutePath().normalize() }.distinct() + } + + /** The per-user cache location of the current platform, `/nucleus/native`. */ + @Suppress("SwallowedException") + internal fun defaultCacheDir( + os: String = System.getProperty("os.name", ""), + userHome: String = System.getProperty("user.home"), + env: (String) -> String? = System::getenv, + ): Path { + // An empty or relative value would make the cache root the relative + // `nucleus/native`, i.e. put native libraries under the process working + // directory. The XDG spec mandates ignoring a relative XDG_CACHE_HOME, + // and a drive-relative LOCALAPPDATA has the same effect on Windows. + fun envPath(name: String): Path? = + try { + env(name) + ?.takeIf { it.isNotBlank() } + ?.let { Path.of(it) } + ?.takeIf { it.isAbsolute } + } catch (_: java.nio.file.InvalidPathException) { + null + } + val base = when { - os.contains("win") -> { - val localAppData = System.getenv("LOCALAPPDATA") - if (localAppData != null) { - Path.of(localAppData) - } else { - Path.of(System.getProperty("user.home"), "AppData", "Local") - } - } - os.contains("mac") -> { - Path.of(System.getProperty("user.home"), "Library", "Caches") - } - else -> { - val xdgCache = System.getenv("XDG_CACHE_HOME") - if (xdgCache != null) { - Path.of(xdgCache) - } else { - Path.of(System.getProperty("user.home"), ".cache") - } - } + os.lowercase().contains("win") -> + envPath("LOCALAPPDATA") ?: Path.of(userHome, "AppData", "Local") + os.lowercase().contains("mac") -> Path.of(userHome, "Library", "Caches") + else -> envPath("XDG_CACHE_HOME") ?: Path.of(userHome, ".cache") } return base.resolve("nucleus").resolve("native") } diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NucleusUiThread.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NucleusUiThread.kt new file mode 100644 index 000000000..a40aae91d --- /dev/null +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/NucleusUiThread.kt @@ -0,0 +1,64 @@ +package dev.nucleusframework.core.runtime + +import java.awt.EventQueue +import java.util.concurrent.Executor + +/** + * Single marshalling point for callbacks that must reach the host's UI thread. + * + * Native integrations (notifications, launchers, media keys, …) receive their + * callbacks on an OS thread — a D-Bus signal thread, a WinRT completion + * thread, the AppKit main thread — and must hand them to whichever thread the + * host treats as its UI thread before touching application state. + * + * That thread depends on the window backend ([WindowBackend]): + * + * - on [WindowBackend.Tao] it is the native Tao main thread, which Nucleus + * registers here via [setExecutor] when the event loop starts; + * - in a plain AWT / Compose Desktop / Swing host that does not go through + * `nucleusApplication`, nothing registers an executor and [post] falls back + * to the AWT event dispatch thread. + * + * Posting to the AWT EDT unconditionally is what issue #310 was: under Tao the + * EDT is *not* Compose's UI thread, so callbacks either ran on the wrong thread + * or were silently dropped. + * + * [post] always queues; it never runs [block] inline, even when called from the + * UI thread itself, so callback ordering is the same on every backend. + */ +public object NucleusUiThread { + @Volatile + private var executor: Executor? = null + + /** + * Registers the executor that marshals to the host's UI thread, or `null` + * to restore the AWT EDT fallback. + * + * Called by Nucleus when the window backend takes over the main thread; + * not intended for application code. + */ + @JvmStatic + public fun setExecutor(executor: Executor?) { + this.executor = executor + } + + /** + * `true` when a backend has registered its UI-thread executor — i.e. [post] + * marshals to the backend's thread rather than to the AWT EDT fallback. + */ + @JvmStatic + public val isRegistered: Boolean + get() = executor != null + + /** Queues [block] on the host's UI thread. Safe to call from any thread. */ + @JvmStatic + public fun post(block: () -> Unit) { + val runnable = Runnable { block() } + val target = executor + if (target != null) { + target.execute(runnable) + } else { + EventQueue.invokeLater(runnable) + } + } +} diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt index 302e0db33..abe6f71c2 100644 --- a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/SingleInstanceManager.kt @@ -59,6 +59,9 @@ public object SingleInstanceManager { private var fileLock: FileLock? = null private var isWatching = false + @Volatile + private var handedOff = false + /** * Checks if the current process is the single running instance. * @@ -112,6 +115,8 @@ public object SingleInstanceManager { } Runtime.getRuntime().addShutdownHook( Thread { + // After a handoff the lock file belongs to the new instance. + if (handedOff) return@Thread releaseLock() lockFile.delete() deleteRestoreRequestFile() @@ -175,7 +180,7 @@ public object SingleInstanceManager { continue } val filename = event.context() as Path - if (filename.toString() == configuration.restoreRequestFileName) { + if (!handedOff && filename.toString() == configuration.restoreRequestFileName) { debugLog { "Restore request file detected" } configuration.restoreRequestFilePath.onRestoreRequest() // Remove the request file after processing @@ -225,6 +230,21 @@ public object SingleInstanceManager { } } + /** + * Gives up the lock while this process keeps running, so the instance it is about to launch + * becomes the single instance — the seamless restart after a hot update, where the old version + * stays on screen until the new one is. From then on this process ignores restore requests and + * leaves the lock file to its successor. No-op when the lock is not held. + */ + public fun releaseForHandoff() { + if (fileLock == null) return + handedOff = true + releaseLock() + fileLock = null + fileChannel = null + debugLog { "Lock released for an update handoff" } + } + private fun releaseLock() { try { fileLock?.release() diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/UpdateHandoff.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/UpdateHandoff.kt new file mode 100644 index 000000000..a3b4f72fb --- /dev/null +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/UpdateHandoff.kt @@ -0,0 +1,227 @@ +package dev.nucleusframework.core.runtime + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Level +import java.util.logging.Logger + +/** + * A Windows NSIS install laid out for hot updates: the launcher and its `app\.cfg` stay at + * [root], while the Java runtime and the application live in `versions\\` ([versionDir]). + * + * A new version is installed into a sibling `versions\\` directory while this one keeps + * running — nothing this process holds open is overwritten — and the rewritten `.cfg` makes the + * next launch of [launcher] start the new version. + */ +public class VersionedInstall( + /** Installation directory: holds the launcher, `app\*.cfg` and `versions\`. */ + public val root: File, + /** The `versions\` directory this process runs from. */ + public val versionDir: File, + /** The application launcher (`jpackage.app-path`), directly under [root]. */ + public val launcher: File, +) { + /** Directory holding every installed version. */ + public val versionsDir: File get() = versionDir.parentFile +} + +/** + * The seamless restart that follows a hot update: the running (old) version launches the new one, + * which calls [signalReady] once its first window is on screen; only then does the old version + * exit, so the application never disappears from the screen while it updates. + * + * Nucleus windows signal readiness on their first presented frame, so applications built on + * `nucleusApplication` need nothing. An application that shows no Nucleus window (tray-only, or + * its own window toolkit) calls [signalReady] itself once it is usable; otherwise the old version + * gives up waiting after a timeout and exits anyway. + */ +public object UpdateHandoff { + /** + * Set to `1` in the environment of an installer run as a hot update. The installer then leaves + * the running application alone instead of closing it, and the old version's uninstaller keeps + * its files in place. + */ + public const val ENV_HOT_INSTALL: String = "NUCLEUS_HOT_UPDATE" + + /** File the new version creates once it is on screen. Set by the old version on the new one. */ + public const val ENV_READY_FILE: String = "NUCLEUS_UPDATE_READY_FILE" + + /** + * Comma-separated process ids of the old version (its JVM and the launcher it runs under), + * whose files the new version deletes once they have all exited. + */ + public const val ENV_PREVIOUS_PID: String = "NUCLEUS_UPDATE_PREVIOUS_PID" + + /** Name of the directory holding the installed versions, under [VersionedInstall.root]. */ + public const val VERSIONS_DIR_NAME: String = "versions" + + /** + * Suffix of a launcher moved aside during a hot update: a running executable can be renamed but + * not overwritten, so the old launcher is renamed before the installer writes the new one. + */ + public const val RETIRED_LAUNCHER_SUFFIX: String = ".nucleus-old" + + private const val RUNTIME_DIR_NAME = "runtime" + private const val TRASH_PREFIX = ".trash-" + private const val PREVIOUS_EXIT_TIMEOUT_SECONDS = 120L + private const val CLEANUP_ATTEMPTS = 10 + private const val CLEANUP_RETRY_DELAY_MS = 300L + + private val logger: Logger = Logger.getLogger(UpdateHandoff::class.java.name) + private val signaled = AtomicBoolean(false) + + /** The versioned install this process runs from, or `null` for any other layout or platform. */ + @JvmStatic + public val versionedInstall: VersionedInstall? by lazy { + detectVersionedInstall( + javaHome = System.getProperty("java.home"), + launcherPath = System.getProperty("jpackage.app-path"), + isWindows = Platform.Current == Platform.Windows, + ) + } + + /** Whether this process was launched by an older version handing over to it after a hot update. */ + @JvmStatic + public val isHandoffLaunch: Boolean get() = System.getenv(ENV_READY_FILE) != null + + /** + * Tells the version that launched this one that it is on screen, so it can exit, then deletes + * the versions left behind by earlier updates once that version is gone. Idempotent and cheap: + * the work runs on a background thread. + */ + @JvmStatic + public fun signalReady() { + if (!signaled.compareAndSet(false, true)) return + val readyFile = System.getenv(ENV_READY_FILE) + if (readyFile == null && Platform.Current != Platform.Windows) return + Thread({ + readyFile?.let(::writeReadyFile) + awaitPreviousInstance() + cleanupRetiredVersions() + }, "nucleus-update-handoff").apply { + isDaemon = true + priority = Thread.MIN_PRIORITY + start() + } + } + + /** + * Deletes the versions and launchers left behind by earlier hot updates. A version still in use + * (another instance running it) cannot be renamed, which is how it is detected and kept. + */ + @JvmStatic + public fun cleanupRetiredVersions() { + val install = versionedInstall ?: return + cleanupRetiredVersions(install) + } + + internal fun cleanupRetiredVersions(install: VersionedInstall) { + // A process is reported gone slightly before Windows releases its image and mapped + // DLLs, so what the previous version held may need a few more attempts. + repeat(CLEANUP_ATTEMPTS) { attempt -> + if (cleanupPass(install)) return + if (attempt < CLEANUP_ATTEMPTS - 1) Thread.sleep(CLEANUP_RETRY_DELAY_MS) + } + logger.fine { "Retired versions still in use; the next start will retry" } + } + + /** One cleanup pass; returns `true` when nothing retired is left. */ + private fun cleanupPass(install: VersionedInstall): Boolean { + var clean = true + val current = install.versionDir.canonicalFile + install.versionsDir.listFiles()?.forEach { dir -> + if (!dir.isDirectory || dir.canonicalFile == current) return@forEach + if (dir.name.startsWith(TRASH_PREFIX)) { + if (!dir.deleteClearingReadOnly()) clean = false + return@forEach + } + // Renaming first makes the deletion all-or-nothing: Windows refuses to rename a + // directory with open files, so a version another instance still runs is left intact + // instead of losing the files it has not opened yet. + val trash = File(dir.parentFile, "$TRASH_PREFIX${dir.name}-${System.nanoTime()}") + if (!dir.renameTo(trash) || !trash.deleteClearingReadOnly()) { + logger.fine { "Could not delete retired version ${dir.name} yet" } + clean = false + } + } + install.root + .listFiles { file -> file.isFile && file.name.endsWith(RETIRED_LAUNCHER_SUFFIX) } + ?.forEach { + // jpackage ships the launcher read-only, which Windows refuses to delete. + it.setWritable(true) + if (!it.delete()) { + logger.fine { "Could not delete retired launcher ${it.name} yet" } + clean = false + } + } + return clean + } + + /** [File.deleteRecursively] that first clears the read-only flag Windows refuses to delete. */ + private fun File.deleteClearingReadOnly(): Boolean { + walkBottomUp().filter { !it.canWrite() }.forEach { it.setWritable(true) } + return deleteRecursively() + } + + private fun writeReadyFile(path: String) { + val target = File(path) + // The variable is inherited by whatever this instance starts later (a restart, say); by then + // the version that waited for it is gone along with its directory, and nobody is listening. + if (target.parentFile?.isDirectory != true) { + logger.fine { "No update handoff waiting on $path" } + return + } + try { + val temp = File(target.parentFile, "${target.name}.tmp") + temp.writeText(ProcessHandle.current().pid().toString()) + Files.move(temp.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.WARNING, "Could not signal the update handoff through $path", e) + } + } + + private fun awaitPreviousInstance() { + val pids = System.getenv(ENV_PREVIOUS_PID)?.split(',')?.mapNotNull { it.trim().toLongOrNull() } ?: return + logger.fine { "Waiting for the previous version to exit: $pids" } + pids.forEach { pid -> awaitExit(pid) } + } + + private fun awaitExit(pid: Long) { + ProcessHandle.of(pid).ifPresent { previous -> + try { + previous.onExit().get(PREVIOUS_EXIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.FINE, "Previous version $pid still running; cleanup may skip it", e) + } + } + } + + /** + * Recognizes the versioned layout from the running JVM: `java.home` is + * `\versions\\runtime` and the launcher sits directly in ``. + */ + internal fun detectVersionedInstall( + javaHome: String?, + launcherPath: String?, + isWindows: Boolean, + ): VersionedInstall? { + if (!isWindows || javaHome == null || launcherPath == null) return null + val runtime = File(javaHome).absoluteFile + val versionDir = runtime.parentFile ?: return null + val versionsDir = versionDir.parentFile ?: return null + val root = versionsDir.parentFile ?: return null + val launcher = File(launcherPath).absoluteFile + val matches = + runtime.name.equals(RUNTIME_DIR_NAME, ignoreCase = true) && + versionsDir.name.equals(VERSIONS_DIR_NAME, ignoreCase = true) && + launcher.parentFile == root + return if (matches) VersionedInstall(root, versionDir, launcher) else null + } +} diff --git a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/WindowBackend.kt b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/WindowBackend.kt index 30effd09f..dddbc821b 100644 --- a/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/WindowBackend.kt +++ b/core-runtime/src/main/kotlin/dev/nucleusframework/core/runtime/WindowBackend.kt @@ -20,7 +20,7 @@ package dev.nucleusframework.core.runtime * ``` */ public enum class WindowBackend { - /** AWT-bound backend (`decorated-window-jbr` / `decorated-window-jni`, or a non-Nucleus AWT app). */ + /** AWT-bound windowing — a plain Compose Desktop / Swing app that does not use `nucleusApplication`. */ Awt, /** No-AWT backend (`decorated-window-tao`), driven by a native event loop. */ diff --git a/core-runtime/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.core-runtime/reachability-metadata.json b/core-runtime/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.core-runtime/reachability-metadata.json new file mode 100644 index 000000000..6407cc61e --- /dev/null +++ b/core-runtime/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.core-runtime/reachability-metadata.json @@ -0,0 +1,11 @@ +{ + "reflection": [ + { + "type": "dev.nucleusframework.core.runtime.JniExceptionReporter", + "jniAccessible": true, + "methods": [ + { "name": "report", "parameterTypes": ["java.lang.Throwable"] } + ] + } + ] +} diff --git a/core-runtime/src/main/resources/META-INF/nucleus/bundled-native-libraries b/core-runtime/src/main/resources/META-INF/nucleus/bundled-native-libraries new file mode 100644 index 000000000..fe4bce291 --- /dev/null +++ b/core-runtime/src/main/resources/META-INF/nucleus/bundled-native-libraries @@ -0,0 +1,2 @@ +# Tells the Nucleus Gradle plugin that NativeLibraryLoader reads nucleus.native.libraryPath, +# so a packaged application may ship its native libraries next to its JARs instead of inside them. diff --git a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/ExecutableRuntimeSandboxTest.kt b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/ExecutableRuntimeSandboxTest.kt new file mode 100644 index 000000000..066f1bd11 --- /dev/null +++ b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/ExecutableRuntimeSandboxTest.kt @@ -0,0 +1,41 @@ +package dev.nucleusframework.core.runtime + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExecutableRuntimeSandboxTest { + @Test + fun `app sandbox container id marks the process sandboxed whatever the format`() { + assertTrue(ExecutableRuntime.isSandboxed(ExecutableType.PKG, "com.example.app")) + assertTrue(ExecutableRuntime.isSandboxed(ExecutableType.DMG, "com.example.app")) + assertTrue(ExecutableRuntime.isSandboxed(ExecutableType.DEV, "com.example.app")) + } + + @Test + fun `a pkg without the app sandbox is not sandboxed`() { + assertFalse(ExecutableRuntime.isSandboxed(ExecutableType.PKG, null)) + assertFalse(ExecutableRuntime.isSandboxed(ExecutableType.PKG, "")) + } + + @Test + fun `appx and flatpak are sandboxed by construction`() { + assertTrue(ExecutableRuntime.isSandboxed(ExecutableType.APPX, null)) + assertTrue(ExecutableRuntime.isSandboxed(ExecutableType.FLATPAK, null)) + } + + @Test + fun `direct distribution formats are not sandboxed`() { + val direct = + listOf( + ExecutableType.DMG, + ExecutableType.NSIS, + ExecutableType.DEB, + ExecutableType.APPIMAGE, + ExecutableType.DEV, + ) + for (type in direct) { + assertFalse(type.name, ExecutableRuntime.isSandboxed(type, null)) + } + } +} diff --git a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/JniExceptionReporterTest.kt b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/JniExceptionReporterTest.kt new file mode 100644 index 000000000..0bf89f4e3 --- /dev/null +++ b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/JniExceptionReporterTest.kt @@ -0,0 +1,70 @@ +package dev.nucleusframework.core.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger + +class JniExceptionReporterTest { + @Test + fun `report logs the throwable at warning`() { + val logger = Logger.getLogger(JniExceptionReporter::class.java.name) + val records = mutableListOf() + val handler = + object : Handler() { + override fun publish(record: LogRecord) { + records += record + } + + override fun flush() = Unit + + override fun close() = Unit + } + val previousLevel = logger.level + val previousUseParent = logger.useParentHandlers + logger.addHandler(handler) + logger.useParentHandlers = false + logger.level = Level.ALL + try { + val boom = IllegalStateException("listener failed") + JniExceptionReporter.report(boom) + assertEquals(1, records.size) + assertEquals(Level.WARNING, records[0].level) + assertSame(boom, records[0].thrown) + assertTrue(records[0].message.contains("JNI")) + } finally { + logger.removeHandler(handler) + logger.level = previousLevel + logger.useParentHandlers = previousUseParent + } + } + + @Test + fun `report ignores null`() { + val logger = Logger.getLogger(JniExceptionReporter::class.java.name) + val records = mutableListOf() + val handler = + object : Handler() { + override fun publish(record: LogRecord) { + records += record + } + + override fun flush() = Unit + + override fun close() = Unit + } + logger.addHandler(handler) + logger.useParentHandlers = false + logger.level = Level.ALL + try { + JniExceptionReporter.report(null) + assertTrue(records.isEmpty()) + } finally { + logger.removeHandler(handler) + } + } +} diff --git a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NativeJniExceptionHygieneTest.kt b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NativeJniExceptionHygieneTest.kt new file mode 100644 index 000000000..d491976c5 --- /dev/null +++ b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NativeJniExceptionHygieneTest.kt @@ -0,0 +1,84 @@ +package dev.nucleusframework.core.runtime + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.File + +/** + * Guards issue #486: native bridges must report a pending JNI exception + * through [JniExceptionReporter] before clearing it. `ExceptionClear` / + * `ExceptionDescribe` live only in `native-common/nucleus_jni.h`. + */ +class NativeJniExceptionHygieneTest { + private val nativeExts = setOf("c", "m", "h", "cpp", "mm") + private val skipDirs = setOf("vendor", "target", ".git", "build") + + @Test + fun `shared helper reports through JniExceptionReporter then clears`() { + val header = File(repoRoot(), "native-common/nucleus_jni.h") + assertTrue("missing ${header.path}", header.isFile) + val text = header.readText() + assertTrue(text.contains("JniExceptionReporter")) + assertTrue(text.contains("ExceptionOccurred")) + assertTrue(text.contains("ExceptionDescribe")) + assertTrue(text.contains("ExceptionClear")) + assertTrue(text.contains("nucleus_jni_clear_exception")) + } + + @Test + fun `native sources do not silently ExceptionClear`() { + val root = repoRoot() + val violations = mutableListOf() + nativeFiles(root).forEach { file -> + val rel = file.relativeTo(root).path + if (rel.replace('\\', '/') == "native-common/nucleus_jni.h") return@forEach + file.readLines().forEachIndexed { index, line -> + if ("ExceptionClear" in line || "ExceptionDescribe" in line) { + violations += "$rel:${index + 1}: $line" + } + } + } + if (violations.isNotEmpty()) { + fail( + "JNI ExceptionClear/ExceptionDescribe must go through " + + "nucleus_jni_clear_exception (issue #486):\n" + + violations.joinToString("\n"), + ) + } + } + + @Test + fun `native sources that check exceptions include the shared helper`() { + val root = repoRoot() + val missing = mutableListOf() + nativeFiles(root).forEach { file -> + val rel = file.relativeTo(root).path.replace('\\', '/') + if (rel == "native-common/nucleus_jni.h") return@forEach + val text = file.readText() + if ("ExceptionCheck" in text && "nucleus_jni.h" !in text) { + missing += rel + } + } + assertFalse( + "files with ExceptionCheck must include nucleus_jni.h:\n${missing.joinToString("\n")}", + missing.isNotEmpty(), + ) + } + + private fun nativeFiles(root: File): Sequence = + root + .walkTopDown() + .onEnter { it.name !in skipDirs } + .filter { it.isFile && it.extension in nativeExts } + .filter { "/src/main/native/" in it.path.replace('\\', '/') || it.name == "nucleus_jni.h" } + + private fun repoRoot(): File { + val cwd = File("").absoluteFile + val candidates = listOfNotNull(cwd, cwd.parentFile) + return candidates.firstOrNull { dir -> + File(dir, "settings.gradle.kts").isFile && File(dir, "core-runtime").isDirectory + } ?: error("cannot locate repository root from $cwd") + } +} diff --git a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoaderTest.kt b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoaderTest.kt index 03d18d95b..f6ef31cd0 100644 --- a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoaderTest.kt +++ b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NativeLibraryLoaderTest.kt @@ -2,16 +2,19 @@ package dev.nucleusframework.core.runtime import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import java.nio.file.Files +import java.nio.file.Path import kotlin.io.path.readText import kotlin.io.path.writeText /** * Verifies the content-addressed cache guarantees that fix issue #304: * different library versions must never share an extraction path, and an - * already-extracted file must never be replaced. + * already-extracted file must never be replaced. Also covers the cache + * directory resolution order of issue #303. */ class NativeLibraryLoaderTest { @Test @@ -58,4 +61,161 @@ class NativeLibraryLoaderTest { assertEquals("library bytes", loadPath.readText()) } + + @Test + fun `the system property is preferred, the override kept as a fallback`() { + val fromProperty = Path.of("/tmp/from-property") + val override = Path.of("/tmp/from-override") + + assertEquals( + listOf(fromProperty, override), + NativeLibraryLoader.requestedCacheDirs(fromProperty.toString(), override), + ) + } + + @Test + fun `programmatic override applies when the property is absent or blank`() { + val override = Path.of("/tmp/from-override") + + assertEquals(listOf(override), NativeLibraryLoader.requestedCacheDirs(null, override)) + assertEquals(listOf(override), NativeLibraryLoader.requestedCacheDirs(" ", override)) + } + + @Test + fun `no configuration means platform default`() { + assertEquals(emptyList(), NativeLibraryLoader.requestedCacheDirs(null, null)) + assertEquals(emptyList(), NativeLibraryLoader.requestedCacheDirs("", null)) + } + + @Test + fun `a property the platform cannot parse is dropped, not fatal`() { + val override = Path.of("/tmp/from-override") + + assertEquals( + listOf(override), + NativeLibraryLoader.requestedCacheDirs("/tmp/bad" + '\u0000' + "dir", override), + ) + } + + @Test + fun `relative property path is resolved against the working directory`() { + val resolved = NativeLibraryLoader.requestedCacheDirs("native-cache", null) + + assertEquals(listOf(Path.of("native-cache").toAbsolutePath().normalize()), resolved) + assertTrue(resolved.single().isAbsolute) + } + + @Test + fun `default cache dir follows the platform conventions`() { + // The env values are absolute for the *test* file system: a real + // `C:\Users\...` is not absolute to the Linux/macOS provider running CI. + val env = mapOf("LOCALAPPDATA" to "/appdata/local", "XDG_CACHE_HOME" to "/xdg/cache") + + assertEquals( + Path.of("/appdata/local", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Windows 11", "/home/me", env::get), + ) + assertEquals( + Path.of("/home/me", "Library", "Caches", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Mac OS X", "/home/me", env::get), + ) + assertEquals( + Path.of("/xdg/cache", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Linux", "/home/me", env::get), + ) + assertEquals( + Path.of("/home/me", ".cache", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Linux", "/home/me") { null }, + ) + } + + @Test + fun `cacheDirectory is settable before the first extraction`() { + val dir = Files.createTempDirectory("nucleus-cache-dir") + try { + // The loader is a process-wide singleton: another test may already + // have extracted a library and latched the root. + NativeLibraryLoader.resetCacheDirForTesting() + NativeLibraryLoader.cacheDirectory = dir + assertEquals(dir, NativeLibraryLoader.cacheDirectory) + } finally { + NativeLibraryLoader.resetCacheDirForTesting() + dir.toFile().deleteRecursively() + } + assertNull(NativeLibraryLoader.cacheDirectory) + } + + @Test + fun `blank or relative cache environment variables are ignored`() { + // A set-but-empty XDG_CACHE_HOME (or a relative one, which the XDG spec + // says to ignore) must not put the cache under the working directory. + assertEquals( + Path.of("/home/me", ".cache", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Linux", "/home/me", mapOf("XDG_CACHE_HOME" to "")::get), + ) + assertEquals( + Path.of("/home/me", ".cache", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Linux", "/home/me", mapOf("XDG_CACHE_HOME" to "relative/dir")::get), + ) + assertEquals( + Path.of("/home/me", "AppData", "Local", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Windows 11", "/home/me", mapOf("LOCALAPPDATA" to " ")::get), + ) + assertEquals( + Path.of("/home/me", "AppData", "Local", "nucleus", "native"), + NativeLibraryLoader.defaultCacheDir("Windows 11", "/home/me", mapOf("LOCALAPPDATA" to "rel/dir")::get), + ) + } + + @Test + fun `cacheRoot uses the configured directory and latches it`() { + val dir = Files.createTempDirectory("nucleus-root") + try { + NativeLibraryLoader.resetCacheDirForTesting() + NativeLibraryLoader.cacheDirectory = dir + + assertEquals(dir, NativeLibraryLoader.cacheRoot()) + // No probe file survives the check. + assertEquals(emptyList(), Files.list(dir).use { it.toList() }) + + // Latched: a later assignment cannot move libraries already loaded. + NativeLibraryLoader.cacheDirectory = Files.createTempDirectory("nucleus-late") + assertEquals(dir, NativeLibraryLoader.cacheRoot()) + } finally { + NativeLibraryLoader.resetCacheDirForTesting() + dir.toFile().deleteRecursively() + } + } + + @Test + fun `an unusable configured directory falls back to the next candidate`() { + val readOnly = Files.createTempDirectory("nucleus-ro") + val fallback = Files.createTempDirectory("nucleus-fallback") + try { + readOnly.toFile().setWritable(false) + NativeLibraryLoader.resetCacheDirForTesting() + NativeLibraryLoader.cacheDirectory = fallback + System.setProperty(NativeLibraryLoader.CACHE_DIR_PROPERTY, readOnly.resolve("sub").toString()) + + // The property naming an unwritable directory must not discard the + // directory the application set itself (the 3-level chain of #303). + assertEquals(fallback, NativeLibraryLoader.cacheRoot()) + } finally { + System.clearProperty(NativeLibraryLoader.CACHE_DIR_PROPERTY) + NativeLibraryLoader.resetCacheDirForTesting() + readOnly.toFile().setWritable(true) + readOnly.toFile().deleteRecursively() + fallback.toFile().deleteRecursively() + } + } + + @Test + fun `cacheRoot falls back to the platform default when nothing is configured`() { + try { + NativeLibraryLoader.resetCacheDirForTesting() + assertEquals(NativeLibraryLoader.defaultCacheDir(), NativeLibraryLoader.cacheRoot()) + } finally { + NativeLibraryLoader.resetCacheDirForTesting() + } + } } diff --git a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NucleusUiThreadTest.kt b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NucleusUiThreadTest.kt new file mode 100644 index 000000000..e1b08bea9 --- /dev/null +++ b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/NucleusUiThreadTest.kt @@ -0,0 +1,84 @@ +package dev.nucleusframework.core.runtime + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.awt.EventQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference + +class NucleusUiThreadTest { + @After + fun tearDown() { + NucleusUiThread.setExecutor(null) + } + + @Test + fun `posts through the registered executor`() { + val executed = mutableListOf() + NucleusUiThread.setExecutor(Executor { it.run() }) + + assertTrue(NucleusUiThread.isRegistered) + NucleusUiThread.post { executed += "first" } + NucleusUiThread.post { executed += "second" } + + assertEquals(listOf("first", "second"), executed) + } + + @Test + fun `runs the block on the executor thread, never inline`() { + val executor = Executors.newSingleThreadExecutor { r -> Thread(r, "ui-thread-under-test") } + try { + NucleusUiThread.setExecutor(executor) + val latch = CountDownLatch(1) + val ranOn = AtomicReference() + + NucleusUiThread.post { + ranOn.set(Thread.currentThread().name) + latch.countDown() + } + + assertTrue(latch.await(5, TimeUnit.SECONDS)) + assertEquals("ui-thread-under-test", ranOn.get()) + assertNotEquals(Thread.currentThread().name, ranOn.get()) + } finally { + executor.shutdownNow() + } + } + + @Test + fun `unregistering restores the awt fallback`() { + NucleusUiThread.setExecutor(Executor { it.run() }) + NucleusUiThread.setExecutor(null) + + assertFalse(NucleusUiThread.isRegistered) + } + + @Test + fun `without an executor the block reaches the awt event dispatch thread`() { + // The pre-Tao behaviour every call site had: a host that never goes + // through nucleusApplication keeps getting its callbacks on the EDT. + val latch = CountDownLatch(2) + val postedOn = AtomicReference(null) + val swungOn = AtomicReference(null) + + NucleusUiThread.post { + postedOn.set(Thread.currentThread()) + latch.countDown() + } + EventQueue.invokeLater { + swungOn.set(Thread.currentThread()) + latch.countDown() + } + + assertTrue("the AWT EDT did not run the blocks", latch.await(10, TimeUnit.SECONDS)) + assertFalse("the test itself must not run on the EDT", EventQueue.isDispatchThread()) + assertEquals(swungOn.get(), postedOn.get()) + } +} diff --git a/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/UpdateHandoffTest.kt b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/UpdateHandoffTest.kt new file mode 100644 index 000000000..9fc4db760 --- /dev/null +++ b/core-runtime/src/test/kotlin/dev/nucleusframework/core/runtime/UpdateHandoffTest.kt @@ -0,0 +1,97 @@ +package dev.nucleusframework.core.runtime + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class UpdateHandoffTest { + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `versioned layout is recognized from java home and launcher`() { + val root = tmp.newFolder("App") + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "versions/1.2.0/runtime").path, + launcherPath = File(root, "App.exe").path, + isWindows = true, + ) + + assertNotNull(install) + assertEquals(root.absoluteFile, install!!.root) + assertEquals("1.2.0", install.versionDir.name) + assertEquals(File(root, "versions").absoluteFile, install.versionsDir) + } + + @Test + fun `flat jpackage layout is not versioned`() { + val root = tmp.newFolder("App") + + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "runtime").path, + launcherPath = File(root, "App.exe").path, + isWindows = true, + ) + + assertNull(install) + } + + @Test + fun `launcher outside the install root is not versioned`() { + val root = tmp.newFolder("App") + + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "versions/1.2.0/runtime").path, + launcherPath = File(tmp.root, "elsewhere/App.exe").path, + isWindows = true, + ) + + assertNull(install) + } + + @Test + fun `versioned layout is Windows only`() { + val root = tmp.newFolder("App") + + val install = + UpdateHandoff.detectVersionedInstall( + javaHome = File(root, "versions/1.2.0/runtime").path, + launcherPath = File(root, "App.exe").path, + isWindows = false, + ) + + assertNull(install) + } + + @Test + fun `cleanup deletes retired versions and launchers but keeps the running one`() { + val root = tmp.newFolder("App") + val current = File(root, "versions/1.2.0").apply { File(this, "runtime").mkdirs() } + val retiredVersion = File(root, "versions/1.1.0").apply { File(this, "app").mkdirs() } + File(retiredVersion, "app/lib.jar").writeText("jar") + val trash = File(root, "versions/.trash-1.0.0-42").apply { mkdirs() } + val launcher = File(root, "App.exe").apply { writeText("new") } + // jpackage ships its launcher read-only; the retired copy keeps the attribute. + val retiredLauncher = File(root, "App.exe.123.nucleus-old").apply { writeText("old") } + retiredLauncher.setWritable(false) + val install = VersionedInstall(root, current, launcher) + + UpdateHandoff.cleanupRetiredVersions(install) + + assertTrue(current.isDirectory) + assertTrue(launcher.isFile) + assertFalse(retiredVersion.exists()) + assertFalse(trash.exists()) + assertFalse(retiredLauncher.exists()) + assertEquals(listOf("1.2.0"), File(root, "versions").list()!!.toList()) + } +} diff --git a/darkmode-detector/src/main/native/linux/nucleus_linux_theme.c b/darkmode-detector/src/main/native/linux/nucleus_linux_theme.c index 7c89cba0b..2cc7aa449 100644 --- a/darkmode-detector/src/main/native/linux/nucleus_linux_theme.c +++ b/darkmode-detector/src/main/native/linux/nucleus_linux_theme.c @@ -11,6 +11,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -155,9 +156,7 @@ static void notify_java(jboolean isDark) { } } - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); diff --git a/darkmode-detector/src/main/native/macos/NucleusDarkModeBridge.m b/darkmode-detector/src/main/native/macos/NucleusDarkModeBridge.m index 6b3918ac9..88f620160 100644 --- a/darkmode-detector/src/main/native/macos/NucleusDarkModeBridge.m +++ b/darkmode-detector/src/main/native/macos/NucleusDarkModeBridge.m @@ -1,5 +1,6 @@ #import #include +#include "../../../../../native-common/nucleus_jni.h" // Cached JavaVM pointer, set in JNI_OnLoad static JavaVM *g_jvm = NULL; @@ -67,9 +68,7 @@ JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) { } } - if ((*cbEnv)->ExceptionCheck(cbEnv)) { - (*cbEnv)->ExceptionClear(cbEnv); - } + nucleus_jni_clear_exception(cbEnv); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); diff --git a/darkmode-detector/src/main/native/windows/nucleus_windows_theme.c b/darkmode-detector/src/main/native/windows/nucleus_windows_theme.c index d748fd771..762078a3a 100644 --- a/darkmode-detector/src/main/native/windows/nucleus_windows_theme.c +++ b/darkmode-detector/src/main/native/windows/nucleus_windows_theme.c @@ -13,6 +13,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) { @@ -93,9 +94,7 @@ static void notify_java(jboolean isDark) { } } - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); diff --git a/decorated-window-awt/api/decorated-window-awt.api b/decorated-window-awt/api/decorated-window-awt.api deleted file mode 100644 index 4f95e8457..000000000 --- a/decorated-window-awt/api/decorated-window-awt.api +++ /dev/null @@ -1,66 +0,0 @@ -public abstract interface class dev/nucleusframework/window/AwtDecoratedDialogScope : androidx/compose/ui/window/DialogWindowScope, dev/nucleusframework/window/DecoratedDialogScope { - public abstract fun getWindow ()Landroidx/compose/ui/awt/ComposeDialog; - public synthetic fun getWindow ()Ljava/awt/Window; -} - -public abstract interface class dev/nucleusframework/window/AwtDecoratedWindowScope : androidx/compose/ui/window/FrameWindowScope, dev/nucleusframework/window/DecoratedWindowScope { - public abstract fun getWindow ()Landroidx/compose/ui/awt/ComposeWindow; - public synthetic fun getWindow ()Ljava/awt/Window; -} - -public final class dev/nucleusframework/window/AwtDecoratedWindowScopeKt { - public static final fun DecoratedWindowBody (Landroidx/compose/ui/window/FrameWindowScope;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V - public static final fun of (Ldev/nucleusframework/window/DecoratedWindowState$Companion;Landroidx/compose/ui/awt/ComposeWindow;)J -} - -public final class dev/nucleusframework/window/AwtTitleBarKt { - public static final fun TitleBarImpl-zkWFBl8 (Ldev/nucleusframework/window/AwtDecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Landroidx/compose/ui/unit/LayoutDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V - public static final fun windowDragHandler (Landroidx/compose/ui/Modifier;Ljava/awt/Window;)Landroidx/compose/ui/Modifier; -} - -public final class dev/nucleusframework/window/ComposableSingletons$AwtTitleBarKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$AwtTitleBarKt; - public fun ()V - public final fun getLambda$-465839594$Nucleus_decorated_window_awt ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBarImplKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBarImplKt; - public fun ()V - public final fun getLambda$822135388$Nucleus_decorated_window_awt ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/DecoratedDialogCoreKt { - public static final fun DecoratedDialogBody (Landroidx/compose/ui/window/DialogWindowScope;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;I)V - public static final fun of (Ldev/nucleusframework/window/DecoratedDialogState$Companion;Landroidx/compose/ui/awt/ComposeDialog;)J -} - -public final class dev/nucleusframework/window/DecoratedDialogMeasurePolicy : androidx/compose/ui/layout/MeasurePolicy { - public static final field $stable I - public static final field INSTANCE Ldev/nucleusframework/window/DecoratedDialogMeasurePolicy; - public fun maxIntrinsicHeight (Landroidx/compose/ui/layout/IntrinsicMeasureScope;Ljava/util/List;I)I - public fun maxIntrinsicWidth (Landroidx/compose/ui/layout/IntrinsicMeasureScope;Ljava/util/List;I)I - public fun measure-3p2s80s (Landroidx/compose/ui/layout/MeasureScope;Ljava/util/List;J)Landroidx/compose/ui/layout/MeasureResult; - public fun minIntrinsicHeight (Landroidx/compose/ui/layout/IntrinsicMeasureScope;Ljava/util/List;I)I - public fun minIntrinsicWidth (Landroidx/compose/ui/layout/IntrinsicMeasureScope;Ljava/util/List;I)I -} - -public final class dev/nucleusframework/window/DialogTitleBarImplKt { - public static final fun DialogTitleBarImpl-zkWFBl8 (Ldev/nucleusframework/window/AwtDecoratedDialogScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Landroidx/compose/ui/unit/LayoutDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V -} - -public final class dev/nucleusframework/window/WindowControlAreaKt { - public static final fun DialogCloseButton-oY_1kOw (Ldev/nucleusframework/window/TitleBarScope;Ljava/awt/Window;JLdev/nucleusframework/window/styling/TitleBarStyle;Landroidx/compose/runtime/Composer;I)V - public static final fun WindowControlArea-BihTXD0 (Ldev/nucleusframework/window/TitleBarScope;Ljava/awt/Window;JLdev/nucleusframework/window/styling/TitleBarStyle;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V -} - -public final class dev/nucleusframework/window/WindowsWindowControlAreaKt { - public static final fun WindowsDialogCloseButton-oY_1kOw (Ldev/nucleusframework/window/TitleBarScope;Ljava/awt/Window;JLdev/nucleusframework/window/styling/TitleBarStyle;Landroidx/compose/runtime/Composer;I)V - public static final fun WindowsWindowControlArea-BihTXD0 (Ldev/nucleusframework/window/TitleBarScope;Ljava/awt/Window;JLdev/nucleusframework/window/styling/TitleBarStyle;ZLkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V -} - -public final class dev/nucleusframework/window/internal/MinimumSizeSupportKt { - public static final fun InstallMinimumSizeAfterCentering-jskYuWU (Landroidx/compose/ui/window/FrameWindowScope;Landroidx/compose/ui/unit/DpSize;Landroidx/compose/runtime/Composer;I)V - public static final fun inflateToMinimumSize-jskYuWU (Landroidx/compose/ui/window/WindowState;Landroidx/compose/ui/unit/DpSize;Landroidx/compose/runtime/Composer;I)V -} - diff --git a/decorated-window-awt/detekt-baseline.xml b/decorated-window-awt/detekt-baseline.xml deleted file mode 100644 index f781a97d9..000000000 --- a/decorated-window-awt/detekt-baseline.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - UndocumentedPublicClass:DecoratedDialogCore.kt:AwtDecoratedDialogScope : DecoratedDialogScopeDialogWindowScope - UndocumentedPublicClass:DecoratedDialogCore.kt:DecoratedDialogMeasurePolicy : MeasurePolicy - UndocumentedPublicFunction:AwtTitleBar.kt:@Suppress("FunctionNaming", "LongParameterList") @Composable public fun AwtDecoratedWindowScope.TitleBarImpl - UndocumentedPublicFunction:AwtTitleBar.kt:public fun Modifier.windowDragHandler: Modifier - UndocumentedPublicFunction:DialogTitleBarImpl.kt:@Suppress("FunctionNaming", "LongParameterList") @Composable public fun AwtDecoratedDialogScope.DialogTitleBarImpl - UndocumentedPublicFunction:WindowControlArea.kt:@Suppress("FunctionNaming") @Composable public fun TitleBarScope.WindowControlArea - UndocumentedPublicFunction:WindowsWindowControlArea.kt:@Suppress("FunctionNaming") @Composable public fun TitleBarScope.WindowsWindowControlArea - - diff --git a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/AwtDecoratedWindowScope.kt b/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/AwtDecoratedWindowScope.kt deleted file mode 100644 index 1e77777c7..000000000 --- a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/AwtDecoratedWindowScope.kt +++ /dev/null @@ -1,312 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.background -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposeWindow -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.FrameWindowScope -import androidx.compose.ui.window.WindowPlacement -import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment -import dev.nucleusframework.window.internal.insideBorder -import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import java.awt.ComponentOrientation -import java.awt.Desktop -import java.awt.Frame -import java.awt.event.ComponentEvent -import java.awt.event.ComponentListener -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent -import java.awt.geom.Area -import java.awt.geom.Rectangle2D -import java.awt.geom.RoundRectangle2D - -/** - * AWT/Compose Desktop sub-interface of [DecoratedWindowScope] adding access - * to the backing [ComposeWindow]. Returned to consumers of the JBR/JNI backends. - */ -@Stable -public interface AwtDecoratedWindowScope : - DecoratedWindowScope, - FrameWindowScope { - override val window: ComposeWindow -} - -/** - * Builds a [DecoratedWindowState] from a Compose Desktop [ComposeWindow]. - */ -public fun DecoratedWindowState.Companion.of(window: ComposeWindow): DecoratedWindowState = - of( - fullscreen = window.placement == WindowPlacement.Fullscreen, - minimized = window.isMinimized, - maximized = window.placement == WindowPlacement.Maximized, - active = window.isActive, - resizable = window.isResizable, - ) - -/** - * Shared body for DecoratedWindow, used by both JBR and JNI variants. - * Each variant calls this from within a [Window] composable, passing the appropriate [undecorated] flag. - */ -@Suppress("FunctionNaming", "MagicNumber", "CyclomaticComplexMethod") -@Composable -public fun FrameWindowScope.DecoratedWindowBody( - title: String, - icon: Painter?, - undecorated: Boolean, - onCloseRequest: () -> Unit = {}, - content: @Composable AwtDecoratedWindowScope.() -> Unit, -) { - var decoratedWindowState by remember { mutableStateOf(DecoratedWindowState.of(window)) } - var isMaximizedInAnyDirection by remember { mutableStateOf(false) } - - val linuxDe = remember { LinuxDesktopEnvironment.Current } - val gnomeCornerArc = 24f - val kdeCornerArc = 10f - - DisposableEffect(window) { - var trackedExtendedState = window.extendedState - - fun updateWindowShape() { - decoratedWindowState = DecoratedWindowState.of(window) - val ws = decoratedWindowState - val hasAnyMaxBit = - (trackedExtendedState and (Frame.MAXIMIZED_VERT or Frame.MAXIMIZED_HORIZ)) != 0 - val gc = window.graphicsConfiguration - val fillsScreen = - gc != null && - ( - window.height >= gc.bounds.height * 0.9 || - window.width >= gc.bounds.width * 0.9 - ) - isMaximizedInAnyDirection = ws.isMaximized || hasAnyMaxBit || fillsScreen - val isMaxOrFull = ws.isFullscreen || isMaximizedInAnyDirection - when (linuxDe) { - LinuxDesktopEnvironment.Gnome -> { - window.shape = - if (isMaxOrFull) { - null - } else { - val w = window.width.toFloat() - val h = window.height.toFloat() - RoundRectangle2D.Float(0f, 0f, w, h, gnomeCornerArc, gnomeCornerArc) - } - } - LinuxDesktopEnvironment.KDE -> { - window.shape = - if (isMaxOrFull) { - null - } else { - val w = window.width.toFloat() - val h = window.height.toFloat() - Area(RoundRectangle2D.Float(0f, 0f, w, h, kdeCornerArc, kdeCornerArc)).apply { - add(Area(Rectangle2D.Float(0f, h - kdeCornerArc, w, kdeCornerArc))) - } - } - } - else -> {} - } - } - - updateWindowShape() - - val adapter = - object : WindowAdapter(), ComponentListener { - override fun windowActivated(e: WindowEvent?) { - updateWindowShape() - } - - override fun windowDeactivated(e: WindowEvent?) { - updateWindowShape() - } - - override fun windowIconified(e: WindowEvent?) { - updateWindowShape() - } - - override fun windowDeiconified(e: WindowEvent?) { - updateWindowShape() - } - - override fun windowStateChanged(e: WindowEvent) { - trackedExtendedState = e.newState - updateWindowShape() - } - - override fun componentResized(e: ComponentEvent?) { - updateWindowShape() - } - - override fun componentMoved(e: ComponentEvent?) { - // No-op: window position changes don't affect decorated state - } - - override fun componentShown(e: ComponentEvent?) { - // No-op: visibility handled elsewhere - } - - override fun componentHidden(e: ComponentEvent?) { - // No-op: visibility handled elsewhere - } - } - - window.addWindowListener(adapter) - window.addWindowStateListener(adapter) - window.addComponentListener(adapter) - - // Frame.setResizable fires a bound property change — without this, - // runtime resizability changes don't recompose the title bar and the - // maximize button stays out of sync until the next window event (#260). - val resizableListener = - java.beans.PropertyChangeListener { updateWindowShape() } - window.addPropertyChangeListener("resizable", resizableListener) - - val quitHandlerInstalled = installSystemQuitHandler(onCloseRequest) - - onDispose { - window.removeWindowListener(adapter) - window.removeWindowStateListener(adapter) - window.removeComponentListener(adapter) - window.removePropertyChangeListener("resizable", resizableListener) - if (quitHandlerInstalled) { - Desktop.getDesktop().setQuitHandler(null) - } - } - } - - val style = LocalDecoratedWindowStyle.current - val borderShape = - when (linuxDe) { - LinuxDesktopEnvironment.Gnome -> - RoundedCornerShape((gnomeCornerArc / 2).dp) - LinuxDesktopEnvironment.KDE -> - RoundedCornerShape( - topStart = (kdeCornerArc / 2).dp, - topEnd = (kdeCornerArc / 2).dp, - bottomStart = 0.dp, - bottomEnd = 0.dp, - ) - else -> RoundedCornerShape(0.dp) - } - val undecoratedWindowBorder = - if (undecorated && !decoratedWindowState.isMaximized && !isMaximizedInAnyDirection) { - Modifier.insideBorder( - width = style.metrics.borderWidth, - color = style.colors.borderFor(decoratedWindowState).value, - shape = borderShape, - ) - } else { - Modifier - } - - // Detect platform layout direction from JVM locale so that RTL locales - // (Hebrew, Arabic, …) automatically mirror the title bar and content. - // Compose Desktop does not propagate java.util.Locale into LocalLayoutDirection. - val platformLayoutDirection = - remember { - if (ComponentOrientation.getOrientation(java.util.Locale.getDefault()).isLeftToRight) { - LayoutDirection.Ltr - } else { - LayoutDirection.Rtl - } - } - - // Sync the AWT window background with the title bar color so that the - // native window surface matches during resize (avoids white flash). - val isWindows = remember { System.getProperty("os.name").startsWith("Windows", ignoreCase = true) } - val titleBarBackground = LocalTitleBarStyle.current.colors.background - LaunchedEffect(window, titleBarBackground) { - val awtColor = java.awt.Color(titleBarBackground.toArgb(), true) - val isDark = - titleBarBackground.red * 0.299f + - titleBarBackground.green * 0.587f + - titleBarBackground.blue * 0.114f < 0.5f - - fun applyRecursive(c: java.awt.Component) { - c.background = awtColor - // [Skiko #1141] Remove this once stable Compose uses Skiko with - // https://github.com/JetBrains/skiko/pull/1141 — - // ContextHandler.draw() always clears to TRANSPARENT now and - // SkiaLayer.update() fills with the AWT background color instead. - if (isWindows) { - try { - c.javaClass - .getMethod("setTransparency", Boolean::class.javaPrimitiveType) - .invoke(c, isDark) - } catch (_: NoSuchMethodException) { - // Not SkiaLayer - } catch (_: Exception) { - // Ignore other reflection errors - } - } - if (c is java.awt.Container) { - c.components.forEach { applyRecursive(it) } - } - } - applyRecursive(window) - javax.swing.SwingUtilities.invokeLater { applyRecursive(window) } - } - - val titleBarInfo = remember { TitleBarInfo(title, icon) } - LaunchedEffect(title) { titleBarInfo.title = title } - LaunchedEffect(icon) { titleBarInfo.icon = icon } - - CompositionLocalProvider( - LocalTitleBarInfo provides titleBarInfo, - LocalLayoutDirection provides platformLayoutDirection, - ) { - Layout( - content = { - val scope = - object : AwtDecoratedWindowScope { - override val state: DecoratedWindowState - get() = decoratedWindowState - - override val window: ComposeWindow - get() = this@DecoratedWindowBody.window - } - scope.content() - }, - modifier = Modifier.background(titleBarBackground).then(undecoratedWindowBorder), - measurePolicy = DecoratedWindowMeasurePolicy, - ) - } -} - -/** - * Installs a system-level quit handler that delegates to [onCloseRequest]. - * On macOS this intercepts Cmd+Q, Dock → Quit, and App Menu → Quit. - * The system quit is always cancelled — [onCloseRequest] decides whether - * to call exitApplication() or show a confirmation dialog. - * - * @return true if the handler was installed successfully. - */ -private fun installSystemQuitHandler(onCloseRequest: () -> Unit): Boolean = - try { - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.APP_QUIT_HANDLER)) { - Desktop.getDesktop().setQuitHandler { _, response -> - onCloseRequest() - response.cancelQuit() - } - true - } else { - false - } - } catch (_: UnsupportedOperationException) { - false - } diff --git a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/AwtTitleBar.kt b/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/AwtTitleBar.kt deleted file mode 100644 index 5874ed45c..000000000 --- a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/AwtTitleBar.kt +++ /dev/null @@ -1,97 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.isActive -import java.awt.Window - -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun AwtDecoratedWindowScope.TitleBarImpl( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, - onPlace: (() -> Unit)? = null, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, -) { - GenericTitleBarImpl( - state = state, - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = applyTitleBar, - onPlace = onPlace, - backgroundContent = backgroundContent, - content = content, - ) -} - -// Handles window dragging via Compose pointer events. -// Drag starts only when the press is not consumed by a child composable (e.g. a button), -// so interactive elements in the title bar keep working correctly. -public fun Modifier.windowDragHandler(window: Window): Modifier = - pointerInput(window) { - val ctx = currentCoroutineContext() - awaitPointerEventScope { - var dragging = false - var startScreenX = 0 - var startScreenY = 0 - var startWindowX = 0 - var startWindowY = 0 - - @Suppress("LoopWithTooManyJumpStatements") - while (ctx.isActive) { - val event = awaitPointerEvent(PointerEventPass.Main) - val change = event.changes.firstOrNull() ?: continue - - when (event.type) { - PointerEventType.Press -> { - if (!change.isConsumed) { - val loc = - java.awt.MouseInfo - .getPointerInfo() - ?.location - startScreenX = loc?.x ?: 0 - startScreenY = loc?.y ?: 0 - startWindowX = window.x - startWindowY = window.y - dragging = true - } - } - PointerEventType.Move -> { - if (dragging) { - val loc = - java.awt.MouseInfo - .getPointerInfo() - ?.location ?: continue - window.setLocation( - startWindowX + (loc.x - startScreenX), - startWindowY + (loc.y - startScreenY), - ) - } - } - PointerEventType.Release -> { - dragging = false - } - else -> Unit - } - } - } - } diff --git a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/DecoratedDialogCore.kt b/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/DecoratedDialogCore.kt deleted file mode 100644 index ec4b589be..000000000 --- a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/DecoratedDialogCore.kt +++ /dev/null @@ -1,256 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.background -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.awt.ComposeDialog -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.layout.Layout -import androidx.compose.ui.layout.Measurable -import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.layout.MeasureScope -import androidx.compose.ui.layout.Placeable -import androidx.compose.ui.layout.layoutId -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.offset -import androidx.compose.ui.window.DialogWindowScope -import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment -import dev.nucleusframework.window.internal.insideBorder -import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import java.awt.event.ComponentEvent -import java.awt.event.ComponentListener -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent -import java.awt.geom.Area -import java.awt.geom.Rectangle2D -import java.awt.geom.RoundRectangle2D - -@Stable -public interface AwtDecoratedDialogScope : - DecoratedDialogScope, - DialogWindowScope { - override val window: ComposeDialog -} - -public object DecoratedDialogMeasurePolicy : MeasurePolicy { - override fun MeasureScope.measure( - measurables: List, - constraints: Constraints, - ): MeasureResult { - if (measurables.isEmpty()) { - return layout(width = constraints.minWidth, height = constraints.minHeight) {} - } - - val titleBars = measurables.filter { it.layoutId == TITLE_BAR_LAYOUT_ID } - if (titleBars.size > 1) { - error("Dialog can have only one title bar") - } - val titleBar = titleBars.firstOrNull() - val titleBarBorder = measurables.firstOrNull { it.layoutId == TITLE_BAR_BORDER_LAYOUT_ID } - - val contentConstraints = constraints.copy(minWidth = 0, minHeight = 0) - - val titleBarPlaceable = titleBar?.measure(contentConstraints) - val titleBarHeight = titleBarPlaceable?.height ?: 0 - - val titleBarBorderPlaceable = titleBarBorder?.measure(contentConstraints) - val titleBarBorderHeight = titleBarBorderPlaceable?.height ?: 0 - - val measuredPlaceable = mutableListOf() - - for (it in measurables) { - if (it.layoutId.toString().startsWith(TITLE_BAR_COMPONENT_LAYOUT_ID_PREFIX)) continue - val offsetConstraints = contentConstraints.offset(vertical = -titleBarHeight - titleBarBorderHeight) - val placeable = it.measure(offsetConstraints) - measuredPlaceable += placeable - } - - return layout(constraints.maxWidth, constraints.maxHeight) { - titleBarPlaceable?.placeRelative(0, 0) - titleBarBorderPlaceable?.placeRelative(0, titleBarHeight) - - measuredPlaceable.forEach { it.placeRelative(0, titleBarHeight + titleBarBorderHeight) } - } - } -} - -/** AWT-bound factory for [DecoratedDialogState]. Defined as an extension so - * the value class itself can stay in `decorated-window-core` (no AWT). */ -public fun DecoratedDialogState.Companion.of(window: ComposeDialog): DecoratedDialogState = of(active = window.isActive) - -/** - * Shared body for DecoratedDialog, used by both JBR and JNI variants. - * Each variant calls this from within a [DialogWindow] composable, passing the appropriate [undecorated] flag. - */ -@Suppress("FunctionNaming", "MagicNumber") -@Composable -public fun DialogWindowScope.DecoratedDialogBody( - title: String, - icon: Painter?, - undecorated: Boolean, - content: @Composable AwtDecoratedDialogScope.() -> Unit, -) { - var decoratedDialogState by remember { mutableStateOf(DecoratedDialogState.of(window)) } - - val linuxDe = remember { LinuxDesktopEnvironment.Current } - val gnomeCornerArc = 24f - val kdeCornerArc = 10f - - DisposableEffect(window) { - fun updateDialogShape() { - decoratedDialogState = DecoratedDialogState.of(window) - when (linuxDe) { - LinuxDesktopEnvironment.Gnome -> { - val w = window.width.toFloat() - val h = window.height.toFloat() - window.shape = RoundRectangle2D.Float(0f, 0f, w, h, gnomeCornerArc, gnomeCornerArc) - } - LinuxDesktopEnvironment.KDE -> { - val w = window.width.toFloat() - val h = window.height.toFloat() - window.shape = - Area(RoundRectangle2D.Float(0f, 0f, w, h, kdeCornerArc, kdeCornerArc)).apply { - add(Area(Rectangle2D.Float(0f, h - kdeCornerArc, w, kdeCornerArc))) - } - } - else -> {} - } - } - - updateDialogShape() - - val adapter = - object : WindowAdapter(), ComponentListener { - override fun windowActivated(e: WindowEvent?) { - updateDialogShape() - } - - override fun windowDeactivated(e: WindowEvent?) { - updateDialogShape() - } - - override fun componentResized(e: ComponentEvent?) { - updateDialogShape() - } - - override fun componentMoved(e: ComponentEvent?) { - // No-op: dialog position changes don't affect decorated state - } - - override fun componentShown(e: ComponentEvent?) { - // No-op: visibility handled elsewhere - } - - override fun componentHidden(e: ComponentEvent?) { - // No-op: visibility handled elsewhere - } - } - - window.addWindowListener(adapter) - window.addComponentListener(adapter) - - onDispose { - window.removeWindowListener(adapter) - window.removeComponentListener(adapter) - } - } - - val style = LocalDecoratedWindowStyle.current - val borderShape = - when (linuxDe) { - LinuxDesktopEnvironment.Gnome -> - RoundedCornerShape((gnomeCornerArc / 2).dp) - LinuxDesktopEnvironment.KDE -> - RoundedCornerShape( - topStart = (kdeCornerArc / 2).dp, - topEnd = (kdeCornerArc / 2).dp, - bottomStart = 0.dp, - bottomEnd = 0.dp, - ) - else -> RoundedCornerShape(0.dp) - } - val undecoratedWindowBorder = - if (undecorated) { - Modifier.insideBorder( - width = style.metrics.borderWidth, - color = style.colors.borderFor(decoratedDialogState.toDecoratedWindowState()).value, - shape = borderShape, - ) - } else { - Modifier - } - - // Sync the AWT window background with the title bar color so that the - // native window surface matches during resize (avoids white flash). - // On Windows, Skiko's ContextHandler.draw() clears to Color.WHITE when - // SkiaLayer.transparency == false (the default). For dark themes we call - // setTransparency(true) so it clears to TRANSPARENT instead, which renders - // as opaque black on the DirectX surface (DXGI_ALPHA_MODE_IGNORE). - val isWindows = remember { System.getProperty("os.name").startsWith("Windows", ignoreCase = true) } - val titleBarBackground = LocalTitleBarStyle.current.colors.background - LaunchedEffect(window, titleBarBackground) { - val awtColor = java.awt.Color(titleBarBackground.toArgb(), true) - val isDark = - titleBarBackground.red * 0.299f + - titleBarBackground.green * 0.587f + - titleBarBackground.blue * 0.114f < 0.5f - - fun applyRecursive(c: java.awt.Component) { - c.background = awtColor - // [Skiko #1141] Remove this once stable Compose uses Skiko with - // https://github.com/JetBrains/skiko/pull/1141 — - // ContextHandler.draw() always clears to TRANSPARENT now and - // SkiaLayer.update() fills with the AWT background color instead. - // Windows only: set SkiaLayer transparency to match the theme so - // Skiko clears to TRANSPARENT (opaque black) instead of WHITE. - // NoSuchMethodException just means this component is not SkiaLayer. - if (isWindows) { - try { - c.javaClass - .getMethod("setTransparency", Boolean::class.javaPrimitiveType) - .invoke(c, isDark) - } catch (_: NoSuchMethodException) { - // Not SkiaLayer - } catch (_: Exception) { - // Ignore other reflection errors - } - } - if (c is java.awt.Container) { - c.components.forEach { applyRecursive(it) } - } - } - applyRecursive(window) - javax.swing.SwingUtilities.invokeLater { applyRecursive(window) } - } - - CompositionLocalProvider(LocalDialogTitleBarInfo provides DialogTitleBarInfo(title, icon)) { - Layout( - content = { - val scope = - object : AwtDecoratedDialogScope { - override val state: DecoratedDialogState - get() = decoratedDialogState - - override val window: ComposeDialog - get() = this@DecoratedDialogBody.window - } - scope.content() - }, - modifier = Modifier.background(titleBarBackground).then(undecoratedWindowBorder), - measurePolicy = DecoratedDialogMeasurePolicy, - ) - } -} diff --git a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/DialogTitleBarImpl.kt b/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/DialogTitleBarImpl.kt deleted file mode 100644 index f3c37e510..000000000 --- a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/DialogTitleBarImpl.kt +++ /dev/null @@ -1,40 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.LayoutDirection -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle - -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun AwtDecoratedDialogScope.DialogTitleBarImpl( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: LayoutDirection = LocalLayoutDirection.current, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - applyTitleBar: (Dp, DecoratedWindowState) -> PaddingValues, - onPlace: (() -> Unit)? = null, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit, -) { - val dialogState = state - GenericTitleBarImpl( - state = dialogState.toDecoratedWindowState(), - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = applyTitleBar, - onPlace = onPlace, - backgroundContent = backgroundContent, - ) { _ -> - content(dialogState) - } -} diff --git a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/WindowControlArea.kt b/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/WindowControlArea.kt deleted file mode 100644 index b4a088f3a..000000000 --- a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/WindowControlArea.kt +++ /dev/null @@ -1,233 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.Image -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.size -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.dp -import dev.nucleusframework.core.runtime.LinuxDesktopEnvironment -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.linux.LinuxTitleBarButton -import dev.nucleusframework.window.utils.linux.linuxTitleBarIcons -import dev.nucleusframework.window.utils.linux.rememberLinuxButtonLayout -import java.awt.Frame -import java.awt.event.WindowEvent - -private val isKde = LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE - -@Suppress("FunctionNaming") -@Composable -public fun TitleBarScope.WindowControlArea( - window: java.awt.Window, - state: DecoratedWindowState, - style: TitleBarStyle, - isFullscreen: Boolean = false, - onExitFullscreen: (() -> Unit)? = null, -) { - CompositionLocalProvider(LocalLayoutDirection provides LocalControlButtonsDirection.current) { - val icons = linuxTitleBarIcons() - val layout = rememberLinuxButtonLayout() - val buttonAlignment = if (layout.controlsOnRight) Alignment.End else Alignment.Start - - for (button in layout.buttons) { - when (button) { - LinuxTitleBarButton.CLOSE -> { - val closeHover = if (state.isActive) icons.closeHoverFocused else icons.closeHover - val closePressed = if (state.isActive) icons.closePressedFocused else icons.closePressed - ControlButton( - onClick = { window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) }, - state = state, - icon = icons.close, - iconHover = closeHover, - iconPressed = closePressed, - contentDescription = "Close", - style = style, - alignment = buttonAlignment, - isCloseButton = true, - ) - } - - LinuxTitleBarButton.MAXIMIZE -> { - if (isFullscreen && onExitFullscreen != null) { - ControlButton( - onClick = onExitFullscreen, - state = state, - icon = icons.maximize, - iconHover = icons.maximizeHover, - iconPressed = icons.maximizePressed, - contentDescription = "Exit fullscreen", - style = style, - alignment = buttonAlignment, - ) - } else { - // Gate on the snapshot-backed state so runtime - // setResizable() recomposes the button (#260). - val frame = window as? Frame - if (frame != null && state.isResizable) { - if (state.isMaximized) { - ControlButton( - onClick = { frame.extendedState = Frame.NORMAL }, - state = state, - icon = icons.restore, - iconHover = icons.restoreHover, - iconPressed = icons.restorePressed, - contentDescription = "Restore", - style = style, - alignment = buttonAlignment, - ) - } else { - ControlButton( - onClick = { frame.extendedState = Frame.MAXIMIZED_BOTH }, - state = state, - icon = icons.maximize, - iconHover = icons.maximizeHover, - iconPressed = icons.maximizePressed, - contentDescription = "Maximize", - style = style, - alignment = buttonAlignment, - ) - } - } - } - } - - LinuxTitleBarButton.MINIMIZE -> { - ControlButton( - onClick = { - (window as? Frame)?.let { - it.extendedState = it.extendedState or Frame.ICONIFIED - } - }, - state = state, - icon = icons.minimize, - iconHover = icons.minimizeHover, - iconPressed = icons.minimizePressed, - contentDescription = "Minimize", - style = style, - alignment = buttonAlignment, - ) - } - } - } - } -} - -/** - * Close button for dialog title bars. - * Unlike [WindowControlArea], this only shows the close button (no minimize/maximize). - */ -@Suppress("FunctionNaming") -@Composable -public fun TitleBarScope.DialogCloseButton( - window: java.awt.Window, - state: DecoratedDialogState, - style: TitleBarStyle, -) { - CompositionLocalProvider(LocalLayoutDirection provides LocalControlButtonsDirection.current) { - val icons = linuxTitleBarIcons() - val layout = rememberLinuxButtonLayout() - val buttonAlignment = if (layout.controlsOnRight) Alignment.End else Alignment.Start - val windowState = state.toDecoratedWindowState() - val closeHover = if (windowState.isActive) icons.closeHoverFocused else icons.closeHover - val closePressed = if (windowState.isActive) icons.closePressedFocused else icons.closePressed - - ControlButton( - onClick = { window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) }, - state = windowState, - icon = icons.close, - iconHover = closeHover, - iconPressed = closePressed, - contentDescription = "Close", - style = style, - alignment = buttonAlignment, - isCloseButton = true, - ) - } -} - -@Suppress("FunctionNaming", "LongParameterList") -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun TitleBarScope.ControlButton( - onClick: () -> Unit, - state: DecoratedWindowState, - icon: Painter, - iconHover: Painter, - iconPressed: Painter, - contentDescription: String, - style: TitleBarStyle, - alignment: Alignment.Horizontal = Alignment.End, - isCloseButton: Boolean = false, -) { - val interactionSource = remember { MutableInteractionSource() } - - Box( - modifier = - Modifier - .align(alignment) - .focusable(false) - .let { if (isKde) it.offset(y = (-2).dp) else it } - .size(style.metrics.titlePaneButtonSize) - .clickable( - interactionSource = interactionSource, - indication = null, - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - var hovered by remember { mutableStateOf(false) } - var pressed by remember { mutableStateOf(false) } - - val isCloseInteracted = isCloseButton && (hovered || pressed) - val currentIcon = - when { - pressed && (state.isActive || isKde) -> iconPressed - hovered && (state.isActive || isKde) -> iconHover - else -> icon - } - - // Apply icon tint when controlButtonIconColor is set, - // but skip tinting for close button hover/pressed (icons have baked-in colors). - val iconTint = style.colors.controlButtonIconColor - val iconHoverTint = style.colors.controlButtonIconHoverColor - val colorFilter = - when { - isCloseInteracted -> null - (hovered || pressed) && iconHoverTint != Color.Unspecified -> ColorFilter.tint(iconHoverTint) - iconTint != Color.Unspecified -> ColorFilter.tint(iconTint) - else -> null - } - - Image( - painter = currentIcon, - contentDescription = contentDescription, - colorFilter = colorFilter, - modifier = - Modifier - .onPointerEvent(PointerEventType.Enter) { hovered = true } - .onPointerEvent(PointerEventType.Exit) { - hovered = false - pressed = false - }.onPointerEvent(PointerEventType.Press) { pressed = true } - .onPointerEvent(PointerEventType.Release) { pressed = false }, - ) - } -} diff --git a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/WindowsWindowControlArea.kt b/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/WindowsWindowControlArea.kt deleted file mode 100644 index a11b92001..000000000 --- a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/WindowsWindowControlArea.kt +++ /dev/null @@ -1,232 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.width -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.dp -import dev.nucleusframework.window.internal.WindowsCaptionButtonStyle -import dev.nucleusframework.window.internal.animateWindowsCaptionColor -import dev.nucleusframework.window.internal.windowsCaptionButtonBackground -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.windows.windowsTitleBarIcons -import java.awt.Frame -import java.awt.event.WindowEvent - -private val WINDOWS_BUTTON_WIDTH = 46.dp - -private const val CLOSE_HOVER_ALPHA_EPSILON = 0.02f - -@Suppress("FunctionNaming") -@Composable -public fun TitleBarScope.WindowsWindowControlArea( - window: java.awt.Window, - state: DecoratedWindowState, - style: TitleBarStyle, - isFullscreen: Boolean = false, - onExitFullscreen: (() -> Unit)? = null, -) { - CompositionLocalProvider(LocalLayoutDirection provides LocalControlButtonsDirection.current) { - val icons = windowsTitleBarIcons() - - // Close button (placed first with Alignment.End, so it's rightmost) - WindowsCaptionButton( - onClick = { window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) }, - state = state, - style = style, - icon = if (state.isActive) icons.close else icons.closeInactive, - iconHover = icons.closeHover, - contentDescription = "Close", - isCloseButton = true, - ) - - // In fullscreen: show exit-fullscreen button instead of maximize/restore - if (isFullscreen && onExitFullscreen != null) { - WindowsCaptionButton( - onClick = onExitFullscreen, - state = state, - style = style, - icon = if (state.isActive) icons.exitFullscreen else icons.exitFullscreenInactive, - contentDescription = "Exit fullscreen", - ) - } else { - // Maximize/Restore button (only if resizable — read from the - // snapshot-backed state so runtime setResizable() recomposes, #260) - val frame = window as? Frame - if (frame != null && state.isResizable) { - if (state.isMaximized) { - WindowsCaptionButton( - onClick = { frame.extendedState = Frame.NORMAL }, - state = state, - style = style, - icon = if (state.isActive) icons.restore else icons.restoreInactive, - contentDescription = "Restore", - ) - } else { - WindowsCaptionButton( - onClick = { frame.extendedState = Frame.MAXIMIZED_BOTH }, - state = state, - style = style, - icon = if (state.isActive) icons.maximize else icons.maximizeInactive, - contentDescription = "Maximize", - ) - } - } - } - - // Minimize button - WindowsCaptionButton( - onClick = { - (window as? Frame)?.let { - it.extendedState = it.extendedState or Frame.ICONIFIED - } - }, - state = state, - style = style, - icon = if (state.isActive) icons.minimize else icons.minimizeInactive, - contentDescription = "Minimize", - ) - } -} - -/** - * Close button for dialog title bars on Windows. - * Unlike [WindowsWindowControlArea], this only shows the close button. - */ -@Suppress("FunctionNaming") -@Composable -public fun TitleBarScope.WindowsDialogCloseButton( - window: java.awt.Window, - state: DecoratedDialogState, - style: TitleBarStyle, -) { - CompositionLocalProvider(LocalLayoutDirection provides LocalControlButtonsDirection.current) { - val icons = windowsTitleBarIcons() - val windowState = state.toDecoratedWindowState() - - WindowsCaptionButton( - onClick = { window.dispatchEvent(WindowEvent(window, WindowEvent.WINDOW_CLOSING)) }, - state = windowState, - style = style, - icon = if (windowState.isActive) icons.close else icons.closeInactive, - iconHover = icons.closeHover, - contentDescription = "Close", - isCloseButton = true, - ) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming", "LongParameterList", "UnusedParameter", "CyclomaticComplexMethod") -@Composable -private fun TitleBarScope.WindowsCaptionButton( - onClick: () -> Unit, - state: DecoratedWindowState, - style: TitleBarStyle, - icon: Painter, - contentDescription: String, - iconHover: Painter? = null, - isCloseButton: Boolean = false, -) { - var hovered by remember { mutableStateOf(false) } - var pressed by remember { mutableStateOf(false) } - val appearing = hovered || pressed - - val isDark = LocalIsDarkTheme.current - val targetBackground = - windowsCaptionButtonBackground( - hovered = hovered, - pressed = pressed, - isCloseButton = isCloseButton, - isDark = isDark, - customHover = style.colors.iconButtonHoveredBackground, - customPressed = style.colors.iconButtonPressedBackground, - ) - val backgroundColor = - animateWindowsCaptionColor( - targetBackground, - appearing = appearing, - durationMillis = WindowsCaptionButtonStyle.BACKGROUND_FADE_OUT_MILLIS, - ) - - val isCloseHovered = - isCloseButton && - (appearing || backgroundColor.alpha > CLOSE_HOVER_ALPHA_EPSILON) - val currentIcon = - when { - isCloseHovered && iconHover != null -> iconHover - else -> icon - } - - val colorFilter = - captionButtonColorFilter( - hovered = hovered, - pressed = pressed, - isCloseHovered = isCloseHovered, - style = style, - ) - - Box( - modifier = - Modifier - .align(Alignment.End) - .focusable(false) - .fillMaxHeight() - .width(WINDOWS_BUTTON_WIDTH) - .background(backgroundColor) - .onPointerEvent(PointerEventType.Enter) { hovered = true } - .onPointerEvent(PointerEventType.Exit) { - hovered = false - pressed = false - }.onPointerEvent(PointerEventType.Press) { pressed = true } - .onPointerEvent(PointerEventType.Release) { pressed = false } - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = onClick, - ), - contentAlignment = Alignment.Center, - ) { - Image( - painter = currentIcon, - contentDescription = contentDescription, - colorFilter = colorFilter, - ) - } -} - -private fun captionButtonColorFilter( - hovered: Boolean, - pressed: Boolean, - isCloseHovered: Boolean, - style: TitleBarStyle, -): ColorFilter? { - val iconTint = style.colors.controlButtonIconColor - val iconHoverTint = style.colors.controlButtonIconHoverColor - return when { - isCloseHovered -> null - (hovered || pressed) && iconHoverTint != Color.Unspecified -> - ColorFilter.tint(iconHoverTint) - iconTint != Color.Unspecified -> ColorFilter.tint(iconTint) - else -> null - } -} diff --git a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/internal/MinimumSizeSupport.kt b/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/internal/MinimumSizeSupport.kt deleted file mode 100644 index 6a10e8937..000000000 --- a/decorated-window-awt/src/main/kotlin/dev/nucleusframework/window/internal/MinimumSizeSupport.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.nucleusframework.window.internal - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.window.FrameWindowScope -import androidx.compose.ui.window.WindowState -import kotlinx.coroutines.yield - -private const val MAX_FRAME_WAIT_ITERATIONS = 8 - -/** - * Inflates [WindowState.size] up-front so Compose centers the window at the - * already-final dimensions. Without this, applying [java.awt.Window.minimumSize] - * after Compose has centered the window would re-anchor the frame at its - * bottom-left corner and visibly shift it (most visible on macOS). - * - * We mutate state during composition — normally an anti-pattern — but - * `SideEffect {}` runs after Window has already read state.size, which is - * too late to influence the initial centering. The mutation is idempotent - * (guarded by a size compare) and only re-runs when [state] or [minimumSize] - * changes, so it never loops. - * - * Pair this with [InstallMinimumSizeAfterCentering] inside the Window content - * to enforce the constraint at the AWT level. - */ -@Composable -public fun WindowState.inflateToMinimumSize(minimumSize: DpSize?) { - remember(this, minimumSize) { - if (minimumSize != null) { - val w = size.width - val h = size.height - if (w < minimumSize.width || h < minimumSize.height) { - size = DpSize(maxOf(w, minimumSize.width), maxOf(h, minimumSize.height)) - } - } - } -} - -/** - * Installs [java.awt.Window.minimumSize] after Compose Desktop has applied - * [WindowState.size] / position to the AWT frame. - * - * We poll for the frame to reach the target dimensions instead of relying on - * a single `yield()`. The yield-once approach worked on Compose Desktop 1.10 - * because the internal `update` block committed `state.size` synchronously - * before the next coroutine resume — but that ordering is an implementation - * detail and could break on a future version. Polling makes the fix robust: - * we wait until AWT actually has the size we expect, with a small cap so we - * never loop forever in pathological cases. - * - * AWT bounds are in logical pixels (= Dp). Do NOT convert via - * [androidx.compose.ui.unit.Density.roundToPx] — that applies the screen - * scale factor and would double the size on Retina/HiDPI displays. - */ -@Composable -public fun FrameWindowScope.InstallMinimumSizeAfterCentering(minimumSize: DpSize?) { - if (minimumSize == null) return - LaunchedEffect(window, minimumSize) { - val targetW = minimumSize.width.value.toInt() - val targetH = minimumSize.height.value.toInt() - var attempts = 0 - while ((window.width < targetW || window.height < targetH) && - attempts < MAX_FRAME_WAIT_ITERATIONS - ) { - yield() - attempts++ - } - window.minimumSize = java.awt.Dimension(targetW, targetH) - } -} diff --git a/decorated-window-awt/src/test/kotlin/dev/nucleusframework/window/RuntimeResizableE2eTest.kt b/decorated-window-awt/src/test/kotlin/dev/nucleusframework/window/RuntimeResizableE2eTest.kt deleted file mode 100644 index 12e101aaf..000000000 --- a/decorated-window-awt/src/test/kotlin/dev/nucleusframework/window/RuntimeResizableE2eTest.kt +++ /dev/null @@ -1,91 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.awt.ComposeWindow -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.application -import java.awt.GraphicsEnvironment -import java.awt.event.WindowEvent -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit -import java.util.concurrent.atomic.AtomicReference -import javax.swing.SwingUtilities -import kotlin.concurrent.thread -import kotlin.test.Test -import kotlin.test.assertTrue - -/** - * End-to-end regression test for issue #260: [DecoratedWindowState.isResizable] - * must recompose immediately when `Frame.setResizable()` is called after the - * window is shown — without waiting for another window event (activation, - * minimize/restore, resize). - * - * Opens a real window; skipped in headless environments (CI without display). - */ -class RuntimeResizableE2eTest { - @Test - fun stateReactsToRuntimeSetResizable() { - if (GraphicsEnvironment.isHeadless()) { - println("SKIPPED: headless environment, no display to open a real window") - return - } - println("Running against display: ${System.getenv("DISPLAY") ?: System.getenv("WAYLAND_DISPLAY")}") - - val sawResizable = CountDownLatch(1) - val sawNonResizable = CountDownLatch(1) - val sawResizableAgain = CountDownLatch(2) - val windowRef = AtomicReference() - - val appThread = - thread(name = "resizable-e2e") { - application(exitProcessOnExit = false) { - // Undecorated, like the real JBR/JNI backends — DecoratedWindowBody - // sets window.shape on Linux, which AWT forbids on decorated frames. - Window( - onCloseRequest = ::exitApplication, - title = "resizable-e2e", - undecorated = true, - ) { - DecoratedWindowBody(title = "resizable-e2e", icon = null, undecorated = true) { - windowRef.set(window) - val resizable = state.isResizable - LaunchedEffect(resizable) { - if (resizable) { - sawResizable.countDown() - sawResizableAgain.countDown() - } else { - sawNonResizable.countDown() - } - } - } - } - } - } - - try { - assertTrue( - sawResizable.await(30, TimeUnit.SECONDS), - "Window never composed with state.isResizable = true", - ) - - SwingUtilities.invokeAndWait { windowRef.get().isResizable = false } - assertTrue( - sawNonResizable.await(10, TimeUnit.SECONDS), - "state.isResizable did not react to runtime setResizable(false) — issue #260 regression", - ) - - SwingUtilities.invokeAndWait { windowRef.get().isResizable = true } - assertTrue( - sawResizableAgain.await(10, TimeUnit.SECONDS), - "state.isResizable did not react to runtime setResizable(true) — issue #260 regression", - ) - } finally { - windowRef.get()?.let { w -> - SwingUtilities.invokeLater { - w.dispatchEvent(WindowEvent(w, WindowEvent.WINDOW_CLOSING)) - } - } - appThread.join(TimeUnit.SECONDS.toMillis(15)) - } - } -} diff --git a/decorated-window-core/api/decorated-window-core.api b/decorated-window-core/api/decorated-window-core.api index e28395a9f..8720bc181 100644 --- a/decorated-window-core/api/decorated-window-core.api +++ b/decorated-window-core/api/decorated-window-core.api @@ -131,6 +131,9 @@ public final class dev/nucleusframework/window/DialogTitleBarInfo { public fun toString ()Ljava/lang/String; } +public abstract interface annotation class dev/nucleusframework/window/ExperimentalNucleusApi : java/lang/annotation/Annotation { +} + public final class dev/nucleusframework/window/LocalModalDialogCountKt { public static final fun getGlobalModalDialogCount ()Landroidx/compose/runtime/MutableState; public static final fun getLocalModalDialogCount ()Landroidx/compose/runtime/ProvidableCompositionLocal; @@ -707,6 +710,7 @@ public final class dev/nucleusframework/window/styling/DecoratedWindowColors { public final class dev/nucleusframework/window/styling/DecoratedWindowMetrics { public static final field $stable I + public fun ()V public synthetic fun (FILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (FLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-D9Ej5fM ()F @@ -768,6 +772,7 @@ public final class dev/nucleusframework/window/styling/TitleBarColors { public final class dev/nucleusframework/window/styling/TitleBarMetrics { public static final field $stable I + public fun ()V public synthetic fun (FFFJILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (FFFJLkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-D9Ej5fM ()F diff --git a/decorated-window-core/src/main/kotlin/dev/nucleusframework/window/ExperimentalNucleusApi.kt b/decorated-window-core/src/main/kotlin/dev/nucleusframework/window/ExperimentalNucleusApi.kt new file mode 100644 index 000000000..2320b8c3c --- /dev/null +++ b/decorated-window-core/src/main/kotlin/dev/nucleusframework/window/ExperimentalNucleusApi.kt @@ -0,0 +1,16 @@ +package dev.nucleusframework.window + +/** + * Marks a Nucleus API that is still experimental: it may change or be removed + * in a minor release without a deprecation cycle. + * + * Opt in with `@OptIn(ExperimentalNucleusApi::class)`, or module-wide with the + * `-opt-in=dev.nucleusframework.window.ExperimentalNucleusApi` compiler argument. + */ +@RequiresOptIn( + message = + "This Nucleus API is experimental and may change or be removed without a deprecation cycle. " + + "Opt in with @OptIn(dev.nucleusframework.window.ExperimentalNucleusApi::class).", +) +@Retention(AnnotationRetention.BINARY) +public annotation class ExperimentalNucleusApi diff --git a/decorated-window-core/src/main/native/linux/nucleus_layout_direction_linux.c b/decorated-window-core/src/main/native/linux/nucleus_layout_direction_linux.c index 5fdce588f..55f857bfa 100644 --- a/decorated-window-core/src/main/native/linux/nucleus_layout_direction_linux.c +++ b/decorated-window-core/src/main/native/linux/nucleus_layout_direction_linux.c @@ -12,6 +12,7 @@ * Linked libraries: -ldl -lpthread */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -237,9 +238,7 @@ static void notify_button_layout(const char *layout) { } } - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); diff --git a/decorated-window-jbr/api/decorated-window-jbr.api b/decorated-window-jbr/api/decorated-window-jbr.api deleted file mode 100644 index feb9f0234..000000000 --- a/decorated-window-jbr/api/decorated-window-jbr.api +++ /dev/null @@ -1,78 +0,0 @@ -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt; - public fun ()V - public final fun getLambda$-1656225001$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$-1991905136$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBar_LinuxKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBar_LinuxKt; - public fun ()V - public final fun getLambda$1500723390$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBar_MacOSKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBar_MacOSKt; - public fun ()V - public final fun getLambda$-238371298$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBar_WindowsKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBar_WindowsKt; - public fun ()V - public final fun getLambda$-1627344401$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$2067210846$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBarKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBarKt; - public fun ()V - public final fun getLambda$-880964242$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-985436865$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1948865750$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$555209157$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBar_LinuxKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBar_LinuxKt; - public fun ()V - public final fun getLambda$-1516208515$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$138814254$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBar_MacOSKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBar_MacOSKt; - public fun ()V - public final fun getLambda$-268479267$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1386543502$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBar_WindowsKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBar_WindowsKt; - public fun ()V - public final fun getLambda$-1069615779$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1496373646$Nucleus_decorated_window_jbr ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/DecoratedDialogKt { - public static final fun DecoratedDialog (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V -} - -public final class dev/nucleusframework/window/DecoratedWindowKt { - public static final fun DecoratedWindow-a32mfzs (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V -} - -public final class dev/nucleusframework/window/DialogTitleBarKt { - public static final fun BasicDialogTitleBar-TgFrcIs (Ldev/nucleusframework/window/DecoratedDialogScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V - public static final fun DialogTitleBar-FU0evQE (Ldev/nucleusframework/window/DecoratedDialogScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V -} - -public final class dev/nucleusframework/window/TitleBarKt { - public static final fun BasicTitleBar-lVb_Clg (Ldev/nucleusframework/window/DecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V - public static final fun TitleBar-TgFrcIs (Ldev/nucleusframework/window/DecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V -} - -public final class dev/nucleusframework/window/utils/ClientRegionHelperKt { - public static final fun clientRegion (Landroidx/compose/ui/Modifier;Ljava/lang/String;)Landroidx/compose/ui/Modifier; -} - diff --git a/decorated-window-jbr/build.gradle.kts b/decorated-window-jbr/build.gradle.kts deleted file mode 100644 index 88803357d..000000000 --- a/decorated-window-jbr/build.gradle.kts +++ /dev/null @@ -1,75 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - id("nucleus.native-module") - alias(libs.plugins.kotlinComposePlugin) - alias(libs.plugins.jetbrainsCompose) - alias(libs.plugins.vanniktechMavenPublish) -} - -val publishVersion = - providers - .environmentVariable("GITHUB_REF") - .orNull - ?.removePrefix("refs/tags/v") - ?: "1.0.0" - -dependencies { - api(project(":decorated-window-core")) - api(project(":decorated-window-awt")) - implementation(project(":core-runtime")) - implementation(libs.compose.desktop.common) - implementation(libs.jbr.api) -} - -java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } -} - -nucleusNative { - macos("nucleus_macos") -} - -mavenPublishing { - coordinates("dev.nucleusframework", "nucleus.decorated-window-jbr", publishVersion) - - pom { - name.set("Nucleus Decorated Window JBR") - description.set("JBR-based custom decorated window with native title bar for Compose Desktop") - url.set("https://github.com/NucleusFramework/Nucleus") - - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/licenses/MIT") - } - } - - developers { - developer { - id.set("nucleusframework") - name.set("NucleusFramework") - url.set("https://github.com/NucleusFramework") - } - } - - scm { - url.set("https://github.com/NucleusFramework/Nucleus") - connection.set("scm:git:git://github.com/NucleusFramework/Nucleus.git") - developerConnection.set("scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git") - } - } - - publishToMavenCentral() - if (project.hasProperty("signingInMemoryKey")) { - signAllPublications() - } -} diff --git a/decorated-window-jbr/detekt-baseline.xml b/decorated-window-jbr/detekt-baseline.xml deleted file mode 100644 index dca1a6689..000000000 --- a/decorated-window-jbr/detekt-baseline.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - UndocumentedPublicFunction:DecoratedDialog.kt:@Suppress("FunctionNaming", "LongParameterList") @Composable public fun DecoratedDialog - UndocumentedPublicFunction:DecoratedWindow.kt:@Suppress("FunctionNaming", "LongParameterList") @Composable public fun DecoratedWindow - UndocumentedPublicFunction:DialogTitleBar.kt:@Suppress("FunctionNaming") @Composable public fun DecoratedDialogScope.BasicDialogTitleBar - UndocumentedPublicFunction:DialogTitleBar.kt:@Suppress("FunctionNaming") @Composable public fun DecoratedDialogScope.DialogTitleBar - UndocumentedPublicFunction:TitleBar.kt:@Suppress("FunctionNaming", "LongParameterList") @Composable public fun DecoratedWindowScope.BasicTitleBar - - diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DecoratedDialog.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DecoratedDialog.kt deleted file mode 100644 index be4dd8e95..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DecoratedDialog.kt +++ /dev/null @@ -1,82 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.window.DialogState -import androidx.compose.ui.window.DialogWindow -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.rememberDialogState -import com.jetbrains.JBR -import dev.nucleusframework.core.runtime.Platform - -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun DecoratedDialog( - onCloseRequest: () -> Unit, - state: DialogState = rememberDialogState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = false, - enabled: Boolean = true, - focusable: Boolean = true, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable AwtDecoratedDialogScope.() -> Unit, -) { - remember { - check(JBR.isAvailable()) { - "DecoratedDialog requires JetBrains Runtime (JBR). " + - "Please run your application on JBR." - } - } - - val undecorated = Platform.Linux == Platform.Current - - // Centre the dialog on its parent window by computing the position - // before DialogWindow is composed. This avoids any visible jump because - // DialogWindow reads state.position and applies it immediately. - val density = LocalDensity.current - remember(state) { - val parent = - java.awt.KeyboardFocusManager - .getCurrentKeyboardFocusManager() - .focusedWindow - if (parent != null && state.position == WindowPosition.PlatformDefault) { - val dialogWidthPx = with(density) { state.size.width.toPx() } - val dialogHeightPx = with(density) { state.size.height.toPx() } - val x = parent.x + (parent.width - dialogWidthPx) / 2 - val y = parent.y + (parent.height - dialogHeightPx) / 2 - state.position = - WindowPosition( - x = with(density) { x.toDp() }, - y = with(density) { y.toDp() }, - ) - } - } - - DialogWindow( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - undecorated = undecorated, - transparent = false, - resizable = resizable, - enabled = enabled, - focusable = focusable, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - ) { - DecoratedDialogBody( - title = title, - icon = icon, - undecorated = undecorated, - content = content, - ) - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DecoratedWindow.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DecoratedWindow.kt deleted file mode 100644 index 66b425b2d..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DecoratedWindow.kt +++ /dev/null @@ -1,69 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowState -import androidx.compose.ui.window.rememberWindowState -import com.jetbrains.JBR -import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.internal.InstallMinimumSizeAfterCentering -import dev.nucleusframework.window.internal.inflateToMinimumSize - -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun DecoratedWindow( - onCloseRequest: () -> Unit, - state: WindowState = rememberWindowState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = true, - enabled: Boolean = true, - focusable: Boolean = true, - alwaysOnTop: Boolean = false, - minimumSize: DpSize? = null, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable AwtDecoratedWindowScope.() -> Unit, -) { - remember { - check(JBR.isAvailable()) { - "DecoratedWindow requires JetBrains Runtime (JBR). " + - "Please run your application on JBR." - } - } - - state.inflateToMinimumSize(minimumSize) - - val undecorated = Platform.Linux == Platform.Current - - Window( - onCloseRequest, - state, - visible, - title, - icon, - undecorated, - transparent = false, - resizable, - enabled, - focusable, - alwaysOnTop, - onPreviewKeyEvent, - onKeyEvent, - ) { - InstallMinimumSizeAfterCentering(minimumSize) - - DecoratedWindowBody( - title = title, - icon = icon, - undecorated = undecorated, - onCloseRequest = onCloseRequest, - content = content, - ) - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Linux.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Linux.kt deleted file mode 100644 index 446f72f7e..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Linux.kt +++ /dev/null @@ -1,55 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import com.jetbrains.JBR -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.linux.rememberLinuxButtonLayout -import java.awt.event.MouseEvent - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedDialogScope.LinuxDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val linuxStyle = createLinuxTitleBarStyle(style) - val dialogState = state - val controlDir = controlButtonsDirection.resolve() - val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight - val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - DialogTitleBarImpl( - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { changed -> !changed.isConsumed } - ) { - JBR.getWindowMove()?.startMovingTogetherWithMouse(window, MouseEvent.BUTTON1) - } - }, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> kdePaddingForButtonLayout() }, - ) { _ -> - DialogCloseButton(window, dialogState, linuxStyle) - content(dialogState) - } - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.MacOS.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.MacOS.kt deleted file mode 100644 index 8a8b99a1d..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.MacOS.kt +++ /dev/null @@ -1,50 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.jetbrains.JBR -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.WindowMouseEventEffect - -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedDialogScope.MacOSDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val titleBar = remember { JBR.getWindowDecorations().createCustomTitleBar() } - - WindowMouseEventEffect(titleBar) - - val controlDir = controlButtonsDirection.resolve() - val isRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (isRtl) WindowControlsSide.End else WindowControlsSide.Start - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { height, _ -> - titleBar.putProperty("controls.rtl", isRtl) - titleBar.height = height.value - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - }, - content = content, - ) - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Windows.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Windows.kt deleted file mode 100644 index 902538a5a..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Windows.kt +++ /dev/null @@ -1,61 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.jetbrains.JBR -import dev.nucleusframework.window.internal.isDark -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.WindowMouseEventEffect - -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedDialogScope.WindowsDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val titleBar = remember { JBR.getWindowDecorations().createCustomTitleBar() } - - WindowMouseEventEffect(titleBar) - - val controlDir = controlButtonsDirection.resolve() - val isRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (isRtl) WindowControlsSide.Start else WindowControlsSide.End - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { height, _ -> - titleBar.putProperty("controls.rtl", isRtl) - titleBar.height = height.value - titleBar.putProperty("controls.dark", style.colors.background.isDark()) - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - val padding = - if (isRtl) { - PaddingValues(start = titleBar.rightInset.dp, end = titleBar.leftInset.dp) - } else { - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - } - padding - }, - backgroundContent = { Spacer(modifier = Modifier.fillMaxSize()) }, - content = content, - ) - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt deleted file mode 100644 index b97c822d9..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt +++ /dev/null @@ -1,82 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle - -@Suppress("FunctionNaming") -@Composable -public fun DecoratedDialogScope.DialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - BasicDialogTitleBar( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = TitleBarLayoutPolicy.Default, - content = content, - ) -} - -@Suppress("FunctionNaming") -@Composable -public fun DecoratedDialogScope.BasicDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val dialogTitleBarInfo = LocalDialogTitleBarInfo.current - val titleBarInfo = remember { TitleBarInfo(dialogTitleBarInfo.title, dialogTitleBarInfo.icon) } - LaunchedEffect(dialogTitleBarInfo.title) { titleBarInfo.title = dialogTitleBarInfo.title } - LaunchedEffect(dialogTitleBarInfo.icon) { titleBarInfo.icon = dialogTitleBarInfo.icon } - val awtScope = this as AwtDecoratedDialogScope - CompositionLocalProvider( - LocalTitleBarInfo provides titleBarInfo, - ) { - when (Platform.Current) { - Platform.Linux -> - awtScope.LinuxDialogTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - content, - ) - Platform.Windows -> - awtScope.WindowsDialogTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - content, - ) - Platform.MacOS -> - awtScope.MacOSDialogTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - content, - ) - Platform.Unknown -> - error("DialogTitleBar is not supported on this platform(${System.getProperty("os.name")})") - } - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.Linux.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.Linux.kt deleted file mode 100644 index 7fc5d29c7..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.Linux.kt +++ /dev/null @@ -1,72 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.platform.LocalViewConfiguration -import com.jetbrains.JBR -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.linux.rememberLinuxButtonLayout -import java.awt.Frame -import java.awt.event.MouseEvent - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedWindowScope.LinuxTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - val linuxStyle = createLinuxTitleBarStyle(style) - val controlDir = controlButtonsDirection.resolve() - val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight - val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start - - var lastPress = 0L - val viewConfig = LocalViewConfiguration.current - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { changed -> !changed.isConsumed } - ) { - JBR.getWindowMove()?.startMovingTogetherWithMouse(window, MouseEvent.BUTTON1) - if ( - System.currentTimeMillis() - lastPress in - viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis - ) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else if (window.isResizable) { - window.extendedState = Frame.MAXIMIZED_BOTH - } - } - lastPress = System.currentTimeMillis() - } - }, - gradientStartColor, - linuxStyle, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> - kdePaddingForButtonLayout() - }, - backgroundContent = backgroundContent, - ) { currentState -> - WindowControlArea(window, currentState, linuxStyle) - content(currentState) - } - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.MacOS.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.MacOS.kt deleted file mode 100644 index 483c0e7b5..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.MacOS.kt +++ /dev/null @@ -1,84 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.jetbrains.JBR -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.WindowMouseEventEffect -import dev.nucleusframework.window.utils.macos.MacUtil - -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedWindowScope.MacOSTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - val newFullscreenControls = modifier.hasNewFullscreenControls() - - if (newFullscreenControls) { - System.setProperty("apple.awt.newFullScreenControls", true.toString()) - System.setProperty( - "apple.awt.newFullScreenControls.background", - "${style.colors.fullscreenControlButtonsBackground.toArgb()}", - ) - MacUtil.updateColors(window) - } else { - System.clearProperty("apple.awt.newFullScreenControls") - System.clearProperty("apple.awt.newFullScreenControls.background") - } - - val titleBar = remember { JBR.getWindowDecorations().createCustomTitleBar() } - - WindowMouseEventEffect(titleBar) - - val controlDir = controlButtonsDirection.resolve() - val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.End else WindowControlsSide.Start - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { height, titleBarState -> - titleBar.putProperty("controls.rtl", controlIsRtl) - titleBar.height = height.value - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - - val padding = - if (titleBarState.isFullscreen && newFullscreenControls) { - if (controlIsRtl) { - PaddingValues(end = 80.dp) - } else { - PaddingValues(start = 80.dp) - } - } else { - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - } - padding - }, - onPlace = { - if (state.isFullscreen) { - MacUtil.updateFullScreenButtons(window) - } - }, - backgroundContent = backgroundContent, - content = content, - ) - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.Windows.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.Windows.kt deleted file mode 100644 index 623dd4a25..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.Windows.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import com.jetbrains.JBR -import dev.nucleusframework.window.internal.isDark -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.WindowMouseEventEffect - -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedWindowScope.WindowsTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - val titleBar = remember { JBR.getWindowDecorations().createCustomTitleBar() } - - WindowMouseEventEffect(titleBar) - - val controlDir = controlButtonsDirection.resolve() - val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { height, _ -> - titleBar.putProperty("controls.rtl", controlIsRtl) - titleBar.height = height.value - titleBar.putProperty("controls.dark", style.colors.background.isDark()) - JBR.getWindowDecorations().setCustomTitleBar(window, titleBar) - PaddingValues(start = titleBar.leftInset.dp, end = titleBar.rightInset.dp) - }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize()) - backgroundContent() - }, - ) { state -> - content(state) - } - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt deleted file mode 100644 index 830c52d5e..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt +++ /dev/null @@ -1,85 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle - -/** - * Platform-aware title bar for [DecoratedWindow]. - * - * @param controlButtonsDirection Controls which side the window control buttons - * (close, minimize, maximize) are placed on, independently of the title bar - * content direction. Defaults to [ControlButtonsDirection.Auto] which follows - * the Compose [LocalLayoutDirection][androidx.compose.ui.platform.LocalLayoutDirection]. - */ -@Suppress("FunctionNaming") -@Composable -public fun DecoratedWindowScope.TitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - BasicTitleBar( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent = backgroundContent, - content = content, - ) -} - -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun DecoratedWindowScope.BasicTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - val awtScope = this as AwtDecoratedWindowScope - when (Platform.Current) { - Platform.Linux -> - awtScope.LinuxTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - backgroundContent, - content, - ) - Platform.Windows -> - awtScope.WindowsTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - backgroundContent, - content, - ) - Platform.MacOS -> - awtScope.MacOSTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - backgroundContent, - content, - ) - Platform.Unknown -> - error("TitleBar is not supported on this platform(${System.getProperty("os.name")})") - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/ClientRegionHelper.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/ClientRegionHelper.kt deleted file mode 100644 index 351c26bea..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/ClientRegionHelper.kt +++ /dev/null @@ -1,154 +0,0 @@ -package dev.nucleusframework.window.utils - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.layout.LayoutCoordinates -import androidx.compose.ui.layout.positionInWindow -import androidx.compose.ui.node.CompositionLocalConsumerModifierNode -import androidx.compose.ui.node.GlobalPositionAwareModifierNode -import androidx.compose.ui.node.ModifierNodeElement -import androidx.compose.ui.node.currentValueOf -import androidx.compose.ui.platform.InspectorInfo -import androidx.compose.ui.unit.toSize -import com.jetbrains.WindowDecorations -import dev.nucleusframework.window.AwtDecoratedDialogScope -import dev.nucleusframework.window.AwtDecoratedWindowScope -import dev.nucleusframework.window.LocalTitleBarInfo -import dev.nucleusframework.window.TitleBarInfo -import java.awt.Window -import java.awt.event.MouseAdapter -import java.awt.event.MouseEvent - -/** - * Registers a composable element as a client region within a decorated window's title bar. - * - * Client regions are interactive areas of the title bar that should respond to mouse events - * as if they were part of the window's client area, rather than the draggable title bar. - * This is essential for interactive title bar controls like buttons, menus, or other widgets - * that should not trigger window dragging. - * - * @param key A unique identifier for this client region. Should be unique within the same - * window's title bar. - * @return A modified [Modifier] that registers this composable as a client region. - */ -public fun Modifier.clientRegion(key: String): Modifier = then(RegisterClientRegionElement(key)) - -private data class RegisterClientRegionElement( - private val key: String, -) : ModifierNodeElement() { - override fun create() = RegisterClientRegionNode(key) - - override fun update(node: RegisterClientRegionNode) { - node.updateKey(key) - } - - override fun InspectorInfo.inspectableProperties() { - name = "registerRegion" - properties["key"] = key - } -} - -private class RegisterClientRegionNode( - var key: String, -) : Modifier.Node(), - GlobalPositionAwareModifierNode, - CompositionLocalConsumerModifierNode { - private var titleBarInfo: TitleBarInfo? = null - - override fun onAttach() { - titleBarInfo = currentValueOf(LocalTitleBarInfo) - } - - override fun onGloballyPositioned(coordinates: LayoutCoordinates) { - val info = titleBarInfo ?: return - val rect = Rect(coordinates.positionInWindow(), coordinates.size.toSize()) - - info.clientRegions[key] = rect - } - - override fun onDetach() { - titleBarInfo?.clientRegions?.remove(key) - titleBarInfo = null - } - - fun updateKey(newKey: String) { - if (key == newKey) return - - val region = titleBarInfo?.clientRegions?.remove(key) - - if (region != null) { - titleBarInfo?.clientRegions[newKey] = region - } - - key = newKey - } -} - -/** - * Sets up mouse event handling for interactive title bar regions in a decorated window. - * - * This effect monitors mouse movements and clicks on the window, determining whether the - * cursor is over a client region (interactive title bar element) or the draggable title bar - * itself. It communicates hit test results to the platform's window decorations system. - * - * @param titleBar The platform window decorations object that receives hit test updates. - */ -@Composable -internal fun AwtDecoratedWindowScope.WindowMouseEventEffect(titleBar: WindowDecorations.CustomTitleBar) { - WindowMouseEventEffectImpl(window, titleBar) -} - -@Composable -internal fun AwtDecoratedDialogScope.WindowMouseEventEffect(titleBar: WindowDecorations.CustomTitleBar) { - WindowMouseEventEffectImpl(window, titleBar) -} - -@Composable -private fun WindowMouseEventEffectImpl( - window: Window, - titleBar: WindowDecorations.CustomTitleBar, -) { - val titleBarInfo = LocalTitleBarInfo.current - - DisposableEffect(window, window.graphicsConfiguration, titleBar) { - val graphicsConfig = window.graphicsConfiguration - val scaleX = graphicsConfig?.defaultTransform?.scaleX ?: 1.0 - val scaleY = graphicsConfig?.defaultTransform?.scaleY ?: 1.0 - val listener = - object : MouseAdapter() { - override fun mousePressed(e: MouseEvent) { - updateHitTest(e) - } - - override fun mouseReleased(e: MouseEvent) { - updateHitTest(e) - } - - override fun mouseDragged(e: MouseEvent) { - updateHitTest(e) - } - - override fun mouseMoved(e: MouseEvent) { - updateHitTest(e) - } - - private fun updateHitTest(e: MouseEvent) { - val point = Offset(x = (e.x * scaleX).toFloat(), y = (e.y * scaleY).toFloat()) - - val isClientRegion = titleBarInfo.clientRegions.any { it.value.contains(point) } - - titleBar.forceHitTest(isClientRegion) - } - } - window.addMouseListener(listener) - window.addMouseMotionListener(listener) - - onDispose { - window.removeMouseListener(listener) - window.removeMouseMotionListener(listener) - } - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/macos/MacUtil.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/macos/MacUtil.kt deleted file mode 100644 index 5e23503e0..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/macos/MacUtil.kt +++ /dev/null @@ -1,60 +0,0 @@ -package dev.nucleusframework.window.utils.macos - -import java.awt.Component -import java.awt.Window -import java.util.logging.Level -import java.util.logging.Logger -import javax.swing.SwingUtilities - -@Suppress("TooGenericExceptionCaught") -internal object MacUtil { - private val logger = Logger.getLogger(MacUtil::class.java.name) - - fun getWindowPtr(w: Window?): Long { - if (w == null) return 0L - try { - val cPlatformWindow = getPlatformWindow(w) ?: return 0L - val ptr = cPlatformWindow.javaClass.superclass.getDeclaredField("ptr") - ptr.isAccessible = true - return ptr.getLong(cPlatformWindow) - } catch (e: Exception) { - logger.log(Level.WARNING, "Failed to get NSWindow pointer from AWT window.", e) - } - return 0L - } - - private fun getPlatformWindow(w: Window): Any? { - try { - val awtAccessor = Class.forName("sun.awt.AWTAccessor") - val componentAccessor = awtAccessor.getMethod("getComponentAccessor").invoke(null) - // Resolve getPeer on the interface (sun.awt package, opened via --add-opens) - // rather than on the anonymous impl class (java.awt package, not opened). - val accessorInterface = Class.forName("sun.awt.AWTAccessor\$ComponentAccessor") - val getPeer = accessorInterface.getMethod("getPeer", Component::class.java) - val peer = getPeer.invoke(componentAccessor, w) ?: return null - val getPlatformWindowMethod = peer.javaClass.getDeclaredMethod("getPlatformWindow") - return getPlatformWindowMethod.invoke(peer) - } catch (e: Exception) { - logger.log(Level.WARNING, "Failed to get cPlatformWindow from AWT window.", e) - } - return null - } - - fun updateColors(w: Window) { - SwingUtilities.invokeLater { - val ptr = getWindowPtr(w) - if (ptr != 0L && NativeMacBridge.isLoaded) { - NativeMacBridge.nativeUpdateColors(ptr) - } - } - } - - fun updateFullScreenButtons(w: Window) { - SwingUtilities.invokeLater { - val ptr = getWindowPtr(w) - if (ptr != 0L && NativeMacBridge.isLoaded) { - NativeMacBridge.nativeUpdateFullScreenButtons(ptr) - } - } - } -} diff --git a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/macos/NativeMacBridge.kt b/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/macos/NativeMacBridge.kt deleted file mode 100644 index 5ca1a3ca2..000000000 --- a/decorated-window-jbr/src/main/kotlin/dev/nucleusframework/window/utils/macos/NativeMacBridge.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.nucleusframework.window.utils.macos - -import dev.nucleusframework.core.runtime.NativeLibraryLoader - -private const val LIBRARY_NAME = "nucleus_macos" - -internal object NativeMacBridge { - private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, NativeMacBridge::class.java) - - val isLoaded: Boolean get() = loaded - - @JvmStatic - external fun nativeUpdateColors(nsWindowPtr: Long) - - @JvmStatic - external fun nativeUpdateFullScreenButtons(nsWindowPtr: Long) -} diff --git a/decorated-window-jbr/src/main/native/macos/NucleusMacBridge.m b/decorated-window-jbr/src/main/native/macos/NucleusMacBridge.m deleted file mode 100644 index f037566de..000000000 --- a/decorated-window-jbr/src/main/native/macos/NucleusMacBridge.m +++ /dev/null @@ -1,32 +0,0 @@ -#import -#include - -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_NativeMacBridge_nativeUpdateColors( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - if (nsWindowPtr == 0) return; - NSWindow *window = (__bridge NSWindow *)(void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - @autoreleasepool { - id delegate = [window delegate]; - if (delegate && [delegate respondsToSelector:@selector(updateColors)]) { - [delegate performSelector:@selector(updateColors)]; - } - } - }); -} - -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_NativeMacBridge_nativeUpdateFullScreenButtons( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - if (nsWindowPtr == 0) return; - NSWindow *window = (__bridge NSWindow *)(void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - @autoreleasepool { - id delegate = [window delegate]; - if (delegate && [delegate respondsToSelector:@selector(updateFullScreenButtons)]) { - [delegate performSelector:@selector(updateFullScreenButtons)]; - } - } - }); -} diff --git a/decorated-window-jbr/src/main/native/macos/build.sh b/decorated-window-jbr/src/main/native/macos/build.sh deleted file mode 100755 index 21a743d0e..000000000 --- a/decorated-window-jbr/src/main/native/macos/build.sh +++ /dev/null @@ -1,60 +0,0 @@ -#!/bin/bash -# Compiles NucleusMacBridge.m into per-architecture dylibs (arm64 + x86_64). -# The outputs are placed in the JAR resources so they ship with the library. -# -# Prerequisites: Xcode command-line tools (clang). -# Usage: ./build.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC="$SCRIPT_DIR/NucleusMacBridge.m" -RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" -OUT_DIR_ARM64="$RESOURCE_DIR/darwin-aarch64" -OUT_DIR_X64="$RESOURCE_DIR/darwin-x64" - -# Detect JAVA_HOME for JNI headers -if [ -z "${JAVA_HOME:-}" ]; then - JAVA_HOME=$(/usr/libexec/java_home 2>/dev/null || true) -fi -if [ -z "${JAVA_HOME:-}" ]; then - echo "ERROR: JAVA_HOME not set and /usr/libexec/java_home failed." >&2 - exit 1 -fi - -JNI_INCLUDE="$JAVA_HOME/include" -JNI_INCLUDE_DARWIN="$JAVA_HOME/include/darwin" - -if [ ! -d "$JNI_INCLUDE" ]; then - echo "ERROR: JNI headers not found at $JNI_INCLUDE" >&2 - exit 1 -fi - -mkdir -p "$OUT_DIR_ARM64" "$OUT_DIR_X64" - -COMMON_FLAGS=( - -dynamiclib - -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_DARWIN" - -framework Cocoa - -mmacosx-version-min=10.13 - -fobjc-arc - -Oz # optimize for smallest code size - -flto # link-time optimization - -fvisibility=hidden # hide all symbols except JNIEXPORT ones - -Wl,-dead_strip # strip unreachable code - -Wl,-x # strip local symbols at link time -) - -# Compile for arm64 -clang -arch arm64 "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_ARM64/libnucleus_macos.dylib" "$SRC" -strip -x "$OUT_DIR_ARM64/libnucleus_macos.dylib" - -# Compile for x86_64 -clang -arch x86_64 "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_X64/libnucleus_macos.dylib" "$SRC" -strip -x "$OUT_DIR_X64/libnucleus_macos.dylib" - -echo "Built per-architecture dylibs:" -ls -lh "$OUT_DIR_ARM64/libnucleus_macos.dylib" -ls -lh "$OUT_DIR_X64/libnucleus_macos.dylib" diff --git a/decorated-window-jewel/build.gradle.kts b/decorated-window-jewel/build.gradle.kts index 3de8eec95..ae6db71b2 100644 --- a/decorated-window-jewel/build.gradle.kts +++ b/decorated-window-jewel/build.gradle.kts @@ -15,10 +15,8 @@ val publishVersion = ?: "1.0.0" dependencies { - // Compile against all backends — consumer picks one at runtime: - // :decorated-window-jbr (JBR), :decorated-window-jni (any JVM), or - // :decorated-window-tao (no-AWT native). - compileOnly(project(":decorated-window-jbr")) + // Window/dialog wrappers only add styling on top of nucleus-application's + // Tao-backed window; the app brings both at runtime. compileOnly(project(":decorated-window-tao")) compileOnly(project(":nucleus-application")) api(project(":core-runtime")) diff --git a/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedDialog.kt b/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedDialog.kt index cd73cf494..7c952e627 100644 --- a/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedDialog.kt +++ b/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedDialog.kt @@ -7,57 +7,11 @@ import androidx.compose.ui.window.DialogState import androidx.compose.ui.window.rememberDialogState import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.application.NucleusDecoratedDialogScope -import dev.nucleusframework.window.DecoratedDialog -import dev.nucleusframework.window.DecoratedDialogScope import dev.nucleusframework.window.NucleusDecoratedWindowTheme import org.jetbrains.jewel.foundation.theme.JewelTheme import dev.nucleusframework.application.DecoratedDialog as NucleusDecoratedDialogFn -/** AWT-backed (JBR / JNI) Jewel-styled wrapper for [DecoratedDialog]. */ -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun JewelDecoratedDialog( - onCloseRequest: () -> Unit, - state: DialogState = rememberDialogState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = false, - enabled: Boolean = true, - focusable: Boolean = true, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable DecoratedDialogScope.() -> Unit, -) { - val windowStyle = rememberJewelWindowStyle() - val titleBarStyle = rememberJewelTitleBarStyle() - - NucleusDecoratedWindowTheme( - isDark = JewelTheme.isDark, - windowStyle = windowStyle, - titleBarStyle = titleBarStyle, - ) { - DecoratedDialog( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - resizable = resizable, - enabled = enabled, - focusable = focusable, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - ) { - ProvideJewelSpellcheckMenu { content() } - } - } -} - -/** - * Backend-agnostic Jewel-styled wrapper. Use inside `nucleusApplication { … }` - * — works on AWT (JBR/JNI) and Tao with the same call site. - */ +/** Jewel-styled dialog. Use inside `nucleusApplication { … }`. */ @Suppress("FunctionNaming", "LongParameterList") @Composable public fun NucleusApplicationScope.JewelDecoratedDialog( diff --git a/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt b/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt index b829979e0..fa791f844 100644 --- a/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt +++ b/decorated-window-jewel/src/main/kotlin/dev/nucleusframework/window/jewel/JewelDecoratedWindow.kt @@ -1,5 +1,3 @@ -@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") - package dev.nucleusframework.window.jewel import androidx.compose.runtime.Composable @@ -7,79 +5,21 @@ import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.application.NucleusDecoratedWindowScope import dev.nucleusframework.application.NucleusWindow -import dev.nucleusframework.window.AwtDecoratedWindowScope -import dev.nucleusframework.window.DecoratedWindow import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.styling.TitleBarStyle -import org.jetbrains.jewel.foundation.theme.JewelTheme -import kotlin.internal.LowPriorityInOverloadResolution import dev.nucleusframework.application.DecoratedWindow as NucleusDecoratedWindowFn private const val LUMINANCE_THRESHOLD = 0.5f -/** AWT-backed (JBR / JNI) Jewel-styled wrapper for [DecoratedWindow]. */ -@Suppress("FunctionNaming", "LongParameterList") -// Low priority: NucleusApplicationScope implements ApplicationScope, so inside -// nucleusApplication both overloads are applicable — the Nucleus one must win. -@LowPriorityInOverloadResolution -@Composable -public fun ApplicationScope.JewelDecoratedWindow( - onCloseRequest: () -> Unit, - state: WindowState = rememberWindowState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = true, - enabled: Boolean = true, - focusable: Boolean = true, - alwaysOnTop: Boolean = false, - minimumSize: DpSize? = null, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - titleBarStyle: TitleBarStyle? = null, - content: @Composable AwtDecoratedWindowScope.() -> Unit, -) { - val colorScheme = JewelTheme.globalColors - val windowStyle = rememberJewelWindowStyle() - val jewelTitleBarStyle = rememberJewelTitleBarStyle() - - val titleBarIsDark = jewelTitleBarStyle.colors.background.luminance() < LUMINANCE_THRESHOLD - - NucleusDecoratedWindowTheme( - isDark = titleBarIsDark, - windowStyle = windowStyle, - titleBarStyle = titleBarStyle ?: jewelTitleBarStyle, - ) { - DecoratedWindow( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - resizable = resizable, - enabled = enabled, - focusable = focusable, - alwaysOnTop = alwaysOnTop, - minimumSize = minimumSize, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - ) { - ProvideJewelSpellcheckMenu { content() } - } - } -} - /** - * Backend-agnostic Jewel-styled wrapper. Use inside `nucleusApplication { … }` - * — works on AWT (JBR/JNI) and Tao with the same call site. The Tao - * `ComposeScene` boundary is handled by re-providing the resolved styles - * inside the new scene. + * Jewel-styled window. Use inside `nucleusApplication { … }`. Each window owns + * its own `ComposeScene`, so the resolved styles are re-provided inside the new + * scene. */ @Suppress("FunctionNaming", "LongParameterList") @Composable @@ -95,17 +35,17 @@ public fun NucleusApplicationScope.JewelDecoratedWindow( alwaysOnTop: Boolean = false, // Fully borderless window (no macOS traffic lights) — for overlay/ghost windows. undecorated: Boolean = false, - // Linux/Tao only: popup overlay of [popupFor] — on Wayland a wl_subsurface + // Linux only: popup overlay of [popupFor] — on Wayland a wl_subsurface // of the parent, the only client-positionable window kind under xdg-shell // (parent-relative coordinates). For drag ghosts. Ignored elsewhere. popupFor: NucleusWindow? = null, - // Replace Compose-drawn context menus with the OS-looking menu. Tao + - // macOS (`NSMenu`), or a Compose flyout on Linux (Adwaita) / Windows - // (Fluent). No-op on AWT. + // Replace Compose-drawn context menus with the OS-looking menu: `NSMenu` + // on macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent). + // The flyout always opens in a native popup surface, whatever + // `nativePopupLayers` says. nativeContextMenu: Boolean = false, // Hide this window from the OS taskbar/Dock while it stays visible and - // focusable (Tao backend; on Linux effective on X11/XWayland only). - // No-op on AWT. + // focusable (on Linux effective on X11/XWayland only). hiddenFromDock: Boolean = false, minimumSize: DpSize? = null, onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, @@ -115,19 +55,30 @@ public fun NucleusApplicationScope.JewelDecoratedWindow( // // Full-window per-pixel transparency: pixels the content leaves at alpha 0 // show the desktop behind the window. Creation-time only, normally paired - // with [undecorated]. Tao backend only. + // with [undecorated]. transparent: Boolean = false, // Click-through window: pointer events fall through to whatever sits below // and the window never intercepts input. Pair with `focusable = false` for - // passive overlays. Reactive. Tao backend only. + // passive overlays. Reactive. clickThrough: Boolean = false, // Show the window on every desktop / macOS Space / Windows virtual desktop - // instead of only the one it was created on. Reactive. Tao backend only. + // instead of only the one it was created on. Reactive. visibleOnAllWorkspaces: Boolean = false, // Linux only: give this window an X11 surface even when the app runs on a // native Wayland session, for the window management Wayland has no protocol // for (stacking, positioning, workspace stickiness). Creation-time only. forceX11: Boolean = false, + // Materialise Compose Popup layers as native transparent windows + // (NSPanel / WS_POPUP HWND) instead of drawing them inline in this + // window's render target, so a popup can leave the window bounds. + // + // Jewel's own components get this for free: `LocalPopupRenderer`'s default + // renderer delegates to `androidx.compose.ui.window.Popup`, so every + // `ListComboBox`, `PopupMenu`, `Dropdown` and tooltip in this window flows + // through the native layers — including their screen-aware placement + // (#569), which keeps a combo box popup on the display when the window + // sits at its bottom edge. Supported on all three platforms. + nativePopupLayers: Boolean = false, content: @Composable NucleusDecoratedWindowScope.() -> Unit, ) { val windowStyle = rememberJewelWindowStyle() @@ -156,6 +107,7 @@ public fun NucleusApplicationScope.JewelDecoratedWindow( forceX11 = forceX11, undecorated = undecorated, popupFor = popupFor, + nativePopupLayers = nativePopupLayers, nativeContextMenu = nativeContextMenu, hiddenFromDock = hiddenFromDock, minimumSize = minimumSize, diff --git a/decorated-window-jni/api/decorated-window-jni.api b/decorated-window-jni/api/decorated-window-jni.api deleted file mode 100644 index 41630162a..000000000 --- a/decorated-window-jni/api/decorated-window-jni.api +++ /dev/null @@ -1,80 +0,0 @@ -public final class dev/nucleusframework/window/ComposableSingletons$DecoratedWindowKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DecoratedWindowKt; - public fun ()V - public final fun getLambda$1409273974$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function3; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt; - public fun ()V - public final fun getLambda$-1656225001$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$-1991905136$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBar_LinuxKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBar_LinuxKt; - public fun ()V - public final fun getLambda$1500723390$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBar_MacOSKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBar_MacOSKt; - public fun ()V - public final fun getLambda$-1851474385$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-238371298$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBar_WindowsKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBar_WindowsKt; - public fun ()V - public final fun getLambda$2067210846$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBarKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBarKt; - public fun ()V - public final fun getLambda$-880964242$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-985436865$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1948865750$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$555209157$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBar_LinuxKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBar_LinuxKt; - public fun ()V - public final fun getLambda$-1516208515$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$138814254$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBar_MacOSKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBar_MacOSKt; - public fun ()V - public final fun getLambda$-268479267$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1386543502$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/ComposableSingletons$TitleBar_WindowsKt { - public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBar_WindowsKt; - public fun ()V - public final fun getLambda$-1069615779$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1496373646$Nucleus_decorated_window_jni ()Lkotlin/jvm/functions/Function2; -} - -public final class dev/nucleusframework/window/DecoratedDialogKt { - public static final fun DecoratedDialog (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V -} - -public final class dev/nucleusframework/window/DecoratedWindowKt { - public static final fun DecoratedWindow-a32mfzs (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V -} - -public final class dev/nucleusframework/window/DialogTitleBarKt { - public static final fun BasicDialogTitleBar-TgFrcIs (Ldev/nucleusframework/window/DecoratedDialogScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V - public static final fun DialogTitleBar-FU0evQE (Ldev/nucleusframework/window/DecoratedDialogScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V -} - -public final class dev/nucleusframework/window/TitleBarKt { - public static final fun BasicTitleBar-lVb_Clg (Ldev/nucleusframework/window/DecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V - public static final fun TitleBar-TgFrcIs (Ldev/nucleusframework/window/DecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V -} - diff --git a/decorated-window-jni/build.gradle.kts b/decorated-window-jni/build.gradle.kts deleted file mode 100644 index 22c7f42b9..000000000 --- a/decorated-window-jni/build.gradle.kts +++ /dev/null @@ -1,76 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - id("nucleus.native-module") - alias(libs.plugins.kotlinComposePlugin) - alias(libs.plugins.jetbrainsCompose) - alias(libs.plugins.vanniktechMavenPublish) -} - -val publishVersion = - providers - .environmentVariable("GITHUB_REF") - .orNull - ?.removePrefix("refs/tags/v") - ?: "1.0.0" - -dependencies { - api(project(":decorated-window-core")) - api(project(":decorated-window-awt")) - implementation(project(":core-runtime")) - implementation(libs.compose.desktop.common) -} - -java { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_11) - } -} - -nucleusNative { - macos("nucleus_macos_jni") - windows("nucleus_windows_decoration") - linux("nucleus_linux_jni") -} - -mavenPublishing { - coordinates("dev.nucleusframework", "nucleus.decorated-window-jni", publishVersion) - - pom { - name.set("Nucleus Decorated Window JNI") - description.set("JBR-free custom decorated window with native title bar for Compose Desktop (via JNI)") - url.set("https://github.com/NucleusFramework/Nucleus") - - licenses { - license { - name.set("MIT License") - url.set("https://opensource.org/licenses/MIT") - } - } - - developers { - developer { - id.set("nucleusframework") - name.set("NucleusFramework") - url.set("https://github.com/NucleusFramework") - } - } - - scm { - url.set("https://github.com/NucleusFramework/Nucleus") - connection.set("scm:git:git://github.com/NucleusFramework/Nucleus.git") - developerConnection.set("scm:git:ssh://git@github.com/NucleusFramework/Nucleus.git") - } - } - - publishToMavenCentral() - if (project.hasProperty("signingInMemoryKey")) { - signAllPublications() - } -} diff --git a/decorated-window-jni/detekt-baseline.xml b/decorated-window-jni/detekt-baseline.xml deleted file mode 100644 index 455550712..000000000 --- a/decorated-window-jni/detekt-baseline.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - UndocumentedPublicFunction:DecoratedDialog.kt:@Suppress("FunctionNaming", "LongParameterList") @Composable public fun DecoratedDialog - UndocumentedPublicFunction:DecoratedWindow.kt:@Suppress("FunctionNaming", "LongParameterList", "CyclomaticComplexMethod", "LongMethod") @Composable public fun DecoratedWindow - UndocumentedPublicFunction:DialogTitleBar.kt:@Suppress("FunctionNaming") @Composable public fun DecoratedDialogScope.BasicDialogTitleBar - UndocumentedPublicFunction:DialogTitleBar.kt:@Suppress("FunctionNaming") @Composable public fun DecoratedDialogScope.DialogTitleBar - UndocumentedPublicFunction:TitleBar.kt:@Suppress("FunctionNaming", "LongParameterList") @Composable public fun DecoratedWindowScope.BasicTitleBar - - diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DecoratedDialog.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DecoratedDialog.kt deleted file mode 100644 index 49e32caaf..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DecoratedDialog.kt +++ /dev/null @@ -1,67 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogState -import androidx.compose.ui.window.DialogWindow -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.rememberDialogState -import dev.nucleusframework.core.runtime.Platform - -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun DecoratedDialog( - onCloseRequest: () -> Unit, - state: DialogState = rememberDialogState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = false, - enabled: Boolean = true, - focusable: Boolean = true, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable AwtDecoratedDialogScope.() -> Unit, -) { - val undecorated = Platform.Linux == Platform.Current || Platform.Windows == Platform.Current - - // Centre the dialog on its parent window before DialogWindow is composed. - // AWT window coordinates and Compose Dp are 1:1 for window positioning, - // so we can mix parent AWT bounds with state.size.value directly. - remember(state) { - val parent = - java.awt.KeyboardFocusManager - .getCurrentKeyboardFocusManager() - .focusedWindow - if (parent != null && !state.position.isSpecified) { - val x = parent.x + (parent.width - state.size.width.value) / 2f - val y = parent.y + (parent.height - state.size.height.value) / 2f - state.position = WindowPosition(x = x.dp, y = y.dp) - } - } - - DialogWindow( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - undecorated = undecorated, - transparent = false, - resizable = resizable, - enabled = enabled, - focusable = focusable, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - ) { - DecoratedDialogBody( - title = title, - icon = icon, - undecorated = undecorated, - content = content, - ) - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DecoratedWindow.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DecoratedWindow.kt deleted file mode 100644 index 5681d0059..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DecoratedWindow.kt +++ /dev/null @@ -1,494 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalContext -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.compositionLocalOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.FrameWindowScope -import androidx.compose.ui.window.Window -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowPosition -import androidx.compose.ui.window.WindowState -import androidx.compose.ui.window.rememberWindowState -import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.internal.InstallMinimumSizeAfterCentering -import dev.nucleusframework.window.internal.inflateToMinimumSize -import dev.nucleusframework.window.utils.linux.JniLinuxWindowBridge -import dev.nucleusframework.window.utils.windows.JniWindowsDecorationBridge -import dev.nucleusframework.window.utils.windows.JniWindowsWindowUtil -import java.awt.Frame -import java.awt.GraphicsEnvironment -import java.awt.Toolkit - -/** - * Composition local that indicates whether the window is currently in - * native (JNI-managed) fullscreen mode. - */ -internal val LocalNativeFullscreen = compositionLocalOf { false } - -/** - * Composition local providing a callback to exit native fullscreen. - */ -internal val LocalExitFullscreen = compositionLocalOf<(() -> Unit)?> { null } - -/** - * Holder for the fullscreen title bar content. - * [NativeWindowsTitleBar] stores its rendering lambda here when in fullscreen, - * and [DecoratedWindow] renders it as an overlay outside the normal layout. - * - * [compositionLocalContext] captures the CompositionLocal context from the - * original position in the tree (inside user content) so the overlay can - * replay it and make user-provided CompositionLocals available. - */ -internal class FullscreenTitleBarHolder { - var content: (@Composable () -> Unit)? by mutableStateOf(null) - var titleBarHeight: Dp by mutableStateOf(0.dp) - var compositionLocalContext: CompositionLocalContext? by mutableStateOf(null) -} - -internal val LocalFullscreenTitleBarHolder = compositionLocalOf { null } - -@Suppress("FunctionNaming", "LongParameterList", "CyclomaticComplexMethod", "LongMethod") -@Composable -public fun DecoratedWindow( - onCloseRequest: () -> Unit, - state: WindowState = rememberWindowState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = true, - enabled: Boolean = true, - focusable: Boolean = true, - alwaysOnTop: Boolean = false, - minimumSize: DpSize? = null, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable AwtDecoratedWindowScope.() -> Unit, -) { - val undecorated = - when (Platform.Current) { - Platform.Windows -> !JniWindowsDecorationBridge.isLoaded - Platform.Linux -> true - else -> false - } - - val useNativeFullscreen = - (Platform.Current == Platform.Windows && JniWindowsDecorationBridge.isLoaded) || - (Platform.Current == Platform.Linux && JniLinuxWindowBridge.isLoaded) - val windowState = - if (useNativeFullscreen) { - remember(state) { NativeFullscreenWindowState(state) } - } else { - state - } - - state.inflateToMinimumSize(minimumSize) - - // ── First-frame maximized fix ────────────────────────────────────── - // When starting with WindowPlacement.Maximized, Compose's Window - // creates the AWT window at state.size (e.g. 800×600) and renders - // the first Skia frame at that size before the WM processes the - // maximize. Override state.size with the screen work area so the - // first frame matches the maximized dimensions. - remember(state) { - if (state.placement == WindowPlacement.Maximized) { - val ge = GraphicsEnvironment.getLocalGraphicsEnvironment() - val gc = ge.defaultScreenDevice.defaultConfiguration - val bounds = gc.bounds - val insets = Toolkit.getDefaultToolkit().getScreenInsets(gc) - val scale = gc.defaultTransform.scaleX.toFloat() - state.size = - DpSize( - ((bounds.width - insets.left - insets.right) / scale).dp, - ((bounds.height - insets.top - insets.bottom) / scale).dp, - ) - } - } - - Window( - onCloseRequest, - windowState, - visible, - title, - icon, - undecorated, - transparent = false, - resizable, - enabled, - focusable, - alwaysOnTop, - onPreviewKeyEvent, - onKeyEvent, - ) { - InstallMinimumSizeAfterCentering(minimumSize) - - if (useNativeFullscreen) { - NativeFullscreenEffect(state, windowState) - if (Platform.Current == Platform.Windows) { - NativeFullscreenSyncEffect(state, windowState) - } - } - - val isNativeFullscreen = useNativeFullscreen && state.placement == WindowPlacement.Fullscreen - val exitFullscreen: (() -> Unit)? = - if (isNativeFullscreen) { - { - val target = - (windowState as? NativeFullscreenWindowState) - ?.placementBeforeFullscreen ?: WindowPlacement.Floating - state.placement = target - } - } else { - null - } - - val titleBarHolder = remember { FullscreenTitleBarHolder() } - - // Clear holder content when leaving fullscreen. - // On macOS, fullscreen is managed by AppKit (toggleFullScreen:), not by - // our JNI mechanism. Compose's WindowState.placement reflects the - // NSWindowStyleMaskFullScreen style mask, making it a reliable proxy - // for macOS native fullscreen state. - val isMacOSFullscreen = - Platform.Current == Platform.MacOS && state.placement == WindowPlacement.Fullscreen - if (!isNativeFullscreen && !isMacOSFullscreen) { - titleBarHolder.content = null - } - - var fullscreenBarVisible by remember { mutableStateOf(false) } - val density = LocalDensity.current - - LaunchedEffect(isNativeFullscreen) { - if (!isNativeFullscreen) fullscreenBarVisible = false - } - - Box( - modifier = - if (isNativeFullscreen) { - Modifier.pointerInput(titleBarHolder.titleBarHeight) { - val titleBarHeightPx = with(density) { titleBarHolder.titleBarHeight.toPx() } - awaitPointerEventScope { - while (true) { - val event = awaitPointerEvent(PointerEventPass.Initial) - val y = - event.changes - .firstOrNull() - ?.position - ?.y ?: continue - fullscreenBarVisible = y < titleBarHeightPx - } - } - } - } else { - Modifier - }, - ) { - CompositionLocalProvider( - LocalNativeFullscreen provides isNativeFullscreen, - LocalExitFullscreen provides exitFullscreen, - LocalFullscreenTitleBarHolder provides titleBarHolder, - ) { - DecoratedWindowBody( - title = title, - icon = icon, - undecorated = undecorated, - onCloseRequest = onCloseRequest, - content = content, - ) - - FullscreenTitleBarRenderers( - titleBarHolder = titleBarHolder, - isNativeFullscreen = isNativeFullscreen, - fullscreenBarVisible = fullscreenBarVisible, - title = title, - icon = icon, - ) - } - } - } -} - -/** - * Renders the fullscreen title bar overlay(s), wrapping with the captured - * [CompositionLocalContext] so user-provided CompositionLocals remain available. - */ -@Suppress("FunctionNaming") -@Composable -private fun BoxScope.FullscreenTitleBarRenderers( - titleBarHolder: FullscreenTitleBarHolder, - isNativeFullscreen: Boolean, - fullscreenBarVisible: Boolean, - title: String, - icon: Painter?, -) { - val ctx = titleBarHolder.compositionLocalContext - val wrapper: @Composable (@Composable () -> Unit) -> Unit = - if (ctx != null) { - { content -> CompositionLocalProvider(ctx) { content() } } - } else { - { content -> content() } - } - - val titleBarInfo = remember { TitleBarInfo(title, icon) } - LaunchedEffect(title) { titleBarInfo.title = title } - LaunchedEffect(icon) { titleBarInfo.icon = icon } - - if (isNativeFullscreen) { - wrapper { - CompositionLocalProvider(LocalTitleBarInfo provides titleBarInfo) { - FullscreenTitleBarOverlay( - holder = titleBarHolder, - visible = fullscreenBarVisible, - modifier = Modifier.align(Alignment.TopCenter), - ) - } - } - } - - // macOS: always-visible overlay managed by MacOSTitleBar - // (newFullscreenControls sets holder.content during macOS fullscreen) - if (!isNativeFullscreen && titleBarHolder.content != null) { - wrapper { - CompositionLocalProvider(LocalTitleBarInfo provides titleBarInfo) { - Box(modifier = Modifier.align(Alignment.TopCenter)) { - titleBarHolder.content?.invoke() - } - } - } - } -} - -/** - * Renders the fullscreen title bar as a sliding overlay. - * Hidden above the top edge by default; slides down when [visible] is true. - * - * Visibility is controlled by the parent via [PointerEventPass.Initial] tracking - * on the root Box, which receives all pointer events without blocking content clicks. - */ -@Suppress("FunctionNaming") -@Composable -private fun FullscreenTitleBarOverlay( - holder: FullscreenTitleBarHolder, - visible: Boolean, - modifier: Modifier = Modifier, -) { - val titleBarContent = holder.content ?: return - val titleBarHeight = holder.titleBarHeight - - val offsetY by animateDpAsState( - targetValue = if (visible) 0.dp else -titleBarHeight, - animationSpec = tween(durationMillis = 200), - ) - - Box( - modifier = - modifier - .fillMaxWidth() - .offset(y = offsetY), - ) { - titleBarContent() - } -} - -/** - * Watches [state].placement and enters/exits native fullscreen accordingly. - * A local [isNativeFullscreen] flag guards against redundant JNI calls if - * [snapshotFlow] emits the same placement multiple times in quick succession. - * - * Works on both Windows (Win32 fullscreen) and Linux (_NET_WM_STATE_FULLSCREEN). - */ -@Composable -private fun FrameWindowScope.NativeFullscreenEffect( - state: WindowState, - windowState: WindowState, -) { - LaunchedEffect(state, window) { - var isNativeFullscreen = false - // Track the last non-Fullscreen placement so we can restore it correctly - // on exit — e.g. Maximized instead of always falling back to Floating. - var lastNonFullscreenPlacement = - state.placement.takeIf { it != WindowPlacement.Fullscreen } - ?: WindowPlacement.Floating - snapshotFlow { state.placement }.collect { placement -> - if (placement != WindowPlacement.Fullscreen) { - lastNonFullscreenPlacement = placement - } - if (placement == WindowPlacement.Fullscreen && !isNativeFullscreen) { - // Persist the pre-fullscreen placement so the exit callback - // restores the correct state (Maximized, Floating, etc.). - (windowState as? NativeFullscreenWindowState) - ?.placementBeforeFullscreen = lastNonFullscreenPlacement - when (Platform.Current) { - Platform.Windows -> { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) JniWindowsDecorationBridge.nativeSetFullscreen(hwnd, true) - } - Platform.Linux -> { - JniLinuxWindowBridge.nativeSetFullscreen(window, true) - } - else -> {} - } - isNativeFullscreen = true - } else if (placement != WindowPlacement.Fullscreen && isNativeFullscreen) { - when (Platform.Current) { - Platform.Windows -> { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) JniWindowsDecorationBridge.nativeSetFullscreen(hwnd, false) - // Safety net: ensure AWT's extendedState matches the - // restored placement. SetWindowPlacement sends the proper - // WM_SIZE events, but AWT may still miss the maximize - // notification if it processed an intermediate resize - // during style restoration. Explicitly setting extendedState - // guarantees DecoratedWindowState.isMaximized stays in sync. - if (lastNonFullscreenPlacement == WindowPlacement.Maximized) { - window.extendedState = Frame.MAXIMIZED_BOTH - } else { - window.extendedState = - window.extendedState and Frame.MAXIMIZED_BOTH.inv() - } - } - Platform.Linux -> { - JniLinuxWindowBridge.nativeSetFullscreen(window, false) - } - else -> {} - } - // The caller may have written any non-Fullscreen value as a - // trigger to exit (e.g. Floating regardless of previous state). - // Override the delegate with the actual pre-fullscreen placement - // so Compose's Window composable syncs to the correct state and - // does not fight the native SetWindowPlacement restoration. - if (placement != lastNonFullscreenPlacement) { - state.placement = lastNonFullscreenPlacement - } - isNativeFullscreen = false - } - } - } -} - -// ────────────────────────────────────────────────────────────────────── -// NativeFullscreenSyncEffect (Windows only) -// ────────────────────────────────────────────────────────────────────── - -/** - * Attaches a [java.awt.event.ComponentListener] that detects when the native - * window is resized while [state].placement is [WindowPlacement.Fullscreen]. - * - * This covers the case where the WM_SIZE safety net in the native WndProc - * clears [isFullscreen] (e.g. because AWT called ShowWindow directly, bypassing - * WM_SYSCOMMAND blocking). When a resize is detected and [nativeIsFullscreen] - * returns false, Kotlin's placement is restored to the pre-fullscreen value so - * the two layers stay in sync. - * - * Setting [state].placement from the AWT event thread is safe because - * Compose's [mutableStateOf] backing is thread-safe. - */ -@Composable -private fun FrameWindowScope.NativeFullscreenSyncEffect( - state: WindowState, - windowState: WindowState, -) { - DisposableEffect(window) { - val listener = - object : java.awt.event.ComponentAdapter() { - override fun componentResized(e: java.awt.event.ComponentEvent) { - if (state.placement != WindowPlacement.Fullscreen) return - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L && !JniWindowsDecorationBridge.nativeIsFullscreen(hwnd)) { - val previous = - (windowState as? NativeFullscreenWindowState) - ?.placementBeforeFullscreen ?: WindowPlacement.Floating - state.placement = previous - } - } - } - window.addComponentListener(listener) - onDispose { window.removeComponentListener(listener) } - } -} - -// ────────────────────────────────────────────────────────────────────── -// NativeFullscreenWindowState wrapper -// ────────────────────────────────────────────────────────────────────── - -/** - * Wraps a [WindowState] to prevent Compose from seeing [WindowPlacement.Fullscreen]. - * - * When the delegate's placement is Fullscreen, this wrapper: - * - **getter**: returns the placement that was active before fullscreen, so Compose's - * Window never triggers its own (broken) exclusive fullscreen mode. - * - **setter**: blocks all writes from Compose's internal sync (which would overwrite - * the Fullscreen value with Floating/Maximized and trigger an immediate exit). - * - * User code writes directly to the delegate (the original [WindowState]), not through - * this wrapper. Only Compose's [Window] composable writes through the wrapper. - */ -internal class NativeFullscreenWindowState( - private val delegate: WindowState, -) : WindowState { - internal var placementBeforeFullscreen: WindowPlacement = - delegate.placement.takeIf { it != WindowPlacement.Fullscreen } - ?: WindowPlacement.Floating - - /** True when the delegate holds [WindowPlacement.Fullscreen]. */ - private val isInNativeFullscreen: Boolean - get() = delegate.placement == WindowPlacement.Fullscreen - - override var placement: WindowPlacement - get() { - val p = delegate.placement - return if (p == WindowPlacement.Fullscreen) placementBeforeFullscreen else p - } - set(value) { - if (isInNativeFullscreen) return - - if (delegate.placement != WindowPlacement.Fullscreen && value == WindowPlacement.Fullscreen) { - placementBeforeFullscreen = delegate.placement - } - delegate.placement = value - } - - override var isMinimized: Boolean - get() = delegate.isMinimized - set(value) { - if (isInNativeFullscreen) return - delegate.isMinimized = value - } - - override var position: WindowPosition - get() = delegate.position - set(value) { - if (isInNativeFullscreen) return - delegate.position = value - } - - override var size: DpSize - get() = delegate.size - set(value) { - if (isInNativeFullscreen) return - delegate.size = value - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Linux.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Linux.kt deleted file mode 100644 index b77c7b292..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Linux.kt +++ /dev/null @@ -1,155 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.unit.LayoutDirection -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.linux.JniLinuxWindowBridge -import dev.nucleusframework.window.utils.linux.rememberLinuxButtonLayout -import java.awt.MouseInfo - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedDialogScope.LinuxDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val controlDir = controlButtonsDirection.resolve() - val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight - val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start - - if (JniLinuxWindowBridge.isLoaded) { - NativeLinuxDialogTitleBar( - modifier, - gradientStartColor, - style, - controlDir, - layoutPolicy, - controlsSide, - content, - ) - } else { - FallbackLinuxDialogTitleBar( - modifier, - gradientStartColor, - style, - controlDir, - layoutPolicy, - controlsSide, - content, - ) - } -} - -// Native dialog title bar: uses JNI _NET_WM_MOVERESIZE for native WM drag. -// No double-click behavior for dialogs. -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -private fun AwtDecoratedDialogScope.NativeLinuxDialogTitleBar( - modifier: Modifier, - gradientStartColor: Color, - style: TitleBarStyle, - controlButtonsDirection: LayoutDirection, - layoutPolicy: TitleBarLayoutPolicy, - controlsSide: WindowControlsSide, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit, -) { - val linuxStyle = createLinuxTitleBarStyle(style) - val dialogState = state - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> kdePaddingForButtonLayout() }, - backgroundContent = { - Spacer( - modifier = - Modifier - .fillMaxSize() - .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - // Initiate native WM move - val mouseLocation = MouseInfo.getPointerInfo()?.location - if (mouseLocation != null) { - JniLinuxWindowBridge.nativeStartWindowMove( - window, - mouseLocation.x, - mouseLocation.y, - 1, - ) - } - } - }, - ) - }, - ) { _ -> - DialogCloseButton(window, dialogState, linuxStyle) - content(dialogState) - } - } -} - -// Fallback dialog title bar: Compose-based drag (no native lib). -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -private fun AwtDecoratedDialogScope.FallbackLinuxDialogTitleBar( - modifier: Modifier, - gradientStartColor: Color, - style: TitleBarStyle, - controlButtonsDirection: LayoutDirection, - layoutPolicy: TitleBarLayoutPolicy, - controlsSide: WindowControlsSide, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit, -) { - val linuxStyle = createLinuxTitleBarStyle(style) - val dialogState = state - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - DialogTitleBarImpl( - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - // No double-click behavior for dialogs, drag is handled by the background Spacer. - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - // Intentional no-op. - } - }, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> kdePaddingForButtonLayout() }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { _ -> - DialogCloseButton(window, dialogState, linuxStyle) - content(dialogState) - } - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.MacOS.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.MacOS.kt deleted file mode 100644 index 1988f34d6..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.MacOS.kt +++ /dev/null @@ -1,68 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.macos.JniMacTitleBarBridge -import dev.nucleusframework.window.utils.macos.JniMacWindowUtil - -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedDialogScope.MacOSDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val controlDir = controlButtonsDirection.resolve() - val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.End else WindowControlsSide.Start - - DisposableEffect(window) { - onDispose { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L) JniMacTitleBarBridge.nativeResetTitleBar(ptr) - } - } - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - DialogTitleBarImpl( - modifier = modifier.titleBarHitTestHandler(window), - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { height, _ -> - JniMacWindowUtil.applyWindowProperties(window) - - val ptr = JniMacWindowUtil.getWindowPtr(window) - val leftInset = - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeApplyTitleBar(ptr, height.value) - } else { - @Suppress("MagicNumber") - val shrink = minOf(height.value / 28f, 1f) - @Suppress("MagicNumber") - height.value + 2f * shrink * 20f - } - val padding = PaddingValues(start = leftInset.dp) - padding - }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize()) - }, - content = content, - ) - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Windows.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Windows.kt deleted file mode 100644 index 6d1968da4..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.Windows.kt +++ /dev/null @@ -1,68 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.windows.JniWindowsDecorationBridge -import dev.nucleusframework.window.utils.windows.JniWindowsWindowUtil - -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedDialogScope.WindowsDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val controlDir = controlButtonsDirection.resolve() - val controlsSide = if (controlDir == LayoutDirection.Rtl) WindowControlsSide.Start else WindowControlsSide.End - - if (JniWindowsDecorationBridge.isLoaded) { - DisposableEffect(window) { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) JniWindowsDecorationBridge.nativeApplyDialogStyle(hwnd) - onDispose { - val h = JniWindowsWindowUtil.getHwnd(window) - if (h != 0L) JniWindowsDecorationBridge.nativeUninstallDecoration(h) - } - } - - val titleBarBackground = style.colors.background - LaunchedEffect(window, titleBarBackground) { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - JniWindowsDecorationBridge.nativeSetBackgroundColor(hwnd, titleBarBackground.toArgb()) - } - } - } - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - DialogTitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { dialogState -> - WindowsDialogCloseButton(window, dialogState, style) - content(dialogState) - } - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt deleted file mode 100644 index b97c822d9..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt +++ /dev/null @@ -1,82 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle - -@Suppress("FunctionNaming") -@Composable -public fun DecoratedDialogScope.DialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - BasicDialogTitleBar( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = TitleBarLayoutPolicy.Default, - content = content, - ) -} - -@Suppress("FunctionNaming") -@Composable -public fun DecoratedDialogScope.BasicDialogTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - content: @Composable TitleBarScope.(DecoratedDialogState) -> Unit = {}, -) { - val dialogTitleBarInfo = LocalDialogTitleBarInfo.current - val titleBarInfo = remember { TitleBarInfo(dialogTitleBarInfo.title, dialogTitleBarInfo.icon) } - LaunchedEffect(dialogTitleBarInfo.title) { titleBarInfo.title = dialogTitleBarInfo.title } - LaunchedEffect(dialogTitleBarInfo.icon) { titleBarInfo.icon = dialogTitleBarInfo.icon } - val awtScope = this as AwtDecoratedDialogScope - CompositionLocalProvider( - LocalTitleBarInfo provides titleBarInfo, - ) { - when (Platform.Current) { - Platform.Linux -> - awtScope.LinuxDialogTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - content, - ) - Platform.Windows -> - awtScope.WindowsDialogTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - content, - ) - Platform.MacOS -> - awtScope.MacOSDialogTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - content, - ) - Platform.Unknown -> - error("DialogTitleBar is not supported on this platform(${System.getProperty("os.name")})") - } - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.Linux.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.Linux.kt deleted file mode 100644 index f7e43196c..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.Linux.kt +++ /dev/null @@ -1,240 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.currentCompositionLocalContext -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.linux.JniLinuxWindowBridge -import dev.nucleusframework.window.utils.linux.rememberLinuxButtonLayout -import java.awt.Frame -import java.awt.MouseInfo - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedWindowScope.LinuxTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - val controlDir = controlButtonsDirection.resolve() - val controlsOnRight = rememberLinuxButtonLayout().controlsOnRight - val controlsSide = if (controlsOnRight) WindowControlsSide.End else WindowControlsSide.Start - - if (JniLinuxWindowBridge.isLoaded) { - NativeLinuxTitleBar( - modifier, - gradientStartColor, - style, - controlDir, - layoutPolicy, - controlsSide, - backgroundContent, - content, - ) - } else { - FallbackLinuxTitleBar( - modifier, - gradientStartColor, - style, - controlDir, - layoutPolicy, - controlsSide, - backgroundContent, - content, - ) - } -} - -// Native title bar: uses JNI to send _NET_WM_MOVERESIZE for native WM drag. -// Double-click to maximize is handled in Compose. -// Supports fullscreen sliding overlay via newFullscreenControls modifier. -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -private fun AwtDecoratedWindowScope.NativeLinuxTitleBar( - modifier: Modifier, - gradientStartColor: Color, - style: TitleBarStyle, - controlButtonsDirection: LayoutDirection, - layoutPolicy: TitleBarLayoutPolicy, - controlsSide: WindowControlsSide, - backgroundContent: @Composable () -> Unit, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, -) { - val linuxStyle = createLinuxTitleBarStyle(style) - val viewConfig = LocalViewConfiguration.current - var lastPressTime = 0L - - val isNativeFullscreen = LocalNativeFullscreen.current - val onExitFullscreen = LocalExitFullscreen.current - val useNewFullscreenControls = modifier.hasNewFullscreenControls() - - // ── Fullscreen with newFullscreenControls: sliding overlay ── - if (isNativeFullscreen && useNewFullscreenControls) { - val holder = LocalFullscreenTitleBarHolder.current - if (holder != null) { - holder.compositionLocalContext = currentCompositionLocalContext - holder.titleBarHeight = linuxStyle.metrics.height - holder.content = { - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - ) { currentState -> - WindowControlArea( - window = window, - state = currentState, - style = linuxStyle, - isFullscreen = true, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) - } - } - } - } - return - } - - // ── Normal title bar (or fullscreen without newFullscreenControls) ── - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> - kdePaddingForButtonLayout() - }, - backgroundContent = { - backgroundContent() - Spacer( - modifier = - Modifier - .fillMaxSize() - .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - val elapsed = now - lastPressTime - if ( - elapsed in - viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis - ) { - // Double-click: toggle maximize - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else if (window.isResizable) { - window.extendedState = Frame.MAXIMIZED_BOTH - } - } else { - // Single press: initiate native WM move - val mouseLocation = MouseInfo.getPointerInfo()?.location - if (mouseLocation != null) { - JniLinuxWindowBridge.nativeStartWindowMove( - window, - mouseLocation.x, - mouseLocation.y, - 1, - ) - } - } - lastPressTime = now - } - }, - ) - }, - ) { currentState -> - WindowControlArea( - window = window, - state = currentState, - style = linuxStyle, - isFullscreen = isNativeFullscreen, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) - } - } -} - -// Fallback title bar: Compose-based drag and double-click (no native lib). -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -private fun AwtDecoratedWindowScope.FallbackLinuxTitleBar( - modifier: Modifier, - gradientStartColor: Color, - style: TitleBarStyle, - controlButtonsDirection: LayoutDirection, - layoutPolicy: TitleBarLayoutPolicy, - controlsSide: WindowControlsSide, - backgroundContent: @Composable () -> Unit, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, -) { - val linuxStyle = createLinuxTitleBarStyle(style) - val viewConfig = LocalViewConfiguration.current - - var lastPress = 0L - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - // Detect double-click to maximize/restore on the title bar area - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else if (window.isResizable) { - window.extendedState = Frame.MAXIMIZED_BOTH - } - } - lastPress = now - } - }, - gradientStartColor = gradientStartColor, - style = linuxStyle, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> - kdePaddingForButtonLayout() - }, - backgroundContent = { - backgroundContent() - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { currentState -> - WindowControlArea(window, currentState, linuxStyle) - content(currentState) - } - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.MacOS.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.MacOS.kt deleted file mode 100644 index 112179daf..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.MacOS.kt +++ /dev/null @@ -1,285 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.macos.JniMacTitleBarBridge -import dev.nucleusframework.window.utils.macos.JniMacWindowUtil -import kotlinx.coroutines.isActive -import kotlin.coroutines.coroutineContext - -private const val MENU_BAR_ANIMATION_MS = 200 - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming", "LongMethod", "CyclomaticComplexMethod") -@Composable -internal fun AwtDecoratedWindowScope.MacOSTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - val useNewFullscreenControls = modifier.hasNewFullscreenControls() - val useLargeCornerRadius = modifier.hasMacOSLargeCornerRadius() - - // Notify native side about the newFullscreenControls preference - DisposableEffect(window, useNewFullscreenControls) { - if (useNewFullscreenControls) { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeSetNewFullscreenControls(ptr, true) - } - } - onDispose { - if (useNewFullscreenControls) { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeSetNewFullscreenControls(ptr, false) - } - } - } - } - - // Install/remove invisible NSToolbar for 26pt corner radius - DisposableEffect(window, useLargeCornerRadius) { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeSetLargeCornerRadius(ptr, useLargeCornerRadius) - } - onDispose { - if (useLargeCornerRadius) { - val ptr2 = JniMacWindowUtil.getWindowPtr(window) - if (ptr2 != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeSetLargeCornerRadius(ptr2, false) - } - } - } - } - - DisposableEffect(window) { - onDispose { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L) { - JniMacTitleBarBridge.nativeResetTitleBar(ptr) - JniMacTitleBarBridge.removeMenuBarOffsetFlow(ptr) - } - } - } - - // Sync RTL state with native side so traffic-light buttons move to the - // correct side. Uses the control buttons direction (decoupled from content). - val controlDir = controlButtonsDirection.resolve() - val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.End else WindowControlsSide.Start - LaunchedEffect(window, controlIsRtl) { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeSetRTL(ptr, controlIsRtl) - } - } - - val background by style.colors.backgroundFor(state) - - // ── Menu bar offset for fullscreen ── - // In fullscreen on non-notch screens, the system menu bar auto-hides. - // When it appears (mouse at top), it pushes the title bar down — and - // since the title bar is in the normal layout, the content below it - // is pushed down too (like Safari). On notch screens the menu bar - // lives in the notch area so the offset stays at 0. - val isFullscreenWithNewControls = state.isFullscreen && useNewFullscreenControls - - // Install/remove the native menu bar monitor during fullscreen. - // The ptr is evaluated inside the effect so it picks up the AWT peer - // even if it wasn't available at initial composition. - DisposableEffect(window, isFullscreenWithNewControls) { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (isFullscreenWithNewControls && ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeInstallMenuBarMonitor(ptr) - } - onDispose { - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeRemoveMenuBarMonitor(ptr) - } - } - } - - // Collect the menu bar offset. The ptr must be fresh here too. - val currentPtr = JniMacWindowUtil.getWindowPtr(window) - val menuBarOffsetPt by remember(currentPtr) { - JniMacTitleBarBridge.menuBarOffsetFlow(currentPtr) - }.collectAsState() - - val menuBarOffset by animateDpAsState( - targetValue = if (isFullscreenWithNewControls) menuBarOffsetPt.dp else 0.dp, - animationSpec = tween(durationMillis = MENU_BAR_ANIMATION_MS), - ) - - // Push animated offset to native so traffic-light buttons follow. - LaunchedEffect(menuBarOffset) { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeSetMenuBarOffset(ptr, menuBarOffset.value) - } - } - - // ── Title bar (always in layout, never overlay) ── - val viewConfig = LocalViewConfiguration.current - var lastPress = 0L - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = - Modifier - .offset(y = menuBarOffset) - .zIndex(if (menuBarOffset > 0.dp) 1f else 0f) - .then(modifier) - .titleBarHitTestHandler(window) - .onPointerEvent(PointerEventType.Press, PointerEventPass.Final) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - if ( - now - lastPress in - viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis - ) { - val p = JniMacWindowUtil.getWindowPtr(window) - if (p != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativePerformTitleBarDoubleClickAction(p) - } - } - lastPress = now - } - }, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlDir, - layoutPolicy = layoutPolicy, - applyTitleBar = { height, titleBarState -> - JniMacWindowUtil.applyWindowProperties(window) - - val p = JniMacWindowUtil.getWindowPtr(window) - val padding = - if (titleBarState.isFullscreen) { - if (controlIsRtl) { - PaddingValues(end = 80.dp) - } else { - PaddingValues(start = 80.dp) - } - } else { - val buttonInset = - if (p != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeApplyTitleBar(p, height.value) - } else { - @Suppress("MagicNumber") - val shrink = minOf(height.value / 28f, 1f) - - @Suppress("MagicNumber") - val leftMargin = minOf(height.value / 2f, 20f) - - @Suppress("MagicNumber") - 2f * leftMargin + 2f * shrink * 20f - } - if (controlIsRtl) { - PaddingValues(end = buttonInset.dp) - } else { - PaddingValues(start = buttonInset.dp) - } - } - padding - }, - onPlace = { - if (state.isFullscreen) { - val p = JniMacWindowUtil.getWindowPtr(window) - if (p != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeUpdateFullScreenButtons(p) - } - } - }, - backgroundContent = { - Spacer(modifier = Modifier.fillMaxSize()) - backgroundContent() - }, - content = content, - ) - } -} - -/** - * Mirrors JBR's `customTitleBarMouseEventHandler` / `forceHitTest` approach. - * Runs on the parent modifier (Main pass, after children have processed events). - * - * - Unconsumed Press → marks a pending drag (button down on empty title bar area). - * - Unconsumed Move while pending → initiates native window drag via JNI. - * - Consumed Press → enters `inUserControl` (interactive child handles it). - * - Release → resets state. - * - * The native NucleusDragView is a pure pass-through; all drag decisions live here. - */ -internal fun Modifier.titleBarHitTestHandler(window: java.awt.Window): Modifier = - pointerInput(window) { - val ctx = coroutineContext - awaitPointerEventScope { - var inUserControl = false - var pendingDrag = false - while (ctx.isActive) { - val event = awaitPointerEvent(PointerEventPass.Main) - event.changes.forEach { - if (!it.isConsumed && !inUserControl) { - when (event.type) { - PointerEventType.Press -> pendingDrag = true - PointerEventType.Move -> - if (pendingDrag) { - startWindowDrag(window) - pendingDrag = false - } - PointerEventType.Release -> pendingDrag = false - } - } else { - if (event.type == PointerEventType.Press) { - inUserControl = true - pendingDrag = false - } - if (event.type == PointerEventType.Release) { - inUserControl = false - } - } - } - } - } - } - -private fun startWindowDrag(window: java.awt.Window) { - val ptr = JniMacWindowUtil.getWindowPtr(window) - if (ptr != 0L && JniMacTitleBarBridge.isLoaded) { - JniMacTitleBarBridge.nativeStartWindowDrag(ptr) - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.Windows.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.Windows.kt deleted file mode 100644 index 3d3468368..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.Windows.kt +++ /dev/null @@ -1,307 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.currentCompositionLocalContext -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.input.pointer.PointerButton -import androidx.compose.ui.input.pointer.PointerEventPass -import androidx.compose.ui.input.pointer.PointerEventType -import androidx.compose.ui.input.pointer.onPointerEvent -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle -import dev.nucleusframework.window.utils.windows.JniWindowsDecorationBridge -import dev.nucleusframework.window.utils.windows.JniWindowsWindowUtil -import java.awt.Frame - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -internal fun AwtDecoratedWindowScope.WindowsTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - val controlDir = controlButtonsDirection.resolve() - val controlIsRtl = controlDir == LayoutDirection.Rtl - val controlsSide = if (controlIsRtl) WindowControlsSide.Start else WindowControlsSide.End - - if (JniWindowsDecorationBridge.isLoaded) { - NativeWindowsTitleBar( - modifier, - gradientStartColor, - style, - controlDir, - layoutPolicy, - controlsSide, - backgroundContent, - content, - ) - } else { - FallbackWindowsTitleBar( - modifier, - gradientStartColor, - style, - controlDir, - layoutPolicy, - controlsSide, - backgroundContent, - content, - ) - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming", "LongMethod") -@Composable -private fun AwtDecoratedWindowScope.NativeWindowsTitleBar( - modifier: Modifier, - gradientStartColor: Color, - style: TitleBarStyle, - controlButtonsDirection: LayoutDirection, - layoutPolicy: TitleBarLayoutPolicy, - controlsSide: WindowControlsSide, - backgroundContent: @Composable () -> Unit, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, -) { - val isNativeFullscreen = LocalNativeFullscreen.current - val onExitFullscreen = LocalExitFullscreen.current - val density = LocalDensity.current - val viewConfig = LocalViewConfiguration.current - var lastPressTime = 0L - - // Install decoration and clean up on dispose - DisposableEffect(window) { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - val heightPx = with(density) { style.metrics.height.roundToPx() } - JniWindowsDecorationBridge.nativeInstallDecoration(hwnd, heightPx) - - onDispose { - val h = JniWindowsWindowUtil.getHwnd(window) - if (h != 0L) JniWindowsDecorationBridge.nativeUninstallDecoration(h) - } - } else { - onDispose { } - } - } - - // Sync native background fill color with the title bar color so that - // WM_ERASEBKGND fills with the correct color during resize (avoids white flash). - val titleBarBackground = style.colors.background - LaunchedEffect(window, titleBarBackground) { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - JniWindowsDecorationBridge.nativeSetBackgroundColor(hwnd, titleBarBackground.toArgb()) - } - } - - // Fix DPI scaling for min/max size on non-JBR JVMs (issue #102) - SyncMinMaxSizeToNative(window) - - val useNewFullscreenControls = modifier.hasNewFullscreenControls() - - // ── Fullscreen with newFullscreenControls: sliding overlay ── - if (isNativeFullscreen && useNewFullscreenControls) { - LaunchedEffect(window) { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) JniWindowsDecorationBridge.nativeSetTitleBarHeight(hwnd, 0) - } - - // Store rendering into the holder so DecoratedWindow can render it - // as a floating overlay outside the DecoratedWindowBody layout. - val holder = LocalFullscreenTitleBarHolder.current - if (holder != null) { - holder.compositionLocalContext = currentCompositionLocalContext - holder.titleBarHeight = style.metrics.height - holder.content = { - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - ) { currentState -> - WindowsWindowControlArea( - window = window, - state = currentState, - style = style, - isFullscreen = true, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) - } - } - } - } - return - } - - // ── Normal title bar (or fullscreen without newFullscreenControls) ── - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { height, currentState -> - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - val heightPx = with(density) { height.roundToPx() } - JniWindowsDecorationBridge.nativeSetTitleBarHeight(hwnd, heightPx) - } - PaddingValues(0.dp) - }, - backgroundContent = { - backgroundContent() - Spacer( - modifier = - Modifier - .fillMaxSize() - .onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - val elapsed = now - lastPressTime - if ( - elapsed in - viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis - ) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else if (window.isResizable) { - window.extendedState = Frame.MAXIMIZED_BOTH - } - } else { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - JniWindowsDecorationBridge.nativeStartDrag(hwnd) - } - } - lastPressTime = now - } - }, - ) - }, - ) { currentState -> - WindowsWindowControlArea( - window = window, - state = currentState, - style = style, - isFullscreen = isNativeFullscreen, - onExitFullscreen = onExitFullscreen, - ) - content(currentState) - } - } -} - -// Fallback title bar: Compose-based drag and double-click (no native lib). -@OptIn(ExperimentalComposeUiApi::class) -@Suppress("FunctionNaming") -@Composable -private fun AwtDecoratedWindowScope.FallbackWindowsTitleBar( - modifier: Modifier, - gradientStartColor: Color, - style: TitleBarStyle, - controlButtonsDirection: LayoutDirection, - layoutPolicy: TitleBarLayoutPolicy, - controlsSide: WindowControlsSide, - backgroundContent: @Composable () -> Unit, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit, -) { - val viewConfig = LocalViewConfiguration.current - var lastPress = 0L - - CompositionLocalProvider(LocalWindowControlsSide provides controlsSide) { - TitleBarImpl( - modifier = - modifier.onPointerEvent(PointerEventType.Press, PointerEventPass.Main) { - if ( - this.currentEvent.button == PointerButton.Primary && - this.currentEvent.changes.any { !it.isConsumed } - ) { - val now = System.currentTimeMillis() - if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis) { - if (state.isMaximized) { - window.extendedState = Frame.NORMAL - } else if (window.isResizable) { - window.extendedState = Frame.MAXIMIZED_BOTH - } - } - lastPress = now - } - }, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = layoutPolicy, - applyTitleBar = { _, _ -> PaddingValues(0.dp) }, - backgroundContent = { - backgroundContent() - Spacer(modifier = Modifier.fillMaxSize().windowDragHandler(window)) - }, - ) { currentState -> - WindowsWindowControlArea(window, currentState, style) - content(currentState) - } - } -} - -/** - * Syncs [java.awt.Window.minimumSize] and [java.awt.Window.maximumSize] to the native - * WM_GETMINMAXINFO handler so that DPI scaling is applied correctly on non-JBR JVMs. - */ -@Suppress("FunctionNaming") -@Composable -private fun SyncMinMaxSizeToNative(window: java.awt.Window) { - DisposableEffect(window) { - val hwnd = JniWindowsWindowUtil.getHwnd(window) - if (hwnd != 0L) { - val syncSizes = { - val min = window.minimumSize - JniWindowsDecorationBridge.nativeSetMinimumSize(hwnd, min.width, min.height) - val max = window.maximumSize - val maxW = if (max.width < Short.MAX_VALUE) max.width else 0 - val maxH = if (max.height < Short.MAX_VALUE) max.height else 0 - JniWindowsDecorationBridge.nativeSetMaximumSize(hwnd, maxW, maxH) - } - syncSizes() - val propertyListener = - java.beans.PropertyChangeListener { evt -> - if (evt.propertyName == "minimumSize" || evt.propertyName == "maximumSize") { - syncSizes() - } - } - window.addPropertyChangeListener(propertyListener) - onDispose { - window.removePropertyChangeListener(propertyListener) - JniWindowsDecorationBridge.nativeSetMinimumSize(hwnd, 0, 0) - JniWindowsDecorationBridge.nativeSetMaximumSize(hwnd, 0, 0) - } - } else { - onDispose { } - } - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt deleted file mode 100644 index 76ed4c9cf..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt +++ /dev/null @@ -1,89 +0,0 @@ -package dev.nucleusframework.window - -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import dev.nucleusframework.core.runtime.Platform -import dev.nucleusframework.window.styling.LocalTitleBarStyle -import dev.nucleusframework.window.styling.TitleBarStyle - -/** - * Platform-aware title bar for [DecoratedWindow]. - * - * @param controlButtonsDirection Controls which side the window control buttons - * (close, minimize, maximize) are placed on, independently of the title bar - * content direction. Defaults to [ControlButtonsDirection.Auto] which follows - * the Compose [LocalLayoutDirection][androidx.compose.ui.platform.LocalLayoutDirection]. - */ -@Suppress("FunctionNaming") -@Composable -public fun DecoratedWindowScope.TitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - BasicTitleBar( - modifier = modifier, - gradientStartColor = gradientStartColor, - style = style, - controlButtonsDirection = controlButtonsDirection, - layoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent = backgroundContent, - content = content, - ) -} - -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun DecoratedWindowScope.BasicTitleBar( - modifier: Modifier = Modifier, - gradientStartColor: Color = Color.Unspecified, - style: TitleBarStyle = LocalTitleBarStyle.current, - controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, - layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, - backgroundContent: @Composable () -> Unit = {}, - content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, -) { - // The jni backend always provides an [AwtDecoratedWindowScope] at runtime - // (DecoratedWindow's content lambda is invoked with the AWT-bound subtype). - // Cast here so app code can declare extensions on the abstract - // `core.DecoratedWindowScope` and stay drop-in swappable with the tao backend. - val awtScope = this as AwtDecoratedWindowScope - when (Platform.Current) { - Platform.Linux -> - awtScope.LinuxTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - backgroundContent, - content, - ) - Platform.Windows -> - awtScope.WindowsTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - backgroundContent, - content, - ) - Platform.MacOS -> - awtScope.MacOSTitleBar( - modifier, - gradientStartColor, - style, - controlButtonsDirection, - layoutPolicy, - backgroundContent, - content, - ) - Platform.Unknown -> - error("TitleBar is not supported on this platform(${System.getProperty("os.name")})") - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/linux/JniLinuxWindowBridge.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/linux/JniLinuxWindowBridge.kt deleted file mode 100644 index eff7b3185..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/linux/JniLinuxWindowBridge.kt +++ /dev/null @@ -1,38 +0,0 @@ -package dev.nucleusframework.window.utils.linux - -import dev.nucleusframework.core.runtime.NativeLibraryLoader - -private const val LIBRARY_NAME = "nucleus_linux_jni" - -internal object JniLinuxWindowBridge { - private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, JniLinuxWindowBridge::class.java) - - val isLoaded: Boolean get() = loaded - - // Initiates a native window move via _NET_WM_MOVERESIZE. - // rootX/rootY: absolute mouse coordinates on screen. - // button: X11 button number (1 = left). - // Returns true on success. - @JvmStatic - external fun nativeStartWindowMove( - awtWindow: java.awt.Window, - rootX: Int, - rootY: Int, - button: Int, - ): Boolean - - // Checks if the window manager supports _NET_WM_MOVERESIZE. - @JvmStatic - external fun nativeIsWmMoveResizeSupported(awtWindow: java.awt.Window): Boolean - - // Toggles native fullscreen via _NET_WM_STATE_FULLSCREEN. - @JvmStatic - external fun nativeSetFullscreen( - awtWindow: java.awt.Window, - fullscreen: Boolean, - ): Boolean - - // Checks if the window currently has _NET_WM_STATE_FULLSCREEN set. - @JvmStatic - external fun nativeIsFullscreen(awtWindow: java.awt.Window): Boolean -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/macos/JniMacTitleBarBridge.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/macos/JniMacTitleBarBridge.kt deleted file mode 100644 index 04c6ef9e1..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/macos/JniMacTitleBarBridge.kt +++ /dev/null @@ -1,138 +0,0 @@ -package dev.nucleusframework.window.utils.macos - -import dev.nucleusframework.core.runtime.NativeLibraryLoader -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import java.util.concurrent.ConcurrentHashMap - -private const val LIBRARY_NAME = "nucleus_macos_jni" - -@Suppress("TooManyFunctions") -internal object JniMacTitleBarBridge { - private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, JniMacTitleBarBridge::class.java) - - val isLoaded: Boolean get() = loaded - - // Register a shutdown hook to disable native → JVM callbacks before the - // JVM tears down. Without this, the NSEvent menu bar monitor can fire - // notifyMenuBarOffsetChanged during JVM_Halt, calling CallStaticVoidMethod - // on a freed sBridgeClass global ref → EXC_BAD_ACCESS → abort. - init { - if (loaded) { - Runtime.getRuntime().addShutdownHook(Thread({ nativeShutdown() }, "nucleus-native-shutdown")) - } - } - - // ── Menu bar offset (event-driven via native NSEvent monitor) ── - - private val menuBarOffsetFlows = ConcurrentHashMap>() - private val emptyFlow = MutableStateFlow(0f) - - // Returns a StateFlow that emits the current menu bar offset for - // the given window. Updated by the native event monitor callback. - fun menuBarOffsetFlow(nsWindowPtr: Long): StateFlow { - if (nsWindowPtr == 0L) return emptyFlow - return menuBarOffsetFlows.getOrPut(nsWindowPtr) { MutableStateFlow(0f) } - } - - fun removeMenuBarOffsetFlow(nsWindowPtr: Long) { - menuBarOffsetFlows.remove(nsWindowPtr) - } - - // Called from native (macOS main thread) when the menu bar offset - // changes. MutableStateFlow.value is thread-safe. - @JvmStatic - fun onMenuBarOffsetChanged( - nsWindowPtr: Long, - offset: Float, - ) { - menuBarOffsetFlows.getOrPut(nsWindowPtr) { MutableStateFlow(0f) }.value = offset - } - - // ── JNI methods ── - - // Sets up (or updates) the custom title bar and repositions traffic light buttons. - // heightPt: title bar height in NSPoints (= dp on macOS). - // Returns the left inset in points to reserve space for the traffic lights. - @JvmStatic - external fun nativeApplyTitleBar( - nsWindowPtr: Long, - heightPt: Float, - ): Float - - // Removes all custom constraints, fullscreen observer, and restores AppKit defaults. - @JvmStatic - external fun nativeResetTitleBar(nsWindowPtr: Long) - - // Updates the position of the replacement fullscreen buttons (called on layout passes). - @JvmStatic - external fun nativeUpdateFullScreenButtons(nsWindowPtr: Long) - - // Performs the macOS title bar double-click action (zoom or minimize) - // respecting the user's AppleActionOnDoubleClick system preference. - @JvmStatic - external fun nativePerformTitleBarDoubleClickAction(nsWindowPtr: Long) - - // Initiates a native window drag using the saved mouseDown event. - // Called from Compose when an unconsumed drag is detected in the title bar. - @JvmStatic - external fun nativeStartWindowDrag(nsWindowPtr: Long) - - // Extracts the native NSWindow pointer from an AWT Window via JNI. - // JNI bypasses module access checks, so this works in GraalVM native-image - // where Kotlin reflection cannot access sun.awt.AWTAccessor. - @JvmStatic - external fun nativeGetNSWindowPtr(awtWindow: java.awt.Window): Long - - // Stores the newFullscreenControls flag on the NSWindow. - // When enabled, the title bar and traffic-light buttons are pushed down - // by the menu bar height when the auto-hidden menu bar appears in fullscreen. - @JvmStatic - external fun nativeSetNewFullscreenControls( - nsWindowPtr: Long, - enabled: Boolean, - ) - - // Returns the current menu bar offset in points (reads the stored value). - @JvmStatic - external fun nativeGetMenuBarOffset(nsWindowPtr: Long): Float - - // Stores the current menu bar offset (in points) and repositions - // the native traffic-light buttons to match the Compose title bar. - @JvmStatic - external fun nativeSetMenuBarOffset( - nsWindowPtr: Long, - offsetPt: Float, - ) - - // Installs a native NSEvent local monitor that detects menu bar - // visibility changes and calls onMenuBarOffsetChanged via JNI. - @JvmStatic - external fun nativeInstallMenuBarMonitor(nsWindowPtr: Long) - - // Removes the native event monitor installed by nativeInstallMenuBarMonitor. - @JvmStatic - external fun nativeRemoveMenuBarMonitor(nsWindowPtr: Long) - - // Installs or removes an invisible NSToolbar to trigger the macOS 26pt - // corner radius. When disabled, the window uses the standard ~10pt radius. - @JvmStatic - external fun nativeSetLargeCornerRadius( - nsWindowPtr: Long, - enabled: Boolean, - ) - - // Sets the RTL (right-to-left) flag on the NSWindow. - // When enabled, traffic-light buttons are positioned on the right side. - // Re-applies constraints immediately so the change is visible live. - @JvmStatic - external fun nativeSetRTL( - nsWindowPtr: Long, - rtl: Boolean, - ) - - // Disables native → JVM callbacks and removes all menu bar monitors. - // Called from the shutdown hook before the JVM starts tearing down. - @JvmStatic - private external fun nativeShutdown() -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/macos/JniMacWindowUtil.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/macos/JniMacWindowUtil.kt deleted file mode 100644 index 03915d7e2..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/macos/JniMacWindowUtil.kt +++ /dev/null @@ -1,82 +0,0 @@ -package dev.nucleusframework.window.utils.macos - -import java.awt.Component -import java.awt.Window -import java.util.logging.Level -import java.util.logging.Logger -import javax.swing.RootPaneContainer - -@Suppress("TooGenericExceptionCaught") -internal object JniMacWindowUtil { - private val logger = Logger.getLogger(JniMacWindowUtil::class.java.name) - private var reflectionFailed = false - - // Extracts the native NSWindow pointer from an AWT window. - // Prefers the JNI path (bypasses module access checks, works in GraalVM native-image). - // Falls back to reflection only if the native library is not loaded. - // Returns 0 if the pointer cannot be obtained (e.g. peer not yet created). - fun getWindowPtr(w: Window?): Long { - if (w == null) return 0L - - // JNI path: works in both JVM and native-image. - // If the native lib is loaded, trust its result (including 0 when the peer is gone) - // and never fall through to reflection — it is blocked by JPMS in native-image. - if (JniMacTitleBarBridge.isLoaded) { - return try { - JniMacTitleBarBridge.nativeGetNSWindowPtr(w) - } catch (e: Exception) { - logger.log(Level.WARNING, "JNI nativeGetNSWindowPtr failed.", e) - 0L - } - } - - // Reflection fallback (JVM only, when native library is unavailable) - if (!reflectionFailed) { - return getWindowPtrViaReflection(w) - } - return 0L - } - - private fun getWindowPtrViaReflection(w: Window): Long { - try { - val awtAccessor = Class.forName("sun.awt.AWTAccessor") - val componentAccessor = awtAccessor.getMethod("getComponentAccessor").invoke(null) - val accessorInterface = Class.forName("sun.awt.AWTAccessor\$ComponentAccessor") - val getPeer = accessorInterface.getMethod("getPeer", Component::class.java) - val peer = getPeer.invoke(componentAccessor, w) ?: return 0L - val platformWindow = - peer.javaClass.getDeclaredMethod("getPlatformWindow").invoke(peer) - ?: return 0L - val ptr = platformWindow.javaClass.superclass.getDeclaredField("ptr") - ptr.isAccessible = true - return ptr.getLong(platformWindow) - } catch (e: IllegalAccessException) { - reflectionFailed = true - logger.log( - Level.WARNING, - "Module access denied for NSWindow pointer reflection (expected in native-image).", - e, - ) - } catch (e: Exception) { - logger.log(Level.WARNING, "Reflection fallback failed to get NSWindow pointer.", e) - } - return 0L - } - - // Sets the AWT client properties that make the content view extend into the title bar - // area and make the title bar transparent. Guards against re-firing PropertyChangeEvents - // on every layout pass, which would cause repeated native style mask updates and jitter. - fun applyWindowProperties(w: Window) { - (w as? RootPaneContainer)?.rootPane?.let { rootPane -> - if (rootPane.getClientProperty("apple.awt.fullWindowContent") != true) { - rootPane.putClientProperty("apple.awt.fullWindowContent", true) - } - if (rootPane.getClientProperty("apple.awt.transparentTitleBar") != true) { - rootPane.putClientProperty("apple.awt.transparentTitleBar", true) - } - if (rootPane.getClientProperty("apple.awt.windowTitleVisible") != false) { - rootPane.putClientProperty("apple.awt.windowTitleVisible", false) - } - } - } -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/windows/JniWindowsDecorationBridge.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/windows/JniWindowsDecorationBridge.kt deleted file mode 100644 index 848208d8d..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/windows/JniWindowsDecorationBridge.kt +++ /dev/null @@ -1,99 +0,0 @@ -package dev.nucleusframework.window.utils.windows - -import dev.nucleusframework.core.runtime.NativeLibraryLoader - -private const val LIBRARY_NAME = "nucleus_windows_decoration" - -@Suppress("TooManyFunctions") -internal object JniWindowsDecorationBridge { - private val loaded = NativeLibraryLoader.load(LIBRARY_NAME, JniWindowsDecorationBridge::class.java) - - val isLoaded: Boolean get() = loaded - - // Installs the custom decoration (subclasses WndProc, sets up DWM shadow). - // Idempotent: if already installed, updates the title bar height. - @JvmStatic - external fun nativeInstallDecoration( - hwnd: Long, - titleBarHeightPx: Int, - ) - - // Removes the custom decoration and restores the original WndProc. - @JvmStatic - external fun nativeUninstallDecoration(hwnd: Long) - - // Toggles the forceHitTestClient flag. When true, WM_NCHITTEST returns - // HTCLIENT in the title bar area so Compose handles the click. - @JvmStatic - external fun nativeSetForceHitTestClient( - hwnd: Long, - force: Boolean, - ) - - // Updates the title bar height used by the hit-test logic. - @JvmStatic - external fun nativeSetTitleBarHeight( - hwnd: Long, - heightPx: Int, - ) - - // Initiates a native window drag (with snap/tile support). - // Called from Compose when an unconsumed press occurs in the title bar. - @JvmStatic - external fun nativeStartDrag(hwnd: Long) - - // Extracts the HWND from an AWT Window via JNI (bypasses JPMS restrictions). - // Returns 0 if the handle cannot be obtained. - @JvmStatic - external fun nativeGetHwnd(awtWindow: java.awt.Window): Long - - // Applies rounded corners and DWM shadow to an undecorated dialog window (WS_POPUP). - // Uses DWMWA_WINDOW_CORNER_PREFERENCE = DWMWCP_ROUND (Windows 11+, no-op on older). - @JvmStatic - external fun nativeApplyDialogStyle(hwnd: Long) - - // Enters or exits native fullscreen mode. - // Enter: saves style/exstyle/placement, removes caption/frame, covers the monitor. - // Exit: restores saved style/exstyle/placement (maximized, floating, etc.). - @JvmStatic - external fun nativeSetFullscreen( - hwnd: Long, - fullscreen: Boolean, - ) - - // Returns true if the window is currently in native fullscreen mode. - @JvmStatic - external fun nativeIsFullscreen(hwnd: Long): Boolean - - // Sets the background fill color for WM_ERASEBKGND (avoids white flash on resize). - // Pass the ARGB int from Compose Color.toArgb(); alpha is ignored (opaque fill). - @JvmStatic - external fun nativeSetBackgroundColor( - hwnd: Long, - argb: Int, - ) - - // Sets the minimum window size in logical pixels. The native - // WM_GETMINMAXINFO handler applies DPI scaling automatically. - // Pass (0, 0) to disable the override and fall back to AWT default. - @JvmStatic - external fun nativeSetMinimumSize( - hwnd: Long, - widthPx: Int, - heightPx: Int, - ) - - // Sets the maximum window size in logical pixels. The native - // WM_GETMINMAXINFO handler applies DPI scaling automatically. - // Pass (0, 0) to disable the override and fall back to AWT default. - @JvmStatic - external fun nativeSetMaximumSize( - hwnd: Long, - widthPx: Int, - heightPx: Int, - ) - - // Returns debug counters as a string (temporary). - @JvmStatic - external fun nativeGetDebugInfo(hwnd: Long): String -} diff --git a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/windows/JniWindowsWindowUtil.kt b/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/windows/JniWindowsWindowUtil.kt deleted file mode 100644 index fdd50fbb2..000000000 --- a/decorated-window-jni/src/main/kotlin/dev/nucleusframework/window/utils/windows/JniWindowsWindowUtil.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.nucleusframework.window.utils.windows - -import java.awt.Window - -internal object JniWindowsWindowUtil { - // Extracts the native HWND from an AWT Window. - // Delegates to native JNI code which bypasses JPMS module restrictions. - // Returns 0 if the handle cannot be obtained (e.g. peer not yet created). - fun getHwnd(w: Window?): Long { - if (w == null || !JniWindowsDecorationBridge.isLoaded) return 0L - return JniWindowsDecorationBridge.nativeGetHwnd(w) - } -} diff --git a/decorated-window-jni/src/main/native/linux/build.sh b/decorated-window-jni/src/main/native/linux/build.sh deleted file mode 100755 index 7e5389213..000000000 --- a/decorated-window-jni/src/main/native/linux/build.sh +++ /dev/null @@ -1,97 +0,0 @@ -#!/bin/bash -# Compiles nucleus_linux_window.c into per-architecture shared libraries (x64 + aarch64). -# The outputs are placed in the JAR resources so they ship with the library. -# -# Prerequisites: gcc, libX11-dev (or libx11-dev), JDK with JNI headers. -# Usage: ./build.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC="$SCRIPT_DIR/nucleus_linux_window.c" -RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" -OUT_DIR_X64="$RESOURCE_DIR/linux-x64" -OUT_DIR_AARCH64="$RESOURCE_DIR/linux-aarch64" - -# Detect JAVA_HOME for JNI headers -if [ -z "${JAVA_HOME:-}" ]; then - # Try common locations - for jdk in /usr/lib/jvm/java-*-openjdk-* /usr/lib/jvm/default-java; do - if [ -d "$jdk/include" ]; then - JAVA_HOME="$jdk" - break - fi - done -fi -if [ -z "${JAVA_HOME:-}" ]; then - echo "ERROR: JAVA_HOME not set and could not auto-detect a JDK." >&2 - exit 1 -fi - -JNI_INCLUDE="$JAVA_HOME/include" -JNI_INCLUDE_LINUX="$JAVA_HOME/include/linux" - -if [ ! -d "$JNI_INCLUDE" ]; then - echo "ERROR: JNI headers not found at $JNI_INCLUDE" >&2 - exit 1 -fi - -HOST_ARCH="$(uname -m)" - -COMMON_FLAGS=( - -shared - -fPIC - -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_LINUX" - -lX11 - -O2 - -fvisibility=hidden - -s - -Wall -Wextra -Wno-unused-parameter -) - -# Build for the host architecture -if [ "$HOST_ARCH" = "x86_64" ]; then - mkdir -p "$OUT_DIR_X64" - gcc "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_X64/libnucleus_linux_jni.so" "$SRC" - echo "Built x64:" - ls -lh "$OUT_DIR_X64/libnucleus_linux_jni.so" -elif [ "$HOST_ARCH" = "aarch64" ]; then - mkdir -p "$OUT_DIR_AARCH64" - gcc "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_AARCH64/libnucleus_linux_jni.so" "$SRC" - echo "Built aarch64:" - ls -lh "$OUT_DIR_AARCH64/libnucleus_linux_jni.so" -else - echo "WARNING: Unsupported host architecture: $HOST_ARCH" >&2 - exit 1 -fi - -# Attempt cross-compilation for the other architecture (optional, non-fatal) -if [ "$HOST_ARCH" = "x86_64" ]; then - if command -v aarch64-linux-gnu-gcc &>/dev/null; then - mkdir -p "$OUT_DIR_AARCH64" - aarch64-linux-gnu-gcc "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_AARCH64/libnucleus_linux_jni.so" "$SRC" || \ - echo "WARNING: aarch64 cross-compilation failed (non-fatal)." - if [ -f "$OUT_DIR_AARCH64/libnucleus_linux_jni.so" ]; then - echo "Built aarch64 (cross):" - ls -lh "$OUT_DIR_AARCH64/libnucleus_linux_jni.so" - fi - else - echo "NOTE: aarch64-linux-gnu-gcc not found, skipping aarch64 cross-build." - fi -elif [ "$HOST_ARCH" = "aarch64" ]; then - if command -v x86_64-linux-gnu-gcc &>/dev/null; then - mkdir -p "$OUT_DIR_X64" - x86_64-linux-gnu-gcc "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_X64/libnucleus_linux_jni.so" "$SRC" || \ - echo "WARNING: x64 cross-compilation failed (non-fatal)." - if [ -f "$OUT_DIR_X64/libnucleus_linux_jni.so" ]; then - echo "Built x64 (cross):" - ls -lh "$OUT_DIR_X64/libnucleus_linux_jni.so" - fi - else - echo "NOTE: x86_64-linux-gnu-gcc not found, skipping x64 cross-build." - fi -fi diff --git a/decorated-window-jni/src/main/native/linux/nucleus_linux_window.c b/decorated-window-jni/src/main/native/linux/nucleus_linux_window.c deleted file mode 100644 index 9243e9afc..000000000 --- a/decorated-window-jni/src/main/native/linux/nucleus_linux_window.c +++ /dev/null @@ -1,385 +0,0 @@ -/** - * JNI bridge for Linux native window move via _NET_WM_MOVERESIZE. - * - * Replicates the JBR's XNETProtocol logic: - * 1. Acquire AWT lock (SunToolkit.awtLock()) - * 2. Ungrab pointer and keyboard - * 3. Send _NET_WM_MOVERESIZE ClientMessage to the root window - * 4. XFlush + release AWT lock - * - * X11 handles are obtained via JNI reflection into AWT internals - * (bypasses JPMS restrictions, same pattern as the Windows nativeGetHwnd). - * - * Linked libraries: -lX11 - */ - -#include -#include -#include -#include -#include - -#define _NET_WM_MOVERESIZE_MOVE 8 -#define _NET_WM_MOVERESIZE_CANCEL 11 - -/* ------------------------------------------------------------------ */ -/* Helper: get X11 Display* from AWT (XToolkit.getDisplay()) */ -/* ------------------------------------------------------------------ */ -static Display *getAwtDisplay(JNIEnv *env) { - jclass xToolkitClass = (*env)->FindClass(env, "sun/awt/X11/XToolkit"); - if (!xToolkitClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return NULL; - } - - jmethodID getDisplay = (*env)->GetStaticMethodID(env, xToolkitClass, "getDisplay", "()J"); - if (!getDisplay || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, xToolkitClass); - return NULL; - } - - jlong displayPtr = (*env)->CallStaticLongMethod(env, xToolkitClass, getDisplay); - (*env)->DeleteLocalRef(env, xToolkitClass); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return NULL; - } - - return (Display *)(uintptr_t)displayPtr; -} - -/* ------------------------------------------------------------------ */ -/* Helper: get X11 Window from AWT peer */ -/* AWTAccessor → getComponentAccessor() → getPeer(window) → */ -/* XBaseWindow.getWindow() (returns the shell window ID) */ -/* ------------------------------------------------------------------ */ -static Window getAwtX11Window(JNIEnv *env, jobject awtWindow) { - if (!awtWindow) return 0; - - /* AWTAccessor.getComponentAccessor() */ - jclass awtAccessorClass = (*env)->FindClass(env, "sun/awt/AWTAccessor"); - if (!awtAccessorClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - jmethodID getCompAccessor = (*env)->GetStaticMethodID(env, awtAccessorClass, - "getComponentAccessor", "()Lsun/awt/AWTAccessor$ComponentAccessor;"); - if (!getCompAccessor || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, awtAccessorClass); - return 0; - } - - jobject compAccessor = (*env)->CallStaticObjectMethod(env, awtAccessorClass, getCompAccessor); - (*env)->DeleteLocalRef(env, awtAccessorClass); - if (!compAccessor || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - /* componentAccessor.getPeer(window) */ - jclass compAccessorClass = (*env)->FindClass(env, "sun/awt/AWTAccessor$ComponentAccessor"); - if (!compAccessorClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, compAccessor); - return 0; - } - - jmethodID getPeer = (*env)->GetMethodID(env, compAccessorClass, - "getPeer", "(Ljava/awt/Component;)Ljava/awt/peer/ComponentPeer;"); - (*env)->DeleteLocalRef(env, compAccessorClass); - if (!getPeer || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, compAccessor); - return 0; - } - - jobject peer = (*env)->CallObjectMethod(env, compAccessor, getPeer, awtWindow); - (*env)->DeleteLocalRef(env, compAccessor); - if (!peer || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - /* peer.getWindow() — XBaseWindow.getWindow() returns the X11 window ID */ - jclass xBaseWindowClass = (*env)->FindClass(env, "sun/awt/X11/XBaseWindow"); - if (!xBaseWindowClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, peer); - return 0; - } - - jmethodID getWindow = (*env)->GetMethodID(env, xBaseWindowClass, "getWindow", "()J"); - (*env)->DeleteLocalRef(env, xBaseWindowClass); - if (!getWindow || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, peer); - return 0; - } - - jlong windowId = (*env)->CallLongMethod(env, peer, getWindow); - (*env)->DeleteLocalRef(env, peer); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - return (Window)windowId; -} - -/* ------------------------------------------------------------------ */ -/* Helper: acquire/release AWT lock via SunToolkit */ -/* ------------------------------------------------------------------ */ -static jboolean awtLock(JNIEnv *env) { - jclass sunToolkitClass = (*env)->FindClass(env, "sun/awt/SunToolkit"); - if (!sunToolkitClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return JNI_FALSE; - } - jmethodID lockMethod = (*env)->GetStaticMethodID(env, sunToolkitClass, "awtLock", "()V"); - if (!lockMethod || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, sunToolkitClass); - return JNI_FALSE; - } - (*env)->CallStaticVoidMethod(env, sunToolkitClass, lockMethod); - (*env)->DeleteLocalRef(env, sunToolkitClass); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return JNI_FALSE; - } - return JNI_TRUE; -} - -static void awtUnlock(JNIEnv *env) { - jclass sunToolkitClass = (*env)->FindClass(env, "sun/awt/SunToolkit"); - if (!sunToolkitClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return; - } - jmethodID unlockMethod = (*env)->GetStaticMethodID(env, sunToolkitClass, "awtUnlock", "()V"); - if (!unlockMethod || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, sunToolkitClass); - return; - } - (*env)->CallStaticVoidMethod(env, sunToolkitClass, unlockMethod); - (*env)->DeleteLocalRef(env, sunToolkitClass); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } -} - -/* ------------------------------------------------------------------ */ -/* nativeStartWindowMove */ -/* Sends _NET_WM_MOVERESIZE ClientMessage to initiate a native WM */ -/* move. This gives us snap/tile support and native drag feel. */ -/* ------------------------------------------------------------------ */ -JNIEXPORT jboolean JNICALL -Java_dev_nucleusframework_window_utils_linux_JniLinuxWindowBridge_nativeStartWindowMove( - JNIEnv *env, jclass clazz, jobject awtWindow, jint rootX, jint rootY, jint button) -{ - Display *display = getAwtDisplay(env); - if (!display) return JNI_FALSE; - - Window xWindow = getAwtX11Window(env, awtWindow); - if (!xWindow) return JNI_FALSE; - - /* Acquire AWT lock — required before any direct Xlib call on AWT's Display */ - if (!awtLock(env)) return JNI_FALSE; - - /* Determine the root window */ - Window rootWindow = XDefaultRootWindow(display); - - /* - * Query the REAL root coordinates via XQueryPointer. - * Java's MouseInfo.getPointerInfo().location returns logical (scaled) - * coordinates on HiDPI screens, but _NET_WM_MOVERESIZE requires - * physical X11 root-window coordinates. XQueryPointer always returns - * unscaled physical pixels, which is exactly what the WM expects. - */ - Window queryRoot, queryChild; - int physRootX, physRootY, winX, winY; - unsigned int mask; - Bool queryOk = XQueryPointer(display, rootWindow, - &queryRoot, &queryChild, - &physRootX, &physRootY, - &winX, &winY, &mask); - - if (!queryOk) { - /* Fallback to the (possibly scaled) coordinates from Java */ - physRootX = rootX; - physRootY = rootY; - } - - /* Release AWT's pointer and keyboard grabs so the WM can take over */ - XUngrabPointer(display, CurrentTime); - XUngrabKeyboard(display, CurrentTime); - - /* Intern the atom */ - Atom wmMoveResize = XInternAtom(display, "_NET_WM_MOVERESIZE", False); - - /* Build and send the ClientMessage */ - XEvent event; - memset(&event, 0, sizeof(event)); - event.xclient.type = ClientMessage; - event.xclient.window = xWindow; - event.xclient.message_type = wmMoveResize; - event.xclient.format = 32; - event.xclient.data.l[0] = physRootX; /* x_root (physical) */ - event.xclient.data.l[1] = physRootY; /* y_root (physical) */ - event.xclient.data.l[2] = _NET_WM_MOVERESIZE_MOVE; /* direction */ - event.xclient.data.l[3] = button; /* X11 button (1=left) */ - event.xclient.data.l[4] = 1; /* source indication: application */ - - XSendEvent(display, rootWindow, False, - SubstructureRedirectMask | SubstructureNotifyMask, - &event); - - XFlush(display); - - awtUnlock(env); - - return JNI_TRUE; -} - -/* ------------------------------------------------------------------ */ -/* nativeSetFullscreen */ -/* Toggles _NET_WM_STATE_FULLSCREEN on the window via a */ -/* _NET_WM_STATE ClientMessage to the root window. */ -/* ------------------------------------------------------------------ */ -JNIEXPORT jboolean JNICALL -Java_dev_nucleusframework_window_utils_linux_JniLinuxWindowBridge_nativeSetFullscreen( - JNIEnv *env, jclass clazz, jobject awtWindow, jboolean fullscreen) -{ - Display *display = getAwtDisplay(env); - if (!display) return JNI_FALSE; - - Window xWindow = getAwtX11Window(env, awtWindow); - if (!xWindow) return JNI_FALSE; - - if (!awtLock(env)) return JNI_FALSE; - - Window rootWindow = XDefaultRootWindow(display); - Atom wmState = XInternAtom(display, "_NET_WM_STATE", False); - Atom wmStateFullscreen = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); - - XEvent event; - memset(&event, 0, sizeof(event)); - event.xclient.type = ClientMessage; - event.xclient.window = xWindow; - event.xclient.message_type = wmState; - event.xclient.format = 32; - event.xclient.data.l[0] = fullscreen ? 1 : 0; /* _NET_WM_STATE_ADD or _REMOVE */ - event.xclient.data.l[1] = (long)wmStateFullscreen; - event.xclient.data.l[2] = 0; - event.xclient.data.l[3] = 1; /* source indication: application */ - event.xclient.data.l[4] = 0; - - XSendEvent(display, rootWindow, False, - SubstructureRedirectMask | SubstructureNotifyMask, - &event); - - XFlush(display); - - awtUnlock(env); - - return JNI_TRUE; -} - -/* ------------------------------------------------------------------ */ -/* nativeIsFullscreen */ -/* Checks if _NET_WM_STATE_FULLSCREEN is set on the window. */ -/* ------------------------------------------------------------------ */ -JNIEXPORT jboolean JNICALL -Java_dev_nucleusframework_window_utils_linux_JniLinuxWindowBridge_nativeIsFullscreen( - JNIEnv *env, jclass clazz, jobject awtWindow) -{ - Display *display = getAwtDisplay(env); - if (!display) return JNI_FALSE; - - Window xWindow = getAwtX11Window(env, awtWindow); - if (!xWindow) return JNI_FALSE; - - if (!awtLock(env)) return JNI_FALSE; - - Atom wmState = XInternAtom(display, "_NET_WM_STATE", False); - Atom wmStateFullscreen = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); - - Atom actualType; - int actualFormat; - unsigned long nItems, bytesAfter; - unsigned char *data = NULL; - - jboolean isFullscreen = JNI_FALSE; - - int result = XGetWindowProperty(display, xWindow, wmState, - 0, 1024, False, XA_ATOM, - &actualType, &actualFormat, - &nItems, &bytesAfter, &data); - - if (result == Success && data && actualType == XA_ATOM && actualFormat == 32) { - Atom *atoms = (Atom *)data; - for (unsigned long i = 0; i < nItems; i++) { - if (atoms[i] == wmStateFullscreen) { - isFullscreen = JNI_TRUE; - break; - } - } - } - - if (data) XFree(data); - - awtUnlock(env); - - return isFullscreen; -} - -/* ------------------------------------------------------------------ */ -/* nativeIsWmMoveResizeSupported */ -/* Checks if the WM advertises _NET_WM_MOVERESIZE in _NET_SUPPORTED. */ -/* ------------------------------------------------------------------ */ -JNIEXPORT jboolean JNICALL -Java_dev_nucleusframework_window_utils_linux_JniLinuxWindowBridge_nativeIsWmMoveResizeSupported( - JNIEnv *env, jclass clazz, jobject awtWindow) -{ - Display *display = getAwtDisplay(env); - if (!display) return JNI_FALSE; - - if (!awtLock(env)) return JNI_FALSE; - - Window rootWindow = XDefaultRootWindow(display); - - Atom netSupported = XInternAtom(display, "_NET_SUPPORTED", False); - Atom wmMoveResize = XInternAtom(display, "_NET_WM_MOVERESIZE", False); - - Atom actualType; - int actualFormat; - unsigned long nItems, bytesAfter; - unsigned char *data = NULL; - - jboolean supported = JNI_FALSE; - - int result = XGetWindowProperty(display, rootWindow, netSupported, - 0, 1024, False, XA_ATOM, - &actualType, &actualFormat, - &nItems, &bytesAfter, &data); - - if (result == Success && data && actualType == XA_ATOM && actualFormat == 32) { - Atom *atoms = (Atom *)data; - for (unsigned long i = 0; i < nItems; i++) { - if (atoms[i] == wmMoveResize) { - supported = JNI_TRUE; - break; - } - } - } - - if (data) XFree(data); - - awtUnlock(env); - - return supported; -} diff --git a/decorated-window-jni/src/main/native/macos/JniMacTitleBar.m b/decorated-window-jni/src/main/native/macos/JniMacTitleBar.m deleted file mode 100644 index b2e226ce4..000000000 --- a/decorated-window-jni/src/main/native/macos/JniMacTitleBar.m +++ /dev/null @@ -1,1593 +0,0 @@ -#import -#import -#import -#include -#include -#include - -// Associated object keys -static const char kTitleBarConstraintsKey = 0; -static const char kTitleBarHeightKey = 1; -static const char kFullscreenObserverKey = 2; -static const char kFullscreenButtonsKey = 3; -static const char kZoomResponderKey = 5; -static const char kDragViewKey = 6; -static const char kNewFullscreenControlsKey = 7; -static const char kMenuBarOffsetKey = 8; -static const char kMenuBarMonitorKey = 9; -static const char kMenuBarLastRawOffsetKey = 10; -static const char kLargeCornerRadiusKey = 11; - -static const char kRTLKey = 13; - -static const float kMinHeightForFullSize = 28.0f; -static const float kDefaultButtonOffset = 23.0f; -// Extra left margin when the invisible toolbar is present (26pt corner radius). -// Matches the button inset used by Apple apps with a toolbar (e.g. Finder, Safari). -static const float kToolbarExtraInset = 6.0f; -// Maximum horizontal margin for the first traffic-light button. -// Capped at the default title bar height (40pt) / 2 so that increasing -// the title bar height beyond the default doesn't push buttons further right. -static const float kDefaultTitleBarHeight = 40.0f; -static const float kMaxButtonLeftMargin = kDefaultTitleBarHeight / 2.0f; -// Pre-Tahoe native traffic-lights: 20 pt between button centers, and the -// standard buttons keep their natural 14x16 pt frame (12 pt visible circle). -static const float kLegacyButtonOffset = 20.0f; - -// macOS 26 (Tahoe) introduced larger, wider-spaced traffic-lights, the large -// corner radius and the Safari-style fullscreen title bar. Everything gated -// on this check falls back to the classic pre-Tahoe chrome (issue #310). -static BOOL isTahoeOrLater(void) { - static BOOL result = NO; - static dispatch_once_t once; - dispatch_once(&once, ^{ - NSOperatingSystemVersion v = (NSOperatingSystemVersion){26, 0, 0}; - result = [[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:v]; - }); - return result; -} - -static float defaultButtonOffset(void) { - return isTahoeOrLater() ? kDefaultButtonOffset : kLegacyButtonOffset; -} - -// _adjustWindowToScreen swizzle state -static IMP sOriginalAdjustWindowToScreen = NULL; - - -// Forward declarations -static void applyConstraints(NSWindow *window, float height); -static void removeExistingConstraints(NSWindow *window); -static void installFullScreenButtons(NSWindow *window, float titleBarHeight); -static void removeFullScreenButtons(NSWindow *window); -static void updateFullScreenButtonsPosition(NSWindow *window); -static void ensureAdjustWindowSwizzle(NSWindow *window); -static void installZoomButtonResponder(NSWindow *window); -static void removeZoomButtonResponder(NSWindow *window); -static void ensureDragView(NSWindow *window); -static void removeDragView(NSWindow *window); -static void installMenuBarMonitor(NSWindow *window); -static void removeMenuBarMonitor(NSWindow *window); -static void neutralizeToolbarFullScreenWindows(void); - -// ─── JVM caching for native → Java callbacks ──────────────────────────────────── - -static JavaVM *sJVM = NULL; -static jclass sBridgeClass = NULL; // global ref -static jmethodID sOnOffsetChanged = NULL; -// Prevents JNI callbacks after JVM shutdown begins. -// Set to true in ensureJVMCached, cleared by nativeShutdown. -static atomic_bool sCallbacksEnabled = ATOMIC_VAR_INIT(false); -// Set to true in nativeShutdown — prevents all pending dispatch_async blocks -// from touching windows/AppKit during JVM teardown. -static atomic_bool sShutdownInProgress = ATOMIC_VAR_INIT(false); - -static void ensureJVMCached(JNIEnv *env) { - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - (*env)->GetJavaVM(env, &sJVM); - jclass local = (*env)->FindClass(env, - "dev/nucleusframework/window/utils/macos/JniMacTitleBarBridge"); - if (local) { - sBridgeClass = (*env)->NewGlobalRef(env, local); - (*env)->DeleteLocalRef(env, local); - sOnOffsetChanged = (*env)->GetStaticMethodID( - env, sBridgeClass, "onMenuBarOffsetChanged", "(JF)V"); - atomic_store(&sCallbacksEnabled, true); - } - }); -} - -// Calls JniMacTitleBarBridge.onMenuBarOffsetChanged(nsWindowPtr, offset). -// MUST be called only from the macOS main thread (AppKit run loop). -// Attaches the main thread to the JVM as a daemon on first call; -// subsequent calls reuse the attached env. The main thread is never -// detached — it lives for the entire lifetime of the application. -// Guarded by sCallbacksEnabled to prevent crashes during JVM shutdown. -static void notifyMenuBarOffsetChanged(NSWindow *window, float offset) { - if (!atomic_load(&sCallbacksEnabled)) return; - if (!sJVM || !sBridgeClass || !sOnOffsetChanged) return; - - JNIEnv *env = NULL; - jint status = (*sJVM)->GetEnv(sJVM, (void **)&env, JNI_VERSION_1_8); - if (status == JNI_EDETACHED) { - if ((*sJVM)->AttachCurrentThreadAsDaemon(sJVM, (void **)&env, NULL) != JNI_OK) { - // JVM is shutting down — disable further callbacks - atomic_store(&sCallbacksEnabled, false); - return; - } - } else if (status != JNI_OK) { - return; - } - if (!env) return; - - // Double-check after potentially blocking on attach - if (!atomic_load(&sCallbacksEnabled)) return; - - (*env)->CallStaticVoidMethod(env, sBridgeClass, sOnOffsetChanged, - (jlong)(uintptr_t)window, (jfloat)offset); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } -} - -// ─── Fullscreen buttons container ─────────────────────────────────────────────── - -// Custom NSView that hosts replacement traffic-light buttons in the content view -// during fullscreen, mirroring JBR's AWTButtonsView. -// Copies of _NSThemeWidget draw inactive-gray when they are not the window's -// real title-bar buttons, so at rest we paint the standard active colours -// ourselves and reveal native close/zoom on hover (issue #531). Miniaturise -// is disabled: performMiniaturize: is a no-op in fullscreen. -@interface NucleusButtonsView : NSView { - BOOL _dispatching; - BOOL _mouseInside; -} -- (void)applyHoverState; -@end - -// Standard Big Sur+ traffic-light sRGB fills (see NucleusTaoButtonsView). -static NSColor *jniTrafficCloseColor(void) { - return [NSColor colorWithSRGBRed:1.0 green:95.0 / 255.0 blue:87.0 / 255.0 alpha:1.0]; -} -static NSColor *jniTrafficZoomColor(void) { - return [NSColor colorWithSRGBRed:40.0 / 255.0 green:200.0 / 255.0 blue:64.0 / 255.0 alpha:1.0]; -} -static NSColor *jniTrafficDisabledColor(NSView *view) { - BOOL dark = NO; - if (@available(macOS 10.14, *)) { - NSAppearanceName name = [view.effectiveAppearance - bestMatchFromAppearancesWithNames:@[ NSAppearanceNameDarkAqua, NSAppearanceNameAqua ]]; - dark = [name isEqualToString:NSAppearanceNameDarkAqua]; - } - return dark ? [NSColor colorWithWhite:0.40 alpha:1.0] - : [NSColor colorWithWhite:0.80 alpha:1.0]; -} - -static void jniFillTrafficCircle(NSView *button, NSColor *color) { - NSRect r = button.frame; - CGFloat d = fmin(MIN(r.size.width, r.size.height), - isTahoeOrLater() ? 14.0 : 12.0); - NSRect oval = NSMakeRect(NSMidX(r) - d / 2.0, NSMidY(r) - d / 2.0, d, d); - [color setFill]; - [[NSBezierPath bezierPathWithOvalInRect:oval] fill]; -} - -@implementation NucleusButtonsView - -- (BOOL)isOpaque { - return NO; -} - -- (void)updateTrackingAreas { - [super updateTrackingAreas]; - for (NSTrackingArea *ta in self.trackingAreas) { - [self removeTrackingArea:ta]; - } - NSTrackingArea *ta = [[NSTrackingArea alloc] - initWithRect:NSZeroRect - options:(NSTrackingMouseEnteredAndExited | - NSTrackingActiveInKeyWindow | - NSTrackingInVisibleRect) - owner:self - userInfo:nil]; - [self addTrackingArea:ta]; -} - -- (void)applyHoverState { - NSArray *buttons = self.subviews; - if (buttons.count < 3) return; - NSButton *closeBtn = (NSButton *)buttons[0]; - NSButton *minBtn = (NSButton *)buttons[1]; - NSButton *zoomBtn = (NSButton *)buttons[2]; - minBtn.enabled = NO; - minBtn.hidden = YES; - closeBtn.hidden = !_mouseInside; - zoomBtn.hidden = !_mouseInside; - [closeBtn setHighlighted:_mouseInside]; - [zoomBtn setHighlighted:_mouseInside]; - [self setNeedsDisplay:YES]; -} - -- (void)mouseEntered:(NSEvent *)event { - if (_dispatching) return; - _dispatching = YES; - _mouseInside = YES; - [super mouseEntered:event]; - [self applyHoverState]; - // Skip miniaturise (index 1) — it is disabled in fullscreen. - NSArray *buttons = self.subviews; - if (buttons.count >= 1) [buttons[0] mouseEntered:event]; - if (buttons.count >= 3) [buttons[2] mouseEntered:event]; - _dispatching = NO; -} - -- (void)mouseExited:(NSEvent *)event { - if (_dispatching) return; - _dispatching = YES; - _mouseInside = NO; - [super mouseExited:event]; - [self applyHoverState]; - NSArray *buttons = self.subviews; - if (buttons.count >= 1) [buttons[0] mouseExited:event]; - if (buttons.count >= 3) [buttons[2] mouseExited:event]; - _dispatching = NO; -} - -- (void)drawRect:(NSRect)dirtyRect { - (void)dirtyRect; - NSArray *buttons = self.subviews; - if (buttons.count < 3) return; - if (!_mouseInside) { - jniFillTrafficCircle(buttons[0], jniTrafficCloseColor()); - jniFillTrafficCircle(buttons[1], jniTrafficDisabledColor(self)); - jniFillTrafficCircle(buttons[2], jniTrafficZoomColor()); - } else { - jniFillTrafficCircle(buttons[1], jniTrafficDisabledColor(self)); - } -} - -// Private AppKit hook: standard window buttons ask their superview whether -// the traffic-light group is hovered before drawing the glyphs. Without it, -// pre-Tahoe systems never show the symbols on hover (mirrors JBR's -// AWTButtonsView). Miniaturise is never in the group — it is disabled. -- (BOOL)_mouseInGroup:(NSButton *)button { - if (self.subviews.count >= 2 && button == self.subviews[1]) return NO; - return _mouseInside; -} - -@end - -// ─── Fullscreen observer ──────────────────────────────────────────────────────── - -@interface NucleusFSObserver : NSObject -@property (nonatomic, weak) NSWindow *window; -@end - -@implementation NucleusFSObserver - -- (instancetype)initWithWindow:(NSWindow *)window { - self = [super init]; - if (self) { - _window = window; - NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - [nc addObserver:self selector:@selector(willEnterFullScreen:) - name:NSWindowWillEnterFullScreenNotification object:window]; - [nc addObserver:self selector:@selector(didEnterFullScreen:) - name:NSWindowDidEnterFullScreenNotification object:window]; - [nc addObserver:self selector:@selector(willExitFullScreen:) - name:NSWindowWillExitFullScreenNotification object:window]; - [nc addObserver:self selector:@selector(didExitFullScreen:) - name:NSWindowDidExitFullScreenNotification object:window]; - } - return self; -} - -- (void)dealloc { - [[NSNotificationCenter defaultCenter] removeObserver:self]; -} - -// About to enter fullscreen — remove constraints and drag view so macOS can animate cleanly -- (void)willEnterFullScreen:(NSNotification *)note { - NSWindow *w = self.window; - if (!w) return; - - removeDragView(w); - removeExistingConstraints(w); - // Remove toolbar before fullscreen animation to avoid white band glitch - if ([objc_getAssociatedObject(w, &kLargeCornerRadiusKey) boolValue]) { - w.toolbar = nil; - } - // Restore the standard chrome so AppKit's fullscreen animation can run. - // Tahoe-only: on older macOS this briefly reveals the opaque native - // title bar sliding to the top during the transition (issue #310). - if (isTahoeOrLater()) { - [w setTitlebarAppearsTransparent:NO]; - [w setTitleVisibility:NSWindowTitleVisible]; - } - [w setMovable:YES]; -} - -// Finished entering fullscreen — install replacement buttons in the content view -- (void)didEnterFullScreen:(NSNotification *)note { - NSWindow *w = self.window; - if (!w) return; - - NSNumber *storedHeight = objc_getAssociatedObject(w, &kTitleBarHeightKey); - float height = storedHeight ? [storedHeight floatValue] : kMinHeightForFullSize; - - installFullScreenButtons(w, height); - - // Reinstall the toolbar (removed in willEnterFullScreen to avoid a white - // band glitch during the animation) so 26pt corners show in fullscreen too. - if ([objc_getAssociatedObject(w, &kLargeCornerRadiusKey) boolValue] && !w.toolbar) { - NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"NucleusToolbar"]; - toolbar.showsBaselineSeparator = NO; - w.toolbar = toolbar; - } - - // Install menu bar monitor if newFullscreenControls is enabled. - BOOL newControls = [objc_getAssociatedObject(w, &kNewFullscreenControlsKey) boolValue]; - if (newControls) { - installMenuBarMonitor(w); - } - - // Hide the native titlebar container to prevent it from intercepting - // click events that should reach the Compose content view. On non-notch - // screens in fullscreen the titlebar sits at y=0, overlapping with the - // Compose title bar area. Our replacement buttons (NucleusButtonsView) - // live in the contentView and remain unaffected. - { - NSView *btn = [w standardWindowButton:NSWindowCloseButton]; - NSView *tb = btn ? btn.superview : nil; - NSView *tbc = tb ? tb.superview : nil; - if (tbc) { - [tbc setHidden:YES]; - } - } - - // The system may create NSToolbarFullScreenWindow lazily (e.g. on the - // next run-loop cycle). Schedule a deferred neutralization pass. - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - neutralizeToolbarFullScreenWindows(); - }); -} - -// About to exit fullscreen — remove replacement buttons, hide native title bar -// and hide the standard traffic lights so they don't appear at the wrong -// position during the transition animation -- (void)willExitFullScreen:(NSNotification *)note { - NSWindow *w = self.window; - if (!w) return; - - removeMenuBarMonitor(w); - removeFullScreenButtons(w); - - // Restore the native titlebar container (hidden in didEnterFullScreen) - // so the exit-fullscreen animation can use it. - { - NSView *btn = [w standardWindowButton:NSWindowCloseButton]; - NSView *tb = btn ? btn.superview : nil; - NSView *tbc = tb ? tb.superview : nil; - if (tbc && [tbc isHidden]) { - [tbc setHidden:NO]; - } - } - - [w setTitlebarAppearsTransparent:YES]; - [w setTitleVisibility:NSWindowTitleHidden]; - - // Hide standard buttons during transition to prevent position glitch - [[w standardWindowButton:NSWindowCloseButton] setHidden:YES]; - [[w standardWindowButton:NSWindowMiniaturizeButton] setHidden:YES]; - [[w standardWindowButton:NSWindowZoomButton] setHidden:YES]; -} - -// Finished exiting fullscreen — restore constraints, then reveal the buttons -- (void)didExitFullScreen:(NSNotification *)note { - NSWindow *w = self.window; - if (!w) return; - - NSNumber *storedHeight = objc_getAssociatedObject(w, &kTitleBarHeightKey); - if (!storedHeight) return; - - float height = [storedHeight floatValue]; - [w setMovable:NO]; - ensureDragView(w); - - // Reinstall the invisible toolbar for 26pt corner radius (removed in - // willEnterFullScreen to avoid a white band glitch during animation). - if ([objc_getAssociatedObject(w, &kLargeCornerRadiusKey) boolValue] && !w.toolbar) { - NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"NucleusToolbar"]; - toolbar.showsBaselineSeparator = NO; - // Keep toolbar.visible = YES (default) so macOS renders 26pt corners - // even in maximized mode. Combined with titlebarAppearsTransparent, - // the empty toolbar is visually invisible. - w.toolbar = toolbar; - } - - applyConstraints(w, height); - - // Reveal buttons now that constraints are in place - [[w standardWindowButton:NSWindowCloseButton] setHidden:NO]; - [[w standardWindowButton:NSWindowMiniaturizeButton] setHidden:NO]; - [[w standardWindowButton:NSWindowZoomButton] setHidden:NO]; -} - -@end - -// ─── Zoom button responder ────────────────────────────────────────────────────── - -// Temporarily re-enables window.movable when the mouse enters the zoom button, -// allowing macOS 15 window tiling to work even though movable is normally NO. -// Mirrors JBR's AWTWindowZoomButtonMouseResponder. -@interface NucleusZoomButtonResponder : NSObject -@property (nonatomic, weak) NSWindow *window; -@property (nonatomic, strong) NSTrackingArea *trackingArea; -@end - -@implementation NucleusZoomButtonResponder - -- (instancetype)initWithWindow:(NSWindow *)window { - self = [super init]; - if (self) { - _window = window; - NSView *zoomButton = [window standardWindowButton:NSWindowZoomButton]; - if (zoomButton) { - // NSTrackingInVisibleRect keeps the rect in sync with the button's - // current bounds, so constraint updates don't leave a stale hit area. - _trackingArea = [[NSTrackingArea alloc] - initWithRect:NSZeroRect - options:(NSTrackingMouseEnteredAndExited | - NSTrackingActiveInKeyWindow | - NSTrackingInVisibleRect) - owner:self - userInfo:nil]; - [zoomButton addTrackingArea:_trackingArea]; - } - } - return self; -} - -- (void)dealloc { - if (_trackingArea) { - NSView *zoomButton = _window ? [_window standardWindowButton:NSWindowZoomButton] : nil; - if (zoomButton) { - [zoomButton removeTrackingArea:_trackingArea]; - } - } -} - -- (void)mouseEntered:(NSEvent *)event { - NSWindow *w = self.window; - if (w && ![w isMovable]) { - [w setMovable:YES]; - } -} - -- (void)mouseExited:(NSEvent *)event { - NSWindow *w = self.window; - if (w && objc_getAssociatedObject(w, &kTitleBarHeightKey)) { - [w setMovable:NO]; - } -} - -@end - -// ─── Native drag view ─────────────────────────────────────────────────────────── - -// Native NSView placed in the titlebar that handles window dragging via -// performWindowDragWithEvent: and double-click zoom/minimize. -// Mirrors JBR's AWTWindowDragView. All events are forwarded to the content -// view so AWT/Compose can process them normally. -// Pure pass-through view: forwards every event to the content view so -// AWT/Compose can process them. Window dragging is initiated by Compose -// via nativeStartWindowDrag when it detects an unconsumed drag, exactly -// mirroring JBR's forceHitTest approach where the decision lives in Compose. -@interface NucleusDragView : NSView -@property (atomic, strong) NSEvent *lastMouseDownEvent; -@end - -@implementation NucleusDragView - -- (BOOL)acceptsFirstMouse:(NSEvent *)event { - return YES; -} - -- (BOOL)shouldDelayWindowOrderingForEvent:(NSEvent *)event { - return [[self.window contentView] shouldDelayWindowOrderingForEvent:event]; -} - -- (void)mouseDown:(NSEvent *)event { - self.lastMouseDownEvent = event; - [[self.window contentView] mouseDown:event]; -} - -- (void)mouseUp:(NSEvent *)event { - self.lastMouseDownEvent = nil; - [[self.window contentView] mouseUp:event]; -} - -- (void)mouseDragged:(NSEvent *)event { - [[self.window contentView] mouseDragged:event]; -} - -- (void)mouseMoved:(NSEvent *)event { - [[self.window contentView] mouseMoved:event]; -} - -- (void)rightMouseDown:(NSEvent *)event { - [[self.window contentView] rightMouseDown:event]; -} - -- (void)rightMouseUp:(NSEvent *)event { - [[self.window contentView] rightMouseUp:event]; -} - -- (void)rightMouseDragged:(NSEvent *)event { - [[self.window contentView] rightMouseDragged:event]; -} - -- (void)otherMouseDown:(NSEvent *)event { - [[self.window contentView] otherMouseDown:event]; -} - -- (void)otherMouseUp:(NSEvent *)event { - [[self.window contentView] otherMouseUp:event]; -} - -- (void)otherMouseDragged:(NSEvent *)event { - [[self.window contentView] otherMouseDragged:event]; -} - -- (void)mouseEntered:(NSEvent *)event { - [[self.window contentView] mouseEntered:event]; -} - -- (void)mouseExited:(NSEvent *)event { - [[self.window contentView] mouseExited:event]; -} - -- (void)scrollWheel:(NSEvent *)event { - [[self.window contentView] scrollWheel:event]; -} - -@end - - -// ─── Fullscreen button helpers ────────────────────────────────────────────────── - -// Neutralizes NSToolbarFullScreenWindow instances by hiding their content -// and making them pass-through for mouse events. This prevents the system's -// fullscreen title bar overlay from intercepting clicks that should reach -// the Compose content view — especially on non-notch screens where the -// overlay sits directly over the custom title bar area. -static void neutralizeToolbarFullScreenWindows(void) { - Class cls = NSClassFromString(@"NSToolbarFullScreenWindow"); - if (!cls) return; - for (NSWindow *win in [NSApp windows]) { - if ([win isKindOfClass:cls]) { - if (![win ignoresMouseEvents]) { - [win setIgnoresMouseEvents:YES]; - } - if (![win.contentView isHidden]) { - [win.contentView setHidden:YES]; - } - } - } -} - -// Computes button size and positions matching the constraint-based layout -// used in floating mode (applyConstraints), so there is no visual jump -// when transitioning between fullscreen and floating. -static void computeButtonMetrics(float titleBarHeight, float *outBtnWidth, float *outBtnHeight, float *outOffset) { - float shrinkFactor = fminf(titleBarHeight / kMinHeightForFullSize, 1.0f); - *outBtnWidth = fminf(titleBarHeight * 0.5f, kMinHeightForFullSize * 0.5f); - if (isTahoeOrLater()) { - *outBtnHeight = (*outBtnWidth) * (14.0f / 12.0f) - 2.0f; - } else { - // Keep the pre-Tahoe native 14x16 pt aspect so the glyphs aren't - // squashed on older macOS. - *outBtnHeight = (*outBtnWidth) * (16.0f / 14.0f); - } - *outOffset = shrinkFactor * defaultButtonOffset(); -} - -// Creates replacement traffic-light buttons in the content view, -// mirroring JBR's setWindowFullScreenControls. -// Button positions match the constraint-based layout used in floating mode. -static void installFullScreenButtons(NSWindow *window, float titleBarHeight) { - // Don't double-install - if (objc_getAssociatedObject(window, &kFullscreenButtonsKey)) return; - - NSView *origClose = [window standardWindowButton:NSWindowCloseButton]; - if (!origClose) return; - - // Neutralize the system's fullscreen title bar overlay - neutralizeToolbarFullScreenWindows(); - - // Compute button metrics matching floating mode - float btnWidth, btnHeight, offset; - computeButtonMetrics(titleBarHeight, &btnWidth, &btnHeight, &offset); - - // Create container spanning the full title bar height at the top of the content view - BOOL isRTL = [objc_getAssociatedObject(window, &kRTLKey) boolValue]; - NucleusButtonsView *container = [[NucleusButtonsView alloc] init]; - NSView *parent = window.contentView; - CGFloat y = parent.frame.size.height - titleBarHeight; - float margin = fminf(titleBarHeight / 2.0f, kMaxButtonLeftMargin); - float containerWidth = margin + 2.0f * offset + btnWidth; - CGFloat containerX = isRTL - ? parent.frame.size.width - containerWidth - : 0; - [container setFrame:NSMakeRect(containerX, y, containerWidth, titleBarHeight)]; - - // Drop FullScreen from the mask: copies built with it draw as inactive - // gray and the miniaturise widget is born disabled (issue #531). - NSUInteger masks = [window styleMask] & ~NSWindowStyleMaskFullScreen; - - // Create replacement buttons positioned with the same formula as applyConstraints. - // In RTL mode, buttons are mirrored inside the container. - NSArray *buttonTypes = @[ - @(NSWindowCloseButton), @(NSWindowMiniaturizeButton), @(NSWindowZoomButton) - ]; - SEL actions[] = { @selector(performClose:), @selector(performMiniaturize:), @selector(toggleFullScreen:) }; - - for (NSUInteger idx = 0; idx < 3; idx++) { - NSButton *btn = [NSWindow standardWindowButton:[buttonTypes[idx] unsignedIntegerValue] - forStyleMask:masks]; - CGFloat centerX; - if (isRTL) { - centerX = containerWidth - margin - idx * offset; - } else { - centerX = margin + idx * offset; - } - CGFloat centerY = titleBarHeight / 2.0f; - [btn setFrame:NSMakeRect(centerX - btnWidth / 2.0f, centerY - btnHeight / 2.0f, - btnWidth, btnHeight)]; - if (idx == 1) { - // Miniaturise is a no-op while the window is fullscreen. - [btn setEnabled:NO]; - [btn setTarget:nil]; - [btn setAction:NULL]; - } else { - [btn setTarget:window]; - [btn setAction:actions[idx]]; - } - [btn setHidden:YES]; - [container addSubview:btn]; - } - - [parent addSubview:container]; - [container applyHoverState]; - - objc_setAssociatedObject(window, &kFullscreenButtonsKey, container, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -// Removes the replacement fullscreen buttons. -static void removeFullScreenButtons(NSWindow *window) { - NucleusButtonsView *container = objc_getAssociatedObject(window, &kFullscreenButtonsKey); - if (!container) return; - - [container removeFromSuperview]; - objc_setAssociatedObject(window, &kFullscreenButtonsKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -// Returns the last raw menu bar offset stored by the native event monitor. -// Thread-safe: objc_getAssociatedObject uses internal locking. -static float getMenuBarOffsetForWindow(NSWindow *window) { - NSNumber *stored = objc_getAssociatedObject(window, &kMenuBarLastRawOffsetKey); - return stored ? [stored floatValue] : 0.0f; -} - -// ─── Menu bar event monitor ───────────────────────────────────────────────────── - -// Installs observers that detect menu bar visibility changes: -// 1) NSEvent local monitor — catches mouse-triggered menu bar show/hide. -// 2) NSMenuDidBeginTrackingNotification — catches keyboard-triggered menu -// activation (Control+F2 / Fn+Control+F2), independent of mouse events. -// 3) NSMenuDidEndTrackingNotification — catches when menu tracking ends -// and the menu bar may be about to hide. -// -// All handlers run on the macOS main thread, so AppKit reads are safe. -// When the offset changes, Kotlin is notified via JNI callback. -static void installMenuBarMonitor(NSWindow *window) { - // Safari-style fullscreen title bar (slide down with the menu bar) is a - // Tahoe-era behaviour; on older macOS it produces a phantom padding and - // a seam line in the title-bar area (issue #310 B4/C). - if (!isTahoeOrLater()) return; - removeMenuBarMonitor(window); - - __weak NSWindow *weakWindow = window; - - // Shared check block — reads the current menu bar state and notifies - // Kotlin via JNI callback if the offset changed since last check. - void (^checkMenuBar)(void) = ^{ - if (atomic_load(&sShutdownInProgress)) return; - NSWindow *w = weakWindow; - if (!w) return; - if (!(w.styleMask & NSWindowStyleMaskFullScreen)) return; - - // Re-neutralize in case the system re-created the overlay window - neutralizeToolbarFullScreenWindows(); - - float offset = 0.0f; - - // On screens with a notch (MacBook Pro 14"/16") the menu bar - // lives permanently in the notch area — no offset needed, the - // title bar sits flush at the top of the usable content area. - // On non-notch screens the menu bar slides in/out dynamically, - // so we offset by its height when visible. - NSScreen *screen = w.screen; - BOOL hasNotch = NO; - if (@available(macOS 12.0, *)) { - hasNotch = screen && screen.safeAreaInsets.top > 0; - } - - if (!hasNotch && [NSMenu menuBarVisible]) { - NSMenu *mainMenu = [[NSApplication sharedApplication] mainMenu]; - if (mainMenu) offset = (float)[mainMenu menuBarHeight]; - } - - NSNumber *lastRaw = objc_getAssociatedObject(w, &kMenuBarLastRawOffsetKey); - float lastOffset = lastRaw ? [lastRaw floatValue] : -1.0f; - - if (offset != lastOffset) { - objc_setAssociatedObject(w, &kMenuBarLastRawOffsetKey, @(offset), - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - notifyMenuBarOffsetChanged(w, offset); - } - }; - - // (1) Mouse event monitor - id eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask: - (NSEventMaskMouseMoved | NSEventMaskLeftMouseDown | - NSEventMaskLeftMouseUp | NSEventMaskLeftMouseDragged | - NSEventMaskMouseEntered | NSEventMaskMouseExited) - handler:^NSEvent *(NSEvent *event) { - checkMenuBar(); - return event; - }]; - - // (2) + (3) Notification observers for keyboard-triggered menu tracking - NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - id beginObserver = [nc addObserverForName:NSMenuDidBeginTrackingNotification - object:nil - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification *note) { - checkMenuBar(); - }]; - id endObserver = [nc addObserverForName:NSMenuDidEndTrackingNotification - object:nil - queue:[NSOperationQueue mainQueue] - usingBlock:^(NSNotification *note) { - checkMenuBar(); - }]; - - // Store all observers in a dictionary for cleanup. - NSDictionary *monitors = @{ - @"event": eventMonitor, - @"beginTracking": beginObserver, - @"endTracking": endObserver, - }; - objc_setAssociatedObject(window, &kMenuBarMonitorKey, monitors, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - // Fire an initial check so the offset is notified immediately — especially - // important on notch screens where the offset is constant and won't change - // in response to mouse/keyboard events. - checkMenuBar(); -} - -static void removeMenuBarMonitor(NSWindow *window) { - NSDictionary *monitors = objc_getAssociatedObject(window, &kMenuBarMonitorKey); - if (monitors) { - id eventMonitor = monitors[@"event"]; - if (eventMonitor) [NSEvent removeMonitor:eventMonitor]; - NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; - id begin = monitors[@"beginTracking"]; - if (begin) [nc removeObserver:begin]; - id end = monitors[@"endTracking"]; - if (end) [nc removeObserver:end]; - } - objc_setAssociatedObject(window, &kMenuBarMonitorKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject(window, &kMenuBarLastRawOffsetKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - // Clear the Compose-side offset so stale values don't linger if the - // monitor is re-installed later (e.g. newFullscreenControls toggled). - objc_setAssociatedObject(window, &kMenuBarOffsetKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -// Repositions the fullscreen button container (called from layout passes). -// Uses the same metrics as installFullScreenButtons / applyConstraints. -// When newFullscreenControls is active, accounts for the menu bar offset -// so buttons move down with the title bar when the menu bar appears. -static void updateFullScreenButtonsPosition(NSWindow *window) { - NucleusButtonsView *container = objc_getAssociatedObject(window, &kFullscreenButtonsKey); - if (!container) return; - - // Re-neutralize in case the system re-created the overlay window - neutralizeToolbarFullScreenWindows(); - - NSView *parent = window.contentView; - if (!parent) return; - - NSNumber *storedHeight = objc_getAssociatedObject(window, &kTitleBarHeightKey); - float titleBarHeight = storedHeight ? [storedHeight floatValue] : kMinHeightForFullSize; - - float btnWidth, btnHeight, offset; - computeButtonMetrics(titleBarHeight, &btnWidth, &btnHeight, &offset); - - // Read the menu bar offset stored by Compose via nativeSetMenuBarOffset. - NSNumber *storedMenuBarOffset = objc_getAssociatedObject(window, &kMenuBarOffsetKey); - float menuBarOffset = storedMenuBarOffset ? [storedMenuBarOffset floatValue] : 0.0f; - - BOOL isRTL = [objc_getAssociatedObject(window, &kRTLKey) boolValue]; - float margin = fminf(titleBarHeight / 2.0f, kMaxButtonLeftMargin); - float containerWidth = margin + 2.0f * offset + btnWidth; - CGFloat y = parent.frame.size.height - titleBarHeight - menuBarOffset; - CGFloat containerX = isRTL - ? parent.frame.size.width - containerWidth - : 0; - [container setFrame:NSMakeRect(containerX, y, containerWidth, titleBarHeight)]; - - // Reposition each button inside the container - NSArray *buttons = [container subviews]; - for (NSUInteger idx = 0; idx < buttons.count && idx < 3; idx++) { - NSView *btn = buttons[idx]; - CGFloat centerX; - if (isRTL) { - centerX = containerWidth - margin - idx * offset; - } else { - centerX = margin + idx * offset; - } - CGFloat centerY = titleBarHeight / 2.0f; - [btn setFrame:NSMakeRect(centerX - btnWidth / 2.0f, centerY - btnHeight / 2.0f, - btnWidth, btnHeight)]; - } - [container applyHoverState]; -} - -// ─── _adjustWindowToScreen swizzle ────────────────────────────────────────────── - -// macOS calls _adjustWindowToScreen for window snapping/tiling near screen edges. -// Since we set movable=NO, this callback is blocked. Override to temporarily -// re-enable movable (mirrors JBR's AWTWindow_Normal._adjustWindowToScreen). -// Re-entrancy guard prevents crashes when the original IMP or -// updateFullScreenButtonsPosition triggers another _adjustWindowToScreen call -// on older macOS versions. -static BOOL sInAdjustWindow = NO; - -static void nucleus_adjustWindowToScreen(id self, SEL _cmd) { - if (sInAdjustWindow) { - // Re-entrant call — just forward to the original implementation - if (sOriginalAdjustWindowToScreen) { - ((void (*)(id, SEL))sOriginalAdjustWindowToScreen)(self, _cmd); - } - return; - } - sInAdjustWindow = YES; - - NSNumber *storedHeight = objc_getAssociatedObject(self, &kTitleBarHeightKey); - BOOL needsRestore = storedHeight && ![(NSWindow *)self isMovable]; - - if (needsRestore) { - [(NSWindow *)self setMovable:YES]; - } - - if (sOriginalAdjustWindowToScreen) { - ((void (*)(id, SEL))sOriginalAdjustWindowToScreen)(self, _cmd); - } - - updateFullScreenButtonsPosition((NSWindow *)self); - - if (needsRestore) { - [(NSWindow *)self setMovable:NO]; - } - - sInAdjustWindow = NO; -} - -// Called only from the main queue (via dispatch_async in nativeApplyTitleBar), -// so no synchronization is needed beyond the idempotency check. -static void ensureAdjustWindowSwizzle(NSWindow *window) { - Class cls = object_getClass(window); - SEL sel = NSSelectorFromString(@"_adjustWindowToScreen"); - Method method = class_getInstanceMethod(cls, sel); - if (!method) return; - // Already swizzled (this class or an ancestor we already patched) - if (method_getImplementation(method) == (IMP)nucleus_adjustWindowToScreen) return; - sOriginalAdjustWindowToScreen = method_getImplementation(method); - method_setImplementation(method, (IMP)nucleus_adjustWindowToScreen); -} - -// ─── Zoom button responder helpers ────────────────────────────────────────────── - -static void installZoomButtonResponder(NSWindow *window) { - if (objc_getAssociatedObject(window, &kZoomResponderKey)) return; - - NucleusZoomButtonResponder *responder = - [[NucleusZoomButtonResponder alloc] initWithWindow:window]; - objc_setAssociatedObject(window, &kZoomResponderKey, responder, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -static void removeZoomButtonResponder(NSWindow *window) { - objc_setAssociatedObject(window, &kZoomResponderKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -// ─── Drag view helpers ────────────────────────────────────────────────────────── - -// Installs the drag view once in the titlebar. Subsequent calls are no-ops. -// The drag view persists across constraint updates so an in-progress drag -// is never interrupted by Compose layout passes. -static void ensureDragView(NSWindow *window) { - if (objc_getAssociatedObject(window, &kDragViewKey)) return; - - NSView *closeBtn = [window standardWindowButton:NSWindowCloseButton]; - if (!closeBtn) return; - NSView *titlebar = closeBtn.superview; - if (!titlebar) return; - - NucleusDragView *dragView = [[NucleusDragView alloc] init]; - [titlebar addSubview:dragView positioned:NSWindowBelow relativeTo:closeBtn]; - objc_setAssociatedObject(window, &kDragViewKey, dragView, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -static void removeDragView(NSWindow *window) { - NucleusDragView *dragView = objc_getAssociatedObject(window, &kDragViewKey); - if (!dragView) return; - [dragView removeFromSuperview]; - objc_setAssociatedObject(window, &kDragViewKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -// ─── Constraint helpers ───────────────────────────────────────────────────────── - -static void removeExistingConstraints(NSWindow *window) { - NSMutableArray *existing = objc_getAssociatedObject(window, &kTitleBarConstraintsKey); - if (!existing) return; - - [NSLayoutConstraint deactivateConstraints:existing]; - objc_setAssociatedObject(window, &kTitleBarConstraintsKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - // Note: drag view is NOT removed here — it persists across constraint - // updates so an in-progress drag is never interrupted. - - // Restore autoresizing mask so AppKit can manage layout again - NSView *closeBtn = [window standardWindowButton:NSWindowCloseButton]; - if (!closeBtn) return; - NSView *titlebar = closeBtn.superview; - NSView *titlebarContainer = titlebar ? titlebar.superview : nil; - - if (titlebarContainer) { - titlebarContainer.translatesAutoresizingMaskIntoConstraints = YES; - } - if (titlebar) { - titlebar.translatesAutoresizingMaskIntoConstraints = YES; - } - closeBtn.translatesAutoresizingMaskIntoConstraints = YES; - NSView *miniBtn = [window standardWindowButton:NSWindowMiniaturizeButton]; - NSView *zoomBtn = [window standardWindowButton:NSWindowZoomButton]; - if (miniBtn) miniBtn.translatesAutoresizingMaskIntoConstraints = YES; - if (zoomBtn) zoomBtn.translatesAutoresizingMaskIntoConstraints = YES; -} - -static void applyConstraints(NSWindow *window, float height) { - NSView *closeBtn = [window standardWindowButton:NSWindowCloseButton]; - NSView *miniBtn = [window standardWindowButton:NSWindowMiniaturizeButton]; - NSView *zoomBtn = [window standardWindowButton:NSWindowZoomButton]; - if (!closeBtn || !miniBtn || !zoomBtn) return; - - NSView *titlebar = closeBtn.superview; - NSView *titlebarContainer = titlebar ? titlebar.superview : nil; - NSView *themeFrame = titlebarContainer ? titlebarContainer.superview : nil; - if (!themeFrame) return; - - removeExistingConstraints(window); - - NSMutableArray *constraints = [NSMutableArray array]; - - titlebarContainer.translatesAutoresizingMaskIntoConstraints = NO; - [constraints addObjectsFromArray:@[ - [titlebarContainer.leftAnchor constraintEqualToAnchor:themeFrame.leftAnchor], - [titlebarContainer.widthAnchor constraintEqualToAnchor:themeFrame.widthAnchor], - [titlebarContainer.topAnchor constraintEqualToAnchor:themeFrame.topAnchor], - [titlebarContainer.heightAnchor constraintEqualToConstant:height], - ]]; - - titlebar.translatesAutoresizingMaskIntoConstraints = NO; - [constraints addObjectsFromArray:@[ - [titlebar.leftAnchor constraintEqualToAnchor:titlebarContainer.leftAnchor], - [titlebar.rightAnchor constraintEqualToAnchor:titlebarContainer.rightAnchor], - [titlebar.topAnchor constraintEqualToAnchor:titlebarContainer.topAnchor], - [titlebar.bottomAnchor constraintEqualToAnchor:titlebarContainer.bottomAnchor], - ]]; - - // Add constraints for the drag view (installed once by ensureDragView) - NucleusDragView *dragView = objc_getAssociatedObject(window, &kDragViewKey); - if (dragView) { - dragView.translatesAutoresizingMaskIntoConstraints = NO; - [constraints addObjectsFromArray:@[ - [dragView.leftAnchor constraintEqualToAnchor:titlebarContainer.leftAnchor], - [dragView.rightAnchor constraintEqualToAnchor:titlebarContainer.rightAnchor], - [dragView.topAnchor constraintEqualToAnchor:titlebarContainer.topAnchor], - [dragView.bottomAnchor constraintEqualToAnchor:titlebarContainer.bottomAnchor], - ]]; - } - - BOOL isRTL = [objc_getAssociatedObject(window, &kRTLKey) boolValue]; - float shrinkFactor = fminf(height / kMinHeightForFullSize, 1.0f); - float offset = shrinkFactor * defaultButtonOffset(); - float extraInset = window.toolbar ? kToolbarExtraInset : 0.0f; - float margin = fminf(height / 2.0f, kMaxButtonLeftMargin) + extraInset; - - NSLayoutAnchor *anchorEdge = isRTL - ? titlebarContainer.rightAnchor - : titlebarContainer.leftAnchor; - - // Pre-Tahoe keeps the native 14x16 pt button aspect (no -2 pt trim). - CGFloat sizeRatio = isTahoeOrLater() ? (14.0 / 12.0) : (16.0 / 14.0); - CGFloat sizeConstant = isTahoeOrLater() ? -2.0 : 0.0; - - NSArray *buttons = @[closeBtn, miniBtn, zoomBtn]; - [buttons enumerateObjectsUsingBlock:^(NSView *btn, NSUInteger idx, BOOL *stop) { - btn.translatesAutoresizingMaskIntoConstraints = NO; - float c = margin + idx * offset; - [constraints addObjectsFromArray:@[ - [btn.widthAnchor constraintLessThanOrEqualToAnchor:titlebarContainer.heightAnchor - multiplier:0.5], - [btn.heightAnchor constraintEqualToAnchor:btn.widthAnchor - multiplier:sizeRatio - constant:sizeConstant], - [btn.centerYAnchor constraintEqualToAnchor:titlebarContainer.topAnchor - constant:height / 2.0f], - [btn.centerXAnchor constraintEqualToAnchor:anchorEdge - constant:(isRTL ? -c : c)], - ]]; - }]; - - [NSLayoutConstraint activateConstraints:constraints]; - objc_setAssociatedObject(window, &kTitleBarConstraintsKey, constraints, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -static void ensureFullscreenObserver(NSWindow *window) { - NucleusFSObserver *existing = objc_getAssociatedObject(window, &kFullscreenObserverKey); - if (existing) return; - - NucleusFSObserver *observer = [[NucleusFSObserver alloc] initWithWindow:window]; - objc_setAssociatedObject(window, &kFullscreenObserverKey, observer, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -static void removeFullscreenObserver(NSWindow *window) { - objc_setAssociatedObject(window, &kFullscreenObserverKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -// ─── NSWindow pointer extraction from AWT Window ──────────────────────────────── - -// Extracts the native NSWindow pointer from a java.awt.Window via JNI. -// Uses direct field access to Component.peer (bypasses module system entirely). -// JNI GetFieldID/GetObjectField don't check module boundaries or access modifiers, -// so this works in both standard JVM and GraalVM native-image. -static jlong getNSWindowPtrFromAWTWindow(JNIEnv *env, jobject awtWindow) { - if (!awtWindow) return 0; - - // Direct field access: java.awt.Component.peer (package-private field) - // JNI doesn't check access modifiers, so this works regardless of module system. - jclass componentClass = (*env)->FindClass(env, "java/awt/Component"); - if (!componentClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - jfieldID peerField = (*env)->GetFieldID(env, componentClass, - "peer", "Ljava/awt/peer/ComponentPeer;"); - (*env)->DeleteLocalRef(env, componentClass); - if (!peerField || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - jobject peer = (*env)->GetObjectField(env, awtWindow, peerField); - if (!peer) return 0; - - // peer.getPlatformWindow() — LWWindowPeer method - jclass peerClass = (*env)->GetObjectClass(env, peer); - jmethodID getPlatformWindow = (*env)->GetMethodID(env, peerClass, - "getPlatformWindow", "()Lsun/lwawt/PlatformWindow;"); - (*env)->DeleteLocalRef(env, peerClass); - if (!getPlatformWindow || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, peer); - return 0; - } - - jobject platformWindow = (*env)->CallObjectMethod(env, peer, getPlatformWindow); - (*env)->DeleteLocalRef(env, peer); - if (!platformWindow || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - // platformWindow.ptr — declared in CFRetainedResource, an ancestor of CPlatformWindow. - // Walk the hierarchy rather than assuming a fixed depth, so JBR refactors don't silently break this. - jfieldID ptrField = NULL; - jclass cls = (*env)->GetObjectClass(env, platformWindow); - while (cls) { - ptrField = (*env)->GetFieldID(env, cls, "ptr", "J"); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - ptrField = NULL; - jclass parent = (*env)->GetSuperclass(env, cls); - (*env)->DeleteLocalRef(env, cls); - cls = parent; - } else { - (*env)->DeleteLocalRef(env, cls); - break; - } - } - - if (!ptrField) { - (*env)->DeleteLocalRef(env, platformWindow); - return 0; - } - - jlong result = (*env)->GetLongField(env, platformWindow, ptrField); - (*env)->DeleteLocalRef(env, platformWindow); - return result; -} - -// ─── JNI exports ──────────────────────────────────────────────────────────────── - -JNIEXPORT jlong JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeGetNSWindowPtr( - JNIEnv *env, jclass clazz, jobject awtWindow) { - return getNSWindowPtrFromAWTWindow(env, awtWindow); -} - -JNIEXPORT jfloat JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeApplyTitleBar( - JNIEnv *env, jclass clazz, jlong nsWindowPtr, jfloat heightPt) { - - if (nsWindowPtr == 0) return 0.0f; - - // This is a synchronous JNI call, so the calling Java thread holds a reference - // to the window's Java peer, keeping the NSWindow alive for the duration. - // objc_getAssociatedObject is thread-safe for reads, so no dispatch to main needed here. - NSWindow *window = (__bridge NSWindow *)(void *)nsWindowPtr; - BOOL largeRadius = [objc_getAssociatedObject(window, &kLargeCornerRadiusKey) boolValue]; - float extraInset = largeRadius ? kToolbarExtraInset : 0.0f; - - float shrink = fminf(heightPt / kMinHeightForFullSize, 1.0f); - float btnOffset = shrink * defaultButtonOffset(); - float leftMargin = fminf(heightPt / 2.0f, kMaxButtonLeftMargin) + extraInset; - float leftInset = 2.0f * leftMargin + 2.0f * btnOffset; - float capturedHeight = heightPt; - - // Capture the raw pointer value — do NOT create a __weak reference here. - // This function is called from a Java thread, and if the NSWindow has - // already been deallocated on the main thread, creating a __weak - // reference would crash in objc_initWeak (EXC_BAD_ACCESS). - void *rawPtr = (void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - // Verify the window is still alive by checking NSApp.windows. - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - - // Store the desired height for fullscreen restore - objc_setAssociatedObject(w, &kTitleBarHeightKey, - @(capturedHeight), OBJC_ASSOCIATION_RETAIN_NONATOMIC); - - ensureFullscreenObserver(w); - ensureAdjustWindowSwizzle(w); - installZoomButtonResponder(w); - - if ((w.styleMask & NSWindowStyleMaskFullScreen) != 0) { - // In fullscreen: update replacement button positions - updateFullScreenButtonsPosition(w); - return; - } - - [w setTitlebarAppearsTransparent:YES]; - [w setTitleVisibility:NSWindowTitleHidden]; - [w setMovable:NO]; - ensureDragView(w); - applyConstraints(w, capturedHeight); - } - }); - - return leftInset; -} - -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeResetTitleBar( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - - if (nsWindowPtr == 0) return; - // Capture the raw pointer value — do NOT create a __weak reference here. - // This function is called from a Java thread, and if the NSWindow has - // already been deallocated on the main thread, creating a __weak - // reference would crash in objc_initWeak (EXC_BAD_ACCESS). - void *rawPtr = (void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - // Verify the window is still alive by checking NSApp.windows. - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - removeMenuBarMonitor(w); - removeFullScreenButtons(w); - removeFullscreenObserver(w); - removeZoomButtonResponder(w); - removeDragView(w); - removeExistingConstraints(w); - objc_setAssociatedObject(w, &kTitleBarHeightKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject(w, &kNewFullscreenControlsKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject(w, &kMenuBarOffsetKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject(w, &kLargeCornerRadiusKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - objc_setAssociatedObject(w, &kRTLKey, nil, - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - w.toolbar = nil; - [w setTitlebarAppearsTransparent:NO]; - [w setTitleVisibility:NSWindowTitleVisible]; - [w setMovable:YES]; - } - }); -} - -// Called from Kotlin on each layout pass during fullscreen to keep -// the replacement buttons positioned correctly. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeUpdateFullScreenButtons( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - - if (nsWindowPtr == 0) return; - void *rawPtr = (void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - updateFullScreenButtonsPosition(w); - } - }); -} - -// Performs the macOS title bar double-click action (zoom or minimize) -// respecting the user's system preference (AppleActionOnDoubleClick). -// Called from Compose when an unconsumed double-click is detected. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativePerformTitleBarDoubleClickAction( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - - if (nsWindowPtr == 0) return; - void *rawPtr = (void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - NSString *action = [[NSUserDefaults standardUserDefaults] - stringForKey:@"AppleActionOnDoubleClick"]; - if (action && [action caseInsensitiveCompare:@"Minimize"] == NSOrderedSame) { - [w performMiniaturize:nil]; - } else if (!action || [action caseInsensitiveCompare:@"None"] != NSOrderedSame) { - [w performZoom:nil]; - } - } - }); -} - -// Initiates a native window drag using the saved mouseDown event. -// Called from the EDT when Compose detects an unconsumed drag in the title bar. -// This mirrors JBR's forceHitTest(false) path where Compose decides the drag. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeStartWindowDrag( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - - if (nsWindowPtr == 0) return; - // Read associated objects while the window is guaranteed alive (synchronous JNI call). - NSWindow *window = (__bridge NSWindow *)(void *)nsWindowPtr; - NucleusDragView *dragView = objc_getAssociatedObject(window, &kDragViewKey); - if (!dragView) return; - - NSEvent *event = dragView.lastMouseDownEvent; - if (!event) return; - dragView.lastMouseDownEvent = nil; - - void *rawPtr = (void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - // Temporarily re-enable movable so performWindowDragWithEvent: - // works on macOS < 26 where the system expects movable=YES. - // Mirrors JBR's forceHitTest(false) approach. - NSNumber *storedHeight = objc_getAssociatedObject(w, &kTitleBarHeightKey); - BOOL needsRestore = storedHeight && ![w isMovable]; - if (needsRestore) [w setMovable:YES]; - [w performWindowDragWithEvent:event]; - if (needsRestore) [w setMovable:NO]; - } - }); -} - -// Stores the newFullscreenControls flag on the window. -// When enabled, the title bar and its traffic-light buttons are pushed down -// by the menu bar height whenever the auto-hidden menu bar becomes visible -// in fullscreen — mirroring Safari's fullscreen title bar behavior. -// Also installs/removes the menu bar event monitor if already in fullscreen. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeSetNewFullscreenControls( - JNIEnv *env, jclass clazz, jlong nsWindowPtr, jboolean enabled) { - - if (nsWindowPtr == 0) return; - ensureJVMCached(env); - void *rawPtr = (void *)nsWindowPtr; - // Force-disable on pre-Tahoe systems — see installMenuBarMonitor. - BOOL flag = (BOOL)enabled && isTahoeOrLater(); - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - objc_setAssociatedObject(w, &kNewFullscreenControlsKey, @(flag), - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - // Install or remove monitor if already in fullscreen. - if (w.styleMask & NSWindowStyleMaskFullScreen) { - if (flag) { - installMenuBarMonitor(w); - } else { - removeMenuBarMonitor(w); - } - } - } - }); -} - -// Returns the last known menu bar offset in points. -// Reads the value stored by the native event monitor (thread-safe). -JNIEXPORT jfloat JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeGetMenuBarOffset( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - - if (nsWindowPtr == 0) return 0.0f; - NSWindow *window = (__bridge NSWindow *)(void *)nsWindowPtr; - return getMenuBarOffsetForWindow(window); -} - -// Stores the current menu bar offset (in points) as seen by Compose. -// Called from the polling loop so that nativeUpdateFullScreenButtons -// can position the traffic-light buttons at the same Y offset, -// keeping native buttons and Compose title bar perfectly in sync. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeSetMenuBarOffset( - JNIEnv *env, jclass clazz, jlong nsWindowPtr, jfloat offsetPt) { - - if (nsWindowPtr == 0) return; - void *rawPtr = (void *)nsWindowPtr; - // Immediately reposition buttons on the main queue. - // Store the offset and reposition atomically on the main thread to avoid - // a race with window disposal (objc_setAssociatedObject on a freed object). - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - objc_setAssociatedObject(w, &kMenuBarOffsetKey, @(offsetPt), - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - updateFullScreenButtonsPosition(w); - } - }); -} - -// Installs an NSEvent local monitor that detects menu bar visibility -// changes on every mouse event and notifies Kotlin via JNI callback. -// Event-driven: no timer, no polling. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeInstallMenuBarMonitor( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - - if (nsWindowPtr == 0) return; - ensureJVMCached(env); - void *rawPtr = (void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - installMenuBarMonitor(w); - } - }); -} - -// Removes the native event monitor and clears the stored raw offset. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeRemoveMenuBarMonitor( - JNIEnv *env, jclass clazz, jlong nsWindowPtr) { - - if (nsWindowPtr == 0) return; - // Capture the raw pointer value — do NOT create a __weak reference here. - // This function is called from a Java thread, and if the NSWindow has - // already been deallocated on the main thread, creating a __weak - // reference would crash in objc_initWeak (EXC_BAD_ACCESS). - void *rawPtr = (void *)nsWindowPtr; - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - // Verify the window is still alive by checking NSApp.windows. - for (NSWindow *w in [NSApp windows]) { - if ((__bridge void *)w == rawPtr) { - removeMenuBarMonitor(w); - return; - } - } - } - }); -} - -// Installs or removes an invisible NSToolbar to trigger macOS 26pt corner radius. -// Also stores the preference so the fullscreen observer can manage the toolbar -// around fullscreen transitions (remove before enter, reinstall after). -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeSetLargeCornerRadius( - JNIEnv *env, jclass clazz, jlong nsWindowPtr, jboolean enabled) { - - if (nsWindowPtr == 0) return; - void *rawPtr = (void *)nsWindowPtr; - // Pre-Tahoe systems do not draw the new corners even with a toolbar - // attached; force-disable so we don't install a useless toolbar that - // shifts the buttons (kToolbarExtraInset) and spawns the AppKit - // NSToolbarFullScreenWindow overlay in fullscreen (issue #310). - BOOL flag = (enabled == JNI_TRUE) && isTahoeOrLater(); - - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - objc_setAssociatedObject(w, &kLargeCornerRadiusKey, @(flag), - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if (flag) { - if (!w.toolbar) { - // Enable full-size content view and transparent title bar BEFORE - // adding the toolbar, so AppKit treats the toolbar as part of the - // existing content area instead of growing the window frame to - // accommodate it. Without this, assigning w.toolbar expands the - // frame height by the toolbar chrome, which later causes Compose's - // center alignment to be off by half that extra height. - [w setStyleMask:([w styleMask] | NSWindowStyleMaskFullSizeContentView)]; - [w setTitlebarAppearsTransparent:YES]; - NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"NucleusToolbar"]; - toolbar.showsBaselineSeparator = NO; - // Keep toolbar.visible = YES (default) so macOS renders 26pt corners - // even in maximized mode. Combined with titlebarAppearsTransparent, - // the empty toolbar is visually invisible. - w.toolbar = toolbar; - } - } else if (w.toolbar) { - w.toolbar = nil; - // Symmetrically revert the style/appearance changes applied - // when the toolbar was installed, so toggling the modifier - // off at runtime restores the standard title bar instead of - // leaving a transparent / full-size-content-view residue. - [w setStyleMask:([w styleMask] & ~NSWindowStyleMaskFullSizeContentView)]; - [w setTitlebarAppearsTransparent:NO]; - } - // Re-apply constraints so button positions update for the new inset - NSNumber *storedHeight = objc_getAssociatedObject(w, &kTitleBarHeightKey); - if (storedHeight && !(w.styleMask & NSWindowStyleMaskFullScreen)) { - applyConstraints(w, [storedHeight floatValue]); - } - } - }); -} - -// Disables native → JVM callbacks and removes all menu bar monitors. -// Must be called from a JVM shutdown hook (on a Java thread) before the JVM -// starts tearing down, to prevent notifyMenuBarOffsetChanged from calling -// CallStaticVoidMethod on a half-destroyed JVM. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeShutdown( - JNIEnv *env, jclass clazz) { - - // Signal all pending dispatch_async blocks to bail out immediately. - atomic_store(&sShutdownInProgress, true); - - // Immediately prevent any further JNI callbacks from the main thread. - atomic_store(&sCallbacksEnabled, false); - - // Asynchronously remove all menu bar monitors on the main queue. - // dispatch_async (not dispatch_sync) avoids a deadlock: if a previously - // queued dispatch_async block is already executing on the main thread - // (past its sShutdownInProgress check), dispatch_sync would block this - // thread while the JVM tears down concurrently, causing the in-flight - // block to access invalid state → SIGSEGV → abort. - // The atomic flags set above already prevent any JNI callback or - // meaningful work, so synchronous cleanup is unnecessary. - dispatch_async(dispatch_get_main_queue(), ^{ - for (NSWindow *w in [NSApp windows]) { - if (objc_getAssociatedObject(w, &kMenuBarMonitorKey)) { - removeMenuBarMonitor(w); - } - } - }); -} - -// Sets the RTL (right-to-left) flag on the window. -// When enabled, the traffic-light buttons are positioned on the right side -// of the title bar, mirroring the layout for RTL locales (Hebrew, Arabic, etc.). -// Re-applies constraints immediately so the change is visible without delay. -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_macos_JniMacTitleBarBridge_nativeSetRTL( - JNIEnv *env, jclass clazz, jlong nsWindowPtr, jboolean rtl) { - - if (nsWindowPtr == 0) return; - void *rawPtr = (void *)nsWindowPtr; - BOOL flag = (rtl == JNI_TRUE); - - dispatch_async(dispatch_get_main_queue(), ^{ - if (atomic_load(&sShutdownInProgress)) return; - @autoreleasepool { - NSWindow *w = nil; - for (NSWindow *win in [NSApp windows]) { - if ((__bridge void *)win == rawPtr) { w = win; break; } - } - if (!w) return; - objc_setAssociatedObject(w, &kRTLKey, @(flag), - OBJC_ASSOCIATION_RETAIN_NONATOMIC); - // Re-apply constraints so buttons move to the correct side - NSNumber *storedHeight = objc_getAssociatedObject(w, &kTitleBarHeightKey); - if (storedHeight) { - if (w.styleMask & NSWindowStyleMaskFullScreen) { - updateFullScreenButtonsPosition(w); - } else { - applyConstraints(w, [storedHeight floatValue]); - } - } - } - }); -} diff --git a/decorated-window-jni/src/main/native/macos/build.sh b/decorated-window-jni/src/main/native/macos/build.sh deleted file mode 100755 index b20c80833..000000000 --- a/decorated-window-jni/src/main/native/macos/build.sh +++ /dev/null @@ -1,69 +0,0 @@ -#!/bin/bash -# Compiles JniMacTitleBar.m into per-architecture dylibs (arm64 + x86_64). -# The outputs are placed in the JAR resources so they ship with the library. -# -# Prerequisites: Xcode command-line tools (clang). -# Usage: ./build.sh - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC="$SCRIPT_DIR/JniMacTitleBar.m" -RESOURCE_DIR="$SCRIPT_DIR/../../resources/nucleus/native" -OUT_DIR_ARM64="$RESOURCE_DIR/darwin-aarch64" -OUT_DIR_X64="$RESOURCE_DIR/darwin-x64" - -# Detect JAVA_HOME for JNI headers -if [ -z "${JAVA_HOME:-}" ]; then - JAVA_HOME=$(/usr/libexec/java_home 2>/dev/null || true) -fi -if [ -z "${JAVA_HOME:-}" ]; then - echo "ERROR: JAVA_HOME not set and /usr/libexec/java_home failed." >&2 - exit 1 -fi - -JNI_INCLUDE="$JAVA_HOME/include" -JNI_INCLUDE_DARWIN="$JAVA_HOME/include/darwin" - -if [ ! -d "$JNI_INCLUDE" ]; then - echo "ERROR: JNI headers not found at $JNI_INCLUDE" >&2 - exit 1 -fi - -mkdir -p "$OUT_DIR_ARM64" "$OUT_DIR_X64" - -COMMON_FLAGS=( - -dynamiclib - -I"$JNI_INCLUDE" -I"$JNI_INCLUDE_DARWIN" - -framework Cocoa - -framework QuartzCore - -mmacosx-version-min=10.13 - -fobjc-arc - -Oz # optimize for smallest code size - -flto # link-time optimization - -fvisibility=hidden # hide all symbols except JNIEXPORT ones - -Wl,-dead_strip # strip unreachable code - -Wl,-x # strip local symbols at link time -) - -# Compile for arm64 -clang -arch arm64 "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_ARM64/libnucleus_macos_jni.dylib" "$SRC" -strip -x "$OUT_DIR_ARM64/libnucleus_macos_jni.dylib" - -# Compile for x86_64 -clang -arch x86_64 "${COMMON_FLAGS[@]}" \ - -o "$OUT_DIR_X64/libnucleus_macos_jni.dylib" "$SRC" -strip -x "$OUT_DIR_X64/libnucleus_macos_jni.dylib" - -# Clear NativeLibraryLoader cache so the fresh library is used on next run. -# Without this, the loader serves the stale cached copy from ~/.cache/nucleus/. -CACHE_DIR="$HOME/.cache/nucleus/native" -if [ -d "$CACHE_DIR" ]; then - rm -rf "$CACHE_DIR" - echo "Cleared NativeLibraryLoader cache: $CACHE_DIR" -fi - -echo "Built per-architecture dylibs:" -ls -lh "$OUT_DIR_ARM64/libnucleus_macos_jni.dylib" -ls -lh "$OUT_DIR_X64/libnucleus_macos_jni.dylib" diff --git a/decorated-window-jni/src/main/native/windows/build.bat b/decorated-window-jni/src/main/native/windows/build.bat deleted file mode 100644 index 416b8b485..000000000 --- a/decorated-window-jni/src/main/native/windows/build.bat +++ /dev/null @@ -1,123 +0,0 @@ -@echo off -REM Compiles nucleus_windows_decoration.c into per-architecture DLLs (x64 + ARM64). -REM The outputs are placed in the JAR resources so they ship with the library. -REM -REM Prerequisites: Visual Studio Build Tools (MSVC) with ARM64 support. -REM Usage: build.bat - -setlocal enabledelayedexpansion - -set "SCRIPT_DIR=%~dp0" -set "SRC=%SCRIPT_DIR%nucleus_windows_decoration.c" -set "RESOURCE_DIR=%SCRIPT_DIR%..\..\resources\nucleus\native" -set "OUT_DIR_X64=%RESOURCE_DIR%\win32-x64" -set "OUT_DIR_ARM64=%RESOURCE_DIR%\win32-aarch64" - -REM Check JAVA_HOME -if "%JAVA_HOME%"=="" ( - echo ERROR: JAVA_HOME is not set. >&2 - exit /b 1 -) -if not exist "%JAVA_HOME%\include\jni.h" ( - echo ERROR: JNI headers not found at %JAVA_HOME%\include >&2 - exit /b 1 -) - -set "JNI_INCLUDE=%JAVA_HOME%\include" -set "JNI_INCLUDE_WIN32=%JAVA_HOME%\include\win32" - -REM Locate vcvarsall.bat -set "VCVARSALL=" -REM Prefer vswhere: resolves any installed VS version (incl. 18+ and previews). -set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe" -if exist "%VSWHERE%" ( - for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -prerelease -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do ( - if exist "%%i\VC\Auxiliary\Build\vcvarsall.bat" set "VCVARSALL=%%i\VC\Auxiliary\Build\vcvarsall.bat" - ) -) -REM Fallback: scan well-known install locations if vswhere did not resolve a path. -if "%VCVARSALL%"=="" ( - for %%v in (18 2022 2019 2017) do ( - for %%e in (Enterprise Professional Community BuildTools) do ( - if exist "C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" ( - set "VCVARSALL=C:\Program Files\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" - goto :found_vc - ) - if exist "C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" ( - set "VCVARSALL=C:\Program Files (x86)\Microsoft Visual Studio\%%v\%%e\VC\Auxiliary\Build\vcvarsall.bat" - goto :found_vc - ) - ) - ) -) -:found_vc -if "%VCVARSALL%"=="" ( - echo ERROR: Could not locate vcvarsall.bat. Install Visual Studio Build Tools. >&2 - exit /b 1 -) - -echo Using vcvarsall.bat: %VCVARSALL% - -REM Create output directories -if not exist "%OUT_DIR_X64%" mkdir "%OUT_DIR_X64%" -if not exist "%OUT_DIR_ARM64%" mkdir "%OUT_DIR_ARM64%" - -REM ---- Compile x64 ---- -REM Use setlocal/endlocal to isolate vcvarsall environment per architecture, -REM preventing PATH accumulation that exceeds cmd.exe line length on CI. -echo. -echo === Building x64 DLL === -setlocal -call "%VCVARSALL%" x64 -if errorlevel 1 ( - echo ERROR: vcvarsall x64 failed >&2 - exit /b 1 -) - -cl /LD /O1 /GS- /nologo ^ - /I"%JNI_INCLUDE%" /I"%JNI_INCLUDE_WIN32%" ^ - "%SRC%" ^ - /Fe:"%OUT_DIR_X64%\nucleus_windows_decoration.dll" ^ - /link /NODEFAULTLIB /ENTRY:DllMain kernel32.lib user32.lib dwmapi.lib gdi32.lib shell32.lib -if errorlevel 1 ( - echo ERROR: x64 compilation failed >&2 - exit /b 1 -) -endlocal - -REM Clean up intermediate files -del /q "%OUT_DIR_X64%\*.obj" "%OUT_DIR_X64%\*.lib" "%OUT_DIR_X64%\*.exp" 2>nul - -REM ---- Compile ARM64 ---- -echo. -echo === Building ARM64 DLL === -setlocal -call "%VCVARSALL%" x64_arm64 -if errorlevel 1 ( - echo WARNING: vcvarsall x64_arm64 failed. ARM64 cross-compilation may not be available. >&2 - endlocal - goto :done -) - -cl /LD /O1 /GS- /nologo ^ - /I"%JNI_INCLUDE%" /I"%JNI_INCLUDE_WIN32%" ^ - "%SRC%" ^ - /Fe:"%OUT_DIR_ARM64%\nucleus_windows_decoration.dll" ^ - /link /NODEFAULTLIB /ENTRY:DllMain kernel32.lib user32.lib dwmapi.lib gdi32.lib shell32.lib -if errorlevel 1 ( - echo WARNING: ARM64 compilation failed. >&2 - endlocal - goto :done -) -endlocal - -REM Clean up intermediate files -del /q "%OUT_DIR_ARM64%\*.obj" "%OUT_DIR_ARM64%\*.lib" "%OUT_DIR_ARM64%\*.exp" 2>nul - -:done -echo. -echo Built DLLs: -if exist "%OUT_DIR_X64%\nucleus_windows_decoration.dll" echo %OUT_DIR_X64%\nucleus_windows_decoration.dll -if exist "%OUT_DIR_ARM64%\nucleus_windows_decoration.dll" echo %OUT_DIR_ARM64%\nucleus_windows_decoration.dll - -endlocal diff --git a/decorated-window-jni/src/main/native/windows/nucleus_windows_decoration.c b/decorated-window-jni/src/main/native/windows/nucleus_windows_decoration.c deleted file mode 100644 index fe5408079..000000000 --- a/decorated-window-jni/src/main/native/windows/nucleus_windows_decoration.c +++ /dev/null @@ -1,1102 +0,0 @@ -/** - * JNI bridge for Windows custom window decoration (title-bar removal). - * - * Subclasses the HWND WndProc to: - * - WM_NCCALCSIZE: extend client area into the title bar - * - WM_NCHITTEST: 3-zone hit test (resize borders, caption, client) - * - WM_NCMOUSEMOVE: forward as WM_MOUSEMOVE for Compose pointer tracking - * - DwmExtendFrameIntoClientArea for DWM shadow - * - * Per-HWND state is stored via SetProp/GetProp. - * DPI-aware: GetDpiForWindow / GetSystemMetricsForDpi resolved dynamically. - * - * Linked libraries: kernel32.lib user32.lib dwmapi.lib gdi32.lib - */ - -#include -#include -#include - -/* ------------------------------------------------------------------ */ -/* /NODEFAULTLIB support */ -/* ------------------------------------------------------------------ */ -int _fltused = 0; - -#pragma function(memset) -void *memset(void *dest, int c, size_t count) { - unsigned char *p = (unsigned char *)dest; - while (count--) *p++ = (unsigned char)c; - return dest; -} - -/* ------------------------------------------------------------------ */ -/* SM_CXPADDEDBORDERWIDTH guard — not in all SDK versions */ -/* ------------------------------------------------------------------ */ -#ifndef SM_CXPADDEDBORDERWIDTH -#define SM_CXPADDEDBORDERWIDTH 92 -#endif - -/* ------------------------------------------------------------------ */ -/* DPI-aware function pointers (resolved once) */ -/* ------------------------------------------------------------------ */ -typedef UINT (WINAPI *PFN_GetDpiForWindow)(HWND); -typedef int (WINAPI *PFN_GetSystemMetricsForDpi)(int, UINT); -typedef BOOL (WINAPI *PFN_AdjustWindowRectExForDpi)(LPRECT, DWORD, BOOL, DWORD, UINT); - -static PFN_GetDpiForWindow pGetDpiForWindow = NULL; -static PFN_GetSystemMetricsForDpi pGetSystemMetricsForDpi = NULL; -static PFN_AdjustWindowRectExForDpi pAdjustWindowRectExForDpi = NULL; -static volatile BOOL dpiApiResolved = FALSE; - -static void resolveDpiApis(void) { - if (dpiApiResolved) return; - HMODULE hUser32 = GetModuleHandleA("user32.dll"); - if (hUser32) { - pGetDpiForWindow = (PFN_GetDpiForWindow) - GetProcAddress(hUser32, "GetDpiForWindow"); - pGetSystemMetricsForDpi = (PFN_GetSystemMetricsForDpi) - GetProcAddress(hUser32, "GetSystemMetricsForDpi"); - pAdjustWindowRectExForDpi = (PFN_AdjustWindowRectExForDpi) - GetProcAddress(hUser32, "AdjustWindowRectExForDpi"); - } - dpiApiResolved = TRUE; -} - -static UINT getDpi(HWND hwnd) { - if (pGetDpiForWindow) return pGetDpiForWindow(hwnd); - HDC hdc = GetDC(hwnd); - UINT dpi = (UINT)GetDeviceCaps(hdc, LOGPIXELSX); - ReleaseDC(hwnd, hdc); - return dpi; -} - -static int getSystemMetrics(int index, UINT dpi) { - if (pGetSystemMetricsForDpi) return pGetSystemMetricsForDpi(index, dpi); - return GetSystemMetrics(index); -} - -/* ------------------------------------------------------------------ */ -/* Per-HWND state */ -/* ------------------------------------------------------------------ */ -static const wchar_t *PROP_NAME = L"NucleusDecoState"; -static const wchar_t *CHILD_PROP_NAME = L"NucleusChildState"; - -typedef struct { - WNDPROC originalWndProc; - int titleBarHeightPx; - BOOL forceHitTestClient; - HWND childHwnd; - /* Background color (COLORREF = 0x00BBGGRR) for WM_ERASEBKGND */ - COLORREF bgColor; - /* Fullscreen state */ - BOOL isFullscreen; - LONG savedStyle; - LONG savedExStyle; - WINDOWPLACEMENT savedPlacement; - /* Min/max size override (logical pixels, 0 = not set) */ - POINT minSizePx; - POINT maxSizePx; - /* Debug counters */ - int hitTestCount; - int hitTestCaption; - int hitTestClient; - int hitTestBorder; - int nccalcsizeCount; - int lastPtY; - int lastWinTop; - int anyMsgCount; -} DecoState; - -typedef struct { - WNDPROC originalWndProc; - HWND parentHwnd; -} ChildState; - -static DecoState *getState(HWND hwnd) { - return (DecoState *)GetPropW(hwnd, PROP_NAME); -} - -static ChildState *getChildState(HWND hwnd) { - return (ChildState *)GetPropW(hwnd, CHILD_PROP_NAME); -} - -/* ------------------------------------------------------------------ */ -/* Resize border width helper */ -/* ------------------------------------------------------------------ */ -static int getResizeBorderWidth(HWND hwnd, BOOL isVertical) { - UINT dpi = getDpi(hwnd); - int frameMetric = isVertical ? SM_CXSIZEFRAME : SM_CYSIZEFRAME; - int border = getSystemMetrics(frameMetric, dpi) - + getSystemMetrics(SM_CXPADDEDBORDERWIDTH, dpi); - return border; -} - -/* ------------------------------------------------------------------ */ -/* Auto-hide taskbar detection */ -/* ------------------------------------------------------------------ */ -static BOOL isAutoHideTaskbar(UINT edge, RECT monitorRect) { - APPBARDATA abd; - abd.cbSize = sizeof(abd); - abd.uEdge = edge; - abd.rc = monitorRect; - return (BOOL)SHAppBarMessage(ABM_GETAUTOHIDEBAR, &abd); -} - -/* ------------------------------------------------------------------ */ -/* Debug output (temporary — writes to debugger + log file) */ -/* ------------------------------------------------------------------ */ -static void debugLog(const char *fmt, ...) { - char buf[512]; - va_list ap; - va_start(ap, fmt); - wvsprintfA(buf, fmt, ap); - va_end(ap); - OutputDebugStringA(buf); - OutputDebugStringA("\n"); -} - -/* ------------------------------------------------------------------ */ -/* Child WndProc: returns HTTRANSPARENT in title bar area so that */ -/* WM_NCHITTEST is forwarded to the parent frame. */ -/* ------------------------------------------------------------------ */ -static LRESULT CALLBACK childWndProc( - HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -{ - ChildState *cs = getChildState(hwnd); - if (!cs) return DefWindowProcW(hwnd, msg, wParam, lParam); - - /* Fill background with parent's bgColor to avoid white flash on resize */ - if (msg == WM_ERASEBKGND) { - DecoState *parentState = getState(cs->parentHwnd); - if (parentState) { - HDC hdc = (HDC)wParam; - RECT rc; - GetClientRect(hwnd, &rc); - HBRUSH brush = CreateSolidBrush(parentState->bgColor); - FillRect(hdc, &rc, brush); - DeleteObject(brush); - return 1; - } - } - - if (msg == WM_NCHITTEST) { - /* Only return HTTRANSPARENT for the top resize border so the - * parent frame can handle HTTOP/HTTOPLEFT/HTTOPRIGHT. - * Everything else (title bar, client) returns HTCLIENT so - * all clicks reach Compose, which handles buttons, switches, - * and initiates native drag for unconsumed clicks. */ - DecoState *parentState = getState(cs->parentHwnd); - if (parentState && !IsZoomed(cs->parentHwnd) && !parentState->isFullscreen) { - POINT pt; - pt.x = (short)LOWORD(lParam); - pt.y = (short)HIWORD(lParam); - - RECT parentRect; - GetWindowRect(cs->parentHwnd, &parentRect); - int borderHeight = getResizeBorderWidth(cs->parentHwnd, FALSE); - - if (pt.y < parentRect.top + borderHeight) { - return HTTRANSPARENT; - } - } - } - - if (msg == WM_NCDESTROY) { - WNDPROC origProc = cs->originalWndProc; - RemovePropW(hwnd, CHILD_PROP_NAME); - HeapFree(GetProcessHeap(), 0, cs); - SetWindowLongPtrW(hwnd, GWLP_WNDPROC, (LONG_PTR)origProc); - return CallWindowProcW(origProc, hwnd, msg, wParam, lParam); - } - - return CallWindowProcW(cs->originalWndProc, hwnd, msg, wParam, lParam); -} - -/* ------------------------------------------------------------------ */ -/* WndProc subclass (frame) */ -/* ------------------------------------------------------------------ */ -static LRESULT CALLBACK decorationWndProc( - HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -{ - DecoState *state = getState(hwnd); - if (!state) return DefWindowProcW(hwnd, msg, wParam, lParam); - - state->anyMsgCount++; - - switch (msg) { - - /* -------------------------------------------------------------- */ - /* WM_ERASEBKGND: fill with bgColor to avoid white flash on */ - /* resize. Without this, the default handler erases to the */ - /* window class brush (white) before Compose/Skiko renders. */ - /* -------------------------------------------------------------- */ - case WM_ERASEBKGND: { - HDC hdc = (HDC)wParam; - RECT rc; - GetClientRect(hwnd, &rc); - HBRUSH brush = CreateSolidBrush(state->bgColor); - FillRect(hdc, &rc, brush); - DeleteObject(brush); - return 1; - } - - /* -------------------------------------------------------------- */ - /* WM_WINDOWPOSCHANGING: prevent BitBlt during resize. */ - /* Without SWP_NOCOPYBITS, Windows copies old content and fills */ - /* the newly exposed strip with the class brush (white) before */ - /* WM_ERASEBKGND fires. */ - /* -------------------------------------------------------------- */ - case WM_WINDOWPOSCHANGING: { - WINDOWPOS *wp = (WINDOWPOS *)lParam; - wp->flags |= SWP_NOCOPYBITS; - break; - } - - /* -------------------------------------------------------------- */ - /* WM_NCCALCSIZE: extend client area into title bar */ - /* -------------------------------------------------------------- */ - case WM_NCCALCSIZE: { - state->nccalcsizeCount++; - if (!wParam) break; /* wParam == FALSE → just use default */ - - /* Fullscreen: client area fills entire window */ - if (state->isFullscreen) { - return 0; - } - - NCCALCSIZE_PARAMS *params = (NCCALCSIZE_PARAMS *)lParam; - RECT originalTop = params->rgrc[0]; - - /* Let the default handler compute the NC area first */ - LRESULT result = CallWindowProcW(state->originalWndProc, - hwnd, msg, wParam, lParam); - - /* Restore the top coordinate so client area extends into title bar */ - params->rgrc[0].top = originalTop.top; - - /* When maximized, the window extends beyond the screen by the - * frame border width. We need to offset the top by that amount - * so the content doesn't go under the taskbar. */ - if (IsZoomed(hwnd)) { - UINT dpi = getDpi(hwnd); - int borderWidth = getSystemMetrics(SM_CYSIZEFRAME, dpi) - + getSystemMetrics(SM_CXPADDEDBORDERWIDTH, dpi); - params->rgrc[0].top += borderWidth; - - /* Account for auto-hide taskbar: reserve 1px so the taskbar - * can still be triggered by moving the mouse to the edge. */ - HMONITOR hMon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - MONITORINFO mi; - mi.cbSize = sizeof(mi); - if (GetMonitorInfoW(hMon, &mi)) { - if (params->rgrc[0].top == mi.rcMonitor.top - && isAutoHideTaskbar(ABE_TOP, mi.rcMonitor)) { - params->rgrc[0].top += 1; - } - if (params->rgrc[0].bottom == mi.rcMonitor.bottom - && isAutoHideTaskbar(ABE_BOTTOM, mi.rcMonitor)) { - params->rgrc[0].bottom -= 1; - } - if (params->rgrc[0].left == mi.rcMonitor.left - && isAutoHideTaskbar(ABE_LEFT, mi.rcMonitor)) { - params->rgrc[0].left += 1; - } - if (params->rgrc[0].right == mi.rcMonitor.right - && isAutoHideTaskbar(ABE_RIGHT, mi.rcMonitor)) { - params->rgrc[0].right -= 1; - } - } - } - - return result; - } - - /* -------------------------------------------------------------- */ - /* WM_NCHITTEST: 3-zone hit test */ - /* -------------------------------------------------------------- */ - case WM_NCHITTEST: { - state->hitTestCount++; - - POINT pt; - pt.x = (short)LOWORD(lParam); - pt.y = (short)HIWORD(lParam); - - RECT windowRect; - GetWindowRect(hwnd, &windowRect); - - state->lastPtY = pt.y; - state->lastWinTop = windowRect.top; - - /* Zone 1: resize borders */ - int borderWidth = getResizeBorderWidth(hwnd, TRUE); - int borderHeight = getResizeBorderWidth(hwnd, FALSE); - - /* When maximized or fullscreen, no resize borders */ - if (!IsZoomed(hwnd) && !state->isFullscreen) { - /* Top-left corner */ - if (pt.x < windowRect.left + borderWidth && - pt.y < windowRect.top + borderHeight) { - state->hitTestBorder++; return HTTOPLEFT; - } - /* Top-right corner */ - if (pt.x >= windowRect.right - borderWidth && - pt.y < windowRect.top + borderHeight) { - state->hitTestBorder++; return HTTOPRIGHT; - } - /* Bottom-left corner */ - if (pt.x < windowRect.left + borderWidth && - pt.y >= windowRect.bottom - borderHeight) { - state->hitTestBorder++; return HTBOTTOMLEFT; - } - /* Bottom-right corner */ - if (pt.x >= windowRect.right - borderWidth && - pt.y >= windowRect.bottom - borderHeight) { - state->hitTestBorder++; return HTBOTTOMRIGHT; - } - /* Left edge */ - if (pt.x < windowRect.left + borderWidth) { - state->hitTestBorder++; return HTLEFT; - } - /* Right edge */ - if (pt.x >= windowRect.right - borderWidth) { - state->hitTestBorder++; return HTRIGHT; - } - /* Top edge */ - if (pt.y < windowRect.top + borderHeight) { - state->hitTestBorder++; return HTTOP; - } - /* Bottom edge */ - if (pt.y >= windowRect.bottom - borderHeight) { - state->hitTestBorder++; return HTBOTTOM; - } - } - - /* Zone 2: title bar area — always HTCLIENT. - * All title bar clicks go to Compose, which handles interactive - * elements directly and initiates native drag for unconsumed clicks - * via nativeStartDrag(). */ - if (pt.y < windowRect.top + state->titleBarHeightPx) { - state->hitTestClient++; - return HTCLIENT; - } - - /* Zone 3: client area */ - state->hitTestClient++; - return HTCLIENT; - } - - /* -------------------------------------------------------------- */ - /* WM_NCLBUTTONDOWN: pass to DefWindowProc for native drag */ - /* AWT's WndProc may not call DefWindowProc for this message, */ - /* so we bypass AWT to ensure native drag/snap behavior. */ - /* -------------------------------------------------------------- */ - case WM_NCLBUTTONDOWN: { - if (wParam == HTCAPTION) { - ReleaseCapture(); - return DefWindowProcW(hwnd, msg, wParam, lParam); - } - break; - } - - /* -------------------------------------------------------------- */ - /* WM_NCLBUTTONDBLCLK: pass to DefWindowProc for native maximize */ - /* -------------------------------------------------------------- */ - case WM_NCLBUTTONDBLCLK: { - if (wParam == HTCAPTION) { - return DefWindowProcW(hwnd, msg, wParam, lParam); - } - break; - } - - /* -------------------------------------------------------------- */ - /* WM_NCMOUSEMOVE: forward as WM_MOUSEMOVE for Compose tracking */ - /* -------------------------------------------------------------- */ - case WM_NCMOUSEMOVE: { - /* Convert screen coords to client coords and post WM_MOUSEMOVE */ - POINT pt; - pt.x = (short)LOWORD(lParam); - pt.y = (short)HIWORD(lParam); - ScreenToClient(hwnd, &pt); - PostMessageW(hwnd, WM_MOUSEMOVE, 0, MAKELPARAM(pt.x, pt.y)); - break; /* also let original handle it */ - } - - /* -------------------------------------------------------------- */ - /* WM_GETMINMAXINFO: fix DPI scaling for min/max size. */ - /* Standard OpenJDK stores logical pixels in ptMinTrackSize but */ - /* Windows expects physical pixels. JBR applies ScaleUpX/Y; */ - /* we replicate that fix here so it works on all JVMs. */ - /* -------------------------------------------------------------- */ - case WM_GETMINMAXINFO: { - /* Let AWT process first (sets unscaled values) */ - LRESULT result = CallWindowProcW(state->originalWndProc, - hwnd, msg, wParam, lParam); - LPMINMAXINFO lpmmi = (LPMINMAXINFO)lParam; - UINT dpi = getDpi(hwnd); - - /* Override with DPI-scaled values if set (per-axis) */ - if (state->minSizePx.x > 0) - lpmmi->ptMinTrackSize.x = MulDiv(state->minSizePx.x, dpi, 96); - if (state->minSizePx.y > 0) - lpmmi->ptMinTrackSize.y = MulDiv(state->minSizePx.y, dpi, 96); - if (state->maxSizePx.x > 0) - lpmmi->ptMaxTrackSize.x = MulDiv(state->maxSizePx.x, dpi, 96); - if (state->maxSizePx.y > 0) - lpmmi->ptMaxTrackSize.y = MulDiv(state->maxSizePx.y, dpi, 96); - return result; - } - - /* -------------------------------------------------------------- */ - /* WM_SYSCOMMAND: block state-changing commands while fullscreen */ - /* to prevent native/Kotlin state desync. The application must */ - /* exit fullscreen via its own UI controls. */ - /* -------------------------------------------------------------- */ - case WM_SYSCOMMAND: { - if (state->isFullscreen) { - WPARAM cmd = wParam & 0xFFF0; - if (cmd == SC_RESTORE || cmd == SC_MAXIMIZE || - cmd == SC_SIZE || cmd == SC_MOVE) { - return 0; - } - } - break; - } - - /* -------------------------------------------------------------- */ - /* WM_SIZE: safety net — detect when the window is resized */ - /* externally while fullscreen (e.g. via ShowWindow called */ - /* directly by AWT). Clears the flag and restores styles so */ - /* the frame is never permanently stripped. */ - /* -------------------------------------------------------------- */ - case WM_SIZE: { - if (state->isFullscreen && wParam != SIZE_MINIMIZED) { - int newW = (int)(short)LOWORD(lParam); - int newH = (int)(short)HIWORD(lParam); - HMONITOR hMon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - MONITORINFO mi; - mi.cbSize = sizeof(mi); - if (GetMonitorInfoW(hMon, &mi)) { - int monW = mi.rcMonitor.right - mi.rcMonitor.left; - int monH = mi.rcMonitor.bottom - mi.rcMonitor.top; - if (newW != monW || newH != monH) { - /* External resize detected — sync native state */ - state->isFullscreen = FALSE; - SetWindowLongW(hwnd, GWL_STYLE, state->savedStyle); - SetWindowLongW(hwnd, GWL_EXSTYLE, state->savedExStyle); - SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED); - } - } - } - break; - } - - /* -------------------------------------------------------------- */ - /* WM_NCDESTROY: clean up state */ - /* -------------------------------------------------------------- */ - case WM_NCDESTROY: { - /* Safety: restore window styles if destroyed while fullscreen - * so the OS does not hold stripped style bits in its cache. */ - if (state->isFullscreen) { - SetWindowLongW(hwnd, GWL_STYLE, state->savedStyle); - SetWindowLongW(hwnd, GWL_EXSTYLE, state->savedExStyle); - } - WNDPROC origProc = state->originalWndProc; - RemovePropW(hwnd, PROP_NAME); - HeapFree(GetProcessHeap(), 0, state); - SetWindowLongPtrW(hwnd, GWLP_WNDPROC, (LONG_PTR)origProc); - return CallWindowProcW(origProc, hwnd, msg, wParam, lParam); - } - - } /* end switch */ - - return CallWindowProcW(state->originalWndProc, hwnd, msg, wParam, lParam); -} - -/* ------------------------------------------------------------------ */ -/* DllMain */ -/* ------------------------------------------------------------------ */ -BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) { - (void)hinstDLL; (void)lpvReserved; - if (fdwReason == DLL_PROCESS_ATTACH) { - resolveDpiApis(); - } - return TRUE; -} - -/* ================================================================== */ -/* JNI exports */ -/* ================================================================== */ - -/* Package: dev.nucleusframework.window.utils.windows */ -/* Class: JniWindowsDecorationBridge */ - -/* -------------------------------------------------------------- */ -/* nativeInstallDecoration(long hwnd, int titleBarHeightPx) */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeInstallDecoration( - JNIEnv *env, jclass clazz, jlong hwndLong, jint titleBarHeightPx) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - - if (!hwnd || !IsWindow(hwnd)) return; - - /* Idempotent: if already installed, just update the height */ - DecoState *existing = getState(hwnd); - if (existing) { - existing->titleBarHeightPx = (int)titleBarHeightPx; - return; - } - - /* Allocate per-HWND state */ - DecoState *state = (DecoState *)HeapAlloc( - GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(DecoState)); - if (!state) return; - - state->titleBarHeightPx = (int)titleBarHeightPx; - state->forceHitTestClient = FALSE; - - /* Store state on the HWND */ - SetPropW(hwnd, PROP_NAME, (HANDLE)state); - - /* Subclass the window */ - LONG_PTR prevWndProc = SetWindowLongPtrW( - hwnd, GWLP_WNDPROC, (LONG_PTR)decorationWndProc); - state->originalWndProc = (WNDPROC)prevWndProc; - - /* Subclass the first child window (Skiko canvas) so WM_NCHITTEST - * returns HTTRANSPARENT in the title bar area, forwarding to frame. */ - HWND child = GetWindow(hwnd, GW_CHILD); - if (child) { - ChildState *cs = (ChildState *)HeapAlloc( - GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(ChildState)); - if (cs) { - cs->parentHwnd = hwnd; - SetPropW(child, CHILD_PROP_NAME, (HANDLE)cs); - cs->originalWndProc = (WNDPROC)SetWindowLongPtrW( - child, GWLP_WNDPROC, (LONG_PTR)childWndProc); - state->childHwnd = child; - } - } - - /* Extend DWM frame into the entire client area ("sheet of glass"). - * This makes the DWM background (opaque black) fill newly exposed - * areas during resize instead of the window-class brush (white). - * On macOS the equivalent is NSWindow.setBackgroundColor — both work - * at the compositor level, below the GPU rendering surface. - * DWM shadow is preserved regardless of margin values. */ - /* Extend just the bottom by 1px to keep DWM shadow without enabling - * glass compositing over the client area. With glass ({-1,-1,-1,-1}), - * transparent DirectX pixels would show the DWM glass backdrop (white by - * default), making the flash worse. With {0,0,0,1} DWM treats the - * client area as opaque: transparent pixels (from setTransparency=true) - * render as black, which is invisible on dark-themed windows. */ - MARGINS margins = {0, 0, 0, 1}; - DwmExtendFrameIntoClientArea(hwnd, &margins); - - /* Force a frame recalculation */ - SetWindowPos(hwnd, NULL, 0, 0, 0, 0, - SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | - SWP_NOZORDER | SWP_NOACTIVATE); -} - -/* -------------------------------------------------------------- */ -/* nativeUninstallDecoration(long hwnd) */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeUninstallDecoration( - JNIEnv *env, jclass clazz, jlong hwndLong) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd || !IsWindow(hwnd)) return; - - DecoState *state = getState(hwnd); - if (!state) return; - - /* Restore child window's original WndProc first */ - if (state->childHwnd && IsWindow(state->childHwnd)) { - ChildState *cs = getChildState(state->childHwnd); - if (cs) { - SetWindowLongPtrW(state->childHwnd, GWLP_WNDPROC, - (LONG_PTR)cs->originalWndProc); - RemovePropW(state->childHwnd, CHILD_PROP_NAME); - HeapFree(GetProcessHeap(), 0, cs); - } - } - - /* Restore frame's original WndProc */ - SetWindowLongPtrW(hwnd, GWLP_WNDPROC, (LONG_PTR)state->originalWndProc); - - RemovePropW(hwnd, PROP_NAME); - HeapFree(GetProcessHeap(), 0, state); - - /* Reset DWM margins */ - MARGINS margins = {0, 0, 0, 0}; - DwmExtendFrameIntoClientArea(hwnd, &margins); - - /* Force frame recalculation */ - SetWindowPos(hwnd, NULL, 0, 0, 0, 0, - SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | - SWP_NOZORDER | SWP_NOACTIVATE); -} - -/* -------------------------------------------------------------- */ -/* nativeSetForceHitTestClient(long hwnd, boolean force) */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeSetForceHitTestClient( - JNIEnv *env, jclass clazz, jlong hwndLong, jboolean force) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd) return; - - DecoState *state = getState(hwnd); - if (state) { - state->forceHitTestClient = force ? TRUE : FALSE; - } -} - -/* -------------------------------------------------------------- */ -/* nativeSetTitleBarHeight(long hwnd, int heightPx) */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeSetTitleBarHeight( - JNIEnv *env, jclass clazz, jlong hwndLong, jint heightPx) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd) return; - - DecoState *state = getState(hwnd); - if (state) { - state->titleBarHeightPx = (int)heightPx; - } -} - -/* -------------------------------------------------------------- */ -/* nativeStartDrag(long hwnd) */ -/* Initiates a native window drag (with snap/tile support). */ -/* Called from Compose when an unconsumed press occurs in the */ -/* title bar background. */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeStartDrag( - JNIEnv *env, jclass clazz, jlong hwndLong) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd || !IsWindow(hwnd)) return; - - POINT pt; - GetCursorPos(&pt); - - /* Post (not Send) to avoid blocking the EDT. The WM_NCLBUTTONDOWN - * handler calls ReleaseCapture + DefWindowProcW to start the modal - * drag loop when AWT's message pump picks this up. */ - PostMessageW(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, MAKELPARAM(pt.x, pt.y)); -} - -/* -------------------------------------------------------------- */ -/* nativeGetHwnd(Window awtWindow) → long */ -/* Extracts the HWND from an AWT Window via JNI reflection. */ -/* JNI bypasses JPMS module restrictions, so sun.awt.windows.* */ -/* classes are accessible without --add-opens. */ -/* -------------------------------------------------------------- */ -JNIEXPORT jlong JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeGetHwnd( - JNIEnv *env, jclass clazz, jobject awtWindow) -{ - if (!awtWindow) return 0; - - /* AWTAccessor.getComponentAccessor() */ - jclass awtAccessorClass = (*env)->FindClass(env, "sun/awt/AWTAccessor"); - if (!awtAccessorClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - jmethodID getCompAccessor = (*env)->GetStaticMethodID(env, awtAccessorClass, - "getComponentAccessor", "()Lsun/awt/AWTAccessor$ComponentAccessor;"); - if (!getCompAccessor || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, awtAccessorClass); - return 0; - } - - jobject compAccessor = (*env)->CallStaticObjectMethod(env, awtAccessorClass, getCompAccessor); - (*env)->DeleteLocalRef(env, awtAccessorClass); - if (!compAccessor || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - /* componentAccessor.getPeer(window) */ - jclass compAccessorClass = (*env)->FindClass(env, "sun/awt/AWTAccessor$ComponentAccessor"); - if (!compAccessorClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, compAccessor); - return 0; - } - - jmethodID getPeer = (*env)->GetMethodID(env, compAccessorClass, - "getPeer", "(Ljava/awt/Component;)Ljava/awt/peer/ComponentPeer;"); - (*env)->DeleteLocalRef(env, compAccessorClass); - if (!getPeer || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, compAccessor); - return 0; - } - - jobject peer = (*env)->CallObjectMethod(env, compAccessor, getPeer, awtWindow); - (*env)->DeleteLocalRef(env, compAccessor); - if (!peer || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - /* peer.getHWnd() */ - jclass wComponentPeerClass = (*env)->FindClass(env, "sun/awt/windows/WComponentPeer"); - if (!wComponentPeerClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, peer); - return 0; - } - - jmethodID getHWnd = (*env)->GetMethodID(env, wComponentPeerClass, "getHWnd", "()J"); - (*env)->DeleteLocalRef(env, wComponentPeerClass); - if (!getHWnd || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - (*env)->DeleteLocalRef(env, peer); - return 0; - } - - jlong hwnd = (*env)->CallLongMethod(env, peer, getHWnd); - (*env)->DeleteLocalRef(env, peer); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - return 0; - } - - return hwnd; -} - -/* ------------------------------------------------------------------ */ -/* Lightweight WndProc for dialogs: only handles WM_ERASEBKGND and */ -/* WM_WINDOWPOSCHANGING to prevent resize flash. */ -/* ------------------------------------------------------------------ */ -static LRESULT CALLBACK dialogDecoWndProc( - HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -{ - DecoState *state = getState(hwnd); - if (!state) return DefWindowProcW(hwnd, msg, wParam, lParam); - - switch (msg) { - - case WM_ERASEBKGND: { - HDC hdc = (HDC)wParam; - RECT rc; - GetClientRect(hwnd, &rc); - HBRUSH brush = CreateSolidBrush(state->bgColor); - FillRect(hdc, &rc, brush); - DeleteObject(brush); - return 1; - } - - case WM_WINDOWPOSCHANGING: { - WINDOWPOS *wp = (WINDOWPOS *)lParam; - wp->flags |= SWP_NOCOPYBITS; - break; - } - - case WM_NCDESTROY: { - WNDPROC origProc = state->originalWndProc; - RemovePropW(hwnd, PROP_NAME); - HeapFree(GetProcessHeap(), 0, state); - SetWindowLongPtrW(hwnd, GWLP_WNDPROC, (LONG_PTR)origProc); - return CallWindowProcW(origProc, hwnd, msg, wParam, lParam); - } - - } - - return CallWindowProcW(state->originalWndProc, hwnd, msg, wParam, lParam); -} - -/* -------------------------------------------------------------- */ -/* nativeApplyDialogStyle(long hwnd) */ -/* Applies rounded corners + DWM shadow to an undecorated popup */ -/* dialog window (WS_POPUP without WS_CAPTION). */ -/* Also subclasses the WndProc to handle WM_ERASEBKGND and */ -/* WM_WINDOWPOSCHANGING (SWP_NOCOPYBITS) to prevent resize flash. */ -/* DWMWA_WINDOW_CORNER_PREFERENCE (33) + DWMWCP_ROUND (2) are */ -/* Windows 11 22000+ only; silently ignored on older Windows. */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeApplyDialogStyle( - JNIEnv *env, jclass clazz, jlong hwndLong) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd || !IsWindow(hwnd)) return; - - /* Idempotent: if already installed, nothing to do */ - if (getState(hwnd)) return; - - /* Allocate per-HWND state for WM_ERASEBKGND background fill */ - DecoState *state = (DecoState *)HeapAlloc( - GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(DecoState)); - if (!state) return; - - SetPropW(hwnd, PROP_NAME, (HANDLE)state); - - /* Subclass with lightweight dialog WndProc */ - LONG_PTR prevWndProc = SetWindowLongPtrW( - hwnd, GWLP_WNDPROC, (LONG_PTR)dialogDecoWndProc); - state->originalWndProc = (WNDPROC)prevWndProc; - - /* Request rounded corners (Windows 11+, silently ignored on older) */ - DWORD preference = 2; /* DWMWCP_ROUND */ - DwmSetWindowAttribute(hwnd, 33 /* DWMWA_WINDOW_CORNER_PREFERENCE */, - &preference, sizeof(preference)); - - /* DWM drop shadow for popup window */ - MARGINS margins = {0, 0, 0, 1}; - DwmExtendFrameIntoClientArea(hwnd, &margins); -} - -/* -------------------------------------------------------------- */ -/* nativeSetFullscreen(long hwnd, boolean fullscreen) */ -/* Enters or exits native fullscreen mode. */ -/* Enter: saves style/exstyle/placement, removes caption/frame, */ -/* covers the entire monitor. */ -/* Exit: restores saved style/exstyle/placement. */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeSetFullscreen( - JNIEnv *env, jclass clazz, jlong hwndLong, jboolean fullscreen) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd || !IsWindow(hwnd)) return; - - DecoState *state = getState(hwnd); - if (!state) return; - - if (fullscreen) { - if (state->isFullscreen) return; /* already fullscreen */ - - /* Save current state */ - state->savedStyle = GetWindowLongW(hwnd, GWL_STYLE); - state->savedExStyle = GetWindowLongW(hwnd, GWL_EXSTYLE); - state->savedPlacement.length = sizeof(WINDOWPLACEMENT); - GetWindowPlacement(hwnd, &state->savedPlacement); - - /* Get monitor dimensions early — needed for the rcNormalPosition - * trick below and for the final SetWindowPos call. */ - HMONITOR hMon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST); - MONITORINFO mi; - mi.cbSize = sizeof(mi); - GetMonitorInfoW(hMon, &mi); - - /* Suppress DWM animations so the "unmaximize" transition that - * Windows plays when WS_MAXIMIZE is stripped does not flash. */ - BOOL disableTransitions = TRUE; - DwmSetWindowAttribute(hwnd, 3 /* DWMWA_TRANSITIONS_FORCEDISABLED */, - &disableTransitions, sizeof(disableTransitions)); - - /* When the window is maximized, override rcNormalPosition to the - * fullscreen monitor rect BEFORE removing WS_MAXIMIZE. Without - * this, Windows "restores" the window to its pre-maximize size for - * one frame, producing a visible shrink-then-expand artifact. */ - if (state->savedPlacement.showCmd == SW_SHOWMAXIMIZED) { - WINDOWPLACEMENT wp = state->savedPlacement; - wp.rcNormalPosition.left = mi.rcMonitor.left; - wp.rcNormalPosition.top = mi.rcMonitor.top; - wp.rcNormalPosition.right = mi.rcMonitor.right; - wp.rcNormalPosition.bottom = mi.rcMonitor.bottom; - SetWindowPlacement(hwnd, &wp); - } - - /* Mark fullscreen BEFORE SetWindowLongW so every WM_NCCALCSIZE - * triggered by style changes already uses the fullscreen path - * (return 0 = client area fills the whole window). */ - state->isFullscreen = TRUE; - - /* Remove window borders, title bar, and maximize flag. - * WS_MAXIMIZE must be stripped because the system constrains - * maximized windows to the work area (excluding the taskbar). */ - LONG style = state->savedStyle - & ~(LONG)(WS_CAPTION | WS_THICKFRAME | WS_MAXIMIZE); - SetWindowLongW(hwnd, GWL_STYLE, style); - - /* Remove extended window styles */ - LONG exStyle = state->savedExStyle - & ~(LONG)(WS_EX_DLGMODALFRAME | WS_EX_WINDOWEDGE - | WS_EX_CLIENTEDGE | WS_EX_STATICEDGE); - SetWindowLongW(hwnd, GWL_EXSTYLE, exStyle); - - /* HWND_TOPMOST keeps the window above the auto-hide taskbar. - * Without it, a WS_EX_TOPMOST taskbar can slide over the window - * when the user hovers the screen edge. */ - SetWindowPos(hwnd, HWND_TOPMOST, - mi.rcMonitor.left, mi.rcMonitor.top, - mi.rcMonitor.right - mi.rcMonitor.left, - mi.rcMonitor.bottom - mi.rcMonitor.top, - SWP_FRAMECHANGED); - - /* Re-enable DWM animations so the exit from fullscreen can - * animate smoothly back to the previous window placement. */ - BOOL enableTransitions = FALSE; - DwmSetWindowAttribute(hwnd, 3 /* DWMWA_TRANSITIONS_FORCEDISABLED */, - &enableTransitions, sizeof(enableTransitions)); - } else { - if (!state->isFullscreen) return; /* already not fullscreen */ - - /* Clear fullscreen flag BEFORE style restoration so every - * WM_NCCALCSIZE triggered by the changes below immediately - * uses the normal path (title-bar extension, resize borders). */ - state->isFullscreen = FALSE; - - /* Restore extended styles first (no size/position side-effects). */ - SetWindowLongW(hwnd, GWL_EXSTYLE, state->savedExStyle); - - /* Restore the main style WITHOUT WS_MAXIMIZE initially. - * If we set WS_MAXIMIZE via SetWindowLongW, Windows constrains - * the window to the work area and sends WM_SIZE(SIZE_RESTORED) - * instead of WM_SIZE(SIZE_MAXIMIZED). AWT then misses the - * maximize event and Frame.getExtendedState() stays stale. - * SetWindowPlacement with SW_SHOWMAXIMIZED goes through the - * proper maximize code path and sends the correct events. */ - LONG restoreStyle = state->savedStyle & ~(LONG)WS_MAXIMIZE; - SetWindowLongW(hwnd, GWL_STYLE, restoreStyle); - - /* Restore window placement (maximized/normal state + position). - * For SW_SHOWMAXIMIZED this re-applies WS_MAXIMIZE internally - * and sends WM_SIZE(SIZE_MAXIMIZED) so AWT detects it. */ - SetWindowPlacement(hwnd, &state->savedPlacement); - - /* Remove topmost and force frame recalculation */ - SetWindowPos(hwnd, HWND_NOTOPMOST, 0, 0, 0, 0, - SWP_NOMOVE | SWP_NOSIZE | SWP_FRAMECHANGED); - } -} - -/* -------------------------------------------------------------- */ -/* nativeIsFullscreen(long hwnd) → boolean */ -/* Returns true if the window is in native fullscreen mode. */ -/* -------------------------------------------------------------- */ -JNIEXPORT jboolean JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeIsFullscreen( - JNIEnv *env, jclass clazz, jlong hwndLong) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd) return JNI_FALSE; - - DecoState *state = getState(hwnd); - if (!state) return JNI_FALSE; - - return state->isFullscreen ? JNI_TRUE : JNI_FALSE; -} - -/* -------------------------------------------------------------- */ -/* nativeSetBackgroundColor(long hwnd, int argb) */ -/* Syncs DWM caption/border color and dark-mode flag with the */ -/* window's title bar theme color. */ -/* Windows 11 22000+ for attrs 34/35; attr 20 back-ported to */ -/* Windows 10 build 17763+. Silently ignored on older versions. */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeSetBackgroundColor( - JNIEnv *env, jclass clazz, jlong hwndLong, jint argb) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd) return; - - int r = (argb >> 16) & 0xFF; - int g = (argb >> 8) & 0xFF; - int b = argb & 0xFF; - COLORREF color = RGB(r, g, b); - - DecoState *state = getState(hwnd); - if (state) { - state->bgColor = color; - } - - /* Set DWM caption color (attr 35) and border color (attr 34). - * Windows 11 22000+; silently ignored on older versions. */ - DwmSetWindowAttribute(hwnd, 35 /* DWMWA_CAPTION_COLOR */, - &color, sizeof(color)); - DwmSetWindowAttribute(hwnd, 34 /* DWMWA_BORDER_COLOR */, - &color, sizeof(color)); - - /* Switch DWM glass between light/dark based on luminance so that the - * "sheet of glass" background that DWM composites during resize - * matches the window theme. DWMWA_USE_IMMERSIVE_DARK_MODE = 20. - * Windows 11 22000+ / Windows 10 build 17763+; silently ignored on older. */ - int luminance = (r * 299 + g * 587 + b * 114) / 1000; - BOOL isDark = (luminance < 128) ? TRUE : FALSE; - DwmSetWindowAttribute(hwnd, 20 /* DWMWA_USE_IMMERSIVE_DARK_MODE */, - &isDark, sizeof(isDark)); -} - -/* -------------------------------------------------------------- */ -/* nativeGetDebugInfo(long hwnd) → String */ -/* Returns debug counters as a string for diagnostics. */ -/* -------------------------------------------------------------- */ -JNIEXPORT jstring JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeGetDebugInfo( - JNIEnv *env, jclass clazz, jlong hwndLong) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - DecoState *state = hwnd ? getState(hwnd) : NULL; - char buf[512]; - if (!state) { - wsprintfA(buf, "NO STATE for hwnd=%p", hwnd); - } else { - wsprintfA(buf, - "anyMsg=%d nccalcsize=%d hitTest=%d caption=%d client=%d border=%d " - "tbH=%d lastPtY=%d lastWinTop=%d forced=%d", - state->anyMsgCount, state->nccalcsizeCount, - state->hitTestCount, state->hitTestCaption, - state->hitTestClient, state->hitTestBorder, - state->titleBarHeightPx, state->lastPtY, state->lastWinTop, - (int)state->forceHitTestClient); - } - return (*env)->NewStringUTF(env, buf); -} - -/* -------------------------------------------------------------- */ -/* nativeSetMinimumSize(long hwnd, int widthPx, int heightPx) */ -/* Stores the minimum window size in logical pixels. The */ -/* WM_GETMINMAXINFO handler applies DPI scaling automatically. */ -/* Pass (0, 0) to disable the override and fall back to AWT. */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeSetMinimumSize( - JNIEnv *env, jclass clazz, jlong hwndLong, jint widthPx, jint heightPx) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd) return; - - DecoState *state = getState(hwnd); - if (!state) return; - - state->minSizePx.x = (LONG)widthPx; - state->minSizePx.y = (LONG)heightPx; -} - -/* -------------------------------------------------------------- */ -/* nativeSetMaximumSize(long hwnd, int widthPx, int heightPx) */ -/* Stores the maximum window size in logical pixels. The */ -/* WM_GETMINMAXINFO handler applies DPI scaling automatically. */ -/* Pass (0, 0) to disable the override and fall back to AWT. */ -/* -------------------------------------------------------------- */ -JNIEXPORT void JNICALL -Java_dev_nucleusframework_window_utils_windows_JniWindowsDecorationBridge_nativeSetMaximumSize( - JNIEnv *env, jclass clazz, jlong hwndLong, jint widthPx, jint heightPx) -{ - HWND hwnd = (HWND)(uintptr_t)hwndLong; - if (!hwnd) return; - - DecoState *state = getState(hwnd); - if (!state) return; - - state->maxSizePx.x = (LONG)widthPx; - state->maxSizePx.y = (LONG)heightPx; -} diff --git a/decorated-window-jni/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-jni/reachability-metadata.json b/decorated-window-jni/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-jni/reachability-metadata.json deleted file mode 100644 index 4e57752e5..000000000 --- a/decorated-window-jni/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-jni/reachability-metadata.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "reflection": [ - { - "type": "dev.nucleusframework.window.utils.macos.JniMacTitleBarBridge", - "jniAccessible": true, - "methods": [ - { - "name": "onMenuBarOffsetChanged", - "parameterTypes": [ - "long", - "float" - ] - } - ] - }, - { - "type": "dev.nucleusframework.window.utils.windows.JniWindowsDecorationBridge", - "jniAccessible": true, - "methods": [ - { - "name": "nativeSetMinimumSize", - "parameterTypes": [ - "long", - "int", - "int" - ] - }, - { - "name": "nativeSetMaximumSize", - "parameterTypes": [ - "long", - "int", - "int" - ] - } - ] - } - ] -} diff --git a/decorated-window-material2/api/decorated-window-material2.api b/decorated-window-material2/api/decorated-window-material2.api index 0bd04a37d..76c661676 100644 --- a/decorated-window-material2/api/decorated-window-material2.api +++ b/decorated-window-material2/api/decorated-window-material2.api @@ -12,11 +12,10 @@ public final class dev/nucleusframework/window/material2/ComposableSingletons$Ma } public final class dev/nucleusframework/window/material2/MaterialDecoratedDialogKt { - public static final fun MaterialDecoratedDialog (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun MaterialDecoratedDialog (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V } public final class dev/nucleusframework/window/material2/MaterialDecoratedWindowKt { - public static final fun MaterialDecoratedWindow-CL87EUo (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/nucleusframework/window/styling/TitleBarStyle;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public static final fun MaterialDecoratedWindow-On4RJk0 (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/nucleusframework/window/styling/TitleBarStyle;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V } diff --git a/decorated-window-material2/build.gradle.kts b/decorated-window-material2/build.gradle.kts index 448a14ed4..142ce6e7a 100644 --- a/decorated-window-material2/build.gradle.kts +++ b/decorated-window-material2/build.gradle.kts @@ -15,9 +15,8 @@ val publishVersion = ?: "1.0.0" dependencies { - // Compile against decorated-window-jbr API but let the consumer choose the runtime - // implementation: either :decorated-window-jbr (JBR) or :decorated-window-jni. - compileOnly(project(":decorated-window-jbr")) + // Window/dialog wrappers only add styling on top of nucleus-application's + // Tao-backed window; the app brings both at runtime. compileOnly(project(":decorated-window-tao")) compileOnly(project(":nucleus-application")) api(project(":core-runtime")) diff --git a/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedDialog.kt b/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedDialog.kt index 31c2890ea..ca282b5b2 100644 --- a/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedDialog.kt +++ b/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedDialog.kt @@ -6,13 +6,15 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.window.DialogState import androidx.compose.ui.window.rememberDialogState -import dev.nucleusframework.window.DecoratedDialog -import dev.nucleusframework.window.DecoratedDialogScope +import dev.nucleusframework.application.NucleusApplicationScope +import dev.nucleusframework.application.NucleusDecoratedDialogScope import dev.nucleusframework.window.NucleusDecoratedWindowTheme +import dev.nucleusframework.application.DecoratedDialog as NucleusDecoratedDialog +/** Material 2 styled dialog. Use inside `nucleusApplication { … }`. */ @Suppress("FunctionNaming", "LongParameterList") @Composable -public fun MaterialDecoratedDialog( +public fun NucleusApplicationScope.MaterialDecoratedDialog( onCloseRequest: () -> Unit, state: DialogState = rememberDialogState(), visible: Boolean = true, @@ -23,18 +25,20 @@ public fun MaterialDecoratedDialog( focusable: Boolean = true, onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable DecoratedDialogScope.() -> Unit, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, ) { - val colors = MaterialTheme.colors - val windowStyle = rememberMaterialWindowStyle(colors) - val titleBarStyle = rememberMaterialTitleBarStyle(colors) + val outerColors = MaterialTheme.colors + val outerTypography = MaterialTheme.typography + val outerShapes = MaterialTheme.shapes + val windowStyle = rememberMaterialWindowStyle(outerColors) + val titleBarStyle = rememberMaterialTitleBarStyle(outerColors) NucleusDecoratedWindowTheme( - isDark = !colors.isLight, + isDark = !outerColors.isLight, windowStyle = windowStyle, titleBarStyle = titleBarStyle, ) { - DecoratedDialog( + NucleusDecoratedDialog( onCloseRequest = onCloseRequest, state = state, visible = visible, @@ -45,7 +49,16 @@ public fun MaterialDecoratedDialog( focusable = focusable, onPreviewKeyEvent = onPreviewKeyEvent, onKeyEvent = onKeyEvent, - content = content, - ) + ) { + // Each window owns its own ComposeScene, so the outer theme tokens + // must be re-provided inside the dialog content. + MaterialTheme( + colors = outerColors, + typography = outerTypography, + shapes = outerShapes, + ) { + content() + } + } } } diff --git a/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt b/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt index 79c7886c9..582d88cc3 100644 --- a/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt +++ b/decorated-window-material2/src/main/kotlin/dev/nucleusframework/window/material2/MaterialDecoratedWindow.kt @@ -9,61 +9,14 @@ import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.application.NucleusDecoratedWindowScope -import dev.nucleusframework.window.AwtDecoratedWindowScope -import dev.nucleusframework.window.DecoratedWindow import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.styling.TitleBarStyle import dev.nucleusframework.application.DecoratedWindow as NucleusDecoratedWindow -@Suppress("FunctionNaming", "LongParameterList") -@Composable -public fun MaterialDecoratedWindow( - onCloseRequest: () -> Unit, - state: WindowState = rememberWindowState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = true, - enabled: Boolean = true, - focusable: Boolean = true, - alwaysOnTop: Boolean = false, - minimumSize: DpSize? = null, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - titleBarStyle: TitleBarStyle? = null, - content: @Composable AwtDecoratedWindowScope.() -> Unit, -) { - val colors = MaterialTheme.colors - val windowStyle = rememberMaterialWindowStyle(colors) - val materialTitleBarStyle = rememberMaterialTitleBarStyle(colors) - - NucleusDecoratedWindowTheme( - isDark = !colors.isLight, - windowStyle = windowStyle, - titleBarStyle = titleBarStyle ?: materialTitleBarStyle, - ) { - DecoratedWindow( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - resizable = resizable, - enabled = enabled, - focusable = focusable, - alwaysOnTop = alwaysOnTop, - minimumSize = minimumSize, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - content = content, - ) - } -} - /** - * Material 2 wrapper that picks the correct backend automatically. Use inside - * `nucleusApplication { … }` — works on AWT (JBR/JNI) and Tao with the same - * call site. + * Material 2 styled window. Use inside `nucleusApplication { … }`: picks + * Material colors via [rememberMaterialTitleBarStyle] and wraps the window with + * [NucleusDecoratedWindowTheme]. */ @Suppress("FunctionNaming", "LongParameterList") @Composable @@ -79,35 +32,35 @@ public fun NucleusApplicationScope.MaterialDecoratedWindow( alwaysOnTop: Boolean = false, // Materialise Compose Popup layers as native transparent windows // (NSPanel / WS_POPUP HWND / Tao popup window on Linux) so menus can - // extend past the window bounds. Honoured by the Tao backend; ignored by AWT. + // extend past the window bounds. nativePopupLayers: Boolean = false, - // Replace Compose-drawn context menus with the OS-looking menu. Tao + - // macOS (`NSMenu`), or a Compose flyout on Linux (Adwaita) / Windows - // (Fluent). No-op on AWT. + // Replace Compose-drawn context menus with the OS-looking menu: `NSMenu` + // on macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent). + // The flyout always opens in a native popup surface, whatever + // `nativePopupLayers` says. nativeContextMenu: Boolean = false, // Hide this window from the OS taskbar/Dock while it stays visible and - // focusable (Tao backend; on Linux effective on X11/XWayland only). - // No-op on AWT. + // focusable (on Linux effective on X11/XWayland only). hiddenFromDock: Boolean = false, minimumSize: DpSize? = null, onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, onKeyEvent: (KeyEvent) -> Boolean = { false }, titleBarStyle: TitleBarStyle? = null, // Fully borderless window (no macOS traffic lights, no CSD outline) — for - // overlay/ghost windows. Tao backend only. + // overlay/ghost windows. undecorated: Boolean = false, // The overlay flags below mirror `dev.nucleusframework.application.DecoratedWindow`. // // Full-window per-pixel transparency: pixels the content leaves at alpha 0 // show the desktop behind the window. Creation-time only, normally paired - // with [undecorated]. Tao backend only. + // with [undecorated]. transparent: Boolean = false, // Click-through window: pointer events fall through to whatever sits below // and the window never intercepts input. Pair with `focusable = false` for - // passive overlays. Reactive. Tao backend only. + // passive overlays. Reactive. clickThrough: Boolean = false, // Show the window on every desktop / macOS Space / Windows virtual desktop - // instead of only the one it was created on. Reactive. Tao backend only. + // instead of only the one it was created on. Reactive. visibleOnAllWorkspaces: Boolean = false, // Linux only: give this window an X11 surface even when the app runs on a // native Wayland session, for the window management Wayland has no protocol diff --git a/decorated-window-material3/api/decorated-window-material3.api b/decorated-window-material3/api/decorated-window-material3.api index 7efc8bbed..59972575e 100644 --- a/decorated-window-material3/api/decorated-window-material3.api +++ b/decorated-window-material3/api/decorated-window-material3.api @@ -17,12 +17,10 @@ public final class dev/nucleusframework/window/material/MaterialColorMappingKt { } public final class dev/nucleusframework/window/material/MaterialDecoratedDialogKt { - public static final fun MaterialDecoratedDialog (Landroidx/compose/ui/window/ApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public static final fun MaterialDecoratedDialog (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V } public final class dev/nucleusframework/window/material/MaterialDecoratedWindowKt { - public static final fun MaterialDecoratedWindow-2nA36Wk (Landroidx/compose/ui/window/ApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/nucleusframework/window/styling/TitleBarStyle;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public static final fun MaterialDecoratedWindow-On4RJk0 (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Ldev/nucleusframework/window/styling/TitleBarStyle;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V } diff --git a/decorated-window-material3/build.gradle.kts b/decorated-window-material3/build.gradle.kts index 0988cf549..6c78fff89 100644 --- a/decorated-window-material3/build.gradle.kts +++ b/decorated-window-material3/build.gradle.kts @@ -15,10 +15,8 @@ val publishVersion = ?: "1.0.0" dependencies { - // Compile against all backends — consumers pick exactly one at runtime: - // :decorated-window-jbr (JBR), :decorated-window-jni (any JVM), or - // :decorated-window-tao (no-AWT native). - compileOnly(project(":decorated-window-jbr")) + // Window/dialog wrappers only add styling on top of nucleus-application's + // Tao-backed window; the app brings both at runtime. compileOnly(project(":decorated-window-tao")) compileOnly(project(":nucleus-application")) api(project(":core-runtime")) diff --git a/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedDialog.kt b/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedDialog.kt index 555fc7d95..008b07d67 100644 --- a/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedDialog.kt +++ b/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedDialog.kt @@ -1,71 +1,17 @@ -@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") - package dev.nucleusframework.window.material import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent -import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.DialogState import androidx.compose.ui.window.rememberDialogState import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.application.NucleusDecoratedDialogScope -import dev.nucleusframework.window.AwtDecoratedDialogScope -import dev.nucleusframework.window.DecoratedDialog import dev.nucleusframework.window.NucleusDecoratedWindowTheme -import kotlin.internal.LowPriorityInOverloadResolution import dev.nucleusframework.application.DecoratedDialog as NucleusDecoratedDialog -/** AWT-backed (JBR / JNI) Material 3 wrapper for [DecoratedDialog]. */ -@Suppress("FunctionNaming", "LongParameterList") -// Low priority: NucleusApplicationScope implements ApplicationScope, so inside -// nucleusApplication both overloads are applicable — the Nucleus one must win. -@LowPriorityInOverloadResolution -@Composable -public fun ApplicationScope.MaterialDecoratedDialog( - onCloseRequest: () -> Unit, - state: DialogState = rememberDialogState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = false, - enabled: Boolean = true, - focusable: Boolean = true, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable AwtDecoratedDialogScope.() -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - val windowStyle = rememberMaterialWindowStyle(colorScheme) - val titleBarStyle = rememberMaterialTitleBarStyle(colorScheme) - - NucleusDecoratedWindowTheme( - isDark = colorScheme.isDark(), - windowStyle = windowStyle, - titleBarStyle = titleBarStyle, - ) { - DecoratedDialog( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - resizable = resizable, - enabled = enabled, - focusable = focusable, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - content = content, - ) - } -} - -/** - * Material 3 wrapper that picks the correct backend automatically. Use inside - * `nucleusApplication { … }` — works on AWT (JBR/JNI) and Tao with the same - * call site. - */ +/** Material 3 styled dialog. Use inside `nucleusApplication { … }`. */ @Suppress("FunctionNaming", "LongParameterList") @Composable public fun NucleusApplicationScope.MaterialDecoratedDialog( diff --git a/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt b/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt index 4db44e62c..97f45a4a7 100644 --- a/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt +++ b/decorated-window-material3/src/main/kotlin/dev/nucleusframework/window/material/MaterialDecoratedWindow.kt @@ -1,5 +1,3 @@ -@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") - package dev.nucleusframework.window.material import androidx.compose.material3.MaterialTheme @@ -7,82 +5,22 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.window.ApplicationScope import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.NucleusApplicationScope import dev.nucleusframework.application.NucleusDecoratedWindowScope -import dev.nucleusframework.window.AwtDecoratedWindowScope -import dev.nucleusframework.window.DecoratedWindow import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.styling.TitleBarStyle -import kotlin.internal.LowPriorityInOverloadResolution import dev.nucleusframework.application.DecoratedWindow as NucleusDecoratedWindow /** - * Material 3 wrapper around the AWT-based `DecoratedWindow` (JBR / JNI - * backends). Picks Material colors via [rememberMaterialTitleBarStyle] and - * wraps with [NucleusDecoratedWindowTheme]. - * - * For new code, prefer the [NucleusApplicationScope] overload below — it - * works the same on AWT and Tao without changing the call site. - */ -@Suppress("FunctionNaming", "LongParameterList") -@Composable -// Low priority: NucleusApplicationScope implements ApplicationScope, so inside -// nucleusApplication both overloads are applicable — the Nucleus one must win. -@LowPriorityInOverloadResolution -public fun ApplicationScope.MaterialDecoratedWindow( - onCloseRequest: () -> Unit, - state: WindowState = rememberWindowState(), - visible: Boolean = true, - title: String = "", - icon: Painter? = null, - resizable: Boolean = true, - enabled: Boolean = true, - focusable: Boolean = true, - alwaysOnTop: Boolean = false, - minimumSize: DpSize? = null, - onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, - onKeyEvent: (KeyEvent) -> Boolean = { false }, - titleBarStyle: TitleBarStyle? = null, - content: @Composable AwtDecoratedWindowScope.() -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - val windowStyle = rememberMaterialWindowStyle(colorScheme) - val materialTitleBarStyle = rememberMaterialTitleBarStyle(colorScheme) - - NucleusDecoratedWindowTheme( - isDark = colorScheme.isDark(), - windowStyle = windowStyle, - titleBarStyle = titleBarStyle ?: materialTitleBarStyle, - ) { - DecoratedWindow( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - resizable = resizable, - enabled = enabled, - focusable = focusable, - alwaysOnTop = alwaysOnTop, - minimumSize = minimumSize, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - content = content, - ) - } -} - -/** - * Material 3 wrapper that picks the correct backend automatically. Use this - * inside `nucleusApplication { … }` — works on AWT (JBR/JNI) and Tao with the - * same call site. + * Material 3 styled window. Use inside `nucleusApplication { … }`: picks + * Material colors via [rememberMaterialTitleBarStyle] and wraps the window with + * [NucleusDecoratedWindowTheme]. * * Theme tokens captured from the outer composition are re-provided inside the - * window content, which matters on Tao (each window owns its own ComposeScene - * and CompositionLocals don't propagate across scenes). + * window content, because each window owns its own ComposeScene and + * CompositionLocals don't propagate across scenes. */ @Suppress("FunctionNaming", "LongParameterList") @Composable @@ -98,35 +36,35 @@ public fun NucleusApplicationScope.MaterialDecoratedWindow( alwaysOnTop: Boolean = false, // Materialise Compose Popup layers as native transparent windows // (NSPanel / WS_POPUP HWND / Tao popup window on Linux) so menus can - // extend past the window bounds. Honoured by the Tao backend; ignored by AWT. + // extend past the window bounds. nativePopupLayers: Boolean = false, - // Replace Compose-drawn context menus with the OS-looking menu. Tao + - // macOS (`NSMenu`), or a Compose flyout on Linux (Adwaita) / Windows - // (Fluent). No-op on AWT. + // Replace Compose-drawn context menus with the OS-looking menu: `NSMenu` + // on macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent). + // The flyout always opens in a native popup surface, whatever + // `nativePopupLayers` says. nativeContextMenu: Boolean = false, // Hide this window from the OS taskbar/Dock while it stays visible and - // focusable (Tao backend; on Linux effective on X11/XWayland only). - // No-op on AWT. + // focusable (on Linux effective on X11/XWayland only). hiddenFromDock: Boolean = false, minimumSize: DpSize? = null, onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, onKeyEvent: (KeyEvent) -> Boolean = { false }, titleBarStyle: TitleBarStyle? = null, // Fully borderless window (no macOS traffic lights, no CSD outline) — for - // overlay/ghost windows. Tao backend only. + // overlay/ghost windows. undecorated: Boolean = false, // The overlay flags below mirror `dev.nucleusframework.application.DecoratedWindow`. // // Full-window per-pixel transparency: pixels the content leaves at alpha 0 // show the desktop behind the window. Creation-time only, normally paired - // with [undecorated]. Tao backend only. + // with [undecorated]. transparent: Boolean = false, // Click-through window: pointer events fall through to whatever sits below // and the window never intercepts input. Pair with `focusable = false` for - // passive overlays. Reactive. Tao backend only. + // passive overlays. Reactive. clickThrough: Boolean = false, // Show the window on every desktop / macOS Space / Windows virtual desktop - // instead of only the one it was created on. Reactive. Tao backend only. + // instead of only the one it was created on. Reactive. visibleOnAllWorkspaces: Boolean = false, // Linux only: give this window an X11 surface even when the app runs on a // native Wayland session, for the window management Wayland has no protocol diff --git a/decorated-window-tao/api/decorated-window-tao.api b/decorated-window-tao/api/decorated-window-tao.api index d64a1f2b1..c07c6e9f0 100644 --- a/decorated-window-tao/api/decorated-window-tao.api +++ b/decorated-window-tao/api/decorated-window-tao.api @@ -1,3 +1,7 @@ +public final class androidx/compose/ui/scene/TaoComposeSceneContextAccess { + public static fun localComposeSceneContext ()Landroidx/compose/runtime/ProvidableCompositionLocal; +} + public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt { public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$DialogTitleBarKt; public fun ()V @@ -7,8 +11,8 @@ public final class dev/nucleusframework/window/ComposableSingletons$DialogTitleB public final class dev/nucleusframework/window/ComposableSingletons$TitleBarKt { public static final field INSTANCE Ldev/nucleusframework/window/ComposableSingletons$TitleBarKt; public fun ()V - public final fun getLambda$-880964242$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-985436865$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-1158225253$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-1381932086$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function2; public final fun getLambda$1948865750$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; public final fun getLambda$555209157$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function2; } @@ -18,7 +22,7 @@ public final class dev/nucleusframework/window/DialogTitleBarKt { } public final class dev/nucleusframework/window/TitleBarKt { - public static final fun BasicTitleBar-lVb_Clg (Ldev/nucleusframework/window/DecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V + public static final fun BasicTitleBar-IkByU14 (Ldev/nucleusframework/window/DecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Ldev/nucleusframework/window/TitleBarLayoutPolicy;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V public static final fun TitleBar-TgFrcIs (Ldev/nucleusframework/window/DecoratedWindowScope;Landroidx/compose/ui/Modifier;JLdev/nucleusframework/window/styling/TitleBarStyle;Ldev/nucleusframework/window/ControlButtonsDirection;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Landroidx/compose/runtime/Composer;II)V } @@ -172,12 +176,65 @@ public abstract interface class dev/nucleusframework/window/tao/ApplicationScope public abstract fun getTaoApplication ()Ldev/nucleusframework/window/tao/TaoApplication; } +public final class dev/nucleusframework/window/tao/ComposableSingletons$DockLayoutKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$DockLayoutKt; + public fun ()V + public final fun getLambda$-1338913852$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-2018802953$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-795381038$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1525993791$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$DockZoneHintsKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$DockZoneHintsKt; + public fun ()V + public final fun getLambda$-928659135$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function2; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$DragPreviewDefaultsKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$DragPreviewDefaultsKt; + public fun ()V + public final fun getLambda$2034763238$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; +} + public final class dev/nucleusframework/window/tao/ComposableSingletons$NativeViewKt { public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$NativeViewKt; public fun ()V public final fun getLambda$1447510722$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function2; } +public final class dev/nucleusframework/window/tao/ComposableSingletons$SatelliteKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$SatelliteKt; + public fun ()V + public final fun getLambda$-1341541115$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1257353356$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1668502741$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$TabHoverPreviewKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$TabHoverPreviewKt; + public fun ()V + public final fun getLambda$-1555734992$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$2077501951$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function2; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$TabStripKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$TabStripKt; + public fun ()V + public final fun getLambda$-802602294$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$33436031$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; +} + +public final class dev/nucleusframework/window/tao/ComposableSingletons$TabWindowsKt { + public static final field INSTANCE Ldev/nucleusframework/window/tao/ComposableSingletons$TabWindowsKt; + public fun ()V + public final fun getLambda$-140057250$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-1501838323$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-2070093395$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-335910787$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1026698370$Nucleus_decorated_window_tao ()Lkotlin/jvm/functions/Function4; +} + public final class dev/nucleusframework/window/tao/D3D11TestTextureProducer : java/lang/AutoCloseable { public static final field $stable I public static final field Companion Ldev/nucleusframework/window/tao/D3D11TestTextureProducer$Companion; @@ -198,13 +255,18 @@ public final class dev/nucleusframework/window/tao/DecoratedDialogKt { } public final class dev/nucleusframework/window/tao/DecoratedWindowComposableKt { - public static final fun DecoratedWindow-sYvZbhs (Ldev/nucleusframework/window/tao/ApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Landroidx/compose/ui/unit/DpSize;ZZZZZZZZLdev/nucleusframework/window/tao/TaoWindow;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLdev/nucleusframework/window/tao/MacOSStyle;ZLandroidx/compose/runtime/CompositionLocalContext;ZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun DecoratedWindow-P1MFPLo (Ldev/nucleusframework/window/tao/ApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;Landroidx/compose/ui/unit/DpSize;ZZZZZZZZZZLdev/nucleusframework/window/tao/TaoWindow;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLdev/nucleusframework/window/tao/MacOSStyle;ZLandroidx/compose/runtime/CompositionLocalContext;ZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V } public final class dev/nucleusframework/window/tao/DecoratedWindowKt { public static final fun getLocalTaoWindow ()Landroidx/compose/runtime/ProvidableCompositionLocal; } +public final class dev/nucleusframework/window/tao/DecoratedWindowNucleusV2Kt { + public static final fun DecoratedDialog-imfDCbw (Ldev/nucleusframework/window/tao/ApplicationScope;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun DecoratedWindow-Iz9xJ8w (Ldev/nucleusframework/window/tao/ApplicationScope;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/WindowState;Ljava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;JJZZZZZZZZZZLdev/nucleusframework/window/tao/TaoWindow;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLdev/nucleusframework/window/tao/MacOSStyle;ZLandroidx/compose/runtime/CompositionLocalContext;ZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V +} + public final class dev/nucleusframework/window/tao/DefaultWindowExceptionHandlerFactory : dev/nucleusframework/window/tao/WindowExceptionHandlerFactory { public static final field $stable I public static final field INSTANCE Ldev/nucleusframework/window/tao/DefaultWindowExceptionHandlerFactory; @@ -229,6 +291,72 @@ public final class dev/nucleusframework/window/tao/DmaBufTestTextureProducer$Com public static synthetic fun createYuv$default (Ldev/nucleusframework/window/tao/DmaBufTestTextureProducer$Companion;IILdev/nucleusframework/window/tao/NucleusYuvFormat;Ldev/nucleusframework/window/tao/NucleusYuvColorSpace;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/DmaBufTestTextureProducer; } +public final class dev/nucleusframework/window/tao/DockLayoutKt { + public static final fun DockLayout (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TaoWindow;Ljava/util/List;Ljava/util/Set;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun getDefaultDockSideOrder ()Ljava/util/List; + public static final fun getDockPanelHeaderHeight ()F +} + +public final class dev/nucleusframework/window/tao/DockSide : java/lang/Enum { + public static final field Bottom Ldev/nucleusframework/window/tao/DockSide; + public static final field Left Ldev/nucleusframework/window/tao/DockSide; + public static final field Right Ldev/nucleusframework/window/tao/DockSide; + public static final field Top Ldev/nucleusframework/window/tao/DockSide; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public final fun getOpposite ()Ldev/nucleusframework/window/tao/DockSide; + public final fun isVertical ()Z + public static fun valueOf (Ljava/lang/String;)Ldev/nucleusframework/window/tao/DockSide; + public static fun values ()[Ldev/nucleusframework/window/tao/DockSide; +} + +public final class dev/nucleusframework/window/tao/DockSplitterKt { + public static final fun DefaultDockSplitter (Ldev/nucleusframework/window/tao/DockSplitterScope;Landroidx/compose/runtime/Composer;I)V + public static final fun getDockSplitterThickness ()F +} + +public abstract interface class dev/nucleusframework/window/tao/DockSplitterScope { + public abstract fun dockSplitterHandle (Landroidx/compose/ui/Modifier;)Landroidx/compose/ui/Modifier; + public abstract fun getOrientation ()Landroidx/compose/foundation/gestures/Orientation; + public abstract fun getPanel ()Ldev/nucleusframework/window/tao/SatelliteEntry; + public abstract fun getSide ()Ldev/nucleusframework/window/tao/DockSide; +} + +public final class dev/nucleusframework/window/tao/DockTarget { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;)V + public synthetic fun (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/nucleusframework/window/tao/TaoWindow; + public final fun component2 ()Ldev/nucleusframework/window/tao/DockSide; + public final fun component3 ()Ljava/lang/Integer; + public final fun copy (Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;)Ldev/nucleusframework/window/tao/DockTarget; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/DockTarget;Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/DockTarget; + public fun equals (Ljava/lang/Object;)Z + public final fun getHost ()Ldev/nucleusframework/window/tao/TaoWindow; + public final fun getOrder ()Ljava/lang/Integer; + public final fun getSide ()Ldev/nucleusframework/window/tao/DockSide; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/DragGhost { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/SatelliteEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;)V + public synthetic fun (Ldev/nucleusframework/window/tao/SatelliteEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/nucleusframework/window/tao/SatelliteEntry; + public final fun component2 ()Landroidx/compose/ui/geometry/Rect; + public final fun component3 ()F + public final fun component4 ()Landroidx/compose/ui/unit/LayoutDirection; + public final fun copy (Ldev/nucleusframework/window/tao/SatelliteEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;)Ldev/nucleusframework/window/tao/DragGhost; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/DragGhost;Ldev/nucleusframework/window/tao/SatelliteEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/DragGhost; + public fun equals (Ljava/lang/Object;)Z + public final fun getLayoutDirection ()Landroidx/compose/ui/unit/LayoutDirection; + public final fun getSatellite ()Ldev/nucleusframework/window/tao/SatelliteEntry; + public final fun getScaleFactor ()F + public final fun getScreenRectPx ()Landroidx/compose/ui/geometry/Rect; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class dev/nucleusframework/window/tao/MacOSStyle : java/lang/Enum { public static final field Auto Ldev/nucleusframework/window/tao/MacOSStyle; public static final field Classic Ldev/nucleusframework/window/tao/MacOSStyle; @@ -252,6 +380,10 @@ public final class dev/nucleusframework/window/tao/MetalTestTextureProducer$Comp public final fun create (II)Ldev/nucleusframework/window/tao/MetalTestTextureProducer; } +public final class dev/nucleusframework/window/tao/NativePopupLayersKt { + public static final fun NativePopupLayers (Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V +} + public final class dev/nucleusframework/window/tao/NativeViewKt { public static final fun NativeView-hGBTI10 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function1;FLkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V } @@ -347,6 +479,11 @@ public final class dev/nucleusframework/window/tao/NucleusPlatformViewFactoryKt public static synthetic fun nucleusNsPlatformView$default (Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/NucleusPlatformView$NsView; } +public final class dev/nucleusframework/window/tao/NucleusWindowV2BridgeKt { + public static final fun rememberSyncedNucleusDialogState (Ldev/nucleusframework/window/tao/v2/DialogState;ZLandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/window/DialogState; + public static final fun rememberSyncedNucleusWindowState (Ldev/nucleusframework/window/tao/v2/WindowState;ZLandroidx/compose/runtime/Composer;I)Landroidx/compose/ui/window/WindowState; +} + public final class dev/nucleusframework/window/tao/NucleusYuvColorSpace : java/lang/Enum { public static final field BT601_FULL Ldev/nucleusframework/window/tao/NucleusYuvColorSpace; public static final field BT601_LIMITED Ldev/nucleusframework/window/tao/NucleusYuvColorSpace; @@ -370,6 +507,460 @@ public final class dev/nucleusframework/window/tao/OverlayInteractionModifierKt public static synthetic fun consumeOverlayPointerEvents$default (Landroidx/compose/ui/Modifier;Landroidx/compose/ui/input/pointer/PointerIcon;ILjava/lang/Object;)Landroidx/compose/ui/Modifier; } +public abstract interface class dev/nucleusframework/window/tao/SatelliteDragOrigin { +} + +public final class dev/nucleusframework/window/tao/SatelliteDragOrigin$DockedPanel : dev/nucleusframework/window/tao/SatelliteDragOrigin { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/TaoWindow;)V + public final fun getHost ()Ldev/nucleusframework/window/tao/TaoWindow; +} + +public final class dev/nucleusframework/window/tao/SatelliteDragOrigin$FloatingWindow : dev/nucleusframework/window/tao/SatelliteDragOrigin { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/TaoWindow;)V + public final fun getWindow ()Ldev/nucleusframework/window/tao/TaoWindow; +} + +public abstract interface class dev/nucleusframework/window/tao/SatelliteDragSession { + public abstract fun cancel ()V + public abstract fun end-k-4lQ0M (J)V + public abstract fun update-k-4lQ0M (J)V +} + +public final class dev/nucleusframework/window/tao/SatelliteEntry { + public static final field $stable I + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZLjava/util/Set;ZZFFILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDockHost ()Ldev/nucleusframework/window/tao/TaoWindow; + public final fun getDockSides ()Ljava/util/Set; + public final fun getId ()Ljava/lang/String; + public final fun getMaxExtent-D9Ej5fM ()F + public final fun getMinExtent-D9Ej5fM ()F + public final fun getPlacement ()Ldev/nucleusframework/window/tao/SatellitePlacement; + public final fun getPreferredDockSide ()Ldev/nucleusframework/window/tao/DockSide; + public final fun getTitle ()Ljava/lang/String; + public final fun getWindowState ()Ldev/nucleusframework/window/tao/SatelliteWindowState; + public final fun isDocked ()Z + public final fun isFloatable ()Z + public final fun isOpen ()Z + public final fun isReorderable ()Z +} + +public final class dev/nucleusframework/window/tao/SatelliteKt { + public static final fun DefaultSatelliteHeader (Ldev/nucleusframework/window/tao/SatelliteScope;Landroidx/compose/runtime/Composer;I)V + public static final fun Satellite-flGUB14 (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZLjava/util/Set;ZZZFFZLandroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun getSatelliteCaptionStripWidth ()F + public static final fun satelliteDragHandle (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/SatelliteScope;)Landroidx/compose/ui/Modifier; +} + +public final class dev/nucleusframework/window/tao/SatelliteLayoutSnapshot { + public static final field $stable I + public fun (Ljava/util/Map;Ljava/util/Map;)V + public final fun component1 ()Ljava/util/Map; + public final fun component2 ()Ljava/util/Map; + public final fun copy (Ljava/util/Map;Ljava/util/Map;)Ldev/nucleusframework/window/tao/SatelliteLayoutSnapshot; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/SatelliteLayoutSnapshot;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/SatelliteLayoutSnapshot; + public fun equals (Ljava/lang/Object;)Z + public final fun getDockExtents ()Ljava/util/Map; + public final fun getSatellites ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/nucleusframework/window/tao/SatellitePlacement { +} + +public final class dev/nucleusframework/window/tao/SatellitePlacement$Docked : dev/nucleusframework/window/tao/SatellitePlacement { + public static final field $stable I + public synthetic fun (Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;FILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;FLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/nucleusframework/window/tao/DockSide; + public final fun component2 ()I + public final fun component3-lTKBWiU ()Landroidx/compose/ui/unit/Dp; + public final fun component4 ()F + public final fun copy-37wYfng (Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;F)Ldev/nucleusframework/window/tao/SatellitePlacement$Docked; + public static synthetic fun copy-37wYfng$default (Ldev/nucleusframework/window/tao/SatellitePlacement$Docked;Ldev/nucleusframework/window/tao/DockSide;ILandroidx/compose/ui/unit/Dp;FILjava/lang/Object;)Ldev/nucleusframework/window/tao/SatellitePlacement$Docked; + public fun equals (Ljava/lang/Object;)Z + public final fun getExtent-lTKBWiU ()Landroidx/compose/ui/unit/Dp; + public final fun getOrder ()I + public final fun getSide ()Ldev/nucleusframework/window/tao/DockSide; + public final fun getWeight ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/SatellitePlacement$Floating : dev/nucleusframework/window/tao/SatellitePlacement { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/SatellitePlacement$Floating$Companion; + public fun ()V + public synthetic fun (Ldev/nucleusframework/window/tao/WindowPositioner;JLandroidx/compose/ui/unit/DpRect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/nucleusframework/window/tao/WindowPositioner;JLandroidx/compose/ui/unit/DpRect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/nucleusframework/window/tao/WindowPositioner; + public final fun component2-MYxV2XQ ()J + public final fun component3 ()Landroidx/compose/ui/unit/DpRect; + public final fun copy-hQcJfNw (Ldev/nucleusframework/window/tao/WindowPositioner;JLandroidx/compose/ui/unit/DpRect;)Ldev/nucleusframework/window/tao/SatellitePlacement$Floating; + public static synthetic fun copy-hQcJfNw$default (Ldev/nucleusframework/window/tao/SatellitePlacement$Floating;Ldev/nucleusframework/window/tao/WindowPositioner;JLandroidx/compose/ui/unit/DpRect;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/SatellitePlacement$Floating; + public fun equals (Ljava/lang/Object;)Z + public final fun getAnchorRect ()Landroidx/compose/ui/unit/DpRect; + public final fun getPositioner ()Ldev/nucleusframework/window/tao/WindowPositioner; + public final fun getSize-MYxV2XQ ()J + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/SatellitePlacement$Floating$Companion { + public final fun getDefaultPositioner ()Ldev/nucleusframework/window/tao/WindowPositioner; + public final fun getDefaultSize-MYxV2XQ ()J +} + +public abstract interface class dev/nucleusframework/window/tao/SatelliteScope { + public fun close ()V + public fun dock (Ldev/nucleusframework/window/tao/DockSide;)V + public static synthetic fun dock$default (Ldev/nucleusframework/window/tao/SatelliteScope;Ldev/nucleusframework/window/tao/DockSide;ILjava/lang/Object;)V + public abstract fun getSatellite ()Ldev/nucleusframework/window/tao/SatelliteEntry; + public abstract fun getWorkspace ()Ldev/nucleusframework/window/tao/SatelliteWorkspace; + public abstract fun isCompositorPlaced ()Z + public abstract fun isDocked ()Z + public fun undock ()V +} + +public final class dev/nucleusframework/window/tao/SatelliteScope$DefaultImpls { + public static fun close (Ldev/nucleusframework/window/tao/SatelliteScope;)V + public static fun dock (Ldev/nucleusframework/window/tao/SatelliteScope;Ldev/nucleusframework/window/tao/DockSide;)V + public static synthetic fun dock$default (Ldev/nucleusframework/window/tao/SatelliteScope;Ldev/nucleusframework/window/tao/DockSide;ILjava/lang/Object;)V + public static fun undock (Ldev/nucleusframework/window/tao/SatelliteScope;)V +} + +public final class dev/nucleusframework/window/tao/SatelliteSnapshot { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/SatellitePlacement;Z)V + public final fun component1 ()Ldev/nucleusframework/window/tao/SatellitePlacement; + public final fun component2 ()Z + public final fun copy (Ldev/nucleusframework/window/tao/SatellitePlacement;Z)Ldev/nucleusframework/window/tao/SatelliteSnapshot; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/SatelliteSnapshot;Ldev/nucleusframework/window/tao/SatellitePlacement;ZILjava/lang/Object;)Ldev/nucleusframework/window/tao/SatelliteSnapshot; + public fun equals (Ljava/lang/Object;)Z + public final fun getPlacement ()Ldev/nucleusframework/window/tao/SatellitePlacement; + public fun hashCode ()I + public final fun isOpen ()Z + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/SatelliteWindowKt { + public static final fun SatelliteWindow (Ldev/nucleusframework/window/tao/ApplicationScope;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/TaoWindow;Ldev/nucleusframework/window/tao/SatelliteWindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V +} + +public final class dev/nucleusframework/window/tao/SatelliteWindowState { + public static final field $stable I + public fun ()V + public synthetic fun (JLdev/nucleusframework/window/tao/WindowPositioner;Landroidx/compose/ui/unit/DpRect;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JLdev/nucleusframework/window/tao/WindowPositioner;Landroidx/compose/ui/unit/DpRect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAnchorRect ()Landroidx/compose/ui/unit/DpRect; + public final fun getOffsetFromParent-Ctc3-3Q ()Landroidx/compose/ui/unit/DpOffset; + public final fun getPositioner ()Ldev/nucleusframework/window/tao/WindowPositioner; + public final fun getSize-MYxV2XQ ()J + public final fun isActive ()Z + public final fun isHiddenByParent ()Z + public final fun reanchor ()V + public final fun setAnchorRect (Landroidx/compose/ui/unit/DpRect;)V + public final fun setPositioner (Ldev/nucleusframework/window/tao/WindowPositioner;)V + public final fun setSize-EaSLcWc (J)V +} + +public final class dev/nucleusframework/window/tao/SatelliteWindowStateKt { + public static final fun rememberSatelliteWindowState-csNNkCE (JLdev/nucleusframework/window/tao/WindowPositioner;Landroidx/compose/ui/unit/DpRect;Landroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/SatelliteWindowState; +} + +public final class dev/nucleusframework/window/tao/SatelliteWorkspace { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/SatelliteWorkspace$Companion; + public fun ()V + public fun (Z)V + public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun beginDrag-0AR0LA0 (Ljava/lang/String;Ldev/nucleusframework/window/tao/SatelliteDragOrigin;J)Ldev/nucleusframework/window/tao/SatelliteDragSession; + public final fun close (Ljava/lang/String;)V + public final fun dock (Ljava/lang/String;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;Ldev/nucleusframework/window/tao/TaoWindow;)V + public static synthetic fun dock$default (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ldev/nucleusframework/window/tao/DockSide;Ljava/lang/Integer;Ldev/nucleusframework/window/tao/TaoWindow;ILjava/lang/Object;)V + public final fun dockExtent-u2uoSUM (Ldev/nucleusframework/window/tao/DockSide;)F + public final fun dockTargetAt-Uv8p0NA (Landroidx/compose/ui/geometry/Rect;J)Ldev/nucleusframework/window/tao/DockTarget; + public final fun dockTargetAt-k-4lQ0M (J)Ldev/nucleusframework/window/tao/DockTarget; + public final fun getDockPreview ()Ldev/nucleusframework/window/tao/DockTarget; + public final fun getDragGhost ()Ldev/nucleusframework/window/tao/DragGhost; + public final fun getDragKind ()Ldev/nucleusframework/window/tao/WorkspaceDragKind; + public final fun getDraggedSatellite ()Ldev/nucleusframework/window/tao/SatelliteEntry; + public final fun getFollowFocus ()Z + public final fun getMembers ()Ljava/util/List; + public final fun getOwner ()Ldev/nucleusframework/window/tao/TaoWindow; + public final fun getPinnedOwner ()Ldev/nucleusframework/window/tao/TaoWindow; + public final fun getSatellites ()Ljava/util/Collection; + public final fun getVisible ()Z + public final fun join (Ldev/nucleusframework/window/tao/TaoWindow;)V + public final fun leave (Ldev/nucleusframework/window/tao/TaoWindow;)V + public final fun open (Ljava/lang/String;)V + public final fun pinTo (Ldev/nucleusframework/window/tao/TaoWindow;)V + public final fun plannedDockExtent-chRvn1I (Ldev/nucleusframework/window/tao/SatelliteEntry;Ldev/nucleusframework/window/tao/DockSide;)F + public final fun restore (Ldev/nucleusframework/window/tao/SatelliteLayoutSnapshot;)V + public final fun satellite (Ljava/lang/String;)Ldev/nucleusframework/window/tao/SatelliteEntry; + public final fun setDockExtent-3ABfNKs (Ldev/nucleusframework/window/tao/DockSide;F)V + public final fun setDockedExtent-3ABfNKs (Ljava/lang/String;F)V + public final fun setDockedWeight (Ljava/lang/String;F)V + public final fun setVisible (Z)V + public final fun snapshot ()Ldev/nucleusframework/window/tao/SatelliteLayoutSnapshot; + public final fun toggle (Ljava/lang/String;)V + public final fun undock (Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement$Floating;)V + public static synthetic fun undock$default (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement$Floating;ILjava/lang/Object;)V +} + +public final class dev/nucleusframework/window/tao/SatelliteWorkspace$Companion { + public final fun getDefaultDockExtent-D9Ej5fM ()F + public final fun getDockZoneWidth-D9Ej5fM ()F + public final fun getMinDockExtent-D9Ej5fM ()F +} + +public final class dev/nucleusframework/window/tao/SatelliteWorkspaceKt { + public static final fun JoinSatelliteWorkspace (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ldev/nucleusframework/window/tao/TaoWindow;Landroidx/compose/runtime/Composer;II)V + public static final fun rememberSatelliteWorkspace (ZLandroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/SatelliteWorkspace; +} + +public final class dev/nucleusframework/window/tao/TabDragGhost { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/TabEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;)V + public synthetic fun (Ldev/nucleusframework/window/tao/TabEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/nucleusframework/window/tao/TabEntry; + public final fun component2 ()Landroidx/compose/ui/geometry/Rect; + public final fun component3 ()F + public final fun component4 ()Landroidx/compose/ui/unit/LayoutDirection; + public final fun copy (Ldev/nucleusframework/window/tao/TabEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;)Ldev/nucleusframework/window/tao/TabDragGhost; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/TabDragGhost;Ldev/nucleusframework/window/tao/TabEntry;Landroidx/compose/ui/geometry/Rect;FLandroidx/compose/ui/unit/LayoutDirection;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDragGhost; + public fun equals (Ljava/lang/Object;)Z + public final fun getLayoutDirection ()Landroidx/compose/ui/unit/LayoutDirection; + public final fun getScaleFactor ()F + public final fun getScreenRectPx ()Landroidx/compose/ui/geometry/Rect; + public final fun getTab ()Ldev/nucleusframework/window/tao/TabEntry; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/nucleusframework/window/tao/TabDragOrigin { +} + +public final class dev/nucleusframework/window/tao/TabDragOrigin$Strip : dev/nucleusframework/window/tao/TabDragOrigin { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/TaoWindow;)V + public final fun getWindow ()Ldev/nucleusframework/window/tao/TaoWindow; +} + +public abstract interface class dev/nucleusframework/window/tao/TabDragSession { + public abstract fun cancel ()V + public abstract fun end-k-4lQ0M (J)V + public abstract fun update-k-4lQ0M (J)V +} + +public final class dev/nucleusframework/window/tao/TabDropGhost { + public static final field $stable I + public synthetic fun (IFLdev/nucleusframework/window/tao/TabEntry;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component2-D9Ej5fM ()F + public final fun component3 ()Ldev/nucleusframework/window/tao/TabEntry; + public final fun copy-lG28NQ4 (IFLdev/nucleusframework/window/tao/TabEntry;)Ldev/nucleusframework/window/tao/TabDropGhost; + public static synthetic fun copy-lG28NQ4$default (Ldev/nucleusframework/window/tao/TabDropGhost;IFLdev/nucleusframework/window/tao/TabEntry;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDropGhost; + public fun equals (Ljava/lang/Object;)Z + public final fun getIndex ()I + public final fun getTab ()Ldev/nucleusframework/window/tao/TabEntry; + public final fun getWidth-D9Ej5fM ()F + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/TabDropTarget { + public static final field $stable I + public fun (Ldev/nucleusframework/window/tao/TabWindowGroup;I)V + public final fun component1 ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public final fun component2 ()I + public final fun copy (Ldev/nucleusframework/window/tao/TabWindowGroup;I)Ldev/nucleusframework/window/tao/TabDropTarget; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/TabDropTarget;Ldev/nucleusframework/window/tao/TabWindowGroup;IILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDropTarget; + public fun equals (Ljava/lang/Object;)Z + public final fun getGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public final fun getIndex ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/TabEntry { + public static final field $stable I + public final fun getGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public final fun getId ()Ljava/lang/String; + public final fun getThumbnail ()Landroidx/compose/ui/graphics/ImageBitmap; + public final fun getTitle ()Ljava/lang/String; + public final fun isSelected ()Z + public final fun setThumbnail (Landroidx/compose/ui/graphics/ImageBitmap;)V +} + +public final class dev/nucleusframework/window/tao/TabGroupSnapshot { + public static final field $stable I + public synthetic fun (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Landroidx/compose/ui/unit/DpOffset;JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/List; + public final fun component3 ()Ljava/lang/String; + public final fun component4-Ctc3-3Q ()Landroidx/compose/ui/unit/DpOffset; + public final fun component5-MYxV2XQ ()J + public final fun copy-19UVGzU (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Landroidx/compose/ui/unit/DpOffset;J)Ldev/nucleusframework/window/tao/TabGroupSnapshot; + public static synthetic fun copy-19UVGzU$default (Ldev/nucleusframework/window/tao/TabGroupSnapshot;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Landroidx/compose/ui/unit/DpOffset;JILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabGroupSnapshot; + public fun equals (Ljava/lang/Object;)Z + public final fun getId ()Ljava/lang/String; + public final fun getPosition-Ctc3-3Q ()Landroidx/compose/ui/unit/DpOffset; + public final fun getSelectedId ()Ljava/lang/String; + public final fun getSize-MYxV2XQ ()J + public final fun getTabIds ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/TabHoverPreview { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/TabHoverPreview$Companion; + public fun ()V + public synthetic fun (JJZLkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JJZLkotlin/jvm/functions/Function3;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getContent ()Lkotlin/jvm/functions/Function3; + public final fun getDelay-UwyO8pc ()J + public final fun getNativeLayer ()Z + public final fun getOffset-RKDOV3M ()J +} + +public final class dev/nucleusframework/window/tao/TabHoverPreview$Companion { + public final fun getDefault ()Ldev/nucleusframework/window/tao/TabHoverPreview; +} + +public final class dev/nucleusframework/window/tao/TabHoverPreviewKt { + public static final fun TabHoverPreviewCard (Ldev/nucleusframework/window/tao/TabHoverPreviewScope;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun TabHoverPreviewPopup (Ldev/nucleusframework/window/tao/TabStripScope;Ldev/nucleusframework/window/tao/TabHoverPreview;Landroidx/compose/runtime/Composer;II)V + public static final fun TabPreview (Ldev/nucleusframework/window/tao/TabEntry;Landroidx/compose/ui/Modifier;Landroidx/compose/ui/layout/ContentScale;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;II)V + public static final fun getHoveredTab (Ldev/nucleusframework/window/tao/TabStripScope;)Ldev/nucleusframework/window/tao/TabEntry; +} + +public abstract interface class dev/nucleusframework/window/tao/TabHoverPreviewScope { + public abstract fun getGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public abstract fun getTab ()Ldev/nucleusframework/window/tao/TabEntry; + public fun getThumbnail ()Landroidx/compose/ui/graphics/ImageBitmap; + public abstract fun getWorkspace ()Ldev/nucleusframework/window/tao/TabWorkspace; +} + +public final class dev/nucleusframework/window/tao/TabHoverPreviewScope$DefaultImpls { + public static fun getThumbnail (Ldev/nucleusframework/window/tao/TabHoverPreviewScope;)Landroidx/compose/ui/graphics/ImageBitmap; +} + +public final class dev/nucleusframework/window/tao/TabLayoutSnapshot { + public static final field $stable I + public fun (Ljava/util/List;)V + public final fun component1 ()Ljava/util/List; + public final fun copy (Ljava/util/List;)Ldev/nucleusframework/window/tao/TabLayoutSnapshot; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/TabLayoutSnapshot;Ljava/util/List;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabLayoutSnapshot; + public fun equals (Ljava/lang/Object;)Z + public final fun getGroups ()Ljava/util/List; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/nucleusframework/window/tao/TabScope { + public fun close ()V + public abstract fun getTab ()Ldev/nucleusframework/window/tao/TabEntry; + public abstract fun getWorkspace ()Ldev/nucleusframework/window/tao/TabWorkspace; + public fun select ()V +} + +public final class dev/nucleusframework/window/tao/TabScope$DefaultImpls { + public static fun close (Ldev/nucleusframework/window/tao/TabScope;)V + public static fun select (Ldev/nucleusframework/window/tao/TabScope;)V +} + +public final class dev/nucleusframework/window/tao/TabStripAnimationKt { + public static final fun getTabReorderAnimation ()Landroidx/compose/animation/core/AnimationSpec; +} + +public final class dev/nucleusframework/window/tao/TabStripDragKt { + public static final fun tabDragHandle (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TabWorkspace;Ldev/nucleusframework/window/tao/TabEntry;)Landroidx/compose/ui/Modifier; +} + +public final class dev/nucleusframework/window/tao/TabStripKt { + public static final fun TabDragGhostCard (Ldev/nucleusframework/window/tao/TabDragGhost;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun TabDropGhostCard (Ldev/nucleusframework/window/tao/TabDropGhost;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun TabGhostCard (Ldev/nucleusframework/window/tao/TabEntry;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V + public static final fun TabStrip (Ldev/nucleusframework/window/tao/TabStripScope;Landroidx/compose/ui/Modifier;Landroidx/compose/animation/core/AnimationSpec;Ldev/nucleusframework/window/tao/TabHoverPreview;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun getDropGhost (Ldev/nucleusframework/window/tao/TabStripScope;)Ldev/nucleusframework/window/tao/TabDropGhost; + public static final fun tabSlot (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TabWindowGroup;I)Landroidx/compose/ui/Modifier; + public static final fun tabStripGeometry (Landroidx/compose/ui/Modifier;Ldev/nucleusframework/window/tao/TabWorkspace;Ldev/nucleusframework/window/tao/TabWindowGroup;)Landroidx/compose/ui/Modifier; +} + +public abstract interface class dev/nucleusframework/window/tao/TabStripScope { + public abstract fun getGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public fun getTabs ()Ljava/util/List; + public abstract fun getWorkspace ()Ldev/nucleusframework/window/tao/TabWorkspace; +} + +public final class dev/nucleusframework/window/tao/TabStripScope$DefaultImpls { + public static fun getTabs (Ldev/nucleusframework/window/tao/TabStripScope;)Ljava/util/List; +} + +public final class dev/nucleusframework/window/tao/TabWindowGroup { + public static final field $stable I + public final fun getId ()Ljava/lang/String; + public final fun getIds ()Ljava/util/List; + public final fun getPosition-Ctc3-3Q ()Landroidx/compose/ui/unit/DpOffset; + public final fun getSelectedId ()Ljava/lang/String; + public final fun getSize-MYxV2XQ ()J + public final fun getWindow ()Ldev/nucleusframework/window/tao/TaoWindow; +} + +public final class dev/nucleusframework/window/tao/TabWindowsKt { + public static final fun DefaultTabTitleBar (Ldev/nucleusframework/window/DecoratedWindowScope;Lkotlin/jvm/functions/Function2;Landroidx/compose/runtime/Composer;I)V + public static final fun Tab (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun TabWindows (Ldev/nucleusframework/window/tao/ApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Landroidx/compose/runtime/CompositionLocalContext;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + +public final class dev/nucleusframework/window/tao/TabWorkspace { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/TabWorkspace$Companion; + public fun ()V + public synthetic fun (JZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (JZLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun beginDrag-0AR0LA0 (Ljava/lang/String;Ldev/nucleusframework/window/tao/TabDragOrigin;J)Ldev/nucleusframework/window/tao/TabDragSession; + public final fun captureThumbnail (Ljava/lang/String;)V + public final fun close (Ljava/lang/String;)V + public final fun dropTargetAt-9KIMszo (JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;)Ldev/nucleusframework/window/tao/TabDropTarget; + public static synthetic fun dropTargetAt-9KIMszo$default (Ldev/nucleusframework/window/tao/TabWorkspace;JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDropTarget; + public final fun dropTargetAt-ubNVwUQ (Landroidx/compose/ui/geometry/Rect;JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;)Ldev/nucleusframework/window/tao/TabDropTarget; + public static synthetic fun dropTargetAt-ubNVwUQ$default (Ldev/nucleusframework/window/tao/TabWorkspace;Landroidx/compose/ui/geometry/Rect;JLdev/nucleusframework/window/tao/TabEntry;Ldev/nucleusframework/window/tao/TabWindowGroup;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TabDropTarget; + public final fun getActiveGroup ()Ldev/nucleusframework/window/tao/TabWindowGroup; + public final fun getCaptureThumbnails ()Z + public final fun getDefaultWindowSize-MYxV2XQ ()J + public final fun getDragGhost ()Ldev/nucleusframework/window/tao/TabDragGhost; + public final fun getDragKind ()Ldev/nucleusframework/window/tao/WorkspaceDragKind; + public final fun getDraggedTab ()Ldev/nucleusframework/window/tao/TabEntry; + public final fun getDropPreview ()Ldev/nucleusframework/window/tao/TabDropTarget; + public final fun getGroups ()Ljava/util/List; + public final fun getTabs ()Ljava/util/Collection; + public final fun group (Ljava/lang/String;)Ldev/nucleusframework/window/tao/TabWindowGroup; + public final fun groupOf (Ldev/nucleusframework/window/tao/TaoWindow;)Ldev/nucleusframework/window/tao/TabWindowGroup; + public final fun move (Ljava/lang/String;Ldev/nucleusframework/window/tao/TabWindowGroup;Ljava/lang/Integer;)V + public static synthetic fun move$default (Ldev/nucleusframework/window/tao/TabWorkspace;Ljava/lang/String;Ldev/nucleusframework/window/tao/TabWindowGroup;Ljava/lang/Integer;ILjava/lang/Object;)V + public final fun reorder (Ljava/lang/String;I)V + public final fun restore (Ldev/nucleusframework/window/tao/TabLayoutSnapshot;)V + public final fun select (Ljava/lang/String;)V + public final fun selectedTab (Ldev/nucleusframework/window/tao/TabWindowGroup;)Ldev/nucleusframework/window/tao/TabEntry; + public final fun snapshot ()Ldev/nucleusframework/window/tao/TabLayoutSnapshot; + public final fun tab (Ljava/lang/String;)Ldev/nucleusframework/window/tao/TabEntry; + public final fun tabsOf (Ldev/nucleusframework/window/tao/TabWindowGroup;)Ljava/util/List; + public final fun tearOff (Ljava/lang/String;Landroidx/compose/ui/geometry/Rect;F)Ldev/nucleusframework/window/tao/TabWindowGroup; +} + +public final class dev/nucleusframework/window/tao/TabWorkspace$Companion { + public final fun getDefaultWindowSize-MYxV2XQ ()J +} + +public final class dev/nucleusframework/window/tao/TabWorkspaceKt { + public static final fun rememberTabWorkspace-IbIYxLY (JZLandroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/TabWorkspace; +} + public final class dev/nucleusframework/window/tao/TaoA11yAction { public static final field $stable I public static final field CLICK I @@ -506,13 +1097,18 @@ public final class dev/nucleusframework/window/tao/TaoApplication { public static final field $stable I public static final field INSTANCE Ldev/nucleusframework/window/tao/TaoApplication; public final fun exit ()V + public final fun expectUnresponsive (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; + public final fun isQuitting ()Z + public final fun onResponsive (Lkotlin/jvm/functions/Function0;)V + public final fun onUnresponsive (Lkotlin/jvm/functions/Function0;)V public final fun openWindow (Ljava/lang/String;DDZZZZLdev/nucleusframework/window/tao/TaoWindow;ZZZZ)Ldev/nucleusframework/window/tao/TaoWindow; public static synthetic fun openWindow$default (Ldev/nucleusframework/window/tao/TaoApplication;Ljava/lang/String;DDZZZZLdev/nucleusframework/window/tao/TaoWindow;ZZZZILjava/lang/Object;)Ldev/nucleusframework/window/tao/TaoWindow; public final fun run (Lkotlin/jvm/functions/Function1;)V } public final class dev/nucleusframework/window/tao/TaoApplicationComposeKt { - public static final fun taoApplication (Lkotlin/jvm/functions/Function3;)V + public static final fun taoApplication (ZLkotlin/jvm/functions/Function3;)V + public static synthetic fun taoApplication$default (ZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)V } public final class dev/nucleusframework/window/tao/TaoCompositionLocalContextBridgeKt { @@ -524,6 +1120,8 @@ public final class dev/nucleusframework/window/tao/TaoCursorIcon { public static final field CROSSHAIR I public static final field DEFAULT I public static final field EW_RESIZE I + public static final field GRAB I + public static final field GRABBING I public static final field HAND I public static final field HELP I public static final field INSTANCE Ldev/nucleusframework/window/tao/TaoCursorIcon; @@ -637,8 +1235,40 @@ public final class dev/nucleusframework/window/tao/TaoModifierMask { public static final field SHIFT I } +public final class dev/nucleusframework/window/tao/TaoMonitor { + public static final field $stable I + public final fun boundsDp (F)Landroidx/compose/ui/unit/DpRect; + public static synthetic fun boundsDp$default (Ldev/nucleusframework/window/tao/TaoMonitor;FILjava/lang/Object;)Landroidx/compose/ui/unit/DpRect; + public final fun containsPx (II)Z + public fun equals (Ljava/lang/Object;)Z + public final fun getBoundsPx ()Landroidx/compose/ui/unit/IntRect; + public final fun getId ()Ljava/lang/String; + public final fun getName ()Ljava/lang/String; + public final fun getScaleFactor ()F + public final fun getWorkAreaPx ()Landroidx/compose/ui/unit/IntRect; + public fun hashCode ()I + public final fun isPrimary ()Z + public fun toString ()Ljava/lang/String; + public final fun workAreaDp (F)Landroidx/compose/ui/unit/DpRect; + public static synthetic fun workAreaDp$default (Ldev/nucleusframework/window/tao/TaoMonitor;FILjava/lang/Object;)Landroidx/compose/ui/unit/DpRect; +} + +public final class dev/nucleusframework/window/tao/TaoMonitors { + public static final field $stable I + public static final field INSTANCE Ldev/nucleusframework/window/tao/TaoMonitors; + public final fun all (Ldev/nucleusframework/window/tao/TaoWindow;)Ljava/util/List; + public static synthetic fun all$default (Ldev/nucleusframework/window/tao/TaoMonitors;Ldev/nucleusframework/window/tao/TaoWindow;ILjava/lang/Object;)Ljava/util/List; + public final fun byId (Ljava/lang/String;Ldev/nucleusframework/window/tao/TaoWindow;)Ldev/nucleusframework/window/tao/TaoMonitor; + public static synthetic fun byId$default (Ldev/nucleusframework/window/tao/TaoMonitors;Ljava/lang/String;Ldev/nucleusframework/window/tao/TaoWindow;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TaoMonitor; + public final fun forWindow (Ldev/nucleusframework/window/tao/TaoWindow;)Ldev/nucleusframework/window/tao/TaoMonitor; + public final fun primary (Ldev/nucleusframework/window/tao/TaoWindow;)Ldev/nucleusframework/window/tao/TaoMonitor; + public static synthetic fun primary$default (Ldev/nucleusframework/window/tao/TaoMonitors;Ldev/nucleusframework/window/tao/TaoWindow;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/TaoMonitor; +} + public final class dev/nucleusframework/window/tao/TaoMouseButton { public static final field $stable I + public static final field BACK I + public static final field FORWARD I public static final field INSTANCE Ldev/nucleusframework/window/tao/TaoMouseButton; public static final field LEFT I public static final field MIDDLE I @@ -650,6 +1280,20 @@ public abstract interface class dev/nucleusframework/window/tao/TaoOpenGlRenderC public abstract fun withContextCurrent (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; } +public final class dev/nucleusframework/window/tao/TaoPointerIcons { + public static final field $stable I + public static final field INSTANCE Ldev/nucleusframework/window/tao/TaoPointerIcons; + public final fun getGrab ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getGrabbing ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getHelp ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getMove ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getNotAllowed ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getProgress ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getResizeEastWest ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getResizeNorthSouth ()Landroidx/compose/ui/input/pointer/PointerIcon; + public final fun getWait ()Landroidx/compose/ui/input/pointer/PointerIcon; +} + public final class dev/nucleusframework/window/tao/TaoRenderBackend : java/lang/Enum { public static final field METAL Ldev/nucleusframework/window/tao/TaoRenderBackend; public static final field OPENGL Ldev/nucleusframework/window/tao/TaoRenderBackend; @@ -711,6 +1355,7 @@ public final class dev/nucleusframework/window/tao/TaoWindow { public final fun exportXdgForeignHandle (J)Ldev/nucleusframework/window/tao/XdgForeignExport; public static synthetic fun exportXdgForeignHandle$default (Ldev/nucleusframework/window/tao/TaoWindow;JILjava/lang/Object;)Ldev/nucleusframework/window/tao/XdgForeignExport; public final fun focus ()V + public final fun getCanPlaceOnScreen ()Z public final fun getHandle ()J public final fun getNativeHandle ()J public final fun getNsWindowHandle ()Ljava/lang/Long; @@ -718,8 +1363,11 @@ public final class dev/nucleusframework/window/tao/TaoWindow { public final fun getX11PortalParent ()Ljava/lang/String; public final fun getX11WindowId ()Ljava/lang/Long; public final fun hide ()V + public final fun isFocused ()Z public final fun isFullscreen ()Z + public final fun isMaximizable ()Z public final fun isMaximized ()Z + public final fun isMinimizable ()Z public final fun isMinimized ()Z public final fun isNativeWaylandSurface ()Z public final fun isPopup ()Z @@ -757,7 +1405,10 @@ public final class dev/nucleusframework/window/tao/TaoWindow { public final fun setIcon (II[B)V public final fun setIgnoreCursorEvents (Z)V public final fun setInnerSize (DD)V + public final fun setMaximizable (Z)V public final fun setMaximized (Z)V + public final fun setMaximumSize (Ljava/lang/Double;Ljava/lang/Double;)V + public final fun setMinimizable (Z)V public final fun setMinimized (Z)V public final fun setMinimumSize (Ljava/lang/Double;Ljava/lang/Double;)V public final fun setOuterPosition (DD)V @@ -809,6 +1460,54 @@ public final class dev/nucleusframework/window/tao/TextureViewKt { public abstract interface class dev/nucleusframework/window/tao/TextureViewSource { } +public final class dev/nucleusframework/window/tao/WindowAnchor : java/lang/Enum { + public static final field Bottom Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field BottomLeft Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field BottomRight Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field Center Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field Left Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field Right Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field Top Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field TopLeft Ldev/nucleusframework/window/tao/WindowAnchor; + public static final field TopRight Ldev/nucleusframework/window/tao/WindowAnchor; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/nucleusframework/window/tao/WindowAnchor; + public static fun values ()[Ldev/nucleusframework/window/tao/WindowAnchor; +} + +public final class dev/nucleusframework/window/tao/WindowConstraintAdjustment { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/WindowConstraintAdjustment$Companion; + public fun ()V + public fun (ZZZZZZ)V + public synthetic fun (ZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Z + public final fun component3 ()Z + public final fun component4 ()Z + public final fun component5 ()Z + public final fun component6 ()Z + public final fun copy (ZZZZZZ)Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public static synthetic fun copy$default (Ldev/nucleusframework/window/tao/WindowConstraintAdjustment;ZZZZZZILjava/lang/Object;)Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public fun equals (Ljava/lang/Object;)Z + public final fun getFlipHorizontal ()Z + public final fun getFlipVertical ()Z + public final fun getResizeHorizontal ()Z + public final fun getResizeVertical ()Z + public final fun getSlideHorizontal ()Z + public final fun getSlideVertical ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/WindowConstraintAdjustment$Companion { + public final fun getAll ()Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public final fun getFlip ()Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public final fun getFlipAndSlide ()Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public final fun getNone ()Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public final fun getSlide ()Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; +} + public abstract interface class dev/nucleusframework/window/tao/WindowExceptionHandlerFactory { public abstract fun exceptionHandler (Ldev/nucleusframework/window/tao/TaoWindow;)Landroidx/compose/ui/window/WindowExceptionHandler; } @@ -817,6 +1516,35 @@ public final class dev/nucleusframework/window/tao/WindowExceptionHandlerFactory public static final fun getLocalWindowExceptionHandlerFactory ()Landroidx/compose/runtime/ProvidableCompositionLocal; } +public final class dev/nucleusframework/window/tao/WindowPositioner { + public static final field $stable I + public fun ()V + public synthetic fun (Ldev/nucleusframework/window/tao/WindowAnchor;Ldev/nucleusframework/window/tao/WindowAnchor;JLdev/nucleusframework/window/tao/WindowConstraintAdjustment;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/nucleusframework/window/tao/WindowAnchor;Ldev/nucleusframework/window/tao/WindowAnchor;JLdev/nucleusframework/window/tao/WindowConstraintAdjustment;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ldev/nucleusframework/window/tao/WindowAnchor; + public final fun component2 ()Ldev/nucleusframework/window/tao/WindowAnchor; + public final fun component3-RKDOV3M ()J + public final fun component4 ()Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public final fun copy-7WlHY6s (Ldev/nucleusframework/window/tao/WindowAnchor;Ldev/nucleusframework/window/tao/WindowAnchor;JLdev/nucleusframework/window/tao/WindowConstraintAdjustment;)Ldev/nucleusframework/window/tao/WindowPositioner; + public static synthetic fun copy-7WlHY6s$default (Ldev/nucleusframework/window/tao/WindowPositioner;Ldev/nucleusframework/window/tao/WindowAnchor;Ldev/nucleusframework/window/tao/WindowAnchor;JLdev/nucleusframework/window/tao/WindowConstraintAdjustment;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/WindowPositioner; + public fun equals (Ljava/lang/Object;)Z + public final fun getChildAnchor ()Ldev/nucleusframework/window/tao/WindowAnchor; + public final fun getConstraintAdjustment ()Ldev/nucleusframework/window/tao/WindowConstraintAdjustment; + public final fun getOffset-RKDOV3M ()J + public final fun getParentAnchor ()Ldev/nucleusframework/window/tao/WindowAnchor; + public fun hashCode ()I + public final fun place-UBP6k7g (JLandroidx/compose/ui/unit/DpRect;Landroidx/compose/ui/unit/DpRect;Landroidx/compose/ui/unit/DpRect;)Landroidx/compose/ui/unit/DpRect; + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/window/tao/WorkspaceDragKind : java/lang/Enum { + public static final field Transfer Ldev/nucleusframework/window/tao/WorkspaceDragKind; + public static final field Window Ldev/nucleusframework/window/tao/WorkspaceDragKind; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/nucleusframework/window/tao/WorkspaceDragKind; + public static fun values ()[Ldev/nucleusframework/window/tao/WorkspaceDragKind; +} + public final class dev/nucleusframework/window/tao/XdgForeignExport : java/lang/AutoCloseable { public static final field $stable I public fun close ()V @@ -880,3 +1608,171 @@ public final class dev/nucleusframework/window/tao/render/TaoSelectionAccessibil public static final fun getLocalTaoTextSelectionA11yPublisher ()Landroidx/compose/runtime/ProvidableCompositionLocal; } +public final class dev/nucleusframework/window/tao/v2/DialogState { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/v2/DialogState$Companion; + public synthetic fun (ZLjava/lang/String;Landroidx/compose/ui/unit/DpRect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBounds ()Landroidx/compose/ui/unit/DpRect; + public final fun getPosition-RKDOV3M ()J + public final fun getScreenId ()Ljava/lang/String; + public final fun getSize-MYxV2XQ ()J + public final fun isInitialized ()Z + public final fun requestBounds (Landroidx/compose/ui/unit/DpRect;)V + public final fun requestBounds (Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;)V + public final fun requestBounds (Lkotlin/jvm/functions/Function1;)V + public final fun requestPosition (Ldev/nucleusframework/window/tao/v2/WindowPositionProvider;)V + public final fun requestPosition-YgX7TsA (FF)V + public final fun requestPosition-jo-Fl9I (J)V + public final fun requestScreen (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;)V + public final fun requestSize (Ldev/nucleusframework/window/tao/v2/WindowSizeProvider;)V + public final fun requestSize-EaSLcWc (J)V + public final fun requestSize-YgX7TsA (FF)V +} + +public final class dev/nucleusframework/window/tao/v2/DialogState$Companion { + public final fun getSaver ()Landroidx/compose/runtime/saveable/Saver; +} + +public final class dev/nucleusframework/window/tao/v2/DialogStateKt { + public static final fun DialogState (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;)Ldev/nucleusframework/window/tao/v2/DialogState; + public static synthetic fun DialogState$default (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/v2/DialogState; + public static final fun DialogStateWithBounds-5EYAyq4 (Landroidx/compose/ui/unit/DpOffset;Landroidx/compose/ui/unit/DpSize;)Ldev/nucleusframework/window/tao/v2/DialogState; + public static synthetic fun DialogStateWithBounds-5EYAyq4$default (Landroidx/compose/ui/unit/DpOffset;Landroidx/compose/ui/unit/DpSize;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/v2/DialogState; + public static final fun rememberDialogState (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;Landroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/v2/DialogState; + public static final fun rememberDialogStateWithBounds-0qe9R64 (Landroidx/compose/ui/unit/DpOffset;Landroidx/compose/ui/unit/DpSize;Landroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/v2/DialogState; +} + +public final class dev/nucleusframework/window/tao/v2/Screen { + public static final field $stable I + public fun equals (Ljava/lang/Object;)Z + public final fun getAvailableBounds ()Landroidx/compose/ui/unit/DpRect; + public final fun getBounds ()Landroidx/compose/ui/unit/DpRect; + public final fun getId ()Ljava/lang/String; + public final fun getInsets ()Landroidx/compose/ui/unit/DpInsets; + public final fun getName ()Ljava/lang/String; + public fun hashCode ()I + public final fun isPrimary ()Z + public fun toString ()Ljava/lang/String; +} + +public abstract interface class dev/nucleusframework/window/tao/v2/WindowBoundsProvider { + public static final field Companion Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider$Companion; + public abstract fun getBounds (Ldev/nucleusframework/window/tao/v2/WindowGeometryProviderScope;)Landroidx/compose/ui/unit/DpRect; +} + +public final class dev/nucleusframework/window/tao/v2/WindowBoundsProvider$Companion { + public final fun Absolute (Landroidx/compose/ui/unit/DpRect;)Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider; + public final fun getDefault ()Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider; +} + +public final class dev/nucleusframework/window/tao/v2/WindowGeometryProviderScope { + public static final field $stable I + public final fun contentToWindowSize-e_xh8Ic (J)J + public final fun getParentWindowMetrics ()Ldev/nucleusframework/window/tao/v2/WindowMetrics; + public final fun getWindowMetrics ()Ldev/nucleusframework/window/tao/v2/WindowMetrics; + public final fun measureWindowContent-KSHjdMI (FFFF)J + public static synthetic fun measureWindowContent-KSHjdMI$default (Ldev/nucleusframework/window/tao/v2/WindowGeometryProviderScope;FFFFILjava/lang/Object;)J +} + +public final class dev/nucleusframework/window/tao/v2/WindowMetrics { + public static final field $stable I + public final fun getBounds ()Landroidx/compose/ui/unit/DpRect; + public final fun getInsets ()Landroidx/compose/ui/unit/DpInsets; + public final fun getScreen ()Ldev/nucleusframework/window/tao/v2/Screen; +} + +public abstract interface class dev/nucleusframework/window/tao/v2/WindowPositionProvider { + public static final field Companion Ldev/nucleusframework/window/tao/v2/WindowPositionProvider$Companion; + public abstract fun getPosition-jJlxhZY (Ldev/nucleusframework/window/tao/v2/WindowGeometryProviderScope;J)J +} + +public final class dev/nucleusframework/window/tao/v2/WindowPositionProvider$Companion { + public final fun Absolute-YgX7TsA (FF)Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public final fun Absolute-jo-Fl9I (J)Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public final fun AlignedToParentWindow-7WlHY6s (Landroidx/compose/ui/Alignment;Landroidx/compose/ui/Alignment;JZ)Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public static synthetic fun AlignedToParentWindow-7WlHY6s$default (Ldev/nucleusframework/window/tao/v2/WindowPositionProvider$Companion;Landroidx/compose/ui/Alignment;Landroidx/compose/ui/Alignment;JZILjava/lang/Object;)Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public final fun AlignedToScreen-BkVx2pU (Landroidx/compose/ui/Alignment;J)Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public static synthetic fun AlignedToScreen-BkVx2pU$default (Ldev/nucleusframework/window/tao/v2/WindowPositionProvider$Companion;Landroidx/compose/ui/Alignment;JILjava/lang/Object;)Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public final fun getCenteredInParentWindow ()Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public final fun getCenteredOnScreen ()Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public final fun getCurrent ()Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; + public final fun getDefault ()Ldev/nucleusframework/window/tao/v2/WindowPositionProvider; +} + +public final class dev/nucleusframework/window/tao/v2/WindowProvidersKt { + public static final fun WindowBoundsProvider (Ldev/nucleusframework/window/tao/v2/WindowSizeProvider;Ldev/nucleusframework/window/tao/v2/WindowPositionProvider;)Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider; + public static final fun WindowBoundsProvider (Lkotlin/jvm/functions/Function1;)Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider; + public static synthetic fun WindowBoundsProvider$default (Ldev/nucleusframework/window/tao/v2/WindowSizeProvider;Ldev/nucleusframework/window/tao/v2/WindowPositionProvider;ILjava/lang/Object;)Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider; +} + +public abstract interface class dev/nucleusframework/window/tao/v2/WindowScreenProvider { + public static final field Companion Ldev/nucleusframework/window/tao/v2/WindowScreenProvider$Companion; + public abstract fun getScreen (Ldev/nucleusframework/window/tao/v2/WindowScreenProviderScope;)Ldev/nucleusframework/window/tao/v2/Screen; +} + +public final class dev/nucleusframework/window/tao/v2/WindowScreenProvider$Companion { + public final fun ById (Ljava/lang/String;)Ldev/nucleusframework/window/tao/v2/WindowScreenProvider; + public final fun getDefault ()Ldev/nucleusframework/window/tao/v2/WindowScreenProvider; + public final fun getPrimary ()Ldev/nucleusframework/window/tao/v2/WindowScreenProvider; +} + +public final class dev/nucleusframework/window/tao/v2/WindowScreenProviderScope { + public static final field $stable I + public final fun getDefaultScreen ()Ldev/nucleusframework/window/tao/v2/Screen; + public final fun getPrimaryScreen ()Ldev/nucleusframework/window/tao/v2/Screen; + public final fun getScreens ()Ljava/util/List; +} + +public abstract interface class dev/nucleusframework/window/tao/v2/WindowSizeProvider { + public static final field Companion Ldev/nucleusframework/window/tao/v2/WindowSizeProvider$Companion; + public abstract fun getSize-Gh9hcWk (Ldev/nucleusframework/window/tao/v2/WindowGeometryProviderScope;)J +} + +public final class dev/nucleusframework/window/tao/v2/WindowSizeProvider$Companion { + public final fun Fixed-EaSLcWc (J)Ldev/nucleusframework/window/tao/v2/WindowSizeProvider; + public final fun Fixed-YgX7TsA (FF)Ldev/nucleusframework/window/tao/v2/WindowSizeProvider; + public final fun PreferredHeight-0680j_4 (F)Ldev/nucleusframework/window/tao/v2/WindowSizeProvider; + public final fun PreferredWidth-0680j_4 (F)Ldev/nucleusframework/window/tao/v2/WindowSizeProvider; + public final fun getCurrent ()Ldev/nucleusframework/window/tao/v2/WindowSizeProvider; + public final fun getDefault ()Ldev/nucleusframework/window/tao/v2/WindowSizeProvider; + public final fun getUnconstrained ()Ldev/nucleusframework/window/tao/v2/WindowSizeProvider; +} + +public final class dev/nucleusframework/window/tao/v2/WindowState { + public static final field $stable I + public static final field Companion Ldev/nucleusframework/window/tao/v2/WindowState$Companion; + public synthetic fun (ZLjava/lang/String;Landroidx/compose/ui/window/WindowPlacement;Ljava/lang/Boolean;Landroidx/compose/ui/unit/DpRect;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getBounds ()Landroidx/compose/ui/unit/DpRect; + public final fun getPlacement ()Landroidx/compose/ui/window/WindowPlacement; + public final fun getPosition-RKDOV3M ()J + public final fun getScreenId ()Ljava/lang/String; + public final fun getSize-MYxV2XQ ()J + public final fun isInitialized ()Z + public final fun isMinimized ()Z + public final fun requestBounds (Landroidx/compose/ui/unit/DpRect;)V + public final fun requestBounds (Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;)V + public final fun requestBounds (Lkotlin/jvm/functions/Function1;)V + public final fun requestMinimized (Z)V + public final fun requestPlacement (Landroidx/compose/ui/window/WindowPlacement;)V + public final fun requestPosition (Ldev/nucleusframework/window/tao/v2/WindowPositionProvider;)V + public final fun requestPosition-YgX7TsA (FF)V + public final fun requestPosition-jo-Fl9I (J)V + public final fun requestScreen (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;)V + public final fun requestSize (Ldev/nucleusframework/window/tao/v2/WindowSizeProvider;)V + public final fun requestSize-EaSLcWc (J)V + public final fun requestSize-YgX7TsA (FF)V +} + +public final class dev/nucleusframework/window/tao/v2/WindowState$Companion { + public final fun getSaver ()Landroidx/compose/runtime/saveable/Saver; +} + +public final class dev/nucleusframework/window/tao/v2/WindowStateKt { + public static final fun WindowState (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;Landroidx/compose/ui/window/WindowPlacement;Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;Z)Ldev/nucleusframework/window/tao/v2/WindowState; + public static synthetic fun WindowState$default (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;Landroidx/compose/ui/window/WindowPlacement;Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;ZILjava/lang/Object;)Ldev/nucleusframework/window/tao/v2/WindowState; + public static final fun WindowStateWithBounds-IeCDzbA (Landroidx/compose/ui/unit/DpOffset;Landroidx/compose/ui/unit/DpSize;Z)Ldev/nucleusframework/window/tao/v2/WindowState; + public static synthetic fun WindowStateWithBounds-IeCDzbA$default (Landroidx/compose/ui/unit/DpOffset;Landroidx/compose/ui/unit/DpSize;ZILjava/lang/Object;)Ldev/nucleusframework/window/tao/v2/WindowState; + public static final fun rememberWindowState (Ldev/nucleusframework/window/tao/v2/WindowScreenProvider;Landroidx/compose/ui/window/WindowPlacement;Ldev/nucleusframework/window/tao/v2/WindowBoundsProvider;ZLandroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/v2/WindowState; + public static final fun rememberWindowStateWithBounds-dpC4h7o (Landroidx/compose/ui/unit/DpOffset;Landroidx/compose/ui/unit/DpSize;ZLandroidx/compose/runtime/Composer;II)Ldev/nucleusframework/window/tao/v2/WindowState; +} + diff --git a/decorated-window-tao/build.gradle.kts b/decorated-window-tao/build.gradle.kts index a6add5433..bb9c011c0 100644 --- a/decorated-window-tao/build.gradle.kts +++ b/decorated-window-tao/build.gradle.kts @@ -1,3 +1,4 @@ +import dev.nucleusframework.gradle.NativeTarget import org.apache.tools.ant.taskdefs.condition.Os import org.jetbrains.kotlin.gradle.dsl.JvmTarget @@ -25,6 +26,12 @@ dependencies { // scene's PlatformContext implements `isKeepScreenOnEnabled`. Tao owns // that context and forwards it to EnergyManager. implementation(project(":energy-manager")) + // ANGLE's libEGL / libGLESv2, backing the Windows Direct3D-11 render path. + // A runtime resource, never linked against: the jar lays the DLLs out under + // nucleus/native/win32-{x64,aarch64}/, which is where NativeLibraryLoader + // resolves them from the classpath. Built by NucleusFramework/angle for + // D3D11 only -- see THIRD_PARTY_NOTICES.md. + implementation(libs.angle.natives) implementation(libs.compose.desktop.common) // Compose Hot Reload interop (TaoHotReloadBridge). compileOnly: these // artifacts are only referenced when running under the hot-reload agent, @@ -39,6 +46,8 @@ dependencies { testImplementation(kotlin("test")) // Skiko native runtime for the opt-in real-window smoke test testImplementation(compose.desktop.currentOs) + // The Material 3 AlertDialog the headful appearance film compares against nucleus-demo + testImplementation(libs.compose.material3) } java { @@ -49,6 +58,7 @@ java { kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) + optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") } } @@ -70,6 +80,32 @@ nucleusNative { macos("nucleus_tao", "Compiles the Rust JNI bridge into a macOS dylib (arm64 + x86_64)") windows("nucleus_tao", "Compiles the Rust JNI bridge + WGL/Deco helpers into Windows DLLs") linux("nucleus_tao", "Compiles the Rust JNI bridge + EGL helper into Linux .so libraries") + // ANGLE comes from `libs.angle.natives`; it must ship next to nucleus_tao.dll + dependencyLibraries(NativeTarget.WINDOWS, "libEGL.dll", "libGLESv2.dll") +} + +// Forwards a `-D` from the Gradle command line into a forked JVM, when set. +fun JavaExec.forwardSystemProperty(key: String) { + System.getProperty(key)?.let { systemProperty(key, it) } +} + +// Same, for test tasks, where the value is also a task input: without that a +// second run with a different seed is served the first run's verdict. +fun Test.forwardSystemProperty(key: String) { + System.getProperty(key)?.let { value -> + systemProperty(key, value) + inputs.property(key, value) + } +} + +// The watchdog concurrency monkey's knobs, forwarded into the test JVM — a +// Gradle `-D` does not reach it otherwise, so a seed sweep would silently run +// the defaults. Registered as task inputs too: a new seed must re-run the +// task instead of being served the previous verdict as UP-TO-DATE. +tasks.withType().configureEach { + forwardSystemProperty("nucleus.tao.watchdogMonkeySeed") + forwardSystemProperty("nucleus.tao.watchdogMonkeySeeds") + forwardSystemProperty("nucleus.tao.watchdogMonkeyProfile") } // ── macOS standalone-popup smoke check ────────────────────────────────────── @@ -96,15 +132,24 @@ tasks.named("jar") { } } -val taoTestClassesJar by tasks.registering(Jar::class) { - archiveClassifier.set("test-classes") - from(sourceSets.test.get().output) -} +val taoTestClassesJar = + tasks.register("taoTestClassesJar") { + archiveClassifier.set("test-classes") + from(sourceSets.test.get().output) + } -val taoTestArtifacts: Configuration by configurations.creating { - isCanBeConsumed = true - isCanBeResolved = false -} +// Consumers get the compiled test classes *and* what those classes need at run +// time. Without the `extendsFrom`, every dependency of the test source set has +// to be repeated in each consumer, and one that is not simply throws +// NoClassDefFoundError the first time the suite reaches the code that uses it — +// which is how `examples/tao-native-test` lost Material 3 and took the whole +// GraalVM job down with the Tao main thread. +val taoTestArtifacts: Configuration = + configurations.create("taoTestArtifacts") { + isCanBeConsumed = true + isCanBeResolved = false + extendsFrom(configurations.testImplementation.get()) + } artifacts { add(taoTestArtifacts.name, taoTestClassesJar) @@ -120,103 +165,130 @@ artifacts { val taoHeadfulKoverReport = layout.buildDirectory.file("kover/bin-reports/taoHeadful.ic") -val taoHeadfulTest by tasks.registering(JavaExec::class) { - description = "Runs the stage-2 real-window Tao test suite (requires a display)" - group = "verification" - classpath = sourceSets.test.get().runtimeClasspath - mainClass.set("dev.nucleusframework.window.tao.headful.TaoHeadfulTestSuiteMain") - // Unattended: a fatal must fail the suite loudly, not block in the #622 - // native dialog until the global watchdog halts and eats the real result. - systemProperty("nucleus.tao.fatalErrorDialog", "false") - // Arms the macOS scrollWheel: injector (nativeDiagInjectScrollWheel) the - // trackpad cases drive; it is inert in any process without this variable. - environment("NUCLEUS_TAO_INPUT_INJECTION", "1") - // Same Kover JVM agent the `test` task uses, so headful window coverage - // is counted. JavaExec is otherwise invisible to Kover. - dependsOn(tasks.named("koverFindJar")) - // Resolve these as RegularFileProperty at configuration time so the - // doFirst action does not capture the Gradle script `layout` object - // (configuration-cache incompatible). - val koverAgentJar = - layout.buildDirectory - .file(libs.versions.kover.map { "kover/kover-jvm-agent-$it.jar" }) - val koverArgsFile = - layout.buildDirectory - .file("tmp/taoHeadful/kover-agent.args") - val koverReportFile = taoHeadfulKoverReport - doFirst { - val agent = koverAgentJar.get().asFile - val report = koverReportFile.get().asFile - report.parentFile.mkdirs() - val argsFile = koverArgsFile.get().asFile - argsFile.parentFile.mkdirs() - argsFile.writeText( - buildString { - appendLine("report.file=${report.absolutePath}") - appendLine("exclude=android.*") - appendLine("exclude=com.android.*") - appendLine("exclude=jdk.internal.*") - }, - ) - jvmArgs("-javaagent:${agent.absolutePath}=file:${argsFile.absolutePath}") - } - // Forward the watchdog / case-name filter overrides into the forked JVM. - System.getProperty("nucleus.tao.headful.watchdogMillis")?.let { - systemProperty("nucleus.tao.headful.watchdogMillis", it) - } - System.getProperty("nucleus.tao.headful.filter")?.let { - systemProperty("nucleus.tao.headful.filter", it) - } - System.getProperty("nucleus.issue576.samples")?.let { - systemProperty("nucleus.issue576.samples", it) - } - // Honor a caller-forced Linux renderer (x11 / wayland) so portal parenting - // e2es can be launched against XWayland from a native Wayland session. - providers.environmentVariable("NUCLEUS_TAO_LINUX_RENDERER").orNull?.let { - environment("NUCLEUS_TAO_LINUX_RENDERER", it) - } - providers.environmentVariable("GDK_BACKEND").orNull?.let { - environment("GDK_BACKEND", it) +val taoHeadfulTest = + tasks.register("taoHeadfulTest") { + description = "Runs the stage-2 real-window Tao test suite (requires a display)" + group = "verification" + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("dev.nucleusframework.window.tao.headful.TaoHeadfulTestSuiteMain") + // Unattended: a fatal must fail the suite loudly, not block in the #622 + // native dialog until the global watchdog halts and eats the real result. + systemProperty("nucleus.tao.fatalErrorDialog", "false") + // Arms the macOS scrollWheel: injector (nativeDiagInjectScrollWheel) the + // trackpad cases drive; it is inert in any process without this variable. + environment("NUCLEUS_TAO_INPUT_INJECTION", "1") + // Same Kover JVM agent the `test` task uses, so headful window coverage + // is counted. JavaExec is otherwise invisible to Kover. + dependsOn(tasks.named("koverFindJar")) + // Resolve these as RegularFileProperty at configuration time so the + // doFirst action does not capture the Gradle script `layout` object + // (configuration-cache incompatible). + val koverAgentJar = + layout.buildDirectory + .file(libs.versions.kover.map { "kover/kover-jvm-agent-$it.jar" }) + val koverArgsFile = + layout.buildDirectory + .file("tmp/taoHeadful/kover-agent.args") + val koverReportFile = taoHeadfulKoverReport + doFirst { + val agent = koverAgentJar.get().asFile + val report = koverReportFile.get().asFile + report.parentFile.mkdirs() + val argsFile = koverArgsFile.get().asFile + argsFile.parentFile.mkdirs() + argsFile.writeText( + buildString { + appendLine("report.file=${report.absolutePath}") + appendLine("exclude=android.*") + appendLine("exclude=com.android.*") + appendLine("exclude=jdk.internal.*") + }, + ) + jvmArgs("-javaagent:${agent.absolutePath}=file:${argsFile.absolutePath}") + } + // Forward the watchdog / case-name filter overrides into the forked JVM. + System.getProperty("nucleus.tao.headful.watchdogMillis")?.let { + systemProperty("nucleus.tao.headful.watchdogMillis", it) + } + System.getProperty("nucleus.tao.headful.filter")?.let { + systemProperty("nucleus.tao.headful.filter", it) + } + // Replays a red monkey run: the case prints the seed it used. + System.getProperty("nucleus.tao.headful.monkeySeed")?.let { + systemProperty("nucleus.tao.headful.monkeySeed", it) + } + // Length of the overnight gesture monkey (MacOsTrackpadGestureMonkeyHeadfulCases), minutes. + System.getProperty("nucleus.tao.headful.monkeyNightMinutes")?.let { + systemProperty("nucleus.tao.headful.monkeyNightMinutes", it) + } + // Replays a journal instead of a random walk (comma-separated action names). + System.getProperty("nucleus.tao.headful.monkeyScript")?.let { + systemProperty("nucleus.tao.headful.monkeyScript", it) + } + System.getProperties().stringPropertyNames().filter { it.startsWith("nucleus.dialog.appearance.") }.forEach { + systemProperty(it, System.getProperty(it)) + } + System.getProperty("nucleus.issue576.samples")?.let { + systemProperty("nucleus.issue576.samples", it) + } + // Honor a caller-forced Linux renderer (x11 / wayland) so portal parenting + // e2es can be launched against XWayland from a native Wayland session. + providers.environmentVariable("NUCLEUS_TAO_LINUX_RENDERER").orNull?.let { + environment("NUCLEUS_TAO_LINUX_RENDERER", it) + } + // Lets the suite run against a nested compositor + // (`mutter --headless --virtual-monitor …`, `kwin_wayland`) instead of the + // session that happens to own the screen. A Wayland window the compositor + // considers occluded gets no frame callbacks, so its swap never completes + // and every render pass is skipped — cases then measure nothing while + // still looking like they ran. + providers.environmentVariable("WAYLAND_DISPLAY").orNull?.let { + environment("WAYLAND_DISPLAY", it) + } + providers.environmentVariable("GDK_BACKEND").orNull?.let { + environment("GDK_BACKEND", it) + } + // NO -XstartOnFirstThread here: taoApplication marshals to the AppKit main + // thread itself (main_thread_dispatch.m), exactly like a normal `java` + // launch — and the flag would deadlock the AWT classes the Compose host + // touches. smokeStandalonePanelMac needs it only because it creates an + // NSPanel directly, without the Tao loop machinery. } - // NO -XstartOnFirstThread here: taoApplication marshals to the AppKit main - // thread itself (main_thread_dispatch.m), exactly like a normal `java` - // launch — and the flag would deadlock the AWT classes the Compose host - // touches. smokeStandalonePanelMac needs it only because it creates an - // NSPanel directly, without the Tao loop machinery. -} // X11 / XWayland portal parenting e2e: forces GDK onto X11 so Tao windows get // a real XID, then parents a session xdg-desktop-portal FileChooser with // `x11:`. Safe to run on a Wayland host (XWayland). Not part of `check`. -val taoX11PortalE2E by tasks.registering(JavaExec::class) { - description = "E2E: X11 XID parents a real XDG portal FileChooser (forces XWayland)" - group = "verification" - onlyIf { Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC) } - classpath = sourceSets.test.get().runtimeClasspath - mainClass.set("dev.nucleusframework.window.tao.headful.TaoHeadfulTestSuiteMain") - systemProperty("nucleus.tao.headful.filter", "x11 XID") - // Unattended — see taoHeadfulTest. - systemProperty("nucleus.tao.fatalErrorDialog", "false") - System.getProperty("nucleus.tao.headful.watchdogMillis")?.let { - systemProperty("nucleus.tao.headful.watchdogMillis", it) +val taoX11PortalE2E = + tasks.register("taoX11PortalE2E") { + description = "E2E: X11 XID parents a real XDG portal FileChooser (forces XWayland)" + group = "verification" + onlyIf { Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC) } + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("dev.nucleusframework.window.tao.headful.TaoHeadfulTestSuiteMain") + systemProperty("nucleus.tao.headful.filter", "x11 XID") + // Unattended — see taoHeadfulTest. + systemProperty("nucleus.tao.fatalErrorDialog", "false") + System.getProperty("nucleus.tao.headful.watchdogMillis")?.let { + systemProperty("nucleus.tao.headful.watchdogMillis", it) + } + environment("NUCLEUS_TAO_LINUX_RENDERER", "x11") } - environment("NUCLEUS_TAO_LINUX_RENDERER", "x11") -} -val smokeStandalonePanelMac by tasks.registering(JavaExec::class) { - description = "Smoke-checks the macOS standalone-popup native chain (ownerless NSPanel + Metal)" - group = "verification" - onlyIf { Os.isFamily(Os.FAMILY_MAC) } - classpath = sourceSets.test.get().runtimeClasspath - mainClass.set("dev.nucleusframework.window.tao.StandalonePanelMacSmokeMain") - // Unattended — see taoHeadfulTest. - systemProperty("nucleus.tao.fatalErrorDialog", "false") - // Run main() on thread 0 (the macOS main thread). The JVM normally runs - // main() on a spawned pthread, but AppKit only permits NSWindow/NSPanel - // creation on the true main thread. -XstartOnFirstThread is the same flag - // LWJGL/GLFW use on macOS. - jvmArgs("-XstartOnFirstThread") -} +val smokeStandalonePanelMac = + tasks.register("smokeStandalonePanelMac") { + description = "Smoke-checks the macOS standalone-popup native chain (ownerless NSPanel + Metal)" + group = "verification" + onlyIf { Os.isFamily(Os.FAMILY_MAC) } + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("dev.nucleusframework.window.tao.StandalonePanelMacSmokeMain") + // Unattended — see taoHeadfulTest. + systemProperty("nucleus.tao.fatalErrorDialog", "false") + // Run main() on thread 0 (the macOS main thread). The JVM normally runs + // main() on a spawned pthread, but AppKit only permits NSWindow/NSPanel + // creation on the true main thread. -XstartOnFirstThread is the same flag + // LWJGL/GLFW use on macOS. + jvmArgs("-XstartOnFirstThread") + } // Manual smoke for #416: transparent DecoratedWindow + opaque marker over desktop. // Captures under build/reports/tao-transparent-smoke and pixel-checks that the @@ -225,73 +297,102 @@ val smokeStandalonePanelMac by tasks.registering(JavaExec::class) { // macOS/X11: AWT Robot. Windows: Robot omits layered windows — point // `-Dnucleus.tao.transparent.smoke.captureTool=` at a CAPTUREBLT helper // (build/tmp-smoke/capture_region.exe). -val taoTransparentSmoke by tasks.registering(JavaExec::class) { - description = "Manual smoke: DecoratedWindow(transparent=true) over the desktop (#416)" - group = "verification" - classpath = sourceSets.test.get().runtimeClasspath - mainClass.set("dev.nucleusframework.window.tao.headful.TransparentWindowSmokeMain") - // Unattended — see taoHeadfulTest. - systemProperty("nucleus.tao.fatalErrorDialog", "false") - // Linux: pin the window to XWayland. Robot goes through the X server, so on - // a native Wayland session it cannot see the Tao surface (both captures come - // back byte-identical) and xdg-shell drops setOuterPosition, leaving the - // capture rect pointing at wherever the compositor did *not* put the window. - // Under XWayland both work. Overridable — the smoke then refuses to emit a - // pixel verdict on Wayland (see TransparentWindowSmokeMain). - if (Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC)) { - environment( - "NUCLEUS_TAO_LINUX_RENDERER", - providers.environmentVariable("NUCLEUS_TAO_LINUX_RENDERER").getOrElse("x11"), - ) - } - val outDir = - layout.buildDirectory - .dir("reports/tao-transparent-smoke") - .get() - .asFile - systemProperty("nucleus.tao.transparent.smoke.outdir", outDir.absolutePath) - if (Os.isFamily(Os.FAMILY_WINDOWS)) { - val captureTool = +val taoTransparentSmoke = + tasks.register("taoTransparentSmoke") { + description = "Manual smoke: DecoratedWindow(transparent=true) over the desktop (#416)" + group = "verification" + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("dev.nucleusframework.window.tao.headful.TransparentWindowSmokeMain") + // Unattended — see taoHeadfulTest. + systemProperty("nucleus.tao.fatalErrorDialog", "false") + // Linux: pin the window to XWayland. Robot goes through the X server, so on + // a native Wayland session it cannot see the Tao surface (both captures come + // back byte-identical) and xdg-shell drops setOuterPosition, leaving the + // capture rect pointing at wherever the compositor did *not* put the window. + // Under XWayland both work. Overridable — the smoke then refuses to emit a + // pixel verdict on Wayland (see TransparentWindowSmokeMain). + if (Os.isFamily(Os.FAMILY_UNIX) && !Os.isFamily(Os.FAMILY_MAC)) { + environment( + "NUCLEUS_TAO_LINUX_RENDERER", + providers.environmentVariable("NUCLEUS_TAO_LINUX_RENDERER").getOrElse("x11"), + ) + } + val outDir = layout.buildDirectory - .file("tmp-smoke/capture_region.exe") + .dir("reports/tao-transparent-smoke") .get() .asFile - systemProperty("nucleus.tao.transparent.smoke.captureTool", captureTool.absolutePath) - doFirst { - if (!captureTool.isFile) { - error( - "CAPTUREBLT helper missing at ${captureTool.absolutePath}. " + - "Build it once with cl against capture_region.c " + - "(see TransparentWindowSmokeMain).", - ) + systemProperty("nucleus.tao.transparent.smoke.outdir", outDir.absolutePath) + if (Os.isFamily(Os.FAMILY_WINDOWS)) { + val captureTool = + layout.buildDirectory + .file("tmp-smoke/capture_region.exe") + .get() + .asFile + systemProperty("nucleus.tao.transparent.smoke.captureTool", captureTool.absolutePath) + doFirst { + if (!captureTool.isFile) { + error( + "CAPTUREBLT helper missing at ${captureTool.absolutePath}. " + + "Build it once with cl against capture_region.c " + + "(see TransparentWindowSmokeMain).", + ) + } } } + // Forward hold duration so a manual look is possible, e.g. + // -Dnucleus.tao.transparent.smoke.holdMs=10000 + System.getProperty("nucleus.tao.transparent.smoke.holdMs")?.let { + systemProperty("nucleus.tao.transparent.smoke.holdMs", it) + } } - // Forward hold duration so a manual look is possible, e.g. - // -Dnucleus.tao.transparent.smoke.holdMs=10000 - System.getProperty("nucleus.tao.transparent.smoke.holdMs")?.let { - systemProperty("nucleus.tao.transparent.smoke.holdMs", it) - } -} // Manual smoke for #622: fatal-exception path end to end — SEVERE log, native // error dialog, exit code 1. The expected outcome is Gradle failing with // "finished with non-zero exit value 1" after the dialog is dismissed. // Not part of `check`. -val taoFatalDialogSmoke by tasks.registering(JavaExec::class) { - description = "Manual smoke: fatal-error path — native dialog then exit code 1 (#622)" +val taoFatalDialogSmoke = + tasks.register("taoFatalDialogSmoke") { + description = "Manual smoke: fatal-error path — native dialog then exit code 1 (#622)" + group = "verification" + classpath = sourceSets.test.get().runtimeClasspath + mainClass.set("dev.nucleusframework.window.tao.headful.FatalErrorDialogSmokeMain") + // Forward the crash delay so the window can be looked at first, e.g. + // -Dnucleus.tao.fatal.smoke.crashAfterMs=10000 + System.getProperty("nucleus.tao.fatal.smoke.crashAfterMs")?.let { + systemProperty("nucleus.tao.fatal.smoke.crashAfterMs", it) + } + // Forward the #622 escape hatch so the smoke can also exercise the + // dialog-less unattended path: -Dnucleus.tao.fatalErrorDialog=false + System.getProperty("nucleus.tao.fatalErrorDialog")?.let { + systemProperty("nucleus.tao.fatalErrorDialog", it) + } + } + +// Smoke for #643: freezes the event loop for real and prints a one-line +// verdict ("severe=1 unresponsive=1 responsive=1"), so every watchdog switch +// can be checked from outside the process — and so the native +// "Application Not Responding" dialog can be looked at. Not part of `check`. +tasks.register("taoWatchdogSmoke") { + description = "Smoke: event-loop watchdog — thread dump, app events, not-responding dialog (#643)" group = "verification" classpath = sourceSets.test.get().runtimeClasspath - mainClass.set("dev.nucleusframework.window.tao.headful.FatalErrorDialogSmokeMain") - // Forward the crash delay so the window can be looked at first, e.g. - // -Dnucleus.tao.fatal.smoke.crashAfterMs=10000 - System.getProperty("nucleus.tao.fatal.smoke.crashAfterMs")?.let { - systemProperty("nucleus.tao.fatal.smoke.crashAfterMs", it) - } - // Forward the #622 escape hatch so the smoke can also exercise the - // dialog-less unattended path: -Dnucleus.tao.fatalErrorDialog=false - System.getProperty("nucleus.tao.fatalErrorDialog")?.let { - systemProperty("nucleus.tao.fatalErrorDialog", it) + mainClass.set("dev.nucleusframework.window.tao.headful.WatchdogDialogSmokeMain") + // Timings and watchdog switches, e.g. + // -Dnucleus.tao.watchdog.smoke.freezeMs=40000 -Dnucleus.tao.watchdogDialog=true + forwardSystemProperty("nucleus.tao.watchdog.smoke.freezeMs") + forwardSystemProperty("nucleus.tao.watchdog.smoke.freezeAfterMs") + forwardSystemProperty("nucleus.tao.watchdog.smoke.drainMs") + forwardSystemProperty("nucleus.tao.watchdog.smoke.holdMs") + forwardSystemProperty("nucleus.tao.watchdog.smoke.expected") + forwardSystemProperty("nucleus.tao.watchdog") + forwardSystemProperty("nucleus.tao.watchdogGraceMs") + forwardSystemProperty("nucleus.tao.watchdogDialog") + forwardSystemProperty("nucleus.tao.fatalErrorDialog") + // Verifies the debug-session exemption end to end: a real JDWP agent on + // the command line, which is what the watchdog looks for. + if (System.getProperty("nucleus.tao.watchdog.smoke.debugAgent").toBoolean()) { + jvmArgs("-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:0") } } diff --git a/decorated-window-tao/src/main/java/androidx/compose/ui/scene/TaoComposeSceneContextAccess.java b/decorated-window-tao/src/main/java/androidx/compose/ui/scene/TaoComposeSceneContextAccess.java new file mode 100644 index 000000000..071933455 --- /dev/null +++ b/decorated-window-tao/src/main/java/androidx/compose/ui/scene/TaoComposeSceneContextAccess.java @@ -0,0 +1,31 @@ +package androidx.compose.ui.scene; + +import androidx.compose.runtime.ProvidableCompositionLocal; + +/** + * Friend-package accessor for Compose's {@code LocalComposeSceneContext}, the + * composition local {@code Popup} / {@code Dialog} read to decide which + * {@link ComposeSceneContext} creates their layer. It is declared + * {@code internal} in the Kotlin module {@code compose-ui} and therefore + * unreachable from another Kotlin module — but Java does not honour Kotlin's + * {@code internal} visibility, and the getter of a top-level property is not + * name-mangled, so a Java file in the same package can call it directly. + * + *

No reflection: this is a static call that compiles cleanly under GraalVM + * native-image with zero reachability metadata. + */ +public final class TaoComposeSceneContextAccess { + private TaoComposeSceneContextAccess() { + } + + /** + * Returns Compose's {@code LocalComposeSceneContext}. + * + * @return the composition local a scene provides for its own + * {@link ComposeSceneContext}; its current value may be + * {@code null} outside any scene + */ + public static ProvidableCompositionLocal localComposeSceneContext() { + return ComposeSceneContext_skikoKt.getLocalComposeSceneContext(); + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt index 3cd55c416..b11131725 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/DialogTitleBar.kt @@ -47,7 +47,7 @@ private val isKdeDlg: Boolean = /** * Tao-backed close-only title bar for [DecoratedDialog]. Mirrors - * `decorated-window-jni`'s `DialogTitleBar`: same signature and the same + * the legacy AWT backend's `DialogTitleBar`: same signature and the same * styling pipeline, with min/max stripped (dialogs render only the close * button on platforms that need a Compose-drawn chrome). */ diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt index ab437dd30..c5d218393 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/TitleBar.kt @@ -46,6 +46,7 @@ import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.deco.LocalFullscreenTitleBarHolder import dev.nucleusframework.window.tao.deco.WindowControlsLinux import dev.nucleusframework.window.tao.deco.WindowControlsWindows +import dev.nucleusframework.window.tao.event.TaoTrackpadRotationContacts import dev.nucleusframework.window.tao.ffi.NativeMetalBridge import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge @@ -67,7 +68,7 @@ private const val SCREEN_POINT_COMPONENT_COUNT = 2 /** * Platform-aware title bar for the Tao-backed [DecoratedWindow]. * - * Signature mirrors `decorated-window-jbr` / `decorated-window-jni` so an app + * Signature mirrors the legacy AWT backend so an app * can swap backends without touching call sites: * - [gradientStartColor] enables the optional centered horizontal gradient. * - [style] resolves all metrics + colors via [LocalTitleBarStyle]; the default @@ -80,7 +81,7 @@ private const val SCREEN_POINT_COMPONENT_COUNT = 2 * - `windowDragHandler` consumes title-bar press events and dispatches them to * `TaoWindow.dragWindow()`, with double-press → toggle-maximize. * - macOS native traffic-light area is reserved via [PaddingValues] (78 dp on - * each side), matching `decorated-window-jni`'s JBR-driven inset path. + * each side), matching the legacy AWT backend's JBR-driven inset path. * - KDE breeze 4 dp edge padding applied on the controls side. * - Linux + Windows control buttons are injected here (no native chrome). */ @@ -105,6 +106,21 @@ public fun DecoratedWindowScope.TitleBar( ) } +/** + * [TitleBar] with the measure policy left open, for chrome that needs a + * different arrangement than the platform default — a strip that fills the + * space between the platform controls, for instance + * ([TitleBarLayoutPolicy.FillCenter]). + * + * @param nativeWindowDrag whether pressing the bar starts the platform's own + * interactive move. On by default, which is what gives the window the OS + * snapping and tiling. Turn it off for a window that moves *itself* during + * the gesture: the platform move is a compositor grab that swallows every + * pointer event up to and including the release, so a window moved that way + * cannot decide anything when it lands. The caller then supplies its own + * drag through [modifier], which covers the whole bar rather than only the + * part its content happens to occupy. + */ @Suppress("FunctionNaming", "LongParameterList", "LongMethod", "CyclomaticComplexMethod") @Composable public fun DecoratedWindowScope.BasicTitleBar( @@ -113,6 +129,7 @@ public fun DecoratedWindowScope.BasicTitleBar( style: TitleBarStyle = LocalTitleBarStyle.current, controlButtonsDirection: ControlButtonsDirection = ControlButtonsDirection.Auto, layoutPolicy: TitleBarLayoutPolicy = TitleBarLayoutPolicy.Default, + nativeWindowDrag: Boolean = true, backgroundContent: @Composable () -> Unit = {}, content: @Composable TitleBarScope.(DecoratedWindowState) -> Unit = {}, ) { @@ -144,7 +161,7 @@ public fun DecoratedWindowScope.BasicTitleBar( } // ── newFullscreenControls (macOS) ───────────────────────────────────── - // Mirrors `decorated-window-jni/TitleBar.MacOS.kt`. In native fullscreen + // Mirrors the legacy AWT backend's macOS title bar. In native fullscreen // on a non-notch screen the system menu bar auto-hides; when it slides // back in we offset the title bar (and the AppKit traffic-light // replacements) by the menu bar height so they read like Safari. @@ -260,7 +277,7 @@ public fun DecoratedWindowScope.BasicTitleBar( val controlsPlacementDir = controlDir // macOS: flip the AppKit traffic-lights to the right edge when RTL is - // active. Mirrors `decorated-window-jni`'s `nativeSetRTL` call path. + // active. Mirrors the legacy AWT backend's `nativeSetRTL` call path. if (Platform.Current == Platform.MacOS) { LaunchedEffect(taoWindow, controlIsRtl) { val nsView = NativeTaoBridge.nativeNsViewHandle(taoWindow.handle) @@ -281,7 +298,12 @@ public fun DecoratedWindowScope.BasicTitleBar( // Bind drag to [taoWindow] explicitly (not only LocalTaoWindow) so // secondary windows stay movable when parent CompositionLocals are // bridged into this scene and would otherwise clobber LocalTaoWindow. - .windowDragArea(window = taoWindow) + // + // Opted out of by a window that moves itself, which is the only way + // a move can decide anything on release: `windowDragArea` hands the + // gesture to the compositor, and the compositor then swallows every + // pointer event including the release. See [nativeWindowDrag]. + .let { if (nativeWindowDrag) it.windowDragArea(window = taoWindow) else it } val overlayHolder = LocalFullscreenTitleBarHolder.current val useOverlay = @@ -316,7 +338,7 @@ public fun DecoratedWindowScope.BasicTitleBar( onPlace = { // macOS fullscreen: keep the AppKit replacement traffic-lights // pinned to whatever Y the Compose title bar is currently at. - // Mirrors `decorated-window-jni`'s `nativeUpdateFullScreenButtons`. + // Mirrors the legacy AWT backend's `nativeUpdateFullScreenButtons`. if (isMacOS && currentState.isFullscreen && NativeMetalBridge.isLoaded) { val nsView = NativeTaoBridge.nativeNsViewHandle(taoWindow.handle) if (nsView != 0L) { @@ -329,7 +351,7 @@ public fun DecoratedWindowScope.BasicTitleBar( // Window controls are declared BEFORE user content so core's // [TitleBarMeasurePolicy] places them at the extreme edge first // (first-declared End item = rightmost in LTR; first-declared - // Start item = leftmost). Mirrors `decorated-window-jni`'s + // Start item = leftmost). Mirrors the legacy AWT backend's // TitleBar.{Linux,Windows}.kt where WindowControlArea is invoked // ahead of `content()`. when (Platform.Current) { @@ -339,6 +361,8 @@ public fun DecoratedWindowScope.BasicTitleBar( win = taoWindow, state = titleBarState, isResizable = taoWindow.isResizable, + isMinimizable = taoWindow.isMinimizable, + isMaximizable = taoWindow.isMaximizable, style = style, layout = linuxLayout, isFullscreen = titleBarState.isFullscreen, @@ -414,7 +438,7 @@ public fun DecoratedWindowScope.BasicTitleBar( /** * Platform-specific reservation insets returned to [GenericTitleBarImpl]'s - * `applyTitleBar` callback. Mirrors `decorated-window-jni`'s `MacOSTitleBar` + * `applyTitleBar` callback. Mirrors the legacy AWT backend's `MacOSTitleBar` * exactly: * - macOS in fullscreen: 80 dp on the controls edge. * - macOS otherwise: Apple's traffic-light formula @@ -464,7 +488,7 @@ private fun macTrafficLightInset(height: Dp): Dp { // ── Drag ────────────────────────────────────────────────────────────────── -// Mirrors `decorated-window-jni/TitleBar.MacOS.kt::titleBarHitTestHandler`. +// Mirrors the legacy AWT backend's `titleBarHitTestHandler`. // Press → mark pendingDrag (no consumption). Move while pending → start the // native window drag. Consumed Press → enter `inUserControl` and skip drag. // @@ -506,7 +530,11 @@ private suspend fun PointerInputScope.titleBarDragPointerLoop(window: TaoWindow) while (ctx.isActive) { val event = awaitPointerEvent(PointerEventPass.Final) event.changes.forEach { - val isTouch = it.type == PointerType.Touch + // The trackpad rotation's synthetic contacts (#660) are Touch + // pointers but no finger on the window: they must never start + // a window move (on macOS the drag would replay the last real + // mouseDown AppKit saw). + val isTouch = it.type == PointerType.Touch && !TaoTrackpadRotationContacts.isContact(it.id) if (!it.isConsumed && !inUserControl) { when (event.type) { PointerEventType.Press -> { diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowControls.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowControls.kt index a46c456be..b159d999d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowControls.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowControls.kt @@ -84,7 +84,9 @@ public fun interface WindowControlsRenderer { * * Nucleus owns the semantics: [direction] decides the button order (and, on * Linux, the desktop's own button layout does), the maximize slot follows the - * live maximized / fullscreen / [TaoWindow.isResizable] state, and close is + * live maximized / fullscreen / [TaoWindow.isResizable] / + * [TaoWindow.isMaximizable] state, the minimize slot follows + * [TaoWindow.isMinimizable], and close is * routed through the app's `onCloseRequest`. Supply a [renderer] to draw the * buttons in the design system's own style; the default reproduces the host * platform's look exactly. @@ -216,7 +218,9 @@ private fun windowControlActions( * `WindowControlsWindows` has always used: fullscreen swaps maximize for * exit-fullscreen, and the maximize slot disappears entirely on a * non-resizable window (`isResizable` is snapshot-backed, so a runtime - * `setResizable()` recomposes — see #260). + * `setResizable()` recomposes — see #260) and on a non-maximizable one + * (`isMaximizable`, the same snapshot shape). The minimize slot does the same + * on a non-minimizable window (#504). */ internal fun resolveWindowControl( slot: WindowControlSlot, @@ -227,18 +231,24 @@ internal fun resolveWindowControl( ): WindowControlAction? = when (slot) { WindowControlSlot.Minimize -> - WindowControlAction(WindowControlType.Minimize) { window.minimize() } + if (window.isMinimizable) { + WindowControlAction(WindowControlType.Minimize) { window.minimize() } + } else { + null + } WindowControlSlot.Maximize -> when { isFullscreen && onExitFullscreen != null -> WindowControlAction(WindowControlType.ExitFullscreen, onExitFullscreen) - !window.isResizable -> null - + // Restore comes first: a window the WM maximized anyway (Linux has + // no client-side maximizable hint) must still be able to leave. state.isMaximized -> WindowControlAction(WindowControlType.Restore) { window.setMaximized(false) } + !window.isResizable || !window.isMaximizable -> null + else -> WindowControlAction(WindowControlType.Maximize) { window.setMaximized(true) } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowDragArea.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowDragArea.kt index 8fa5fb8ef..262226ff7 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowDragArea.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowDragArea.kt @@ -87,7 +87,7 @@ public fun Modifier.windowDragArea( val now = System.currentTimeMillis() if (now - lastPress in viewConfig.doubleTapMinTimeMillis..viewConfig.doubleTapTimeoutMillis && - (window.isMaximized || window.isResizable) + (window.isMaximized || (window.isResizable && window.isMaximizable)) ) { window.setMaximized(!window.isMaximized) // Cancel any in-flight touch drag armed with the diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowGlassRegion.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowGlassRegion.kt index 2cc512ee4..6efe25dde 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowGlassRegion.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/WindowGlassRegion.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.geometry.Rect import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -21,6 +20,7 @@ import dev.nucleusframework.window.tao.LocalTaoWindow import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.ffi.NativeMetalBridge import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import dev.nucleusframework.window.tao.onPositionChanged /** * Kind of system pane rendered by [windowGlassRegion] — mapping directly to @@ -131,7 +131,7 @@ public fun Modifier.windowGlassRegion( // Pushed straight from layout rather than from an effect: the material // has to land in the same frame as the Compose bounds, or it visibly // trails the panel during a live resize. - Modifier.onGloballyPositioned { coordinates -> + Modifier.onPositionChanged { coordinates -> val rect = coordinates.boundsInWindow() bounds = rect if (rect != pushedBounds) push(rect) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ApplicationScope.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ApplicationScope.kt index 2ace042e2..37a31c452 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ApplicationScope.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ApplicationScope.kt @@ -7,7 +7,17 @@ import androidx.compose.runtime.setValue /** * Scope exposed by [taoApplication]. Mirrors `androidx.compose.ui.window.ApplicationScope` * so call sites can stay nearly identical between the AWT-based backends - * (`decorated-window-jni`, `decorated-window-jbr`) and the Tao backend. + * (removed in 2.6) and the Tao backend. + * + * On macOS, Cmd+Q, Dock → Quit and logout / restart / shutdown request a close + * from every open window, newest first — the same `onCloseRequest` the close + * button runs, so it can confirm or cancel. The app exits once every window + * closed; one that stays open cancels the quit. Windows the framework owns + * (workspace satellites, tab windows) are left as they are. While a quit is + * in progress [TaoApplication.isQuitting] is `true`, so a hide-to-tray + * `onCloseRequest` can let it through. [exitApplication] called from such a + * close request is that window's consent: the app still exits only once no + * other window refused. */ public interface ApplicationScope { /** Posts an exit request to the Tao event loop, unblocking [taoApplication]. */ @@ -23,6 +33,7 @@ internal class ComposableApplicationScope( var isOpen by mutableStateOf(true) override fun exitApplication() { + if (taoApplication.consentToQuit()) return isOpen = false } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedDialog.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedDialog.kt index 93a305f9a..e7ce7d904 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedDialog.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedDialog.kt @@ -14,8 +14,10 @@ import androidx.compose.runtime.CompositionLocalContext import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment import androidx.compose.ui.UiComposable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent @@ -33,7 +35,7 @@ import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsDecoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge /** - * Tao-backed equivalent of `decorated-window-jni`'s `DecoratedDialog`. + * Tao-backed equivalent of the legacy AWT backend's `DecoratedDialog`. * * Same parameter set and rendering pipeline as the AWT-based backends: * non-resizable by default, close-only chrome via [DialogTitleBar]. @@ -44,7 +46,7 @@ import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge * `gtk_window_set_transient_for` on Linux/GTK. The dialog sits above its * owner in z-order, follows it across minimisation / Spaces / workspace * switches, stays out of the taskbar, and disappears with it. The parent is **not** disabled - * — that matches `decorated-window-jni` (its `JDialog` is not + * — that matches the legacy AWT backend (its `JDialog` is not * `APPLICATION_MODAL`) and avoids losing the parent's keyboard focus across * the dialog lifetime. The parent is captured from [LocalTaoWindow] at the * call site, so a `DecoratedDialog` declared outside any [DecoratedWindow] @@ -96,25 +98,7 @@ public fun ApplicationScope.DecoratedDialog( // avoid the flash, so we don't pre-compute a position here. val autoCenterRequested = state.position !is WindowPosition.Absolute val sizeSpecified = state.size.width.isSpecified && state.size.height.isSpecified - val initialPosition = - remember(parent) { - val explicit = state.position - if (explicit is WindowPosition.Absolute) return@remember explicit - // Wrap-content dialogs (#532) don't know their height yet — a - // centre computed against Dp.Unspecified is wrong. The size - // bridge below recentres once the measured size is specified. - if (!sizeSpecified) return@remember explicit - val centered = - when (Platform.Current) { - Platform.Windows -> - centerOnParentWindows(parent, state.size.width.value, state.size.height.value) - Platform.Linux -> - centerOnParentLinux(parent, state.size.width.value, state.size.height.value) - else -> null - } ?: return@remember explicit - state.position = centered - centered - } + val initialPosition = remember(parent) { initialDialogPosition(state, parent, sizeSpecified) } // DialogState only carries size + position; reuse the WindowState plumbing // of DecoratedWindow underneath and forward changes both ways. @@ -140,6 +124,10 @@ public fun ApplicationScope.DecoratedDialog( minimumSize = null, visible = visible, resizable = resizable, + // The dialog chrome is close-only ([DialogTitleBar]); keep the native + // macOS traffic-lights in step (#504). + minimizable = false, + maximizable = false, enabled = enabled, focusable = focusable, alwaysOnTop = false, @@ -163,12 +151,14 @@ public fun ApplicationScope.DecoratedDialog( // is already resolvable via [TaoWindow.nativeHandle]. // Do not wait for wrap-content size: on Wayland a hidden dialog // without an owner never receives a configure, so setContent - // never runs and wrap-content deadlocks (#532). + // never runs and wrap-content deadlocks (#532). macOS centres the + // creation-size frame on the owner here; the size bridge below + // moves it again once measured (#546). DisposableEffect(windowScope.window, parent) { - applyDialogOwnerRelationship( - dialog = windowScope.window, - parent = parent, - autoCenter = autoCenterRequested && sizeSpecified, + applyWindowOwnerRelationship( + child = windowScope.window, + owner = parent, + autoCenter = autoCenterRequested, ) onDispose { /* native handle destruction restores focus to owner */ } } @@ -178,11 +168,13 @@ public fun ApplicationScope.DecoratedDialog( ) // Bidirectional bridge between DialogState and the WindowState plumbed - // into the underlying DecoratedWindow. After wrap-content resolves, - // position is still not Absolute — centre on the parent once. + // into the underlying DecoratedWindow. A wrap-content dialog centres on + // the parent once its measured size lands (#546) — one-shot, since + // `windowState.size` also follows every user resize. + val recenterPending = remember { mutableStateOf(autoCenterRequested && !sizeSpecified) } LaunchedEffect(windowState.size) { if (state.size != windowState.size) state.size = windowState.size - recenterAfterWrapContent(autoCenterRequested, parent, windowState, state) + if (recenterPending.value) recenterPending.value = !recenterOnParent(parent, windowState, state) } LaunchedEffect(windowState.position) { val p = windowState.position @@ -200,77 +192,172 @@ public fun ApplicationScope.DecoratedDialog( } /** - * Wires the native owner relationship between [dialog] and [parent]. - * - * Mirrors `decorated-window-jni`'s `DecoratedDialog`, which uses Compose - * Desktop's `DialogWindow` → AWT `JDialog`: the JDialog is created with the - * parent as owner but **not** `APPLICATION_MODAL`, so the parent stays - * interactive. + * The position handed to the underlying window at creation: the explicit + * [WindowPosition.Absolute], else the parent's centre (Windows / Linux — + * macOS centres natively, see [applyWindowOwnerRelationship]). * - * On Win32 we never call `EnableWindow(parent, false)`: disabling the parent - * strips its keyboard focus and Win32 won't restore it cleanly when the - * dialog closes (`SetForegroundWindow` gets rejected once we lose the - * foreground role), leaving the user having to click the parent to revive it. - * On macOS `addChildWindow:ordered:` gives us the right behaviour (parent - * stays usable, child stays above) but it also makes the child visible at - * its current frame — we therefore pass [autoCenter] through so the native - * side can pre-position the child on the owner's centre atomically right - * before `addChildWindow:` makes it appear, avoiding a one-frame flash at - * Tao's default origin. - * - * No-op when the relevant bridge or the parent is unavailable. + * Wrap-content dialogs (#532) don't know their height yet — a centre computed + * against `Dp.Unspecified` is wrong. With a parent, [recenterOnParent] centres + * once the measured size lands, so the window gets `PlatformDefault` rather + * than an alignment it would resolve against the *screen* (#546). Without one + * the screen is the reference, as AWT's `setLocationRelativeTo(null)`: the + * window's own wrap-content path resolves the alignment at the real size. */ -private fun recenterAfterWrapContent( - autoCenterRequested: Boolean, - parent: TaoWindow?, - windowState: WindowState, +private fun initialDialogPosition( state: DialogState, -) { - if (!autoCenterRequested || state.position is WindowPosition.Absolute) return - if (!windowState.size.width.isSpecified || !windowState.size.height.isSpecified) return + parent: TaoWindow?, + sizeSpecified: Boolean, +): WindowPosition { + val explicit = state.position + if (explicit is WindowPosition.Absolute) return explicit + if (!sizeSpecified) { + return when { + parent != null -> WindowPosition.PlatformDefault + explicit is WindowPosition.Aligned -> explicit + else -> WindowPosition.Aligned(Alignment.Center) + } + } val centered = when (Platform.Current) { - Platform.Windows -> - centerOnParentWindows(parent, windowState.size.width.value, windowState.size.height.value) - Platform.Linux -> - centerOnParentLinux(parent, windowState.size.width.value, windowState.size.height.value) + Platform.Windows -> centerOnParentWindows(parent, state.size.width.value, state.size.height.value) + Platform.Linux -> centerOnParentFromBounds(parent, state.size.width.value, state.size.height.value) else -> null - } ?: return - windowState.position = centered + } ?: return explicit state.position = centered + return centered } -private fun applyDialogOwnerRelationship( - dialog: TaoWindow, +/** + * Centres a wrap-content dialog on [parent] once [windowState] carries its + * measured size (#546). `true` once done — the caller retries until then. + */ +private fun recenterOnParent( + parent: TaoWindow?, + windowState: WindowState, + state: DialogState, +): Boolean { + val size = windowState.size + if (!size.width.isSpecified || !size.height.isSpecified) return false + val centered = centerOnParent(parent, size.width.value, size.height.value) + if (centered != null) { + windowState.position = centered + state.position = centered + } + return true +} + +/** + * [WindowPosition.Absolute] centring a [dialogWidthDp] × [dialogHeightDp] + * window on [parent], or `null` without a realised parent. + */ +private fun centerOnParent( parent: TaoWindow?, + dialogWidthDp: Float, + dialogHeightDp: Float, +): WindowPosition.Absolute? = + when (Platform.Current) { + Platform.Windows -> centerOnParentWindows(parent, dialogWidthDp, dialogHeightDp) + Platform.Linux, Platform.MacOS -> centerOnParentFromBounds(parent, dialogWidthDp, dialogHeightDp) + else -> null + } + +/** + * Wires the native owner relationship between [child] and [owner]. + * + * Shared by [DecoratedDialog] and [SatelliteWindow]: both want the same + * secondary-window semantics — the child sits above its owner in z-order, + * follows it across minimisation / Spaces / workspace switches, stays out of + * the taskbar, and disappears with it — while the owner stays interactive. + * + * For dialogs this mirrors the legacy AWT backend, which uses Compose + * Desktop's `DialogWindow` → AWT `JDialog`: the JDialog is created with the + * parent as owner but **not** `APPLICATION_MODAL`. + * + * On Win32 we never call `EnableWindow(owner, false)`: disabling the owner + * strips its keyboard focus and Win32 won't restore it cleanly when the + * child closes (`SetForegroundWindow` gets rejected once we lose the + * foreground role), leaving the user having to click the owner to revive it. + * On macOS `addChildWindow:ordered:` gives us the right behaviour (owner + * stays usable, child stays above) but it also makes the child visible at + * its current frame — we therefore pass [autoCenter] through so the native + * side can pre-position the child on the owner's centre atomically right + * before `addChildWindow:` makes it appear, avoiding a one-frame flash at + * Tao's default origin. Satellites resolve their own anchored position + * instead and pass `false`. + * + * Re-invoking with a different [owner] reparents the child (AppKit tears the + * previous `addChildWindow:` down itself, Win32 and GTK overwrite the owner), + * without moving it. + * + * No-op when the relevant bridge or the owner is unavailable. + */ +internal fun applyWindowOwnerRelationship( + child: TaoWindow, + owner: TaoWindow?, autoCenter: Boolean, + /** + * Whether the platform may take [child] down together with [owner] — the + * JDialog behaviour a dialog wants. A satellite passes `false`: it outlives + * the window it is anchored to, since the workspace hands it to another + * one when that window closes. + */ + destroyWithOwner: Boolean = true, ) { - if (parent == null) return + if (owner == null) return when (Platform.Current) { Platform.Windows -> { if (!NativeTaoWindowsDecoBridge.isLoaded) return - val dialogHwnd = dialog.nativeHandle - val parentHwnd = parent.nativeHandle - if (dialogHwnd == 0L || parentHwnd == 0L) return - NativeTaoWindowsDecoBridge.nativeSetOwner(dialogHwnd, parentHwnd) + val childHwnd = child.nativeHandle + val ownerHwnd = owner.nativeHandle + if (childHwnd == 0L || ownerHwnd == 0L) return + NativeTaoWindowsDecoBridge.nativeSetOwner(childHwnd, ownerHwnd) } Platform.MacOS -> { if (!NativeTaoMacOsDecoBridge.isLoaded) return - val dialogView = dialog.nativeHandle - val parentView = parent.nativeHandle - if (dialogView == 0L || parentView == 0L) return - NativeTaoMacOsDecoBridge.nativeSetOwner(dialogView, parentView, autoCenter) + val childView = child.nativeHandle + val ownerView = owner.nativeHandle + if (childView == 0L || ownerView == 0L) return + NativeTaoMacOsDecoBridge.nativeSetOwner(childView, ownerView, autoCenter) } Platform.Linux -> { // GTK route: `gtk_window_set_transient_for` covers z-order / // minimisation / focus return; `skip_taskbar_hint` and // `destroy_with_parent` round out the JDialog semantics. The - // actual centring is already done synchronously on the JVM side - // (see [centerOnParentLinux]) before the dialog window is shown, + // actual positioning is already done synchronously on the JVM side + // (see [centerOnParentFromBounds]) before the child window is shown, // so we don't need a native pre-position step like macOS. - NativeTaoBridge.nativeLinuxSetDialogOwner(dialog.handle, parent.handle) + NativeTaoBridge.nativeLinuxSetDialogOwner(child.handle, owner.handle, destroyWithOwner) + } + else -> Unit + } +} + +/** + * Severs the native owner link of [child] — the inverse of + * [applyWindowOwnerRelationship] — leaving it a plain top-level window. + * + * Used by [SatelliteWindow] right before its owner is destroyed: Win32 + * destroys owned windows together with their owner and GTK does the same for + * `destroy_with_parent` transients, which would take down a satellite the app + * is reparenting in that very frame. AppKit only orphans child windows, so + * there this merely keeps the three platforms on one code path. + */ +internal fun clearWindowOwnerRelationship(child: TaoWindow) { + when (Platform.Current) { + Platform.Windows -> { + if (!NativeTaoWindowsDecoBridge.isLoaded) return + val childHwnd = child.nativeHandle + if (childHwnd == 0L) return + NativeTaoWindowsDecoBridge.nativeSetOwner(childHwnd, 0L) + } + Platform.MacOS -> { + if (!NativeTaoMacOsDecoBridge.isLoaded) return + val childView = child.nativeHandle + if (childView == 0L) return + NativeTaoMacOsDecoBridge.nativeSetOwner(childView, 0L, false) } + Platform.Linux -> NativeTaoBridge.nativeLinuxSetDialogOwner(child.handle, 0L, false) else -> Unit } } @@ -284,7 +371,8 @@ private fun applyDialogOwnerRelationship( * macOS goes through [applyDialogOwnerRelationship]'s native centring path * instead, because `addChildWindow:` makes the child visible synchronously * — pre-computing the position on the JVM side leaves a window of time in - * which AppKit can paint at the wrong origin. + * which AppKit can paint at the wrong origin. Its post-measure re-centre + * (#546) is [centerOnParentFromBounds]. */ private fun centerOnParentWindows( parent: TaoWindow?, @@ -319,8 +407,8 @@ private fun centerOnParentWindows( } /** - * Linux counterpart of [centerOnParentWindows]. Pulls the parent's outer rect - * via the GTK-backed `nativeLinuxGetWindowRect` and converts physical → logical + * Linux and macOS counterpart of [centerOnParentWindows]. Pulls the parent's + * outer rect via [TaoWindow.outerBoundsPx] and converts physical → logical * pixels using the parent's own scale factor. Returns `null` when the parent * isn't realised yet, in which case Tao keeps its default origin. * @@ -330,13 +418,13 @@ private fun centerOnParentWindows( * through the standard `WindowState` pipeline so the LE position effect fires * with the centred coords *before* the window is shown. */ -private fun centerOnParentLinux( +private fun centerOnParentFromBounds( parent: TaoWindow?, dialogWidthDp: Float, dialogHeightDp: Float, ): WindowPosition.Absolute? { if (parent == null) return null - val parentRectPhys = NativeTaoBridge.nativeLinuxGetWindowRect(parent.handle) ?: return null + val parentRectPhys = parent.outerBoundsPx() ?: return null val scaleMilli = NativeTaoBridge.nativeScaleFactor(parent.handle).coerceAtLeast(1) val scale = scaleMilli / 1000.0 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt index 5bbc94e2b..4b54b957e 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindow.kt @@ -210,7 +210,7 @@ public val LocalTaoWindow: ProvidableCompositionLocal = staticCompos private val ModalScrimColor = Color(0x66000000) /** - * Tao-backed equivalent of `decorated-window-jni`'s `DecoratedWindow`. + * Tao-backed equivalent of the legacy AWT backend's `DecoratedWindow`. * Imperative-on-the-outside, Composable-on-the-inside: opens a single Tao * window, mounts the user [content] inside its dedicated `ComposeScene`, and * returns the [TaoWindow] handle for further imperative control. @@ -219,7 +219,7 @@ private val ModalScrimColor = Color(0x66000000) * AWT-based backends so an app can swap modules with minimal call-site change. * `enabled = false` swallows pointer + keyboard events at the host level so * the window appears unresponsive (no native disabled-state visual — matches - * `decorated-window-jni`'s behavior). `focusable = false` calls + * the legacy AWT backend's behavior). `focusable = false` calls * `tao::Window::set_focusable(false)`, which prevents the window from ever * becoming key (useful for HUD/overlay windows). */ @@ -307,7 +307,7 @@ internal fun ApplicationScope.openDecoratedWindow( // On macOS we keep native decorations (traffic-light buttons live there). // On Windows + Linux we drop them — we draw the close/min/max buttons // ourselves via [WindowControlsWindows] / [WindowControlsLinux] inside - // the user's [TitleBar] composable, mirroring decorated-window-jni. + // the user's [TitleBar] composable, mirroring the legacy AWT backend. // `undecorated` opts out entirely (borderless, no traffic lights). // Linux still gets the native GTK drop shadow through // `undecoratedShadow` below (yaru.dart-style hidden-titlebar CSD). @@ -420,8 +420,8 @@ internal fun ApplicationScope.openDecoratedWindow( // Trackpad pinch / rotate / smart-magnify, intercepted before AppKit // dispatches them down the responder chain (Tao 0.35 doesn't surface - // these events). Synthesised as two-finger Touch pointers in the host - // so cross-platform `detectTransformGestures` reacts uniformly. + // these events). Pinch is forwarded as Compose Scale events (#660); + // rotation still synthesises two-finger Touch pointers. window.onTrackpadGesture { kind, phase, x, y, value -> exceptionHandler.catchExceptions { if (enabled) host.onTrackpadGesture(kind, phase, x, y, value) @@ -499,6 +499,10 @@ internal fun ApplicationScope.openDecoratedWindow( fullyTransparent = transparent, ) } + // For NativePopupLayers { }: null when every popup is native already. + // Remembered so the static local keeps one value per window. + val nativePopupLayerFactory = + remember { if (host.nativePopupLayers) null else host.nativePopupLayerFactory() } CompositionLocalProvider( LocalTitleBarInfo provides TitleBarInfo(title, icon), LocalTaoWindow provides window, @@ -509,6 +513,7 @@ internal fun ApplicationScope.openDecoratedWindow( dev.nucleusframework.window.tao.scene.LocalTaoMetalTextureHost provides host.metalTextureHost(), LocalTaoNativeViewHost provides host.nativeViewHost(), + LocalTaoNativePopupLayerFactory provides nativePopupLayerFactory, LocalTaoCompositionLocalContextBridge provides host::setSceneCompositionLocalContext, ) { // Re-centre the native AppKit traffic-lights whenever the @@ -726,6 +731,10 @@ private fun ApplicationScope.openDecoratedWindowLinux( fullyTransparent = transparent, ) } + // For NativePopupLayers { }: null when every popup is native already. + // Remembered so the static local keeps one value per window. + val nativePopupLayerFactory = + remember { if (host.nativePopupLayers) null else host.nativePopupLayerFactory() } CompositionLocalProvider( LocalTitleBarInfo provides TitleBarInfo(title, icon), LocalTaoWindow provides window, @@ -733,6 +742,7 @@ private fun ApplicationScope.openDecoratedWindowLinux( LocalWindowClearColorLayers provides clearColorLayers, LocalFullscreenTitleBarHolder provides fullscreenHolder, LocalTaoNativeViewHost provides host.nativeViewHost(), + LocalTaoNativePopupLayerFactory provides nativePopupLayerFactory, LocalTaoCompositionLocalContextBridge provides host::setSceneCompositionLocalContext, // Read as state: a Wayland hide/show rebuilds the EGL + Skia // context pair, and TextureView imports must follow it. @@ -1095,9 +1105,8 @@ private fun ApplicationScope.openDecoratedWindowWindows( // Trackpad pinch-to-zoom. Windows delivers a precision-touchpad pinch (and // a real Ctrl+wheel) as a Ctrl-flagged WM_MOUSEWHEEL; the Tao patch routes - // those to the magnify hook instead of a scroll, and the host synthesises a - // two-finger Touch pinch so cross-platform `detectTransformGestures` zooms - // uniformly — same model as macOS. + // those to the magnify hook instead of a scroll, and the host forwards + // Compose Scale events (#660) — same model as macOS. window.onTrackpadGesture { kind, phase, x, y, value -> exceptionHandler.catchExceptions { if (enabled) host.onTrackpadGesture(kind, phase, x, y, value) @@ -1160,6 +1169,10 @@ private fun ApplicationScope.openDecoratedWindowWindows( fullyTransparent = transparent, ) } + // For NativePopupLayers { }: null when every popup is native already. + // Remembered so the static local keeps one value per window. + val nativePopupLayerFactory = + remember { if (host.nativePopupLayers) null else host.nativePopupLayerFactory() } CompositionLocalProvider( LocalTitleBarInfo provides TitleBarInfo(title, icon), LocalTaoWindow provides window, @@ -1169,6 +1182,7 @@ private fun ApplicationScope.openDecoratedWindowWindows( LocalBackdropComposeTint provides host.backdropTintArgbState, LocalFullscreenTitleBarHolder provides fullscreenHolder, LocalTaoNativeViewHost provides host.nativeViewHost(), + LocalTaoNativePopupLayerFactory provides nativePopupLayerFactory, LocalTaoCompositionLocalContextBridge provides host::setSceneCompositionLocalContext, dev.nucleusframework.window.tao.popup.LocalTaoPopupHostWindows provides host.popupHost(), diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindowComposable.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindowComposable.kt index 52d57694f..ba5145880 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindowComposable.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindowComposable.kt @@ -43,7 +43,7 @@ import kotlin.math.roundToInt /** * Composable variant of [openDecoratedWindow]. API mirrors - * `decorated-window-jni`'s `DecoratedWindow`. + * the legacy AWT backend's `DecoratedWindow`. * * Reactive parameters (`title`, `alwaysOnTop`, `visible`, `focusable`, * `minimumSize`, `icon`, every field of [state]) push to the underlying @@ -55,7 +55,7 @@ import kotlin.math.roundToInt * natively, [state] is updated. The `applied` snapshot guards against * feedback loops so we don't write back values we ourselves originated. * - * Limitations vs. `decorated-window-jni`: + * Known limitations: * - `enabled` only applies at construction (no live disabling yet). * - User `content` lambda captures latest via `rememberUpdatedState`; state * declared in the parent application scope and read inside `content` @@ -73,6 +73,8 @@ public fun ApplicationScope.DecoratedWindow( minimumSize: DpSize? = null, visible: Boolean = true, resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, enabled: Boolean = true, focusable: Boolean = true, alwaysOnTop: Boolean = false, @@ -232,6 +234,9 @@ public fun ApplicationScope.DecoratedWindow( var isMinimized: Boolean? = null var wrapSettled: Boolean = !wrapWidth && !wrapHeight + /** The `Aligned` request a wrap-content window resolves once its real size is known (#546). */ + val initialAligned: WindowPosition.Aligned? = state.position as? WindowPosition.Aligned + /** Physical px of the last programmatic [TaoWindow.setInnerSize]; null = user/OS resize. */ var pendingProgrammaticPx: IntSize? = null } @@ -410,6 +415,20 @@ public fun ApplicationScope.DecoratedWindow( window.setResizable(resizable) } } + // `minimizable` is post-creation only (no builder flag): same re-apply + // shape as `resizable` above (#504). + LaunchedEffect(window, minimizable) { + if (window.isMinimizable != minimizable) { + window.setMinimizable(minimizable) + } + } + // `maximizable` likewise: the caption button / zoom button / Win+Up go + // with it on Windows and macOS, the Compose chrome everywhere. + LaunchedEffect(window, maximizable) { + if (window.isMaximizable != maximizable) { + window.setMaximizable(maximizable) + } + } LaunchedEffect(window, measuredContent.value) { if (applied.wrapSettled) return@LaunchedEffect val measured = measuredContent.value ?: return@LaunchedEffect @@ -435,6 +454,16 @@ public fun ApplicationScope.DecoratedWindow( applied.size = resolved latestState.size = resolved applied.wrapSettled = true + // Let the resize land: the scene below fills the new size, and on + // macOS the Aligned centring reads the live NSWindow frame. + repeat(ALIGNED_POSITION_RETRIES) { if (applied.pendingProgrammaticPx != null) delay(ALIGNED_POSITION_RETRY_MS) } + // The scene now fills the window it was measured for (#546: the + // TitleBar's fillMaxWidth collapsed under the wrap modifiers). + window.resolvedSizePolicy().settled.value = true + // #546: the position effect skipped `Aligned` while the size was the + // creation fallback; resolve it now. + val aligned = applied.initialAligned ?: return@LaunchedEffect + alignWithRetries(window, aligned, resolved) } LaunchedEffect(window, state.size, state.placement) { // Maximized / Fullscreen windows derive their size from the @@ -477,10 +506,29 @@ public fun ApplicationScope.DecoratedWindow( // outer origin so the ghost tracks the cursor instead of // landing up/left by the decoration inset + outer offset. val (xDp, yDp) = absolutePositionForPopup(window, pos) + // Asked for before the window is shown, so the platform can map + // it where it belongs: GTK and Win32 both carry a move issued + // ahead of the map into the initial placement. Without this the + // window is mapped wherever the WM felt like and only then + // moved — a satellite visibly flashes at the screen's default + // spot before snapping beside its parent. window.setOuterPosition(xDp, yDp) + // X11: the WM applies its own placement at map time regardless, + // and a move issued before the map has been seen to race it + // (under Xvfb/openbox the window intermittently stayed at GTK's + // unallocated 1×1). Re-apply once the frame is real — that both + // overrides the WM and repairs an early move that was lost. + if (Platform.Current == Platform.Linux) { + awaitMappedOnX11(window) + window.setOuterPosition(xDp, yDp) + } applied.position = pos } is WindowPosition.Aligned -> { + // Wrap-content (#546): the size is still the creation fallback; + // the wrap-content effect above resolves `initialAligned` once + // the real one is known. + if (!applied.wrapSettled) return@LaunchedEffect // Use max(state.size, minimumSize) so the centring math matches // the size the window will actually occupy on screen — Tao // grows the window to honour `minimumSize` asynchronously, and @@ -494,14 +542,7 @@ public fun ApplicationScope.DecoratedWindow( // native-image start is not, and a single failed attempt left // the window wherever the WM had centred it, for good, since // this effect only re-runs when `state.position` changes. - var landed = applyAlignedPosition(window, pos, effectiveSize) - var attempt = 0 - while (!landed && attempt < ALIGNED_POSITION_RETRIES) { - delay(ALIGNED_POSITION_RETRY_MS) - attempt++ - landed = applyAlignedPosition(window, pos, effectiveSize) - } - if (landed) { + if (alignWithRetries(window, pos, effectiveSize)) { applied.position = pos } } @@ -629,6 +670,19 @@ private const val ALIGNED_POSITION_RETRY_MS = 16L /** Native px slop when matching a programmatic setInnerSize echo (#576). */ private const val PROGRAMMATIC_SIZE_ECHO_PX = 1 +/** [applyAlignedPosition], retried while the native window is still being created (see [ALIGNED_POSITION_RETRIES]). */ +private suspend fun alignWithRetries( + window: TaoWindow, + position: WindowPosition.Aligned, + size: DpSize, +): Boolean { + repeat(ALIGNED_POSITION_RETRIES) { + if (applyAlignedPosition(window, position, size)) return true + delay(ALIGNED_POSITION_RETRY_MS) + } + return applyAlignedPosition(window, position, size) +} + /** * Resolves a [WindowPosition.Aligned] against the primary monitor's work area * and pushes the resulting outer position to [window]. Returns `true` when the @@ -839,3 +893,22 @@ private fun actualWindowSizeDp( if (w <= 0 || h <= 0) return null return w to h } + +/** + * Suspends until [window] reports real outer bounds (both axes past GTK's 1px + * unallocated placeholder). Gives up after [X11_MAP_WAIT_RETRIES] polls — the + * move is then issued regardless, which is the previous behaviour. + */ +private suspend fun awaitMappedOnX11(window: TaoWindow) { + repeat(X11_MAP_WAIT_RETRIES) { + val b = window.outerBoundsPx() + if (b != null && b.size == RECT_ARRAY_LENGTH && b[2] > 1L && b[3] > 1L) return + delay(X11_MAP_WAIT_RETRY_MS) + } +} + +private const val RECT_ARRAY_LENGTH = 4 + +/** ~1.5 s: a slow Xvfb maps well within this; a real session in a few polls. */ +private const val X11_MAP_WAIT_RETRIES = 60 +private const val X11_MAP_WAIT_RETRY_MS = 25L diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindowNucleusV2.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindowNucleusV2.kt new file mode 100644 index 000000000..4a21a4812 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DecoratedWindowNucleusV2.kt @@ -0,0 +1,210 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.isSpecified +import dev.nucleusframework.window.tao.DecoratedDialog as DecoratedDialogV1 +import dev.nucleusframework.window.tao.DecoratedWindow as DecoratedWindowV1 +import dev.nucleusframework.window.tao.v2.DialogState as NucleusDialogState +import dev.nucleusframework.window.tao.v2.WindowState as NucleusWindowState + +/** + * [DecoratedWindow] overload for the AWT-free window API v2 clone + * ([dev.nucleusframework.window.tao.v2.WindowState]). + * + * The whole v2 surface is applied — `requestBounds`, `requestSize`, + * `requestPosition`, `requestScreen` — and `bounds` / `screenId` / `placement` + * / `isMinimized` are published back from the native window. Compose's own + * `androidx.compose.ui.window.v2.WindowState` is deliberately not accepted: + * its geometry scope needs a displayable `java.awt.Window`, so half of it + * would be inert here. See + * [dev.nucleusframework.window.tao.v2.rememberWindowState] for the one-import + * migration. + * + * @param minSize Minimum inner size. [DpSize.Unspecified] means no minimum. + * @param maxSize Maximum inner size. [DpSize.Unspecified] means no maximum. + */ +@Suppress("LongParameterList", "FunctionNaming") +@Composable +public fun ApplicationScope.DecoratedWindow( + onCloseRequest: () -> Unit, + state: NucleusWindowState, + title: String = "", + icon: Painter? = null, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + visible: Boolean = true, + resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + isDialog: Boolean = false, + undecorated: Boolean = false, + transparent: Boolean = false, + popupFor: TaoWindow? = null, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + nativePopupLayers: Boolean = false, + macOSStyle: MacOSStyle = MacOSStyle.Classic, + hiddenFromDock: Boolean = false, + compositionLocalContext: CompositionLocalContext? = null, + clickThrough: Boolean = false, + visibleOnAllWorkspaces: Boolean = false, + forceX11: Boolean = false, + alwaysOnBottom: Boolean = false, + content: @Composable TaoDecoratedWindowScope.() -> Unit, +) { + val v1 = remember(state) { nucleusWindowStateToV1(state) } + val nativeWindow = remember(state) { mutableStateOf(null) } + DecoratedWindowV1( + onCloseRequest = onCloseRequest, + state = v1, + title = title, + icon = icon, + minimumSize = minSizeOrNull(minSize), + visible = visible, + resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + isDialog = isDialog, + undecorated = undecorated, + transparent = transparent, + popupFor = popupFor, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + nativePopupLayers = nativePopupLayers, + macOSStyle = macOSStyle, + hiddenFromDock = hiddenFromDock, + compositionLocalContext = compositionLocalContext, + clickThrough = clickThrough, + visibleOnAllWorkspaces = visibleOnAllWorkspaces, + forceX11 = forceX11, + alwaysOnBottom = alwaysOnBottom, + content = { + ApplyMaxSizeNucleus(maxSize) + CaptureNativeWindowNucleus(nativeWindow) + content() + }, + ) + BindNucleusWindowState(state, v1, visible, nativeWindow.value) +} + +/** + * [DecoratedDialog] overload for the AWT-free dialog API v2 clone + * ([dev.nucleusframework.window.tao.v2.DialogState]). + * + * @param minSize Minimum inner size. [DpSize.Unspecified] means no minimum. + * @param maxSize Maximum inner size. [DpSize.Unspecified] means no maximum. + */ +@Suppress("LongParameterList", "FunctionNaming") +@Composable +public fun ApplicationScope.DecoratedDialog( + onCloseRequest: () -> Unit, + state: NucleusDialogState, + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = false, + enabled: Boolean = true, + focusable: Boolean = true, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + compositionLocalContext: CompositionLocalContext? = null, + content: @Composable TaoDecoratedDialogScope.() -> Unit, +) { + val v1 = remember(state) { nucleusDialogStateToV1(state) } + val nativeWindow = remember(state) { mutableStateOf(null) } + // Same capture DecoratedDialog itself uses for the native owner relationship; + // here it feeds `parentWindowMetrics` for AlignedToParentWindow. + val parentWindow = LocalTaoWindow.current + // Clamping is a side effect, not composition output: writing v1.size during + // composition schedules a recomposition on every native resize past maxSize. + LaunchedEffect(v1, v1.size, minSize, maxSize) { + val clamped = clampSize(v1.size, minSize, maxSize) + if (clamped != v1.size) { + v1.size = clamped + } + } + DecoratedDialogV1( + onCloseRequest = onCloseRequest, + state = v1, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + enabled = enabled, + focusable = focusable, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + compositionLocalContext = compositionLocalContext, + content = { + ApplySizeConstraintsNucleus(minSize, maxSize) + CaptureNativeDialogWindowNucleus(nativeWindow) + content() + }, + ) + BindNucleusDialogState(state, v1, visible, minSize, maxSize, nativeWindow.value, parentWindow) +} + +/** Publishes the scope's [TaoWindow] so the bridge can read real geometry. */ +@Composable +private fun TaoDecoratedWindowScope.CaptureNativeWindowNucleus(holder: MutableState) { + val window = this.window + LaunchedEffect(window) { holder.value = window } +} + +@Composable +private fun TaoDecoratedDialogScope.CaptureNativeDialogWindowNucleus(holder: MutableState) { + val window = this.window + LaunchedEffect(window) { holder.value = window } +} + +@Composable +private fun TaoDecoratedWindowScope.ApplyMaxSizeNucleus(maxSize: DpSize) { + val window = this.window + LaunchedEffect(window, maxSize) { + if (maxSize.width.isSpecified && maxSize.height.isSpecified) { + window.setMaximumSize(maxSize.width.value.toDouble(), maxSize.height.value.toDouble()) + } else { + window.setMaximumSize(null, null) + } + } +} + +@Composable +private fun TaoDecoratedDialogScope.ApplySizeConstraintsNucleus( + minSize: DpSize, + maxSize: DpSize, +) { + val window = this.window + LaunchedEffect(window, minSize, maxSize) { + val min = minSizeOrNull(minSize) + if (min != null) { + window.setMinimumSize(min.width.value.toDouble(), min.height.value.toDouble()) + } else { + window.setMinimumSize(null, null) + } + if (maxSize.width.isSpecified && maxSize.height.isSpecified) { + window.setMaximumSize(maxSize.width.value.toDouble(), maxSize.height.value.toDouble()) + } else { + window.setMaximumSize(null, null) + } + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockLayout.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockLayout.kt new file mode 100644 index 000000000..a7193a490 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockLayout.kt @@ -0,0 +1,810 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.workspace.RelocatedContentHost +import dev.nucleusframework.window.tao.workspace.publishHostGeometry +import dev.nucleusframework.window.tao.workspace.rememberHostGeometry + +/** + * Lays [content] out with the satellites docked into this window around it. + * + * Panels attach to the four edges of the layout ([DockSide]). The sides nest + * in [sideOrder], outermost first: the first side runs the full length of the + * layout and owns its corners, the next one runs the length that is left, and + * so on down to [content]. The default ([DefaultDockSideOrder] — top, bottom, + * left, right) is the classic border layout; a reader that wants its navigation on the right at + * full height and its commentary strip under the text *and* the left panel + * says `listOf(Right, Bottom, Left, Top)`. + * + * The panels on one side share it in one of two ways: + * + * - **Split** (the default): they divide the side's length in proportion to + * their [SatellitePlacement.Docked.weight], one above the other on a + * vertical side, side by side on a horizontal one, and share the side's + * thickness, [SatelliteWorkspace.dockExtent]. A splitter between the side + * and the content drags that thickness; a divider between two panels moves + * their weights. + * - **Layered** ([layeredSides]): each panel is a full-length layer of its + * own [SatellitePlacement.Docked.extent], laid from the edge towards the + * content — three panels docked on a layered right side are three columns + * next to each other, each with its own splitter and width. This is the + * arrangement of a nested split-pane tree, without the tree. + * + * With nothing docked — or while the workspace is not + * [SatelliteWorkspace.visible] — the layout is just [content]. When the window + * is too small for what the extents ask, the panels along that axis are drawn + * proportionally smaller so the content keeps a minimum and nothing overflows; + * the extents themselves are kept and come back with the room. + * + * Sides are physical: the layout lays itself out left-to-right whatever the + * `LayoutDirection` in force, so [DockSide.Left] is the left edge of the + * screen in a right-to-left app too. The direction is restored for the + * content, the panels and the slots, which see the one the layout was + * composed in. + * + * Compose it inside a window that joined the workspace, typically as the body + * of a `WindowScaffold`. The window it is composed in ([host], resolved from + * [LocalTaoWindow]) is what [SatelliteEntry.dockHost] refers to. + * + * The layout is also the drop target for satellite drags + * ([Modifier.satelliteDragHandle]): a strip of [SatelliteWorkspace.DockZoneWidth] + * inside each edge lights up while a dragged satellite hovers it, and a panel + * dragged out of its dock is previewed under the pointer until released. The + * preview of the drop is the same card, drawn on the very space the release + * fills ([DockLayoutState.dropRectPx]). Over a side that already has panels, + * the pointer's place along the stack picks the rank the drop takes — the card + * is then the share it gets between the two panels it lands between — so the + * panels of a side are reordered by dragging one over the others; the rank it + * holds is no target. A panel docked again without a drag + * (`SatelliteScope.dock()`, [SatelliteWorkspace.dock] with no order) comes + * back to the rank it left. + * + * Each panel is the satellite's `header` above its `content`, composed here + * in the host window's scene under the satellite's own saveable-state + * registry — see [Satellite]. A panel keeps its composition — its `remember`s + * included — through every change of this layout: a splitter drag, a + * reorder, a move to another side, a [SatelliteWorkspace.restore], a new + * [sideOrder]. Only leaving the host (undocking, docking elsewhere, closing) + * disposes it. The same holds for [content]. + * + * @param sideOrder the four sides from the outermost in; every side exactly once. + * @param layeredSides the sides whose panels are layers rather than a split. + * @param splitter the drag handle drawn between a side and the content, and + * between two panels; [DefaultDockSplitter] is a plain bar in the window + * style's border colour. Apply [DockSplitterScope.dockSplitterHandle] to + * whatever the user is meant to grab. + * @param panel composed around each docked panel — its header over its + * content, handed in as the lambda's argument — to give it a frame, a card, + * a padding. Must invoke the lambda it is given. + */ +@Suppress("LongParameterList") +@Composable +@ExperimentalNucleusApi +public fun DockLayout( + workspace: SatelliteWorkspace, + modifier: Modifier = Modifier, + host: TaoWindow? = LocalTaoWindow.current, + sideOrder: List = DefaultDockSideOrder, + layeredSides: Set = emptySet(), + splitter: @Composable DockSplitterScope.() -> Unit = { DefaultDockSplitter() }, + panel: @Composable SatelliteScope.(panel: @Composable () -> Unit) -> Unit = { it() }, + content: @Composable () -> Unit, +) { + require(sideOrder.size == DockSide.entries.size && sideOrder.toSet().size == DockSide.entries.size) { + "sideOrder must name each of the four sides exactly once, was $sideOrder" + } + val containerSize = LocalWindowInfo.current.containerSize + // Published so drags can be hit-tested against this layout on screen and + // undocked windows placed over their panel. + val geometry = rememberHostGeometry(workspace.dockHosts, host) + val docked = + if (host == null || !workspace.visible) { + emptyList() + } else { + workspace.satellites.filter { entry -> + entry.isOpen && entry.content != null && entry.dockHost === host && entry.isDocked + } + } + val direction = LocalLayoutDirection.current + val state = remember(workspace) { DockLayoutState(workspace) } + state.docked = docked + state.layeredSides = layeredSides + state.containerSize = containerSize + state.direction = direction + state.splitter = splitter + state.panel = panel + + // The content and every panel are movable, so a change of the layout's + // shape — a side that gains its first panel, a panel that changes side, a + // new side order — moves their subtrees instead of rebuilding them. + val latestContent by rememberUpdatedState(content) + val movableContent = + remember { + movableContentOf { + CompositionLocalProvider(LocalLayoutDirection provides state.direction) { latestContent() } + } + } + state.pruneMovables(docked) + + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Ltr) { + Box( + modifier + .publishHostGeometry(geometry, containerSize, direction) + .dockTransferTarget(workspace, host, geometry) + .onSizeChanged { state.layoutSize = it } + .onPositionChanged { state.layoutBoundsInWindowPx = it.boundsInWindow() }, + ) { + DockBand(state, sideOrder, 0, movableContent) + if (host != null) DockZoneHints(workspace, host, state) + } + } +} + +/** + * What the bands, the panels and the splitters read: the layout's inputs as + * snapshot state, so the subtree that reads one recomposes when it changes — + * the bands are separate composables and would otherwise be skipped — and the + * gesture handlers, which run outside composition, read the current values. + */ +internal class DockLayoutState( + val workspace: SatelliteWorkspace, +) { + var docked: List by mutableStateOf(emptyList()) + var layeredSides: Set by mutableStateOf(emptySet()) + var containerSize: IntSize by mutableStateOf(IntSize.Zero) + var direction: LayoutDirection by mutableStateOf(LayoutDirection.Ltr) + var splitter: @Composable DockSplitterScope.() -> Unit by mutableStateOf({}) + var panel: @Composable SatelliteScope.(panel: @Composable () -> Unit) -> Unit by mutableStateOf({ it() }) + var layoutSize: IntSize by mutableStateOf(IntSize.Zero) + val stackLengthsPx = HashMap() + + /** The layout's own rect and each side's band — the side plus everything inside it — in host window px. */ + var layoutBoundsInWindowPx: Rect by mutableStateOf(Rect.Zero) + val bandBoundsInWindowPx = mutableStateMapOf() + + /** + * Where the satellite [dragged] would land if dropped on [side], in the + * layout's own px: a strip of [thicknessPx] along the side's edge of its + * band — not of the whole layout, since an outer side owns the corners — + * pushed inwards past the layers already there on a layered side, where a + * new panel is a new innermost layer. On a split side that already has a + * stack the panel joins the stack, so the stack itself is the answer — at + * [thicknessPx], since the newcomer's limits may change the side's. + * + * A [dragged] panel that is the only one on *another* side of this same + * layout is counted as already gone: it frees its side, and the band it + * leaves behind is where the drop will actually be. Without that the + * preview would promise the layout as it stands mid-drag rather than the + * one the release produces. + */ + fun landingRectPx( + side: DockSide, + thicknessPx: Float, + joinsStack: Boolean, + dragged: SatelliteEntry? = null, + ): Rect { + val origin = layoutBoundsInWindowPx.topLeft + val band = bandPx(side, dragged) + val stack = + panelsOn(side) + .mapNotNull { it.dockedBoundsInWindowPx } + .takeIf { it.isNotEmpty() } + ?.reduce { acc, rect -> unionOf(acc, rect) } + ?.translate(-origin) + if (stack != null && joinsStack && !isLayered(side)) { + return if (thicknessPx > 0f) stack.withThickness(side, thicknessPx) else stack + } + val inset = if (stack != null && isLayered(side)) stack else null + return when (side) { + DockSide.Left -> { + val left = inset?.right ?: band.left + Rect(left, band.top, left + thicknessPx, band.bottom) + } + DockSide.Right -> { + val right = inset?.left ?: band.right + Rect(right - thicknessPx, band.top, right, band.bottom) + } + DockSide.Top -> { + val top = inset?.bottom ?: band.top + Rect(band.left, top, band.right, top + thicknessPx) + } + DockSide.Bottom -> { + val bottom = inset?.top ?: band.bottom + Rect(band.left, bottom - thicknessPx, band.right, bottom) + } + } + } + + /** + * The ranks a panel dropped on [side] can take among the panels already + * shown there, as one rect per rank in rank order — in the layout's own + * px, like [landingRectPx]. Together the rects cover the side's stack and + * [stripPx], its drop strip: rank `k` is the region between the centres of + * the panels of ranks `k - 1` and `k`, the first reaching the side's own + * edge (a layered side) or the start of the band (a split side), the last + * running through the strip. The [dragged] panel is not counted — its + * neighbours' centres are the boundaries, so its own region is the rank it + * has now. Empty while no other panel is docked there, one has not been + * placed yet, or [dragged] is pinned to its rank: nothing to order + * against. A rank in front of a pinned panel is not on offer either — it + * is an empty rect, so the index of a slot is still the rank it stands + * for, and the first rank on offer covers the area of the ones dropped. + */ + fun dropSlotsPx( + side: DockSide, + stripPx: Rect, + dragged: SatelliteEntry?, + ): List { + // A pinned panel has one rank and it is not the user's to change. + if (dragged != null && !dragged.isReorderable) return emptyList() + val origin = layoutBoundsInWindowPx.topLeft + val panels = panelsOn(side).filter { it !== dragged } + if (panels.isEmpty()) return emptyList() + val rects = panels.map { (it.dockedBoundsInWindowPx ?: return emptyList()).translate(-origin) } + val band = (bandBoundsInWindowPx[side] ?: layoutBoundsInWindowPx).translate(-origin) + val layered = isLayered(side) + var region = rects.reduce(::unionOf).let { unionOf(it, stripPx) } + if (layered) { + // Out to the side's own edge: a drop past the outermost layer is the first rank. + region = + when (side) { + DockSide.Left -> region.copy(left = band.left) + DockSide.Right -> region.copy(right = band.right) + DockSide.Top -> region.copy(top = band.top) + DockSide.Bottom -> region.copy(bottom = band.bottom) + } + } + val alongX = side.isVertical == layered + val cuts = rects.map { if (alongX) it.center.x else it.center.y }.sorted() + val edges = + listOf(if (alongX) region.left else region.top) + cuts + listOf(if (alongX) region.right else region.bottom) + val ascending = + List(rects.size + 1) { index -> + if (alongX) { + Rect(edges[index], region.top, edges[index + 1], region.bottom) + } else { + Rect(region.left, edges[index], region.right, edges[index + 1]) + } + } + val byRank = if (ranksDescend(side)) ascending.asReversed() else ascending + // The ranks in front of a pinned panel are not on offer: a drop there + // would shift it. They stay in the list — the index of a slot is the + // rank it stands for — as empty rects, and the first rank on offer + // takes their area, so aiming at a pinned panel lands right behind it, + // which is where the drop actually goes. + val floor = workspace.pinnedFloor(panels) + if (floor <= 0) return byRank + return List(floor) { Rect.Zero } + byRank.take(floor + 1).reduce(::unionOf) + byRank.drop(floor + 1) + } + + /** + * The band of [side] in the layout's own px — the side plus everything + * inside it — grown over the space the [dragged] panel frees when it is + * the only one on another side of this layout: that band is where the + * drop actually lands, not the one measured mid-drag. + */ + private fun bandPx( + side: DockSide, + dragged: SatelliteEntry?, + ): Rect { + val origin = layoutBoundsInWindowPx.topLeft + val layout = layoutBoundsInWindowPx.translate(-origin) + val measured = (bandBoundsInWindowPx[side] ?: layoutBoundsInWindowPx).translate(-origin) + val leaving = dragged?.takeIf { it.isDocked && it !in panelsOn(side) && panelsOn(sideOf(it)).size == 1 } + val freed = leaving?.dockedBoundsInWindowPx?.translate(-origin) ?: return measured + return unionOf(measured, freed).intersect(layout) + } + + /** + * The space the [dragged] panel occupies once dropped at rank [order] on + * [side], in the layout's own px — what the drop preview is drawn on, so + * that what the user sees lit up is what the release produces. + * + * - A side with no other panel: the strip along its edge, [extentPx] + * thick ([landingRectPx]). + * - A layered side: a full-length layer of [extentPx], laid where rank + * [order] puts it — the layers of lower rank keep their thickness + * between it and the edge, the others move inwards to make room. + * - A split side: its share of the stack's length once the weights are + * re-divided with its own ([SatelliteWorkspace.dockSeedWeight]) among + * the others', at rank [order], dividers counted. + * + * The [dragged] panel is not counted among the others, as in + * [dropSlotsPx]. A `null` [order] is the rank [SatelliteWorkspace.dock] + * gives without one — the rank last held on that side, else the end — + * and a rank in front of a pinned panel is pushed past it, as the drop is. + */ + fun dropRectPx( + side: DockSide, + dragged: SatelliteEntry?, + order: Int?, + extentPx: Float, + ): Rect { + val origin = layoutBoundsInWindowPx.topLeft + val others = panelsOn(side).filter { it !== dragged } + val rects = others.map { it.dockedBoundsInWindowPx?.translate(-origin) }.filterNotNull() + // No other panel, or one not placed yet: the strip along the edge. + if (rects.size != others.size || others.isEmpty()) { + return landingRectPx(side, extentPx, joinsStack = true, dragged = dragged) + } + val floor = if (dragged?.isReorderable == false) 0 else workspace.pinnedFloor(others) + val rank = (order ?: dragged?.dockMemory?.get(side)?.order ?: others.size).coerceIn(floor, others.size) + val band = bandPx(side, dragged) + if (isLayered(side)) { + val alongX = side.isVertical + val thicknesses = rects.map { if (alongX) it.width else it.height }.toMutableList() + thicknesses.add(rank, extentPx) + val before = thicknesses.take(rank).sum() + return when (side) { + DockSide.Left -> Rect(band.left + before, band.top, band.left + before + extentPx, band.bottom) + DockSide.Right -> Rect(band.right - before - extentPx, band.top, band.right - before, band.bottom) + DockSide.Top -> Rect(band.left, band.top + before, band.right, band.top + before + extentPx) + DockSide.Bottom -> Rect(band.left, band.bottom - before - extentPx, band.right, band.bottom - before) + } + } + // A split side: the stack keeps its thickness and its length, and the + // panels — the dragged one among them — divide the length by weight, + // the dividers between them taking what they take today. + val all = panelsOn(side).mapNotNull { it.dockedBoundsInWindowPx?.translate(-origin) } + val stack = all.reduce(::unionOf) + val alongX = !side.isVertical + val length = if (alongX) stack.width else stack.height + val dividerPx = + if (all.size > 1) { + (length - all.sumOf { (if (alongX) it.width else it.height).toDouble() }.toFloat()).coerceAtLeast(0f) / + (all.size - 1) + } else { + 0f + } + val weights = others.map(::weightOf).toMutableList() + weights.add(rank, dragged?.let { workspace.dockSeedWeight(it, side) } ?: 1f) + val total = weights.sum() + val available = length - dividerPx * others.size + val start = weights.take(rank).sum() / total * available + dividerPx * rank + val share = weights[rank] / total * available + val rect = + if (alongX) { + Rect(stack.left + start, stack.top, stack.left + start + share, stack.bottom) + } else { + Rect(stack.left, stack.top + start, stack.right, stack.top + start + share) + } + // At the thickness the side takes once the panel joins it: its limits + // may widen or narrow the whole stack. + return if (extentPx > 0f) rect.withThickness(side, extentPx) else rect + } + + /** Whether rank `0` sits at the high coordinate: the outer layer of a right or bottom layered side. */ + private fun ranksDescend(side: DockSide): Boolean = + isLayered(side) && (side == DockSide.Right || side == DockSide.Bottom) + + /** One movable subtree per docked satellite, so a panel changing side keeps its composition. */ + private val movables = HashMap Unit>() + + fun movableOf(entry: SatelliteEntry): @Composable () -> Unit = + movables.getOrPut(entry) { movableContentOf { DockPanel(this, entry) } } + + fun pruneMovables(docked: List) { + movables.keys.retainAll(docked.toSet()) + } + + fun isLayered(side: DockSide): Boolean = side in layeredSides + + /** The side [entry] is docked on. */ + fun sideOf(entry: SatelliteEntry): DockSide = (entry.placement as SatellitePlacement.Docked).side + + fun panelsOn(side: DockSide): List = + docked + .filter { (it.placement as SatellitePlacement.Docked).side == side } + .sortedWith(compareBy({ (it.placement as SatellitePlacement.Docked).order }, { it.id })) + + /** A layered panel's own thickness, falling back to the side's. */ + fun extentOf(entry: SatelliteEntry): Dp { + val docked = entry.placement as SatellitePlacement.Docked + return docked.extent ?: workspace.dockExtent(docked.side) + } + + /** Thickness taken by every panel on [side] but [excluding], in px. */ + fun sideThicknessPx( + side: DockSide, + density: Density, + excluding: SatelliteEntry? = null, + ): Float { + val panels = panelsOn(side).filter { it !== excluding } + if (panels.isEmpty()) return 0f + val layered = isLayered(side) + return with(density) { + if (!layered) return@with workspace.dockExtent(side).toPx() + panels.sumOf { extentOf(it).toPx().toDouble() }.toFloat() + } + } + + /** + * The factor the thicknesses along one axis are drawn at so they fit: `1` + * while the panels leave [MinContentExtent] to the content, less once the + * window has shrunk under what the extents ask for. The stored extents are + * untouched — the layout gives them back as soon as there is room again — + * and the same rule holds for every panel, so a shrunk window shows the + * same proportions as the full one, like a split pane's percentages do. + */ + fun fit( + vertical: Boolean, + density: Density, + ): Float { + val along = if (vertical) layoutSize.width else layoutSize.height + if (along <= 0) return 1f + val sides = if (vertical) listOf(DockSide.Left, DockSide.Right) else listOf(DockSide.Top, DockSide.Bottom) + val total = sides.sumOf { sideThicknessPx(it, density).toDouble() }.toFloat() + return fitFactor(along, total, density) + } + + /** + * [fit] as it will be once [dragged] is dropped on [side] at [thicknessPx] + * (unfitted): the panel counted out of wherever it is now and into [side] + * — a new layer on a layered side, the side's new shared thickness on a + * split one. What a drop preview is drawn at, so a window already short of + * room shows the thickness the release produces rather than today's. + */ + fun fitAfterDrop( + side: DockSide, + dragged: SatelliteEntry, + thicknessPx: Float, + density: Density, + ): Float { + val along = if (side.isVertical) layoutSize.width else layoutSize.height + if (along <= 0) return 1f + val total = + listOf(side, side.opposite) + .sumOf { s -> + val rest = sideThicknessPx(s, density, excluding = dragged) + when { + s != side -> rest + isLayered(side) -> rest + thicknessPx + else -> thicknessPx + }.toDouble() + }.toFloat() + return fitFactor(along, total, density) + } + + private fun fitFactor( + along: Int, + total: Float, + density: Density, + ): Float { + val available = (along - with(density) { MinContentExtent.toPx() }).coerceAtLeast(0f) + return if (total > available && total > 0f) available / total else 1f + } + + /** The thickness [side] is drawn at: its own, fitted to the window. */ + @Composable + fun drawnSideExtent(side: DockSide): Dp = workspace.dockExtent(side) * fit(side.isVertical, LocalDensity.current) + + /** The thickness the layer [entry] is drawn at: its own, fitted to the window. */ + @Composable + fun drawnExtent(entry: SatelliteEntry): Dp { + val side = (entry.placement as SatellitePlacement.Docked).side + return extentOf(entry) * fit(side.isVertical, LocalDensity.current) + } + + /** + * Grows a thickness by [towardsContentPx], keeping [MinContentExtent] of + * the layout free along the axis once everything else on it is counted. + */ + fun clampThicknessPx( + side: DockSide, + currentPx: Float, + towardsContentPx: Float, + density: Density, + panel: SatelliteEntry? = null, + ): Float { + val along = if (side.isVertical) layoutSize.width else layoutSize.height + val others = sideThicknessPx(side, density) + sideThicknessPx(side.opposite, density) - currentPx + val maxPx = along - with(density) { MinContentExtent.toPx() } - others + var nextPx = currentPx + towardsContentPx + if (along > 0 && maxPx > 0f) nextPx = nextPx.coerceAtMost(maxPx) + // What the panels allow: a layered [panel]'s own range, else the range + // the panels sharing [side] leave it. + val range = panel?.extentRange ?: workspace.sideExtentRange(side) + return with(density) { nextPx.coerceIn(range.start.toPx(), range.endInclusive.toPx()) } + } +} + +/** This rect, [px] thick from its [side] edge. */ +private fun Rect.withThickness( + side: DockSide, + px: Float, +): Rect = + when (side) { + DockSide.Left -> Rect(left, top, left + px, bottom) + DockSide.Right -> Rect(right - px, top, right, bottom) + DockSide.Top -> Rect(left, top, right, top + px) + DockSide.Bottom -> Rect(left, bottom - px, right, bottom) + } + +/** One child of a band, keyed so the band keeps its subtree wherever it lands in the row. */ +private class BandItem( + val key: String, + val content: @Composable () -> Unit, +) + +/** + * The side [sideOrder]`[index]` around whatever is inside it: the next side, + * down to the content. + * + * Every child is [key]ed, the content included, because Compose otherwise + * identifies children by their position: a side gaining its first panel would + * shift the content along the row and destroy its subtree — the document's + * scroll position, and every `remember` under it, lost on the first dock. + * With keys the subtrees move and nothing is rebuilt. + */ +@Composable +private fun DockBand( + state: DockLayoutState, + sideOrder: List, + index: Int, + content: @Composable () -> Unit, +) { + if (index == sideOrder.size) { + content() + return + } + val side = sideOrder[index] + val panels = state.panelsOn(side) + val inner: @Composable () -> Unit = { DockBand(state, sideOrder, index + 1, content) } + val layered = state.isLayered(side) + val outerToInner = if (layered) layeredItems(state, side, panels) else splitItems(state, side, panels) + val leading = side == DockSide.Left || side == DockSide.Top + val contentItem = BandItem(CONTENT_KEY, inner) + val children = if (leading) outerToInner + contentItem else listOf(contentItem) + outerToInner.asReversed() + // The band's rect is what a drop preview on this side is drawn against. + val measured = + Modifier.fillMaxSize().onPositionChanged { + state.bandBoundsInWindowPx[side] = it.boundsInWindow() + } + if (side.isVertical) { + Row(measured) { + for (item in children) { + key(item.key) { + if (item === contentItem) { + Box(Modifier.weight(1f).fillMaxHeight()) { item.content() } + } else { + item.content() + } + } + } + } + } else { + Column(measured) { + for (item in children) { + key(item.key) { + if (item === contentItem) { + Box(Modifier.weight(1f).fillMaxWidth()) { item.content() } + } else { + item.content() + } + } + } + } + } +} + +/** A layered side: each panel a layer of its own extent, its splitter on its content side. */ +private fun layeredItems( + state: DockLayoutState, + side: DockSide, + panels: List, +): List = + panels.flatMap { entry -> + listOf( + BandItem("panel:${entry.id}") { + val extent = state.drawnExtent(entry) + val sized = + if (side.isVertical) { + Modifier.fillMaxHeight().width( + extent, + ) + } else { + Modifier.fillMaxWidth().height(extent) + } + Box(sized) { state.movableOf(entry)() } + }, + BandItem("splitter:${entry.id}") { + val orientation = if (side.isVertical) Orientation.Horizontal else Orientation.Vertical + val scope = + remember(state, side, entry) { + DockSplitterScopeImpl(side, orientation, entry) { deltaPx, density -> + val currentPx = with(density) { state.extentOf(entry).toPx() } + val nextPx = + state.clampThicknessPx(side, currentPx, towardsContent(side, deltaPx), density, entry) + state.workspace.setDockedExtent(entry.id, with(density) { nextPx.toDp() }) + } + } + SplitterSlot(state, scope) + }, + ) + } + +/** A split side: one stack sharing the side's extent, then the splitter that drags it. */ +private fun splitItems( + state: DockLayoutState, + side: DockSide, + panels: List, +): List { + if (panels.isEmpty()) return emptyList() + return listOf( + BandItem("stack:$side") { SplitStack(state, side, panels) }, + BandItem("splitter:$side") { + val orientation = if (side.isVertical) Orientation.Horizontal else Orientation.Vertical + val scope = + remember(state, side) { + DockSplitterScopeImpl(side, orientation, panel = null) { deltaPx, density -> + val currentPx = with(density) { state.workspace.dockExtent(side).toPx() } + val nextPx = state.clampThicknessPx(side, currentPx, towardsContent(side, deltaPx), density) + state.workspace.setDockExtent(side, with(density) { nextPx.toDp() }) + } + } + SplitterSlot(state, scope) + }, + ) +} + +/** + * The panels of a split side, dividing its length by weight, with a divider + * between neighbours that moves weight from one to the other. + * + * Each panel is [key]ed on its satellite, because Compose otherwise identifies + * them by their position on the side: undocking the first of two panels would + * dispose the *second* one's subtree and hand the first one's — its + * `remember`s, its saveable registry, the content of a satellite that has just + * left — to the panel that survives. + */ +@Composable +private fun SplitStack( + state: DockLayoutState, + side: DockSide, + panels: List, +) { + val extent = state.drawnSideExtent(side) + val orientation = if (side.isVertical) Orientation.Vertical else Orientation.Horizontal + val measure = Modifier.onSizeChanged { state.stackLengthsPx[side] = if (side.isVertical) it.height else it.width } + + @Composable + fun WeightDivider( + before: SatelliteEntry, + after: SatelliteEntry, + ) { + val scope = + remember(state, side, before, after) { + DockSplitterScopeImpl(side, orientation, before) { deltaPx, density -> + moveWeight(state, side, before, after, deltaPx, density) + } + } + SplitterSlot(state, scope) + } + + if (side.isVertical) { + Column(Modifier.fillMaxHeight().width(extent).then(measure)) { + panels.forEachIndexed { index, entry -> + if (index > 0) key("divider:${entry.id}") { WeightDivider(panels[index - 1], entry) } + key(entry.id) { + Box(Modifier.fillMaxWidth().weight(weightOf(entry))) { state.movableOf(entry)() } + } + } + } + } else { + Row(Modifier.fillMaxWidth().height(extent).then(measure)) { + panels.forEachIndexed { index, entry -> + if (index > 0) key("divider:${entry.id}") { WeightDivider(panels[index - 1], entry) } + key(entry.id) { + Box(Modifier.fillMaxHeight().weight(weightOf(entry))) { state.movableOf(entry)() } + } + } + } + } +} + +internal fun weightOf(entry: SatelliteEntry): Float = (entry.placement as SatellitePlacement.Docked).weight + +/** The `splitter` slot, composed in the direction the layout was declared in. */ +@Composable +private fun SplitterSlot( + state: DockLayoutState, + scope: DockSplitterScope, +) { + CompositionLocalProvider(LocalLayoutDirection provides state.direction) { + state.splitter(scope) + } +} + +/** + * One docked satellite: its header strip over its content, inside the + * layout's `panel` slot. Movable — see [DockLayoutState.movableOf]. + */ +@Composable +private fun DockPanel( + state: DockLayoutState, + entry: SatelliteEntry, +) { + if (entry.content == null) return + val workspace = state.workspace + // The host answers how it is placed, and a panel moves between hosts, so + // the scope reads it through the entry rather than capturing a window. + val scope = remember(workspace, entry) { SatelliteScopeImpl(workspace, entry, isDocked = true) { entry.dockHost } } + // Dimmed while its ghost is being dragged: the panel is on its way out. + val leaving = workspace.dragGhost?.satellite === entry + val containerSize = state.containerSize + // Written here, not with the bounds: a window resize that leaves the + // panel's rect alone moves no layout callback. + SideEffect { entry.dockHostContainerSizePx = containerSize } + Box( + Modifier + .fillMaxSize() + .alpha(if (leaving) LEAVING_PANEL_ALPHA else 1f) + .onPositionChanged { coordinates -> + // Read by SatelliteWorkspace.undock to lift the window off the panel. + entry.dockedBoundsInWindowPx = coordinates.boundsInWindow() + }, + ) { + CompositionLocalProvider(LocalLayoutDirection provides state.direction) { + state.panel(scope) { + Column(Modifier.fillMaxSize()) { + Box(Modifier.fillMaxWidth()) { + val header = entry.header + if (header != null) header(scope) else scope.DefaultSatelliteHeader() + } + Box(Modifier.fillMaxWidth().weight(1f)) { + RelocatedContentHost(entry.stateSlot, scope, entry.content) + } + } + } + } + } +} + +/** + * The default [DockLayout] side order: top and bottom run the full width and + * own the corners, left and right sit between them — the classic border layout. + */ +@ExperimentalNucleusApi +public val DefaultDockSideOrder: List = listOf(DockSide.Top, DockSide.Bottom, DockSide.Left, DockSide.Right) + +/** Height of the [DefaultSatelliteHeader] strip above a docked panel's content. */ +@ExperimentalNucleusApi +public val DockPanelHeaderHeight: Dp = 30.dp + +private const val CONTENT_KEY = "content" +internal val MinContentExtent: Dp = 120.dp +private const val LEAVING_PANEL_ALPHA = 0.35f diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockSplitter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockSplitter.kt new file mode 100644 index 000000000..6b5329f41 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockSplitter.kt @@ -0,0 +1,133 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle + +/** + * What the [DockLayout] `splitter` slot composes in: which side and panel the + * splitter resizes, along which axis, and the modifier that makes an element + * the grip. + */ +@ExperimentalNucleusApi +public interface DockSplitterScope { + /** The side this splitter belongs to. */ + public val side: DockSide + + /** + * The axis the splitter is dragged along: [Orientation.Horizontal] for a + * bar between things side by side (a vertical line), [Orientation.Vertical] + * for a bar between things stacked. + */ + public val orientation: Orientation + + /** + * The panel this splitter resizes: the layer just outside it on a layered + * side, or the panel just before it on a split side. `null` for the + * splitter between a split side's stack and the content, which drags the + * side's [SatelliteWorkspace.dockExtent]. + */ + public val panel: SatelliteEntry? + + /** + * Attaches the resize gesture and the resize cursor. Apply it to the + * element the user grabs; it may be larger than what is drawn — a 1 dp + * line can carry a wider invisible grip through `Modifier.requiredWidth`. + */ + public fun Modifier.dockSplitterHandle(): Modifier +} + +/** + * The stock splitter: a bar of [DockSplitterThickness] in the window style's + * border colour, the whole of it the grip. + */ +@Composable +@ExperimentalNucleusApi +public fun DockSplitterScope.DefaultDockSplitter() { + val color = LocalDecoratedWindowStyle.current.colors.border + val sizeModifier = + if (orientation == Orientation.Horizontal) { + Modifier.fillMaxHeight().width(DockSplitterThickness) + } else { + Modifier.fillMaxWidth().height(DockSplitterThickness) + } + Box(sizeModifier.background(color).dockSplitterHandle()) +} + +/** Sign of a pointer delta that grows [side] towards the content. */ +internal fun towardsContent( + side: DockSide, + deltaPx: Float, +): Float = + when (side) { + DockSide.Left, DockSide.Top -> deltaPx + DockSide.Right, DockSide.Bottom -> -deltaPx + } + +internal class DockSplitterScopeImpl( + override val side: DockSide, + override val orientation: Orientation, + override val panel: SatelliteEntry?, + private val onDragPx: (deltaPx: Float, density: Density) -> Unit, +) : DockSplitterScope { + private val horizontal: Boolean get() = orientation == Orientation.Horizontal + + override fun Modifier.dockSplitterHandle(): Modifier = + pointerHoverIcon(if (horizontal) TaoPointerIcons.ResizeEastWest else TaoPointerIcons.ResizeNorthSouth) + .pointerInput(this@DockSplitterScopeImpl) { + detectDragGestures { change, drag -> + change.consume() + onDragPx(if (horizontal) drag.x else drag.y, this) + } + } +} + +/** + * Moves [deltaPx] of the stack's length from [after] to [before]: the + * divider follows the pointer one-to-one, and neither panel drops under + * [SatelliteWorkspace.MinDockExtent]. + */ +internal fun moveWeight( + state: DockLayoutState, + side: DockSide, + before: SatelliteEntry, + after: SatelliteEntry, + deltaPx: Float, + density: Density, +) { + val lengthPx = state.stackLengthsPx[side]?.takeIf { it > 0 } ?: return + val total = state.panelsOn(side).sumOf { weightOf(it).toDouble() }.toFloat() + val pxPerWeight = lengthPx / total + val minWeight = with(density) { SatelliteWorkspace.MinDockExtent.toPx() } / pxPerWeight + val beforeWeight = weightOf(before) + val afterWeight = weightOf(after) + // Both panels already under the minimum — a stack too short for its + // panels — leaves nothing to move. + val low = minWeight - beforeWeight + val high = afterWeight - minWeight + if (low > high) return + val delta = (deltaPx / pxPerWeight).coerceIn(low, high) + if (delta == 0f || delta.isNaN()) return + state.workspace.setDockedWeight(before.id, beforeWeight + delta) + state.workspace.setDockedWeight(after.id, afterWeight - delta) +} + +/** Thickness of the [DefaultDockSplitter] bar. */ +@ExperimentalNucleusApi +public val DockSplitterThickness: Dp = 6.dp diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt new file mode 100644 index 000000000..8cfe02b6d --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockTransferTarget.kt @@ -0,0 +1,106 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.window.tao.workspace.HostGeometry +import dev.nucleusframework.window.tao.workspace.positionInWindowPx + +/** + * Makes the layout the drop target of a [SatelliteWorkspace.transferDrag]: + * the drag that rides the platform's DnD session where windows cannot be + * hit-tested from the source (native Wayland). The events arrive in this + * window's own coordinates, which is exactly what the source lacks, so the + * zone under the pointer is resolved here — previewed while hovering, recorded + * on the session at the drop for the source to act on when the session ends. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun Modifier.dockTransferTarget( + workspace: SatelliteWorkspace, + host: TaoWindow?, + geometry: HostGeometry?, +): Modifier { + if (host == null || geometry == null) return this + val target = remember(workspace, host, geometry) { DockTransferTarget(workspace, host, geometry) } + return dragAndDropTarget( + shouldStartDragAndDrop = { workspace.transferDrag != null }, + target = target, + ) +} + +internal class DockTransferTarget( + private val workspace: SatelliteWorkspace, + private val host: TaoWindow, + private val geometry: HostGeometry, +) : DragAndDropTarget { + override fun onEntered(event: DragAndDropEvent) = preview(event) + + override fun onMoved(event: DragAndDropEvent) = preview(event) + + override fun onExited(event: DragAndDropEvent) = clearPreview() + + override fun onEnded(event: DragAndDropEvent) = clearPreview() + + override fun onDrop(event: DragAndDropEvent): Boolean { + val drag = workspace.transferDrag ?: return false + val position = event.positionInWindowPx() + val zone = zoneAt(position)?.let { workspace.targetFor(drag.entry, it) } + val outcome = + when { + zone != null && zone != drag.own -> TransferDrop.Dock(zone) + // Back onto its own side, or onto the very panel it came from: + // the gesture was abandoned, not a tear-out. + zone != null || drag.isOwnPanel(position) -> TransferDrop.Stay + else -> return false + } + drag.drop = outcome + clearPreview() + return true + } + + /** + * The zone [positionInWindowPx] is in, resolved against the rectangles the + * layout draws ([HostGeometry.zoneBoundsInWindowPx]) so a drop lands where + * the highlight promised — inset behind existing layers included, at the + * rank of the stack the pointer is over — and against the layout's edges + * while none are published. + */ + internal fun zoneAt(positionInWindowPx: Offset): DockTarget? { + val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne() + val zones = geometry.zoneBoundsInWindowPx + if (zones.isEmpty()) { + return dockSideAt(geometry.layoutBoundsInWindowPx, positionInWindowPx, zonePx)?.let { DockTarget(host, it) } + } + // A stack the pointer is over wins over a strip running across its corner. + val (side, zone) = + zones.entries.firstOrNull { (_, zone) -> zone.slots.any { it.contains(positionInWindowPx) } } + ?: zones.entries.firstOrNull { (_, zone) -> zone.strip.contains(positionInWindowPx) } + ?: return null + return DockTarget(host, side, zone.slotAt(positionInWindowPx)) + } + + private fun preview(event: DragAndDropEvent) { + val drag = workspace.transferDrag ?: return + workspace.dockPreview = + zoneAt(event.positionInWindowPx()) + ?.let { workspace.targetFor(drag.entry, it) } + ?.takeIf { it != drag.own } + } + + private fun clearPreview() { + if (workspace.dockPreview?.host === host) workspace.dockPreview = null + } + + /** Whether [positionInWindowPx] is on the dragged panel itself, in this host. */ + private fun SatelliteTransferDrag.isOwnPanel(positionInWindowPx: Offset): Boolean = + (origin as? SatelliteDragOrigin.DockedPanel)?.host === host && + entry.dockedBoundsInWindowPx?.contains(positionInWindowPx) == true +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt new file mode 100644 index 000000000..be75b4586 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DockZoneHints.kt @@ -0,0 +1,172 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import dev.nucleusframework.window.tao.workspace.DockDropZone +import kotlin.math.roundToInt + +/** + * The four drop zones of this layout, shown while a satellite is being + * dragged anywhere in the workspace. + * + * Every side is outlined faintly as soon as the drag starts — that is what + * tells the user the gesture exists — and on the one the satellite has + * entered the panel's own card ([SatelliteGhostCard], the card that follows + * the pointer) is drawn on the space the release will fill + * ([DockLayoutState.dropRectPx]): the side's own band rather than the whole + * edge, at the width the drop will produce, inside the layers already there + * on a layered side, and on a side with panels at the rank the pointer picks + * — the share of the stack the panel gets between the two it lands between. + * The rank the dragged panel already holds is not a target: a side it is + * alone on is left out altogether, and with neighbours the strip past the + * stack is not lit while the panel is the last of them, since a drop there + * changes nothing. + */ +@Composable +internal fun BoxScope.DockZoneHints( + workspace: SatelliteWorkspace, + host: TaoWindow, + state: DockLayoutState, +) { + val dragged = workspace.draggedSatellite ?: return + val density = LocalDensity.current + val hinted = hintedSides(dragged, host, workspace.satellites) + val zoneWidthPx = with(density) { SatelliteWorkspace.DockZoneWidth.toPx() } + // What a drag is hit-tested against is what is drawn: the idle strips and + // the ranks of each stack, published to the geometry the workspace + // resolves drops on. Cleared when the drag ends, so a stale set can never + // answer for a later one. Recomputed on every recomposition rather than + // remembered: the rects come from the measured bands, which move without + // any of the keys a remember could name (a side order change, a splitter + // drag). Four strips and a handful of slots. + val zones = + hinted.associateWith { side -> + val strip = state.landingRectPx(side, zoneWidthPx, joinsStack = false, dragged = dragged) + DockDropZone(strip, state.dropSlotsPx(side, strip, dragged)) + } + val origin = state.layoutBoundsInWindowPx.topLeft + DisposableEffect(zones, origin) { + val geometry = workspace.dockHostGeometry(host) + geometry?.zoneBoundsInWindowPx = zones.mapValues { (_, zone) -> zone.translate(origin) } + onDispose { geometry?.zoneBoundsInWindowPx = emptyMap() } + } + val own = workspace.ownTarget(dragged, host) + // Keeps the closed-hand cursor over the whole layout for the length of the + // drag: the grip itself is only under the pointer while the satellite + // floats, and a docked panel's header is left behind at the first move. + Box( + Modifier + .matchParentSize() + .pointerHoverIcon(TaoPointerIcons.Grabbing, overrideDescendants = true), + ) + for (side in hinted) { + SideHint(workspace, state, host, side, zones.getValue(side), dragged, own) + } +} + +/** + * One side's feedback: the panel's card on the space it will take when the + * side is the one aimed at, else the faint strip that says it could be. + */ +@Suppress("LongParameterList") // the drag's whole state, read once per side +@Composable +private fun SideHint( + workspace: SatelliteWorkspace, + state: DockLayoutState, + host: TaoWindow, + side: DockSide, + zone: DockDropZone, + dragged: SatelliteEntry, + own: DockTarget?, +) { + val preview = workspace.dockPreview + val active = preview?.host === host && preview.side == side + // Its own side, with itself last: the strip past the stack is the rank it + // holds, so lighting it up would promise a move that does not happen. + if (!active && own?.side == side && own.order == zone.slots.lastIndex) return + if (!active) { + PreviewAt(zone.strip) { DragPreviewSurface(Modifier.fillMaxSize(), hint = true) } + return + } + val density = LocalDensity.current + // The width the drop will actually produce: on a layered side the panel's + // own, elsewhere the side's — which on a side that has no extent yet is + // the satellite's own size, not the default. + val extent = + if (state.isLayered(side)) { + workspace.dockSeedExtent(dragged, side) + } else { + workspace.plannedDockExtent(dragged, side) + } + val order = preview.order?.takeIf { zone.slots.isNotEmpty() } + // Fitted to the window as the layout will fit it once the panel is in — + // the preview is the thickness the release draws. + val rawPx = with(density) { extent.toPx() } + val extentPx = rawPx * state.fitAfterDrop(side, dragged, rawPx, density) + val rect = state.dropRectPx(side, dragged, order, extentPx) + PreviewAt(rect) { SatelliteGhostCard(dragged.title, Modifier.fillMaxSize()) } +} + +/** [content] laid over [rect], in the layout's own px. */ +@Composable +private fun PreviewAt( + rect: Rect, + content: @Composable () -> Unit, +) { + if (rect.isEmpty) return + val density = LocalDensity.current + Box( + Modifier + .offset { IntOffset(rect.left.roundToInt(), rect.top.roundToInt()) } + .size(with(density) { rect.width.toDp() }, with(density) { rect.height.toDp() }), + ) { + content() + } +} + +/** + * The sides worth hinting while [dragged] is in flight over [host]: every one + * except the side [dragged] is already docked on **in this window** while + * there is no other rank for it there — it is alone, or pinned + * ([SatelliteEntry.isReorderable]) — since dropping it back is a no-op and + * offering it would promise a move that does not happen. With other panels + * on that side it is a target again: the panel can be dropped at another + * rank among them. + * Dragged from another window, or floating, every side is a real target — + * among the sides the satellite was declared for ([SatelliteEntry.dockSides]). + * [satellites] are the workspace's, to tell a lone panel from a stack. + */ +internal fun hintedSides( + dragged: SatelliteEntry, + host: TaoWindow, + satellites: Collection, +): List { + val own = (dragged.placement as? SatellitePlacement.Docked)?.side?.takeIf { dragged.dockHost === host } + val alone = + own != null && + satellites.none { + it !== dragged && + it.isShown && + it.dockHost === host && + (it.placement as? SatellitePlacement.Docked)?.side == own + } + // Its own side is a target only while another rank is on offer there. + val stuck = alone || !dragged.isReorderable + return DockSide.entries.filter { it in dragged.dockSides && !(stuck && it == own) } +} + +/** The smallest rect containing both. */ +internal fun unionOf( + a: Rect, + b: Rect, +): Rect = Rect(minOf(a.left, b.left), minOf(a.top, b.top), maxOf(a.right, b.right), maxOf(a.bottom, b.bottom)) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DragPreviewDefaults.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DragPreviewDefaults.kt new file mode 100644 index 000000000..77c8667d2 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/DragPreviewDefaults.kt @@ -0,0 +1,57 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.styling.LocalTitleBarStyle + +/** + * The one look every drop preview of the workspaces has — the card that + * follows the pointer out of a window, the same card drawn on the space a + * release will fill, and the faint outline of a place that could be dropped + * on: a tinted, rounded surface in the title bar's content colour. + * + * One surface rather than one per gesture, so a tab and a panel, a preview in + * hand and a preview on its target, all read as the same thing. + */ +internal object DragPreviewDefaults { + val CornerRadius: Dp = 8.dp + val BorderWidth: Dp = 1.dp + + /** The card: what is being dragged, in hand or on the space it will take. */ + const val FILL_ALPHA = 0.22f + const val BORDER_ALPHA = 0.55f + + /** The hint: a place that could be dropped on, but is not the one aimed at. */ + const val HINT_FILL_ALPHA = 0.06f + const val HINT_BORDER_ALPHA = 0.22f +} + +/** + * The tinted, rounded surface of a drop preview; [hint] draws it at the + * intensity of a place that is merely on offer. + */ +@Composable +internal fun DragPreviewSurface( + modifier: Modifier = Modifier, + hint: Boolean = false, + content: @Composable BoxScope.() -> Unit = {}, +) { + val accent = LocalTitleBarStyle.current.colors.content + val shape = RoundedCornerShape(DragPreviewDefaults.CornerRadius) + val fill = if (hint) DragPreviewDefaults.HINT_FILL_ALPHA else DragPreviewDefaults.FILL_ALPHA + val border = if (hint) DragPreviewDefaults.HINT_BORDER_ALPHA else DragPreviewDefaults.BORDER_ALPHA + Box( + modifier = + modifier + .background(accent.copy(alpha = fill), shape) + .border(DragPreviewDefaults.BorderWidth, accent.copy(alpha = border), shape), + content = content, + ) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativePopupLayers.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativePopupLayers.kt new file mode 100644 index 000000000..f5159329c --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativePopupLayers.kt @@ -0,0 +1,78 @@ +@file:OptIn(InternalComposeUiApi::class) + +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.scene.ComposeSceneContext +import androidx.compose.ui.scene.ComposeSceneLayer +import androidx.compose.ui.scene.TaoComposeSceneContextAccess +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import dev.nucleusframework.window.tao.scene.TaoPopupLayerFactory + +/** + * The window's native popup layer factory, for [NativePopupLayers]. Provided + * by every Tao window that draws its own popups in-scene; `null` when the + * window already runs on native popup layers (nothing to opt into) or has no + * native popup pipeline. + */ +internal val LocalTaoNativePopupLayerFactory: ProvidableCompositionLocal = + staticCompositionLocalOf { null } + +/** + * Materialises every Compose `Popup` / `DropdownMenu` / `Tooltip` opened + * directly inside [content] as a native popup surface — an `NSPanel` on + * macOS, a transparent `WS_POPUP` HWND on Windows, a Tao popup window on + * Linux — exactly as `DecoratedWindow(nativePopupLayers = true)` does for the + * whole window, but for this subtree only. Popups opened elsewhere in the + * window keep drawing inside its render target. + * + * This is what an OS-looking flyout needs: it must be able to leave the + * window like the platform's own menus, and it must not depend on what the + * application chose for its other popups. Popups opened from *inside* a + * native surface (a submenu) already live in that surface's own scene and + * need no further opt-in. + * + * A no-op when the window already runs on native popup layers, when it has + * no native popup pipeline (not attached yet, native bridge missing), or + * outside a Tao window: [content] then composes unchanged. + */ +@Suppress("FunctionNaming") +@Composable +public fun NativePopupLayers(content: @Composable () -> Unit) { + val layerFactory = LocalTaoNativePopupLayerFactory.current + val local = TaoComposeSceneContextAccess.localComposeSceneContext() + // Platform type: the scene provides it for its own composition, so it is + // only null outside any scene (the application root). + val sceneContext: ComposeSceneContext? = local.current + if (layerFactory == null || sceneContext == null) { + content() + return + } + val nativeLayerContext = + remember(sceneContext, layerFactory) { NativeLayerSceneContext(sceneContext, layerFactory) } + CompositionLocalProvider(local provides nativeLayerContext, content = content) +} + +/** + * The window scene's own context with one difference: layers come out of the + * window's native popup pipeline instead of the scene's canvas. Everything + * else — the platform context above all — is the scene's, so nothing that + * reads the context sees a different window. + */ +private class NativeLayerSceneContext( + sceneContext: ComposeSceneContext, + private val layerFactory: TaoPopupLayerFactory, +) : ComposeSceneContext by sceneContext { + override fun createLayer( + density: Density, + layoutDirection: LayoutDirection, + focusable: Boolean, + consumePointerInputOutside: Boolean, + ): ComposeSceneLayer = layerFactory(density, layoutDirection, focusable, consumePointerInputOutside) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt index b243fcf76..a05edd2cf 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NativeView.kt @@ -20,7 +20,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp @@ -70,9 +69,10 @@ public fun NativeView( val view = remember { factory() } val latestUpdate by rememberUpdatedState(update) - DisposableEffect(view) { - onDispose { view.dispose() } - } + // `view.dispose()` is owned by [EmbeddedNativeView], sequenced *after* the + // host detach: `dispose()` promises the handle is never touched again, and + // a separate effect here ran first on unmount — the detach then walked a + // widget the app had already destroyed (SIGSEGV in `nativeDetach`). SideEffect { latestUpdate(view) } when (view) { @@ -129,14 +129,24 @@ private fun EmbeddedNativeView( val host = LocalTaoNativeViewHost.current val latestContent by rememberUpdatedState(content) if (!enabled || host == null) { + DisposableEffect(view) { + onDispose { view.dispose() } + } Box(modifier) return } val regionToken = remember { Any() } + // One effect for attach, detach and dispose, so the order is fixed by + // construction: the host lets go of the handle, then the app frees it. + // The keys never change for a live embedding (the host is the window's, + // the token is remembered), so this only fires on unmount. DisposableEffect(host, regionToken) { host.attach(handle, regionToken) - onDispose { host.detach(handle, regionToken) } + onDispose { + host.detach(handle, regionToken) + view.dispose() + } } val density = LocalDensity.current @@ -154,7 +164,7 @@ private fun EmbeddedNativeView( modifier = modifier .punchNativeViewHole() - .onGloballyPositioned { coords -> + .onPositionChanged { coords -> val pos = coords.positionInRoot() val xPx = pos.x.roundToInt() val yPx = pos.y.roundToInt() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NucleusPlatformView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NucleusPlatformView.kt index 2a2fe4c26..4178d4d25 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NucleusPlatformView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NucleusPlatformView.kt @@ -102,7 +102,13 @@ public sealed interface NucleusPlatformView { * punched rect shows the desktop instead of the widget. */ public interface GtkWidget : NucleusPlatformView { - /** Pointer to the user-supplied `GtkWidget*` (cast to Long). */ + /** + * Pointer to the user-supplied `GtkWidget*` (cast to Long). The app + * owns a reference to it (`g_object_ref_sink`) for as long as the + * handle is in use and releases it from [dispose]: the container's + * unparent on detach drops the container's own reference, and a + * widget nobody else holds is finalised right there. + */ public val gtkWidgetHandle: Long } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NucleusWindowV2Bridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NucleusWindowV2Bridge.kt new file mode 100644 index 000000000..0b1ccb36b --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/NucleusWindowV2Bridge.kt @@ -0,0 +1,992 @@ +@file:OptIn(ExperimentalComposeUiApi::class) +@file:Suppress("TooManyFunctions") + +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpInsets +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.size +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.v2.CombinedBoundsProvider +import dev.nucleusframework.window.tao.v2.DEFAULT_WINDOW_SIZE +import dev.nucleusframework.window.tao.v2.Screen +import dev.nucleusframework.window.tao.v2.WindowBoundsProvider +import dev.nucleusframework.window.tao.v2.WindowGeometryProviderScope +import dev.nucleusframework.window.tao.v2.WindowMetrics +import dev.nucleusframework.window.tao.v2.WindowPositionProvider +import dev.nucleusframework.window.tao.v2.WindowScreenProvider +import dev.nucleusframework.window.tao.v2.evaluateBounds +import dev.nucleusframework.window.tao.v2.evaluatePosition +import dev.nucleusframework.window.tao.v2.evaluateScreen +import dev.nucleusframework.window.tao.v2.evaluateSize +import dev.nucleusframework.window.tao.v2.screenScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.Collections +import java.util.WeakHashMap +import androidx.compose.ui.window.DialogState as DialogStateV1 +import androidx.compose.ui.window.WindowState as WindowStateV1 +import dev.nucleusframework.window.tao.v2.DialogState as NucleusDialogState +import dev.nucleusframework.window.tao.v2.WindowState as NucleusWindowState + +/** + * Binds the AWT-free window API v2 clone ([dev.nucleusframework.window.tao.v2]) + * to the v1 [WindowStateV1] the Tao window path consumes. + * + * Nothing is dropped here: every provider is evaluated against a + * [WindowGeometryProviderScope] built from [TaoMonitors] and the live + * [TaoWindow], and `requestScreen` really moves the window. + */ +private val v2Logger: java.util.logging.Logger = + java.util.logging.Logger + .getLogger("dev.nucleusframework.window.tao.windowV2") + +private class InitialGeometry( + val placement: WindowPlacement, + val isMinimized: Boolean, + val bounds: ResolvedV2Bounds, + val screenId: String, +) + +/** + * Draining a request channel is destructive, so the initial conversion is + * memoized per state: a window that leaves and re-enters composition before + * ever being shown must still land on the geometry it asked for. + */ +private val initialWindowGeometry: MutableMap = + Collections.synchronizedMap(WeakHashMap()) + +private val initialDialogGeometry: MutableMap = + Collections.synchronizedMap(WeakHashMap()) + +/** Snapshots the pending requests of [state] into a v1 [WindowStateV1]. */ +internal fun nucleusWindowStateToV1(state: NucleusWindowState): WindowStateV1 { + if (state.isInitialized) { + val bounds = state.bounds + return WindowStateV1( + placement = state.placement, + isMinimized = state.isMinimized, + position = WindowPosition(bounds.left, bounds.top), + size = bounds.size, + ) + } + val initial = initialWindowGeometry.getOrPut(state) { drainInitialWindowGeometry(state) } + return WindowStateV1( + placement = initial.placement, + isMinimized = initial.isMinimized, + position = initial.bounds.position, + size = initial.bounds.size, + ) +} + +/** Snapshots the pending requests of [state] into a v1 [DialogStateV1]. */ +internal fun nucleusDialogStateToV1(state: NucleusDialogState): DialogStateV1 { + if (state.isInitialized) { + val bounds = state.bounds + return DialogStateV1( + position = WindowPosition(bounds.left, bounds.top), + size = bounds.size, + ) + } + val initial = initialDialogGeometry.getOrPut(state) { drainInitialDialogGeometry(state) } + return DialogStateV1( + position = initial.bounds.position, + size = initial.bounds.size, + ) +} + +private fun drainInitialWindowGeometry(state: NucleusWindowState): InitialGeometry { + val screen = resolveScreen(drainLast(state.screenRequests), window = null) + return InitialGeometry( + placement = state.placementRequests.tryReceive().getOrNull() ?: WindowPlacement.Floating, + isMinimized = state.minimizedRequests.tryReceive().getOrNull() ?: false, + bounds = resolveInitialBounds(drainLast(state.boundsRequests), screen), + screenId = screen.id, + ) +} + +private fun drainInitialDialogGeometry(state: NucleusDialogState): InitialGeometry { + val screen = resolveScreen(drainLast(state.screenRequests), window = null) + return InitialGeometry( + placement = WindowPlacement.Floating, + isMinimized = false, + bounds = resolveInitialBounds(drainLast(state.boundsRequests), screen), + screenId = screen.id, + ) +} + +/** + * Applies [v2]'s requests to [v1] and publishes the observed geometry back. + * + * [nativeWindow] is `null` until the window is realized (and always `null` for + * hosts that never expose it); every provider is still evaluable then, against + * the monitor geometry alone. + */ +@Composable +internal fun BindNucleusWindowState( + v2: NucleusWindowState, + v1: WindowStateV1, + visible: Boolean, + nativeWindow: TaoWindow? = null, +) { + val latestV2 = v2 + val latestV1 = v1 + val latestNativeWindow by rememberUpdatedState(nativeWindow) + LaunchedEffect(v2, v1) { + // When the last placement was handed to v1. Both consumers below run on + // this effect's dispatcher (the Tao main thread), so a plain var is the + // whole synchronisation story. + var placementAppliedNs = Long.MIN_VALUE + launch { + for (placement in latestV2.placementRequests) { + latestV1.placement = placement + placementAppliedNs = System.nanoTime() + } + } + launch { + for (minimized in latestV2.minimizedRequests) { + latestV1.isMinimized = minimized + } + } + launch { + for (provider in latestV2.boundsRequests) { + // The native flags decide, not the v1 bookkeeping alone: a burst + // of placement toggles can leave AppKit still zoomed while v1 + // already says Floating (each `zoom:` is a toggle, and the ones + // issued mid-animation may not land in order). + val window = latestNativeWindow + val v1LeavesPlacement = latestV1.placement != WindowPlacement.Floating + val nativeStuck = + !v1LeavesPlacement && window != null && (window.isMaximized || window.isFullscreen) + val leftPlacement = v1LeavesPlacement || nativeStuck + // …and even the native flags are only a sample. AppKit clears + // `isZoomed` *before* it animates, so right after a toggle both + // the v1 bookkeeping and the flags can read Floating while the + // zoom that will re-assert the old frame has not landed yet — + // and then it lands on top of the bounds we are about to apply, + // with nobody watching, because `leftPlacement` said there was + // nothing to leave. A placement applied moments ago is therefore + // its own reason to confirm the result. + val placementInFlight = + System.nanoTime() - placementAppliedNs < PLACEMENT_IN_FLIGHT_GRACE_NS + if (leftPlacement) { + // Bounds on a non-floating window make it floating (the v2 + // contract) — but the restore is asynchronous, and on macOS + // an animated un-zoom whose final frame lands *after* our + // size would put the pre-zoom frame back over it. Let the + // window actually leave the placement first, then resolve + // against the restored geometry. + // + // Who issues the restore matters: `setMaximized(false)` is a + // `zoom:` toggle on macOS. When v1 is leaving the placement + // its own effect issues it — a second one here re-zooms. Only + // when v1 already reads Floating (its effect stays idle) does + // the bridge clear the native state itself. + latestV1.placement = WindowPlacement.Floating + if (window != null) { + if (nativeStuck) restoreAndAwaitFloating(window) else awaitFloating(window) + } + } + val resolved = resolveBounds(provider, latestV1, latestNativeWindow) + latestV1.size = resolved.size + latestV1.position = resolved.position + if (leftPlacement || placementInFlight) { + latestNativeWindow?.let { confirmBounds(it, latestV1, resolved) } + } + } + } + launch { + for (provider in latestV2.screenRequests) { + val window = latestNativeWindow + val target = resolveScreen(provider, window) + latestV1.position = positionOnScreen(target, latestV1, window) + latestV2.screenIdOrNull = target.id + } + } + } + LaunchedEffect(nativeWindow) { + val window = nativeWindow ?: return@LaunchedEffect + correctInitialOuterSize( + window = window, + initialOuterSize = initialWindowGeometry[latestV2]?.bounds?.size, + currentSize = { latestV1.size }, + applySize = { latestV1.size = it }, + ) + } + val geometrySignal = rememberNativeGeometrySignal(nativeWindow) + LaunchedEffect(v1.size, v1.position, v1.placement, v1.isMinimized, visible, nativeWindow) { + latestV2.placementOrNull = v1.placement + latestV2.minimizedOrNull = v1.isMinimized + + suspend fun publish() = + publishObserved( + window = nativeWindow, + position = v1.position, + size = v1.size, + setBounds = { latestV2.boundsOrNull = it }, + setScreenId = { latestV2.screenIdOrNull = it }, + markInitialized = { if (visible) latestV2.isInitialized = true }, + ) + publish() + for (event in geometrySignal) { + publish() + } + } +} + +/** [BindNucleusWindowState] for a dialog state. */ +@Composable +internal fun BindNucleusDialogState( + v2: NucleusDialogState, + v1: DialogStateV1, + visible: Boolean, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + nativeWindow: TaoWindow? = null, + /** The window the dialog was opened from, for `AlignedToParentWindow`. */ + parentWindow: TaoWindow? = null, +) { + val latestV2 = v2 + val latestV1 = v1 + val latestNativeWindow by rememberUpdatedState(nativeWindow) + val latestParentWindow by rememberUpdatedState(parentWindow) + LaunchedEffect(v2, v1, minSize, maxSize) { + launch { + for (provider in latestV2.boundsRequests) { + val resolved = resolveDialogBounds(provider, latestV1, latestNativeWindow, latestParentWindow) + // minSize / maxSize are inner sizes (they drive + // TaoWindow.setMinimumSize / setMaximumSize), so clamp the inner + // size the outer request converted to. + latestV1.size = clampSize(resolved.size, minSize, maxSize) + latestV1.position = resolved.position + } + } + launch { + for (provider in latestV2.screenRequests) { + val window = latestNativeWindow + val target = resolveScreen(provider, window) + latestV1.position = positionOnScreenDp(target, latestV1.position, latestV1.size, window) + latestV2.screenIdOrNull = target.id + } + } + } + LaunchedEffect(nativeWindow) { + val window = nativeWindow ?: return@LaunchedEffect + correctInitialOuterSize( + window = window, + initialOuterSize = initialDialogGeometry[latestV2]?.bounds?.size, + currentSize = { latestV1.size }, + applySize = { latestV1.size = clampSize(it, minSize, maxSize) }, + ) + } + val geometrySignal = rememberNativeGeometrySignal(nativeWindow) + LaunchedEffect(v1.size, v1.position, visible, nativeWindow) { + suspend fun publish() = + publishObserved( + window = nativeWindow, + position = v1.position, + size = v1.size, + setBounds = { latestV2.boundsOrNull = it }, + setScreenId = { latestV2.screenIdOrNull = it }, + markInitialized = { if (visible) latestV2.isInitialized = true }, + ) + publish() + for (event in geometrySignal) { + publish() + } + } +} + +// ── Request resolution ────────────────────────────────────────────────────── + +private fun resolveScreen( + provider: WindowScreenProvider?, + window: TaoWindow?, +): Screen { + val scope = screenScope(window) + return provider?.let { scope.evaluateScreen(it) } ?: scope.defaultScreen +} + +/** + * Evaluates [provider] against the live window, converting the outer rectangle + * it returns into the inner size the v1 state carries. + */ +private fun resolveBounds( + provider: WindowBoundsProvider, + v1: WindowStateV1, + window: TaoWindow?, +): ResolvedV2Bounds { + val total = window.decorationInsets(v1.size) + val scope = geometryScope(window, v1.position, v1.size, total) + val resolved = scope.resolve(provider, v1.position) + return ResolvedV2Bounds( + position = resolved.position, + size = resolved.size.minusInsets(total), + ) +} + +private fun resolveDialogBounds( + provider: WindowBoundsProvider, + v1: DialogStateV1, + window: TaoWindow?, + parentWindow: TaoWindow?, +): ResolvedV2Bounds { + val total = window.decorationInsets(v1.size) + val scope = geometryScope(window, v1.position, v1.size, total, parentWindow) + val resolved = scope.resolve(provider, v1.position) + return ResolvedV2Bounds( + position = resolved.position, + size = resolved.size.minusInsets(total), + ) +} + +/** + * Initial bounds, before any native window exists. + * + * The scope reports [screen] — the one the initial `WindowScreenProvider` + * picked, so `CenteredOnScreen` and friends resolve against it — and, as the + * window's own metrics, a default-sized rectangle centred there. That stands in + * for a window that does not exist yet: `WindowSizeProvider.Current` reads + * 800×600 (Compose's own default) and `WindowPositionProvider.Current` reads + * the centre of the target screen instead of throwing or reporting a corner. + */ +private fun resolveInitialBounds( + provider: WindowBoundsProvider?, + screen: Screen, +): ResolvedV2Bounds { + val fallback = ResolvedV2Bounds(WindowPosition.PlatformDefault, DEFAULT_WINDOW_SIZE) + if (provider == null) return fallback + val available = screen.availableBounds + val left = available.left + ((available.right - available.left - DEFAULT_WINDOW_SIZE.width).value / 2f).dp + val top = available.top + ((available.bottom - available.top - DEFAULT_WINDOW_SIZE.height).value / 2f).dp + val scope = + WindowGeometryProviderScope( + windowMetrics = + WindowMetrics( + screen = screen, + bounds = + DpRect( + left = left, + top = top, + right = left + DEFAULT_WINDOW_SIZE.width, + bottom = top + DEFAULT_WINDOW_SIZE.height, + ), + insets = ZERO_INSETS, + ), + parentWindowMetrics = null, + ) + // Before the window exists, "the current position" is the one the window + // manager has not chosen yet. `requestSize` / `WindowBoundsProvider(size)` + // pair their size with `WindowPositionProvider.Current`, and resolving that + // against the placeholder rectangle above would pin the window to an + // absolute point — the v1 `size =` idiom this replaces leaves placement to + // the platform, so keep that here. (`WindowSizeProvider.Current` reads the + // placeholder's default size, which is already the v1 default.) + if (provider is CombinedBoundsProvider && provider.positionProvider === WindowPositionProvider.Current) { + val size = sanitizeSize(scope.evaluateSize(provider.sizeProvider)) + return ResolvedV2Bounds(WindowPosition.PlatformDefault, size) + } + return scope.resolve(provider, WindowPosition.PlatformDefault) +} + +/** + * The window's position after moving it to [target], preserving its offset + * inside the work area and clamping it so the whole window stays visible. + */ +private fun positionOnScreen( + target: Screen, + v1: WindowStateV1, + window: TaoWindow?, +): WindowPosition = positionOnScreenDp(target, v1.position, v1.size, window) + +private fun positionOnScreenDp( + target: Screen, + currentPosition: WindowPosition, + currentSize: DpSize, + window: TaoWindow?, +): WindowPosition { + val available = target.availableBounds + val outer = window?.outerBoundsDpOrNull() + val size = + outer?.size?.takeIf { it.width.isSpecified && it.height.isSpecified } + ?: currentSize.takeIf { it.width.isSpecified && it.height.isSpecified } + ?: DEFAULT_WINDOW_SIZE + val source = window?.let { screenScope(it).defaultScreen } + val fraction = relativePosition(outer, currentPosition, source) + val maxX = (available.right - available.left - size.width).value.coerceAtLeast(0f) + val maxY = (available.bottom - available.top - size.height).value.coerceAtLeast(0f) + return WindowPosition.Absolute( + x = available.left + (fraction.x.value * maxX).dp, + y = available.top + (fraction.y.value * maxY).dp, + ) +} + +/** + * Where the window sits inside its current screen's work area, as a `0..1` + * fraction on each axis. Centres the window when its current position is + * unknown — a window that never reported a position has nothing to preserve. + */ +private fun relativePosition( + outer: DpRect?, + currentPosition: WindowPosition, + source: Screen?, +): DpOffset { + val left = outer?.left ?: (currentPosition as? WindowPosition.Absolute)?.x ?: return HALF_OFFSET + val top = outer?.top ?: (currentPosition as? WindowPosition.Absolute)?.y ?: return HALF_OFFSET + val available = source?.availableBounds ?: return HALF_OFFSET + val spanX = (available.right - available.left).value + val spanY = (available.bottom - available.top).value + if (spanX <= 0f || spanY <= 0f) return HALF_OFFSET + return DpOffset( + x = ((left - available.left).value / spanX).coerceIn(0f, 1f).dp, + y = ((top - available.top).value / spanY).coerceIn(0f, 1f).dp, + ) +} + +private val HALF_OFFSET = DpOffset(0.5f.dp, 0.5f.dp) + +private val ZERO_INSETS = DpInsets(top = 0.dp, left = 0.dp, bottom = 0.dp, right = 0.dp) + +// ── Scope construction ────────────────────────────────────────────────────── + +private fun geometryScope( + window: TaoWindow?, + currentPosition: WindowPosition, + currentInnerSize: DpSize, + /** Total outer-minus-inner difference, as reported by the platform. */ + decorationSize: DpSize, + /** The owner a dialog was opened from; popups resolve theirs natively. */ + parentWindow: TaoWindow? = null, +): WindowGeometryProviderScope { + val scale = TaoMonitors.referenceScale(window) + val screen = Screen(TaoMonitors.forWindow(window), scale) + // `Current` must read the geometry already *requested*, not the native + // rectangle: applies are asynchronous, so two back-to-back requests — + // `requestSize` then `requestPosition`, whose implicit size is Current — + // would otherwise have the second one read the not-yet-resized window and + // revert the first. The v1 state is that pending truth wherever it has one + // (an Absolute position, a specified size); the native window fills the + // axes it does not, and everything before the window exists. + val native = window?.outerBoundsDpOrNull() + val pending = approximateOuterRect(currentPosition, currentInnerSize.plusInsets(decorationSize)) + val bounds = + when { + native == null -> pending + pending == null -> native + else -> { + val left = if (currentPosition is WindowPosition.Absolute) pending.left else native.left + val top = if (currentPosition is WindowPosition.Absolute) pending.top else native.top + val width = if (currentInnerSize.width.isSpecified) pending.size.width else native.size.width + val height = if (currentInnerSize.height.isSpecified) pending.size.height else native.size.height + DpRect(left = left, top = top, right = left + width, bottom = top + height) + } + } + ?: DpRect( + left = screen.availableBounds.left, + top = screen.availableBounds.top, + right = screen.availableBounds.left + DEFAULT_WINDOW_SIZE.width, + bottom = screen.availableBounds.top + DEFAULT_WINDOW_SIZE.height, + ) + // A dialog's owner is wired at the platform level, so the overload that + // opens it hands the owner over; popup overlays know theirs natively. + val parent = parentWindow ?: window?.popupParent + return WindowGeometryProviderScope( + windowMetrics = WindowMetrics(screen = screen, bounds = bounds, insets = splitInsets(decorationSize)), + parentWindowMetrics = parent?.let { parentMetrics(it, scale) }, + scale = scale, + measureContent = window?.contentMeasurerOrNull(), + ) +} + +private fun parentMetrics( + parent: TaoWindow, + scale: Float, +): WindowMetrics? { + val bounds = parent.outerBoundsDpOrNull() ?: return null + return WindowMetrics( + screen = Screen(TaoMonitors.forWindow(parent), scale), + bounds = bounds, + insets = ZERO_INSETS, + ) +} + +/** + * Decoration insets as a per-side [DpInsets], derived from the one thing the + * platform actually reports: the total outer-minus-inner difference. + * + * The split assumes the common frame shape — equal side borders, the remaining + * vertical difference on top for the title bar. Exact for the undecorated + * client-side-decorated windows `DecoratedWindow` draws by default (all zero), + * and off by at most a border width on a natively decorated one. + */ +private fun splitInsets(total: DpSize): DpInsets { + if (!total.width.isSpecified || !total.height.isSpecified) return ZERO_INSETS + if (total.width.value <= 0f && total.height.value <= 0f) return ZERO_INSETS + val side = (total.width.value / 2f).coerceAtLeast(0f) + val bottom = minOf(side, total.height.value) + return DpInsets( + top = (total.height.value - bottom).dp, + left = side.dp, + bottom = bottom.dp, + right = side.dp, + ) +} + +// ── Observed geometry ─────────────────────────────────────────────────────── + +private suspend fun publishObserved( + window: TaoWindow?, + position: WindowPosition, + size: DpSize, + setBounds: (DpRect) -> Unit, + setScreenId: (String) -> Unit, + markInitialized: () -> Unit, +) { + val rect = observedRect(position, size, window) ?: return + setBounds(rect) + setScreenId(TaoMonitors.forWindow(window).id) + markInitialized() +} + +/** + * Evaluates [provider] into a v1 position + **outer** size. + * + * [CombinedBoundsProvider] is unfolded instead of going through `getBounds`: + * only the split form can express "let the window manager position it" + * (unspecified position) or "size to content" (unspecified axis) without the + * `NaN` a [DpRect] would turn either sentinel into. + */ +private fun WindowGeometryProviderScope.resolve( + provider: WindowBoundsProvider, + currentPosition: WindowPosition, +): ResolvedV2Bounds { + if (provider is CombinedBoundsProvider) { + val size = evaluateSize(provider.sizeProvider) + val position = evaluatePosition(provider.positionProvider, size) + return ResolvedV2Bounds( + position = positionOfOffset(position, currentPosition), + size = sanitizeSize(size), + ) + } + val rect = evaluateBounds(provider) + return ResolvedV2Bounds( + position = positionOf(rect, currentPosition), + size = sanitizeSize(rect.size), + ) +} + +private fun positionOfOffset( + offset: DpOffset, + current: WindowPosition, +): WindowPosition = + when { + offset.isSpecified -> WindowPosition.Absolute(offset.x, offset.y) + current is WindowPosition.Absolute -> current + else -> WindowPosition.PlatformDefault + } + +private fun positionOf( + rect: DpRect, + current: WindowPosition, +): WindowPosition = + when { + rect.left.isSpecified && rect.top.isSpecified -> WindowPosition.Absolute(rect.left, rect.top) + current is WindowPosition.Absolute -> current + else -> WindowPosition.PlatformDefault + } + +/** Zero or negative axes (an unmeasured content pass) become wrap-content. */ +private fun sanitizeSize(size: DpSize): DpSize = + DpSize( + width = if (size.width.isSpecified && size.width.value > 0f) size.width else Dp.Unspecified, + height = if (size.height.isSpecified && size.height.value > 0f) size.height else Dp.Unspecified, + ) + +private fun drainLast(channel: Channel): T? { + var last: T? = null + while (true) { + last = channel.tryReceive().getOrNull() ?: return last + } +} + +/** + * Corrects the one geometry the creation path cannot get right on its own. + * + * A v2 bounds provider returns the *outer* rectangle, but before the window + * exists its decoration insets are unknown, so the initial outer size had to be + * applied as the inner size — a natively decorated frame then comes out larger + * by its chrome. Once the window is mapped the insets are measurable: if the + * inner size is still the initial request, shrink it by them so the outer + * rectangle matches what was asked. A window the user has already resized + * (v1 size no longer the initial one) is left alone. + */ +private suspend fun correctInitialOuterSize( + window: TaoWindow, + initialOuterSize: DpSize?, + currentSize: () -> DpSize, + applySize: (DpSize) -> Unit, +) { + val requested = initialOuterSize ?: return + if (!requested.width.isSpecified || !requested.height.isSpecified) return + repeat(OBSERVED_BOUNDS_RETRIES) { attempt -> + val outer = window.outerBoundsDpOrNull() + if (outer != null && outer.size.width.value > 1f && outer.size.height.value > 1f) { + if (currentSize() != requested) return + val insets = window.decorationInsets(requested) + if (insets.width.value > 0f || insets.height.value > 0f) { + applySize(requested.minusInsets(insets)) + } + return + } + if (attempt < OBSERVED_BOUNDS_RETRIES - 1) delay(OBSERVED_BOUNDS_RETRY_MS) + } +} + +/** + * Suspends until [window] has actually left its maximized / fullscreen + * placement: the flag is down *and* the outer rectangle has stopped moving for + * [PLACEMENT_SETTLED_POLLS] consecutive polls. The flag alone is not enough — + * macOS clears `isZoomed` at the start of the un-zoom animation, whose final + * frame would still land on top of anything applied meanwhile. Bounded by + * [PLACEMENT_RESTORE_RETRIES] polls; gives up silently, and the geometry is + * then applied as before. + */ +private suspend fun awaitFloating(window: TaoWindow) { + var previous: List? = null + var stable = 0 + repeat(PLACEMENT_RESTORE_RETRIES) { + if (!window.isMaximized && !window.isFullscreen) { + val current = window.outerBoundsPx()?.toList() + stable = if (current != null && current == previous) stable + 1 else 0 + previous = current + if (stable >= PLACEMENT_SETTLED_POLLS) return + } else { + stable = 0 + previous = null + } + delay(PLACEMENT_RESTORE_RETRY_MS) + } +} + +/** + * Apply-and-confirm for geometry applied right after leaving a placement. + * + * The flag-and-stillness wait above cannot see an un-zoom animation that has + * not started yet: AppKit can pause between clearing `isZoomed` and animating, + * and its final frame then lands on top of whatever was applied meanwhile. So + * after applying, watch the window settle and compare it with the target; if + * the animation put the old frame back, the v1 state now carries that observed + * size, and re-assigning the target re-runs the apply. Bounded attempts; a + * window manager that refuses the size wins. + */ +private suspend fun confirmBounds( + window: TaoWindow, + v1: WindowStateV1, + target: ResolvedV2Bounds, +) { + repeat(CONFIRM_ATTEMPTS) { + awaitSettled(window) + val outer = window.outerBoundsDpOrNull() ?: return + val insets = window.decorationInsets(v1.size) + val sizeOk = + !target.size.width.isSpecified || + !target.size.height.isSpecified || + ( + kotlin.math.abs( + (outer.size.width - insets.width - target.size.width).value, + ) <= CONFIRM_TOLERANCE_DP && + kotlin.math.abs((outer.size.height - insets.height - target.size.height).value) <= + CONFIRM_TOLERANCE_DP + ) + val position = target.position + val positionOk = + position !is WindowPosition.Absolute || + ( + kotlin.math.abs((outer.left - position.x).value) <= CONFIRM_TOLERANCE_DP && + kotlin.math.abs((outer.top - position.y).value) <= CONFIRM_TOLERANCE_DP + ) + // A re-asserted placement is a failure to converge in its own right, and + // it has to be tested *before* the geometry: a maximized window ignores + // v1's size, so `decorationInsets` derives the insets from a target that + // never landed (2560 outer - 820 target = 1740 of "decoration"), and the + // subtraction above then reports `sizeOk` for any outer rectangle at all. + val placementClear = !window.isMaximized && !window.isFullscreen + if (placementClear && sizeOk && positionOk) return + if (!placementClear) { + // Clearing it is v1's job, not ours. `setMaximized(false)` is a + // `zoom:` toggle on macOS, and the window composable's placement + // effect issues exactly one when v1 goes Floating while its own + // `applied` bookkeeping still reads Maximized — which is precisely + // what a late zoom leaves behind, since the resize it triggers + // writes Maximized into both. Poking the native flag here instead + // would race that effect and re-zoom. Only when v1 already reads + // Floating (its effect stays idle) does the bridge clear the native + // state itself. + if (v1.placement != WindowPlacement.Floating) { + v1.placement = WindowPlacement.Floating + awaitFloating(window) + } else { + restoreAndAwaitFloating(window) + } + } + v1.size = target.size + v1.position = target.position + } +} + +/** + * Clears a native maximized / fullscreen state the v1 bookkeeping does not + * know about (its `applied.placement` already reads Floating, so its own + * effect will not act), then waits for the window to leave it. + * + * Not on macOS for the maximized case: there `setMaximized(false)` is a + * `zoom:` *toggle*, and issuing one while AppKit is still draining a queue of + * zoom animations keeps the race alive — every corrective toggle can land on a + * frame that is already un-zoomed and zoom it again. Setting the frame is what + * un-zooms deterministically (`isZoomed` is "frame equals the zoomed frame"), + * so the caller applies the target and lets [confirmBounds] re-apply until the + * animation queue has drained. Fullscreen is not a toggle and is cleared + * everywhere. + */ +private suspend fun restoreAndAwaitFloating(window: TaoWindow) { + if (window.isFullscreen) window.setFullscreen(false) + if (window.isMaximized && Platform.Current != Platform.MacOS) window.setMaximized(false) + if (Platform.Current == Platform.MacOS) awaitSettled(window) else awaitFloating(window) +} + +/** Waits until the outer rectangle holds still for [PLACEMENT_SETTLED_POLLS] polls. */ +private suspend fun awaitSettled(window: TaoWindow) { + var previous: List? = null + var stable = 0 + repeat(PLACEMENT_RESTORE_RETRIES) { + val current = window.outerBoundsPx()?.toList() + stable = if (current != null && current == previous) stable + 1 else 0 + previous = current + if (stable >= PLACEMENT_SETTLED_POLLS) return + delay(PLACEMENT_RESTORE_RETRY_MS) + } +} + +/** + * How long after a placement was applied a bounds request still has to confirm + * its result. Covers a queued `zoom:` animation whose final frame lands after + * the bounds were applied; long enough for a burst of them to drain, short + * enough that ordinary geometry requests — a frame-paced move sends one per + * frame through the same channel — never pay for the confirmation. + */ +private const val PLACEMENT_IN_FLIGHT_GRACE_NS = 1_000_000_000L + +private const val PLACEMENT_RESTORE_RETRIES = 60 +private const val PLACEMENT_RESTORE_RETRY_MS = 50L +private const val PLACEMENT_SETTLED_POLLS = 3 +private const val CONFIRM_ATTEMPTS = 3 +private const val CONFIRM_TOLERANCE_DP = 2f + +// ── Fallback for hosts that only wrap the v1 surface ──────────────────────── + +/** + * v1 [WindowStateV1] kept in sync with the AWT-free v2 [state]. + * + * For hosts (themed `NucleusWindowHost` implementations) that only wrap the v1 + * window surface. The native window is unavailable on that path, so geometry + * providers resolve against monitor data alone and the published `bounds` is + * the inner size rather than the outer one. + */ +@Composable +public fun rememberSyncedNucleusWindowState( + state: NucleusWindowState, + visible: Boolean, +): WindowStateV1 { + val v1 = remember(state) { nucleusWindowStateToV1(state) } + BindNucleusWindowState(state, v1, visible) + return v1 +} + +/** + * v1 [DialogStateV1] kept in sync with the AWT-free v2 [state]. Same fallback + * contract as [rememberSyncedNucleusWindowState]. + */ +@Composable +public fun rememberSyncedNucleusDialogState( + state: NucleusDialogState, + visible: Boolean, +): DialogStateV1 { + val v1 = remember(state) { nucleusDialogStateToV1(state) } + BindNucleusDialogState(state, v1, visible) + return v1 +} + +// ── Shared geometry helpers ───────────────────────────────────────────────── + +/** Native geometry is only readable once Tao has realized the window. */ +private const val OBSERVED_BOUNDS_RETRIES = 20 +private const val OBSERVED_BOUNDS_RETRY_MS = 50L +private const val RECT_ARRAY_SIZE = 4 + +internal data class ResolvedV2Bounds( + val position: WindowPosition, + val size: DpSize, +) + +/** + * Signals every native move / resize of [window]. + * + * Keying the observed-geometry effect on the v1 state alone is not enough: the + * window manager moves and resizes a window without the v1 state changing — + * the initial geometry apply itself lands *after* that effect has run — which + * would leave `bounds` reporting a stale rectangle for the rest of the window's + * life. + * + * A conflated channel rather than snapshot state: the callbacks fire on the + * event-loop thread from inside the platform's resize handling, which can be + * *within* a Compose measure/layout pass. Writing snapshot state there + * re-enters layout through the recomposition it schedules + * ("performMeasureAndLayout called during measure layout"); a channel send + * carries no such obligation, and the receiving coroutine resumes on the + * dispatcher once the native frame has unwound. + * + * One registration per window instance ([LaunchedEffect] keyed on the window), + * matching the listeners' append-only contract. + */ +@Composable +internal fun rememberNativeGeometrySignal(window: TaoWindow?): Channel { + val signal = remember(window) { Channel(Channel.CONFLATED) } + LaunchedEffect(window) { + val target = window ?: return@LaunchedEffect + target.onMoved { _, _ -> signal.trySend(Unit) } + target.onResized { _, _ -> signal.trySend(Unit) } + } + return signal +} + +internal fun minSizeOrNull(minSize: DpSize): DpSize? = + if (minSize.width.isSpecified && minSize.height.isSpecified) minSize else null + +internal fun clampSize( + size: DpSize, + minSize: DpSize, + maxSize: DpSize, +): DpSize { + var width = size.width + var height = size.height + val min = minSizeOrNull(minSize) + if (min != null) { + if (width.isSpecified && width < min.width) width = min.width + if (height.isSpecified && height < min.height) height = min.height + } + if (maxSize.width.isSpecified && width.isSpecified && width > maxSize.width) width = maxSize.width + if (maxSize.height.isSpecified && height.isSpecified && height > maxSize.height) height = maxSize.height + return DpSize(width, height) +} + +/** + * Observed window rectangle, preferring the native geometry. + * + * Compose v2 documents `WindowState.bounds` as the whole window, insets + * included ([androidx.compose.ui.window.v2.WindowMetrics.bounds]), which is + * exactly [TaoWindow.outerBoundsPx]. The v1 state is *not* a substitute: it + * pairs the outer position ([TaoWindow.setOuterPosition]) with the inner size + * ([TaoWindow.setInnerSize]), so publishing it would make `bounds.size` mean + * one thing before the first native measurement and another after — enough to + * shrink a window by its decoration insets on every `requestBounds(bounds)` + * round-trip, or across a `WindowState.Saver` restore. + */ +internal suspend fun observedRect( + position: WindowPosition, + size: DpSize, + nativeWindow: TaoWindow?, +): DpRect? { + if (nativeWindow != null) { + repeat(OBSERVED_BOUNDS_RETRIES) { attempt -> + nativeWindow.outerBoundsDpOrNull()?.let { return it } + if (attempt < OBSERVED_BOUNDS_RETRIES - 1) delay(OBSERVED_BOUNDS_RETRY_MS) + } + } + return approximateOuterRect(position, size) +} + +/** + * Best-effort rectangle for hosts that never expose the native window — a + * themed [dev.nucleusframework.window.tao.rememberSyncedWindowState] host binds + * with `nativeWindow = null` — and for a window the platform bridge can't + * measure yet. + * + * An approximation on two counts: the size is the inner one (insets unknown + * without a window to measure), and a position that hasn't become + * [WindowPosition.Absolute] yet is reported at the origin. Publishing it anyway + * is what keeps `WindowState.isInitialized` from staying `false` — and `bounds` + * / `size` / `position` from throwing — forever on a window manager that emits + * no initial move event. + */ +internal fun approximateOuterRect( + position: WindowPosition, + size: DpSize, +): DpRect? { + if (!size.width.isSpecified || !size.height.isSpecified) return null + val absolute = position as? WindowPosition.Absolute + val left = absolute?.x ?: 0.dp + val top = absolute?.y ?: 0.dp + return DpRect( + left = left, + top = top, + right = left + size.width, + bottom = top + size.height, + ) +} + +/** + * Decoration insets (outer minus inner size), or [DpSize.Zero] when they can't + * be measured — which is also the right answer for the undecorated CSD windows + * Tao draws by default. + */ +internal fun TaoWindow?.decorationInsets(innerSize: DpSize): DpSize { + val window = this ?: return DpSize.Zero + if (!innerSize.width.isSpecified || !innerSize.height.isSpecified) return DpSize.Zero + val outer = window.outerBoundsDpOrNull() ?: return DpSize.Zero + return DpSize( + width = (outer.right - outer.left - innerSize.width).coerceAtLeast(0.dp), + height = (outer.bottom - outer.top - innerSize.height).coerceAtLeast(0.dp), + ) +} + +/** Inner size → outer (v2) size. Unspecified axes stay unspecified. */ +internal fun DpSize.plusInsets(insets: DpSize): DpSize = + DpSize( + width = if (width.isSpecified) width + insets.width else width, + height = if (height.isSpecified) height + insets.height else height, + ) + +/** Outer (v2) size → inner size. Unspecified axes stay unspecified. */ +internal fun DpSize.minusInsets(insets: DpSize): DpSize = + DpSize( + width = if (width.isSpecified) (width - insets.width).coerceAtLeast(0.dp) else width, + height = if (height.isSpecified) (height - insets.height).coerceAtLeast(0.dp) else height, + ) + +internal fun TaoWindow.outerBoundsDpOrNull(): DpRect? { + val rect = outerBoundsPx() ?: return null + if (rect.size != RECT_ARRAY_SIZE) return null + val scale = scaleFactor.takeIf { it > 0f } ?: 1f + val left = rect[0] / scale + val top = rect[1] / scale + return DpRect( + left = left.dp, + top = top.dp, + right = (left + rect[2] / scale).dp, + bottom = (top + rect[3] / scale).dp, + ) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/OnPositionChanged.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/OnPositionChanged.kt new file mode 100644 index 000000000..ffe97df95 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/OnPositionChanged.kt @@ -0,0 +1,71 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.registerOnLayoutRectChanged +import androidx.compose.ui.node.DelegatableNode.RegistrationHandle +import androidx.compose.ui.node.LayoutAwareModifierNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo + +/** + * A cheaper [androidx.compose.ui.layout.onGloballyPositioned]: [callback] gets + * this modifier's coordinates only when its position can have changed, not on + * every placement of anything above it (#560). + * + * Two triggers, because neither covers the other: + * - `registerOnLayoutRectChanged` (no throttle, no debounce: inline on the + * scene thread, right after layout) — fires when the *layout node's* rect + * moves in the window, ancestors' layers included. It knows nothing of where + * this modifier sits in the chain. + * - `onPlaced` — fires when this node is laid out again, which is how a change + * *inside* the chain (a `padding` before this modifier) reaches it. + * + * The coordinates are this modifier's, exactly as `onGloballyPositioned` + * reported them, so the callback reads `boundsInWindow()` (clipping included) + * or `positionInRoot()` unchanged. It may run twice for one change and never + * for a pure layer transform set *earlier in the same chain*; callers that push + * to native code dedup on the value they push. + */ +internal fun Modifier.onPositionChanged(callback: (LayoutCoordinates) -> Unit): Modifier = + this then OnPositionChangedElement(callback) + +private data class OnPositionChangedElement( + val callback: (LayoutCoordinates) -> Unit, +) : ModifierNodeElement() { + override fun create(): OnPositionChangedNode = OnPositionChangedNode(callback) + + override fun update(node: OnPositionChangedNode) { + node.callback = callback + } + + override fun InspectorInfo.inspectableProperties() { + name = "onPositionChanged" + } +} + +private class OnPositionChangedNode( + var callback: (LayoutCoordinates) -> Unit, +) : Modifier.Node(), + LayoutAwareModifierNode { + private var coordinates: LayoutCoordinates? = null + private var handle: RegistrationHandle? = null + + override fun onAttach() { + handle = + registerOnLayoutRectChanged(throttleMillis = 0, debounceMillis = 0) { + coordinates?.takeIf { it.isAttached }?.let(callback) + } + } + + override fun onDetach() { + handle?.unregister() + handle = null + coordinates = null + } + + override fun onPlaced(coordinates: LayoutCoordinates) { + this.coordinates = coordinates + callback(coordinates) + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/Satellite.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/Satellite.kt new file mode 100644 index 000000000..b61f9d060 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/Satellite.kt @@ -0,0 +1,632 @@ +// #636: the window openers below are `@ComposableOpenTarget(-1)` with +// `@UiComposable` content lambdas — callable from any applier, always composing +// UI — so a non-UI composable called in the caller's scope cannot reclassify +// the window content. ktlint's `annotation` and `function-type-modifier-spacing` +// rules contradict each other on the resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation") + +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.UiComposable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.BasicTitleBar +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.TitleBarLayoutPolicy +import dev.nucleusframework.window.WindowScaffold +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.workspace.DragGhostWindow +import dev.nucleusframework.window.tao.workspace.RelocatedContentHost +import dev.nucleusframework.window.tao.workspace.ScreenDrag +import dev.nucleusframework.window.tao.workspace.screenDragHandle + +/** + * What a satellite's `header` and `content` lambdas get to see: the satellite + * itself, its workspace, and the three actions a palette chrome needs. + * + * The same scope instance serves both hosts, so a header written once shows + * "Dock" while floating and "Float" / "Close" while docked without knowing + * which window it is being composed into. + */ +@ExperimentalNucleusApi +public interface SatelliteScope { + /** The workspace the satellite belongs to. */ + public val workspace: SatelliteWorkspace + + /** The satellite being composed. */ + public val satellite: SatelliteEntry + + /** + * `true` when this composition is the panel inside a [DockLayout], `false` + * when it is the floating window — a property of the host, not of + * [SatelliteEntry.placement], so the content never sees the other host's + * value during the frame in which the two swap. + */ + public val isDocked: Boolean + + /** + * `true` when the window this satellite is composed in is placed by the + * compositor rather than by the app ([TaoWindow.canPlaceOnScreen] `false` + * — a native Wayland surface). + * + * That is the one thing chrome has to adapt to, and it says nothing about + * the platform: where it holds, *moving the window* is the compositor's + * gesture and only the area an app leaves unclaimed can start it, while + * *moving the satellite* — docking it, tearing it out — rides the + * platform's drag-and-drop session from a [Modifier.satelliteDragHandle]. + * The two cannot share one area, so a floating satellite's title bar + * reserves [SatelliteCaptionStripWidth] for the compositor and hands it to + * the `floatingCaption` slot of [Satellite]; everywhere else the whole bar + * drags the satellite and that slot is not composed at all. + * + * `false` until the native window exists, and while the satellite has no + * window at all (a docked panel reads its host's value). + */ + public val isCompositorPlaced: Boolean + + /** Docks the satellite on [side] of the workspace owner; defaults to the last side it was docked on. */ + public fun dock(side: DockSide = satellite.preferredDockSide) { + workspace.dock(satellite.id, side) + } + + /** Lifts the satellite out of its dock into a floating window. */ + public fun undock() { + workspace.undock(satellite.id) + } + + /** Hides the satellite until [SatelliteWorkspace.open]. */ + public fun close() { + workspace.close(satellite.id) + } +} + +internal class SatelliteScopeImpl( + override val workspace: SatelliteWorkspace, + override val satellite: SatelliteEntry, + override val isDocked: Boolean, + /** + * The window this scope's content is composed in, read on every access: + * the scope outlives the window (a satellite docks, undocks, moves host) + * and a window answers [TaoWindow.canPlaceOnScreen] only once its native + * surface exists. + */ + private val host: () -> TaoWindow? = { null }, +) : SatelliteScope { + override val isCompositorPlaced: Boolean get() = host()?.canPlaceOnScreen == false +} + +/** + * Declares a satellite of [workspace] and hosts it wherever its placement + * says: as a [SatelliteWindow] owned by the workspace's current owner while + * floating, or — while docked — inside the [DockLayout] of the window it is + * docked into. Only one host composes the [content] at a time. + * + * Declare it once, at application scope, next to the windows that join the + * workspace: + * + * ```kotlin + * val workspace = rememberSatelliteWorkspace() + * DecoratedWindow(onCloseRequest = ::exitApplication) { + * JoinSatelliteWorkspace(workspace) + * DockLayout(workspace) { Document() } + * } + * Satellite(workspace, id = "tools", title = "Tools") { ToolsPanel() } + * ``` + * + * `rememberSaveable` state inside [content] survives docking and undocking: + * the workspace carries it from one host to the next. Plain `remember` state + * does not, exactly as when any composable moves between windows — hoist it + * or make it saveable. + * + * On native **Wayland** the dock drag rides the platform's drag-and-drop + * session from the header strip: a reduced picture of the palette follows the + * pointer instead of the window, and releasing it in a dock zone docks the + * satellite (see [Modifier.satelliteDragHandle]). The palette is moved by the + * caption strip beside its window controls, the compositor's own drag. + * `NUCLEUS_TAO_LINUX_RENDERER=x11` restores the window-following gesture of + * the other platforms. + * + * The workspace remembers the satellite ([SatelliteEntry]) after this + * composable leaves composition, so [initialPlacement] and [initiallyOpen] + * only apply the first time an [id] is declared (and never when a + * [SatelliteWorkspace.restore] already placed it). + * + * @param id stable identity within the workspace. + * @param title shown by the default [header] and as the floating window title. + * @param initialPlacement where the satellite starts on first declaration. + * @param initiallyOpen whether it is shown on first declaration. + * @param dockSides the sides the satellite may be docked on: the others are + * neither offered while it is dragged nor accepted by + * [SatelliteWorkspace.dock]. Empty makes it a floating-only palette. Fixed + * on first declaration, like the placement; a docked [initialPlacement] + * must name one of them. + * @param floatable whether the satellite can be a window of its own. `false` + * is a fixed panel: no tear-out, [SatelliteWorkspace.undock] refuses it, + * the default header offers no Float action, and a drag can only move it + * inside the dock. Requires a docked [initialPlacement]. + * @param reorderable whether the user may change its rank on its side. + * `false` pins it to the rank it was declared with: its own drag is offered + * none, and another panel can be dropped after it but never in front of it. + * Requires a docked [initialPlacement]. With `floatable = false` and a + * single [dockSides], the panel is furniture and its header is not even a + * drag handle. + * @param resizable whether the floating window can be resized by the user. + * @param minExtent the thinnest the panel may be docked — its width on a left + * or right side, its height on a top or bottom one. + * [SatelliteWorkspace.MinDockExtent] by default, and never below it. The + * splitters stop there, a split side the panel joins is brought to it, and + * the drop preview shows the width the drop will produce. + * @param maxExtent the thickest the panel may be docked; unbounded by + * default, and enforced the same way: on the splitters, on a side the + * panel joins, and in the drop preview. Neither limit constrains the + * floating window. + * @param hideWhileOwnerFullscreenOrMaximized hide the floating window while + * the owner fills the screen; see [SatelliteWindow]. + * @param compositionLocalContext parent locals bridged into the floating + * window's own scene, as for [SatelliteWindow]. Docked content composes + * inside the host window and needs no bridge. + * @param floatingContentWrapper composed around the floating window's chrome + * and content, inside the window's own scene — the hook framework layers + * use to provide their per-window locals. Must invoke the lambda it is given. + * @param header chrome shown in the floating window's title bar and above the + * docked panel; [DefaultSatelliteHeader] draws the title and dock actions. + * @param floatingCaption composed inside the strip of the floating title bar + * that is left to the compositor's window move — the + * [SatelliteCaptionStripWidth] beside the window controls, reserved only + * where the window is placed by the compositor + * ([SatelliteScope.isCompositorPlaced]). It is *not* a + * [Modifier.satelliteDragHandle]: a press in it moves the window, so what + * belongs here is the affordance that says so, not a control. Not composed + * at all on the platforms where the whole bar drags the satellite. + * @param content the satellite's body. + */ +@Suppress("LongParameterList", "FunctionNaming") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun ApplicationScope.Satellite( + workspace: SatelliteWorkspace, + id: String, + title: String, + initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), + initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, + resizable: Boolean = true, + minExtent: Dp = SatelliteWorkspace.MinDockExtent, + maxExtent: Dp = Dp.Infinity, + hideWhileOwnerFullscreenOrMaximized: Boolean = true, + compositionLocalContext: CompositionLocalContext? = null, + floatingContentWrapper: + @Composable @UiComposable TaoDecoratedWindowScope.(content: @Composable @UiComposable () -> Unit) -> Unit = + { it() }, + header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, + floatingCaption: @Composable @UiComposable SatelliteScope.() -> Unit = {}, + content: @Composable @UiComposable SatelliteScope.() -> Unit, +) { + val entry = + remember(workspace, id) { + workspace.register( + id, + title, + initialPlacement, + initiallyOpen, + dockSides, + floatable, + reorderable, + minExtent, + maxExtent, + ) + } + // The satellite's own window, once it has one: the scope is created before + // it and survives it, so it is read through a lambda. + var floatingWindow by remember(entry) { mutableStateOf(null) } + val scope = remember(entry) { SatelliteScopeImpl(workspace, entry, isDocked = false) { floatingWindow } } + // Published as snapshot state so the DockLayout hosting the panel picks up + // a new lambda without this composable knowing where the panel lives. + SideEffect { + entry.title = title + entry.header = header + entry.content = content + } + DisposableEffect(workspace, entry) { + onDispose { workspace.unregister(entry) } + } + + // Before the early return below: the ghost belongs to a satellite that is + // *docked* — it is the preview of it being torn out. + workspace.dragGhost?.takeIf { it.satellite === entry }?.let { ghost -> + DragGhostWindow( + screenRectPx = ghost.screenRectPx, + scaleFactor = ghost.scaleFactor, + title = ghost.satellite.title, + compositionLocalContext = compositionLocalContext, + layoutDirection = ghost.layoutDirection, + ) { + SatelliteGhostCard(ghost.satellite.title, Modifier.fillMaxSize()) + } + } + + val placement = entry.placement + val owner = workspace.owner + if (!entry.isOpen || !workspace.visible || placement !is SatellitePlacement.Floating || owner == null) return + + val currentHeader by rememberUpdatedState(header) + + // Where the satellite actually is, recorded as its placement the moment + // its window goes away. Closing one keeps "its placement and state" — and + // the placement a user recognises is where they dragged it to, not the rule + // it was declared with. Same for the workspace-wide `visible` sweep, which + // takes every palette down and brings it back. + DisposableEffect(workspace, entry) { + onDispose { workspace.recordFloatingPlacement(entry) } + } + + SatelliteWindow( + onCloseRequest = { workspace.close(id) }, + parent = owner, + state = entry.windowState, + title = title, + resizable = resizable, + hideWhileParentFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, + compositionLocalContext = compositionLocalContext, + ) { + val windowScope: TaoDecoratedWindowScope = this + SideEffect { + floatingWindow = window + // A system quit leaves the palette to the workspace (see TaoWindow.closesOnQuit). + window.closesOnQuit = false + } + DisposableEffect(window) { + onDispose { if (floatingWindow === window) floatingWindow = null } + } + // Native Wayland: the workspace cannot move the window itself (no + // client-side placement), so the bar keeps the compositor's move — + // the only way the palette stays draggable there. The header strip + // then carries the dock drag over the platform DnD session, and a + // caption strip next to the window controls is left to the compositor + // move: the split Chrome's tab strip makes between a tab and the empty + // strip beside it. + val workspaceDrag = window.canPlaceOnScreen + val currentCaption by rememberUpdatedState(floatingCaption) + floatingContentWrapper { + with(windowScope) { + WindowScaffold( + titleBar = { + // The whole bar is the drag handle, not just the strip + // the header draws: the bar is taller than the header, + // and the platform move that would otherwise own those + // few dp is a compositor grab, so a satellite moved + // there could never dock on release. A palette gives up + // OS snapping for that; see `nativeWindowDrag`. + // + // FillCenter hands its single centre child exactly the + // width left between the platform controls (traffic + // lights inset, caption buttons) — the header is a strip, + // not a centred title. + BasicTitleBar( + modifier = if (workspaceDrag) Modifier.satelliteDragHandle(scope) else Modifier, + layoutPolicy = TitleBarLayoutPolicy.FillCenter, + nativeWindowDrag = !workspaceDrag, + ) { + if (workspaceDrag) { + Box(Modifier.fillMaxWidth()) { currentHeader(scope) } + } else { + Row(Modifier.fillMaxWidth().fillMaxHeight()) { + // Full height on purpose: the header strip + // wraps its content and would leave the rest + // of the bar to the compositor move, so half + // a press aimed at the strip would move the + // window instead of starting the dock drag. + Box( + modifier = Modifier.weight(1f).fillMaxHeight().satelliteDragHandle(scope), + contentAlignment = Alignment.Center, + ) { currentHeader(scope) } + // Unclaimed on purpose: the bar's compositor + // move is what a press here starts, and the + // app's own content for it goes inside. + Box( + modifier = Modifier.width(SatelliteCaptionStripWidth).fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { currentCaption(scope) } + } + } + } + }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + RelocatedContentHost(entry.stateSlot, scope, entry.content) + } + } + } + } + } +} + +/** + * The card a panel is previewed as while it is dragged — following the pointer + * out of its dock, and drawn on the space a release will fill: the satellite's + * grip and title on the shared [DragPreviewSurface]. + */ +@Composable +internal fun SatelliteGhostCard( + title: String, + modifier: Modifier = Modifier, +) { + val accent = LocalTitleBarStyle.current.colors.content + DragPreviewSurface(modifier) { + Row( + modifier = Modifier.fillMaxWidth().padding(GHOST_PADDING_DP.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DragGrip(accent) + BasicText( + text = title, + modifier = Modifier.padding(start = GRIP_GAP_DP.dp), + style = + TextStyle( + color = accent, + fontSize = HEADER_TITLE_SP.sp, + fontWeight = FontWeight.Medium, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** + * Makes this element the grip that drags the satellite between its hosts. + * + * Dragging a floating satellite moves its window along with the pointer; a + * docked one shows an outline following the pointer. In both cases the dock + * zones of every window in the workspace light up as the pointer enters them + * ([SatelliteWorkspace.dockPreview]), and releasing: + * + * - in a zone docks the satellite there (or re-docks it, from another side + * or another window); + * - anywhere else, from a dock, lifts the panel out as a window under the + * pointer; from a floating window, just leaves it where it was dropped. + * + * The pointer turns into an open hand over the grip and a closed one while + * dragging, and a press without movement does nothing, so buttons can sit + * inside it. + * The press is claimed, which keeps an enclosing title bar from starting the + * native window move instead (see `Modifier.noWindowDrag`) — the window is + * moved by the workspace so the drop can be decided from the pointer position, + * at the cost of the OS's own snapping while a satellite is dragged. + * + * A floating satellite's title bar already carries this handle across its + * whole surface, so custom chrome for one needs it only on elements *outside* + * that bar. A docked panel's header needs it. + * + * On native **Wayland** the gesture rides the platform's drag-and-drop + * session instead, since xdg-shell gives a client neither its windows' screen + * position nor a way to place them: a reduced picture of the palette follows + * the pointer, the dock zones of the window the pointer is over light up, and + * releasing in one docks the satellite there. The floating window itself does + * not follow — it stays where it is. There the handle covers the header strip + * of the floating title bar rather than the whole bar, and the caption strip + * beside the window controls keeps the compositor's move, so the palette can + * still be moved. Custom floating chrome gets the same split for free: it is + * composed inside that handle. + * + * No-op outside a Tao window, and on a satellite a drag could not move + * anywhere — fixed to one side, pinned to its rank and alone in the workspace + * — rather than leaving a gesture that can only end where it started. + * + * Drives [SatelliteWorkspace.beginDrag]. + */ +@ExperimentalNucleusApi +public fun Modifier.satelliteDragHandle(scope: SatelliteScope): Modifier = + if (!scope.workspace.canBeDragged(scope.satellite)) { + this + } else { + screenDragHandle( + key = scope, + isDragging = { scope.workspace.draggedSatellite === scope.satellite }, + beginTransfer = { window -> + scope.workspace.beginTransferDrag(scope.satellite.id, scope.dragOrigin(window)) + }, + ) { window, pointerScreenPx -> + scope.workspace.beginDrag(scope.satellite.id, scope.dragOrigin(window), pointerScreenPx)?.asScreenDrag() + } + } + +private fun SatelliteScope.dragOrigin(window: TaoWindow): SatelliteDragOrigin = + if (isDocked) SatelliteDragOrigin.DockedPanel(window) else SatelliteDragOrigin.FloatingWindow(window) + +private fun SatelliteDragSession.asScreenDrag(): ScreenDrag = + object : ScreenDrag { + override fun update(pointerScreenPx: Offset) = this@asScreenDrag.update(pointerScreenPx) + + override fun end(pointerScreenPx: Offset) = this@asScreenDrag.end(pointerScreenPx) + + override fun cancel() = this@asScreenDrag.cancel() + } + +/** + * Width of the strip a floating satellite's title bar leaves to the + * compositor's window move, next to the window controls, on a window the + * compositor places ([SatelliteScope.isCompositorPlaced]). The + * `floatingCaption` slot of [Satellite] is composed inside it. + * + * Wide enough to aim at without looking, narrow enough to leave the header + * the rest of the bar — the same bargain Chrome's tab strip makes with the + * empty strip beside the last tab. + */ +@ExperimentalNucleusApi +public val SatelliteCaptionStripWidth: Dp = 56.dp + +/** + * The stock satellite header: the title, then "Dock" while floating or + * "Float" and "Close" while docked. Colours come from [LocalTitleBarStyle], so + * it matches whatever title-bar theme the app installed. + * + * Dragging it moves the satellite between windows and docks. While docked the + * strip carries the [satelliteDragHandle] itself; while floating it does not, + * because the title bar it sits in already is one — a second handle nested + * inside the first would start two drags for one gesture. + * + * On a floating satellite whose title bar it shares with the compositor's + * window move (native Wayland), the strip is drawn as a rounded chip instead + * of blending into the bar: there the part that drags into a dock and the + * part that moves the window are two places, and the user has to be able to + * see which is which. Chrome's tab strip and GIMP's dock tabs draw the same + * distinction for the same reason. + */ + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +@ExperimentalNucleusApi +public fun SatelliteScope.DefaultSatelliteHeader() { + val colors = LocalTitleBarStyle.current.colors + var hovered by remember { mutableStateOf(false) } + val window = LocalTaoWindow.current + val chip = !isDocked && isCompositorPlaced + val shape = if (chip) RoundedCornerShape(CHIP_CORNER_DP.dp) else RectangleShape + val background = + when { + chip && hovered -> colors.content.copy(alpha = CHIP_HOVER_ALPHA) + chip -> colors.content.copy(alpha = CHIP_ALPHA) + hovered -> colors.content.copy(alpha = GRIP_HOVER_ALPHA) + else -> Color.Transparent + } + Row( + modifier = + Modifier + .fillMaxWidth() + // Docked, the strip sizes itself: the dock frame imposes no + // height, so a custom header can be as tall as it likes. + // Floating, full height so the whole header strip is the grip, + // not just the band its content happens to occupy. The chip is + // inset inside that, so it reads as an object sitting in the + // bar while the area a press lands on stays the whole strip. + .then( + if (isDocked) { + Modifier.height(DockPanelHeaderHeight).background(colors.background) + } else { + Modifier.fillMaxHeight() + }, + ).then(if (chip) Modifier.padding(vertical = CHIP_INSET_DP.dp) else Modifier) + .then(if (isDocked) Modifier.satelliteDragHandle(this) else Modifier) + .onPointerEvent(PointerEventType.Enter) { hovered = true } + .onPointerEvent(PointerEventType.Exit) { hovered = false } + .background(background, shape) + .padding(horizontal = HEADER_PADDING_DP.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DragGrip(colors.content) + BasicText( + text = satellite.title, + modifier = Modifier.weight(1f).padding(start = GRIP_GAP_DP.dp), + style = TextStyle(color = colors.content, fontSize = HEADER_TITLE_SP.sp, fontWeight = FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (isDocked) { + if (satellite.isFloatable) HeaderAction("Float", colors.content) { undock() } + HeaderAction("Close", colors.content) { close() } + } else { + if (satellite.dockSides.isNotEmpty()) HeaderAction("Dock", colors.content) { dock() } + } + } +} + +/** Two columns of dots: the "this strip can be dragged" glyph. */ +@Composable +private fun DragGrip(color: Color) { + Canvas(Modifier.size(width = GRIP_WIDTH_DP.dp, height = GRIP_HEIGHT_DP.dp)) { + val dot = GRIP_DOT_RADIUS_DP.dp.toPx() + val stepX = size.width - dot * 2 + val stepY = (size.height - dot * 2) / (GRIP_DOT_ROWS - 1) + for (column in 0 until GRIP_DOT_COLUMNS) { + for (row in 0 until GRIP_DOT_ROWS) { + drawCircle( + color = color.copy(alpha = GRIP_ALPHA), + radius = dot, + center = Offset(dot + column * stepX, dot + row * stepY), + ) + } + } + } +} + +@Composable +private fun HeaderAction( + label: String, + color: Color, + onClick: () -> Unit, +) { + // `clickable` consumes the press, which is what opts a title-bar child out + // of the window drag — same contract as the built-in TitleBar's buttons. + Box( + modifier = + Modifier + .clickable(onClick = onClick) + .padding(horizontal = HEADER_ACTION_PADDING_DP.dp, vertical = HEADER_ACTION_VERTICAL_PADDING_DP.dp), + ) { + BasicText(text = label, style = TextStyle(color = color, fontSize = HEADER_ACTION_SP.sp)) + } +} + +private const val HEADER_PADDING_DP = 8 + +/** The chip's corner radius, matching the tab strip's own tabs. */ +private const val CHIP_CORNER_DP = 8 + +/** Gap between the chip and the bar's edges, so it reads as sitting inside it. */ +private const val CHIP_INSET_DP = 4 +private const val CHIP_ALPHA = 0.14f +private const val CHIP_HOVER_ALPHA = 0.22f +private const val GRIP_WIDTH_DP = 7 +private const val GRIP_HEIGHT_DP = 13 +private const val GRIP_GAP_DP = 8 +private const val GRIP_DOT_RADIUS_DP = 1 +private const val GRIP_DOT_COLUMNS = 2 +private const val GRIP_DOT_ROWS = 3 +private const val GRIP_ALPHA = 0.55f +private const val GRIP_HOVER_ALPHA = 0.08f +private const val GHOST_PADDING_DP = 8 +private const val HEADER_ACTION_PADDING_DP = 6 +private const val HEADER_ACTION_VERTICAL_PADDING_DP = 2 +private const val HEADER_TITLE_SP = 13 +private const val HEADER_ACTION_SP = 12 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteDragSessions.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteDragSessions.kt new file mode 100644 index 000000000..e116ea746 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteDragSessions.kt @@ -0,0 +1,198 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.LayoutDirection +import dev.nucleusframework.window.tao.workspace.TransferDrag +import dev.nucleusframework.window.tao.workspace.TransferGhostSource +import dev.nucleusframework.window.tao.workspace.sanitizedOrNull +import dev.nucleusframework.window.tao.workspace.toWindowCoordinate + +/** + * The session for a drag of [entry] from [origin], with the pointer at + * [pointerScreenPx]; `null` when the origin's geometry is not available yet. + */ +internal fun SatelliteWorkspace.createDragSession( + entry: SatelliteEntry, + origin: SatelliteDragOrigin, + pointerScreenPx: Offset, +): SatelliteDragSession? = + when (origin) { + is SatelliteDragOrigin.FloatingWindow -> { + val outer = origin.outerBoundsPx() ?: return null + FloatingDragSession( + workspace = this, + entry = entry, + origin = origin, + grabOffsetPx = pointerScreenPx - Offset(outer[0].toFloat(), outer[1].toFloat()), + pointer = pointerScreenPx, + ) + } + is SatelliteDragOrigin.DockedPanel -> { + val geometry = dockHostGeometry(origin.host) ?: return null + val panel = entry.dockedBoundsInWindowPx ?: return null + val clientOrigin = geometry.clientOriginPx() ?: return null + DockedDragSession( + workspace = this, + entry = entry, + host = origin.host, + panelScreenRectPx = panel.translate(clientOrigin), + grabOffsetPx = pointerScreenPx - (clientOrigin + panel.topLeft), + pointer = pointerScreenPx, + scaleFactor = geometry.scaleOrOne(), + ) + } + } + +/** The part every satellite drag shares: it acts only while live, and cancelling releases it. */ +private abstract class SatelliteDragSessionBase( + protected val workspace: SatelliteWorkspace, +) : SatelliteDragSession { + /** `true` while this session is the one the workspace is publishing. */ + protected val isLive: Boolean get() = workspace.isLiveDrag(this) + + final override fun cancel() { + workspace.releaseDrag(this) + } +} + +private class FloatingDragSession( + workspace: SatelliteWorkspace, + private val entry: SatelliteEntry, + private val origin: SatelliteDragOrigin.FloatingWindow, + /** Pointer offset from the window's outer top-left at the grab. */ + private val grabOffsetPx: Offset, + /** Where the pointer was last seen; a rejected sample leaves it alone. */ + private var pointer: Offset, +) : SatelliteDragSessionBase(workspace) { + override fun update(pointerScreenPx: Offset) { + if (!isLive) return + pointer = pointerScreenPx.sanitizedOrNull() ?: pointer + val topLeft = pointer - grabOffsetPx + origin.move(topLeft.x.toWindowCoordinate(), topLeft.y.toWindowCoordinate()) + // From the window, not the pointer: the palette is what the user sees + // moving, so the zone its edge has reached is the one to preview. + workspace.dockPreview = workspace.dockTargetFor(entry, Rect(topLeft, windowSizePx()), pointer) + } + + override fun end(pointerScreenPx: Offset) { + if (!isLive) return + update(pointerScreenPx) + val target = workspace.dockPreview + cancel() + if (target != null) workspace.dropAt(entry.id, target) + } + + /** The window's own size; read live, since a resize mid-drag is allowed. */ + @Suppress("MagicNumber") // outer frame is [x, y, w, h] + private fun windowSizePx(): Size = + origin.outerBoundsPx()?.let { Size(it[2].toFloat(), it[3].toFloat()) } ?: Size.Zero +} + +private class DockedDragSession( + workspace: SatelliteWorkspace, + private val entry: SatelliteEntry, + private val host: TaoWindow, + /** The panel's rect on screen at the grab; released inside it, the drag is a no-op. */ + private val panelScreenRectPx: Rect, + /** Pointer offset from the panel's top-left at the grab. */ + private val grabOffsetPx: Offset, + /** Where the pointer was last seen; a rejected sample leaves it alone. */ + private var pointer: Offset, + /** The host's px-per-dp, carried to the ghost window. */ + private val scaleFactor: Float, +) : SatelliteDragSessionBase(workspace) { + /** Its own slot on its own side: dropping there changes nothing. */ + private val own: DockTarget? = workspace.ownTarget(entry, host) + + /** The dock's layout direction, as it published it: what the ghost card is laid out in. */ + private val direction: LayoutDirection = + workspace.dockHostGeometry(host)?.layoutDirection ?: LayoutDirection.Ltr + + override fun update(pointerScreenPx: Offset) { + if (!isLive) return + pointer = pointerScreenPx.sanitizedOrNull() ?: pointer + val ghost = ghostRectPx() + // From the ghost, not the pointer: it is the thing on screen standing + // in for the panel, so the zone its edge has reached is the one to + // preview — the same rule as for a floating palette's window. + workspace.dockPreview = workspace.dockTargetFor(entry, ghost, pointer)?.takeIf { it != own } + // Follows the pointer for the whole gesture, including over a dock + // zone: the panel is out of the layout as soon as the drag starts, and + // seeing it hover is what makes the tear-out read. A fixed panel has + // no tear-out to read, so it stays where it is and only the zone + // feedback moves — showing a ghost would promise a window the release + // does not produce. + if (entry.isFloatable) workspace.dragGhost = DragGhost(entry, ghost, scaleFactor, direction) + } + + private fun ghostRectPx(): Rect = Rect(pointer - grabOffsetPx, panelScreenRectPx.size) + + override fun end(pointerScreenPx: Offset) { + if (!isLive) return + pointer = pointerScreenPx.sanitizedOrNull() ?: pointer + val drop = pointer + val target = workspace.dockTargetFor(entry, ghostRectPx(), drop)?.takeIf { it != own } + cancel() + when { + target != null -> workspace.dropAt(entry.id, target) + // Released on its own panel, or anywhere at all for a fixed one: + // the gesture was abandoned, not a tear-out. + !entry.isFloatable || panelScreenRectPx.contains(drop) -> Unit + else -> workspace.undock(entry.id, workspace.floatingAtScreen(drop - grabOffsetPx, panelScreenRectPx.size)) + } + } +} + +/** What the [DockLayout] under a transfer drag's release recorded for it. */ +internal sealed interface TransferDrop { + /** Dock the satellite in [target]. */ + data class Dock( + val target: DockTarget, + ) : TransferDrop + + /** Leave everything as it is: released on its own panel, or on the side it already occupies. */ + data object Stay : TransferDrop +} + +/** + * A satellite drag carried by the platform's DnD session (native Wayland, + * see [TransferDrag]). The window under the release resolves the drop and + * writes it to [drop]; [end] then applies it: + * + * - a dock zone docks the satellite there (or re-docks it); + * - no record at all — released over content, another app, the desktop — + * lifts a docked panel out as a window the compositor places, and leaves a + * floating window where it is. + */ +internal class SatelliteTransferDrag( + private val workspace: SatelliteWorkspace, + val entry: SatelliteEntry, + val origin: SatelliteDragOrigin, + override val ghostSizePx: Size, + override val ghostSource: TransferGhostSource, +) : TransferDrag { + override val title: String get() = entry.title + + /** Written by the target that took the drop, read once the session ends. */ + var drop: TransferDrop? = null + + /** The slot the dragged panel already occupies; dropping back onto it changes nothing. */ + val own: DockTarget? = (origin as? SatelliteDragOrigin.DockedPanel)?.let { workspace.ownTarget(entry, it.host) } + + override fun end() { + if (!workspace.isLiveTransfer(this)) return + val outcome = drop + workspace.endTransferDrag(this) + when (outcome) { + is TransferDrop.Dock -> workspace.dropAt(entry.id, outcome.target) + TransferDrop.Stay -> Unit + null -> if (origin is SatelliteDragOrigin.DockedPanel) workspace.undock(entry.id) + } + } + + override fun cancel() { + workspace.endTransferDrag(this) + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatellitePlacement.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatellitePlacement.kt new file mode 100644 index 000000000..d4131a3df --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatellitePlacement.kt @@ -0,0 +1,129 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.ExperimentalNucleusApi + +/** + * Edge of a window's content area a docked satellite attaches to. + * + * Sides are **physical**: [Left] is the left edge of the screen whatever the + * `LayoutDirection` in force, so a right-to-left app that wants its navigation + * panels on the right says [Right]. See [DockLayout] for how the four sides + * nest. + */ +@ExperimentalNucleusApi +public enum class DockSide { + /** Left edge; the panel runs the full content height. */ + Left, + + /** Right edge; the panel runs the full content height. */ + Right, + + /** Top edge; the panel runs the full content width. */ + Top, + + /** Bottom edge; the panel runs the full content width. */ + Bottom, + ; + + /** `true` for [Left] and [Right], whose extent is a width. */ + public val isVertical: Boolean get() = this == Left || this == Right + + /** The edge across the content: [Left] for [Right], [Top] for [Bottom], and back. */ + public val opposite: DockSide + get() = + when (this) { + Left -> Right + Right -> Left + Top -> Bottom + Bottom -> Top + } +} + +/** + * Where a satellite of a [SatelliteWorkspace] lives. + * + * A satellite is declared once with [Satellite] and hosted according to its + * placement: as its own OS window ([Floating]) or inside the content of the + * window it is docked into ([Docked]). The workspace moves satellites between + * the two with [SatelliteWorkspace.dock] and [SatelliteWorkspace.undock]; + * `rememberSaveable` state inside the satellite survives the move. + */ +@ExperimentalNucleusApi +public sealed interface SatellitePlacement { + /** + * An OS window owned by the workspace's current owner window: anchored + * once by [positioner], then following the owner (see [SatelliteWindow]). + * + * @property positioner where the window lands relative to the owner when + * it is first shown. + * @property size requested window size. + * @property anchorRect rectangle in the owner's coordinate space the + * [positioner] anchors to; `null` anchors to the whole owner frame. + */ + public data class Floating( + val positioner: WindowPositioner = DefaultPositioner, + val size: DpSize = DefaultSize, + val anchorRect: DpRect? = null, + ) : SatellitePlacement { + /** Defaults shared by every floating placement. */ + public companion object { + /** Hangs the satellite off the owner's top-right corner with a 12 dp gap. */ + public val DefaultPositioner: WindowPositioner = + WindowPositioner( + parentAnchor = WindowAnchor.TopRight, + childAnchor = WindowAnchor.TopLeft, + offset = DpOffset(DEFAULT_GAP_DP.dp, 0.dp), + ) + + /** The [SatelliteWindowState] default size. */ + public val DefaultSize: DpSize = DpSize(DEFAULT_SATELLITE_WIDTH_DP.dp, DEFAULT_SATELLITE_HEIGHT_DP.dp) + } + } + + /** + * A panel composed inside a [DockLayout] of the window the satellite is + * docked into ([SatelliteEntry.dockHost]). + * + * How the panels on one side share it is the layout's decision + * (`DockLayout(layeredSides = …)`), and the two numbers here serve the two + * arrangements: on a *split* side the panels divide the side's length in + * proportion to their [weight] and share its thickness + * ([SatelliteWorkspace.dockExtent]); on a *layered* side each panel is a + * full-length layer of its own [extent], from the edge inwards. Both are + * kept up to date by the layout's splitters and travel with the + * [SatelliteLayoutSnapshot]. + * + * @property side the edge the panel attaches to. + * @property order rank among the panels docked on the same side of the + * same layout, low to high from the top (left/right sides) or the left + * (top/bottom sides) on a split side, and from the edge towards the + * content on a layered one. [SatelliteWorkspace.dock] and + * [SatelliteWorkspace.undock] keep a side's ranks contiguous from `0` + * and remember the rank a satellite leaves with, so it comes back to + * it; a declared placement's order is the position it is inserted at. + * @property extent the panel's own thickness on a layered side — its + * width on [DockSide.Left] / [DockSide.Right], its height on + * [DockSide.Top] / [DockSide.Bottom]. `null` falls back to the side's + * [SatelliteWorkspace.dockExtent]; [SatelliteWorkspace.dock] seeds it + * from the floating window's size. Ignored on a split side. + * @property weight the panel's share of a split side's length, relative + * to its neighbours. Ignored on a layered side. + */ + public data class Docked( + val side: DockSide, + val order: Int = 0, + val extent: Dp? = null, + val weight: Float = 1f, + ) : SatellitePlacement { + init { + require(weight > 0f) { "weight must be positive, was $weight" } + } + } +} + +private const val DEFAULT_GAP_DP = 12 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindow.kt new file mode 100644 index 000000000..07f230e60 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindow.kt @@ -0,0 +1,906 @@ +// #636: the window openers below are `@ComposableOpenTarget(-1)` with +// `@UiComposable` content lambdas — callable from any applier, always composing +// UI — so a non-UI composable called in the caller's scope cannot reclassify +// the window content. ktlint's `annotation` and `function-type-modifier-spacing` +// rules contradict each other on the resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation", "MagicNumber") + +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.UiComposable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge +import kotlinx.coroutines.delay + +/** + * A satellite window: an auxiliary top-level that belongs to another window. + * + * Satellites are the floating tool palettes, inspectors and mixer strips of a + * desktop app — windows that are *about* a document window rather than + * documents of their own. The archetype comes from Flutter's multi-window + * design; this is the Tao implementation of the same contract: + * + * - **Anchored** — the initial position comes from a [WindowPositioner] + * ([SatelliteWindowState.positioner]) resolved against the parent's frame + * or a sub-rectangle of it, and kept inside the monitor work area. + * - **Follows its parent** — once placed, the satellite holds its offset from + * the parent's top-left corner: drag the parent and the satellite comes + * along. Drag the *satellite* and the new offset is what gets preserved. + * - **Above, but not modal** — it stays in front of its parent in z-order, + * keeps out of the taskbar / Dock / Alt-Tab, follows it across workspaces + * and minimisation, and leaves it fully interactive. + * - **Steps aside** — while the parent is fullscreen or maximized the + * satellite hides itself rather than covering content + * ([hideWhileParentFullscreenOrMaximized]). With that turned off it stays + * over its parent instead: the owner link is re-asserted across the + * transition, which is what keeps the platform from re-stacking the + * satellite behind the window it belongs to. + * - **Dies with its parent** — closing the parent closes the satellite; + * [onCloseRequest] fires so the caller can drop it from composition. + * - **Reparentable** — pass a different [parent] and the satellite moves to + * the new owner without changing its position on screen, which is how a + * single palette can serve whichever document window is active. This holds + * even when the previous owner closes in the same frame: the satellite steps + * out of its owner link before the old window is destroyed, so the OS never + * takes it down with it. + * + * ```kotlin + * DecoratedWindow(onCloseRequest = ::exitApplication) { + * TitleBar { Text("Document") } + * Button({ palette = !palette }) { Text("Inspector") } + * if (palette) { + * SatelliteWindow( + * onCloseRequest = { palette = false }, + * state = rememberSatelliteWindowState( + * size = DpSize(260.dp, 420.dp), + * positioner = WindowPositioner( + * parentAnchor = WindowAnchor.TopRight, + * childAnchor = WindowAnchor.TopLeft, + * offset = DpOffset(12.dp, 0.dp), + * ), + * ), + * title = "Inspector", + * ) { + * Inspector() + * } + * } + * } + * ``` + * + * ### Platform notes + * Positioning a satellite requires the platform to let a client place its own + * windows. Native **Wayland** does not (xdg-shell gives the compositor full + * authority — GDK reports every toplevel at `(0, 0)` and ignores moves), so + * there the satellite is a plain owned window: correct z-order, ownership, + * lifetime and hide-while-maximized, but compositor-chosen placement, no + * follow, and [SatelliteWindowState.offsetFromParent] stays `null` rather than + * publishing a made-up offset. The window is still draggable, by the + * compositor's own move. Run with `NUCLEUS_TAO_LINUX_RENDERER=x11`, or give + * the window `forceX11`, when the anchoring matters. X11, XWayland, Windows + * and macOS all follow. + * + * The work area the [WindowPositioner] keeps the satellite inside is the + * parent's own monitor on Windows. macOS and Linux fall back to the primary + * monitor's work area, so a parent on a secondary display whose Dock / panel + * layout differs may see its satellite flipped or slid against the wrong edge. + * + * @param onCloseRequest invoked when the user closes the satellite, and when + * its parent is destroyed. Drop the satellite from composition here. + * @param parent the window the satellite belongs to. Defaults to the enclosing + * [DecoratedWindow] via [LocalTaoWindow]; pass it explicitly to anchor to a + * window that isn't the one being composed. A `null` parent degrades to a + * plain top-level window. + * @param hideWhileParentFullscreenOrMaximized hide the satellite while the + * parent fills the screen instead of floating over it. `true` matches the + * Flutter archetype. + */ +@Suppress("LongParameterList", "FunctionNaming", "LongMethod") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun ApplicationScope.SatelliteWindow( + onCloseRequest: () -> Unit, + parent: TaoWindow? = LocalTaoWindow.current, + state: SatelliteWindowState = rememberSatelliteWindowState(), + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = true, + focusable: Boolean = true, + hideWhileParentFullscreenOrMaximized: Boolean = true, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + // Parent composition locals bridged into the satellite's own ComposeScene + // from its first composition, exactly like [DecoratedDialog]. + compositionLocalContext: CompositionLocalContext? = null, + content: @Composable @UiComposable TaoDecoratedWindowScope.() -> Unit, +) { + val latestContent by rememberUpdatedState(content) + val latestOnClose by rememberUpdatedState(onCloseRequest) + + // The anchor is computed from the parent's frame, so the satellite waits + // for the parent to have one. A satellite declared inside its parent's + // content composes in the very frame the parent window is created: an + // anchor resolved then is measured against a frame the parent has not been + // given yet, and the platform maps the satellite there — visibly, at the + // wrong place, until the settle loop below drags it across. One frame of + // waiting costs nothing; a palette that flashes in the middle of the screen + // before snapping beside its document is what users report. + val parentPlaced = parentHasFrame(parent) + if (!parentPlaced) return + + // Win32 and GTK destroy owned windows with their owner. The anchoring + // below steps out of the link when the owner announces its close, but a + // satellite created in the very frame its owner is being taken down never + // hears that announcement — and is destroyed with it. The composable is + // still declared, its remembered window is dead, and nothing would ever + // bring the palette back: it stays open, floating, and invisible for the + // rest of the session. Rebuilding on this key re-creates the window + // against whoever owns the satellite now — at its anchor, since the + // placement it had died with the window that was showing it. + var generation by remember(state) { mutableStateOf(0) } + + key(generation) { + // Resolved synchronously, before the native window exists, so + // DecoratedWindow's position effect applies it *before* show() — the same + // no-flash ordering DecoratedDialog relies on for its centring. Computed + // once: WindowState only ever reads its initial position, and a satellite + // never re-runs its placement on recomposition or reparenting anyway (see + // [SatelliteWindowState.reanchor]). + val initialPosition = + remember { + parent?.let { anchoredWindowPosition(it, state) } ?: WindowPosition.PlatformDefault + } + val windowState = + rememberWindowState( + size = state.size, + position = initialPosition, + ) + LaunchedEffect(state.size) { + if (windowState.size != state.size) windowState.size = state.size + } + + DecoratedWindow( + onCloseRequest = { latestOnClose() }, + state = windowState, + title = title, + icon = icon, + minimumSize = null, + // The suppression flag is folded in here rather than pushed to the + // window imperatively, so a satellite that is *also* toggled by the app + // has one single source of truth for visibility. + visible = visible && !state.isHiddenByParent, + resizable = resizable, + focusable = focusable, + alwaysOnTop = false, + // A palette never fills the screen: it follows its parent at an + // offset ([SatelliteAnchoring]) and docks from its screen geometry, + // neither of which means anything for a maximized window. Drops the + // caption / zoom button, the title-bar double-click and Win+Up; + // `resizable` is untouched, a palette still resizes. + maximizable = false, + // Dialog-flavoured border; the owner relationship below is what + // keeps it off the taskbar and above its parent. + isDialog = true, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + compositionLocalContext = compositionLocalContext, + content = { + val satellite = window + + // The satellite's own window, destroyed by the platform rather than + // by this composition — the owned-window teardown described on + // [generation]. Rebuild against the current owner; the listener is + // detached before our own close, so an ordinary dispose never + // rebuilds. Nothing else can tell the two apart from in here. + DisposableEffect(satellite) { + val destroyed: () -> Unit = { generation++ } + satellite.onDestroyed(destroyed) + onDispose { satellite.removeDestroyedListener(destroyed) } + } + + // Runs inside the satellite's own composition, so `window` is the + // satellite's TaoWindow and its native handle is resolvable. + val anchoring = + remember(satellite, parent) { + SatelliteAnchoring( + satellite = satellite, + parent = parent, + state = state, + hideWhileParentFills = hideWhileParentFullscreenOrMaximized, + ) + } + + // The parent's death is observed natively but acted on from + // composition, so a reparent that lands in the same frame as the + // old owner's close — "close the document the palette is attached + // to" — is not mistaken for the satellite's own end of life: by the + // time this scene recomposes, [parent] already names the new owner. + // Dying with the parent is the case where it still names the old one. + var destroyedParent by remember(satellite) { mutableStateOf(null) } + LaunchedEffect(parent, destroyedParent) { + if (parent != null && parent === destroyedParent) latestOnClose() + } + + // Hands keyboard focus back to the parent when the satellite goes + // away while it is the active window (closed from its own header, + // docked on a drag release). Win32 only does this by itself for + // dialogs ended through `EndDialog`; destroying an active owned + // `WS_OVERLAPPED` window activates the next window in the Z-order, + // which can belong to another application and sends the parent to + // the background. Both calls are queued on the event loop in order, + // so the parent is foreground before the satellite's HWND dies. + // Skipped when the parent is the one being destroyed, or when the + // satellite was not focused (an app-driven close must not steal + // the foreground). + val currentParent by rememberUpdatedState(parent) + val currentDestroyedParent by rememberUpdatedState(destroyedParent) + DisposableEffect(satellite) { + onDispose { + val target = currentParent + if (satellite.isFocused && target != null && target !== currentDestroyedParent) target.focus() + } + } + + DisposableEffect(anchoring) { + applyWindowOwnerRelationship( + child = satellite, + owner = parent, + autoCenter = false, + destroyWithOwner = false, + ) + anchoring.onParentDestroyed = { destroyedParent = it } + anchoring.attach() + state.reanchorRequest = { anchoring.reanchor() } + onDispose { + anchoring.detach() + state.reanchorRequest = null + } + } + + // Linux has no client-side maximizable hint (tao's is a no-op), so + // a WM shortcut can still maximize the palette; undo it, the + // anchoring and the dock hit-test have no meaning at that size. + val maximized = this@DecoratedWindow.state.isMaximized + LaunchedEffect(satellite, maximized) { + if (maximized) satellite.setMaximized(false) + } + + // Re-synced on change so flipping the flag while the parent is + // already maximized takes effect at once, not on its next resize. + LaunchedEffect(anchoring, hideWhileParentFullscreenOrMaximized) { + anchoring.setHideWhileParentFills(hideWhileParentFullscreenOrMaximized) + } + + SettleInitialPlacement(satellite, anchoring) + RealignAfterSteppingBack(satellite, anchoring, state.isHiddenByParent) + + DisposableEffect(satellite) { + val listener: (Boolean) -> Unit = { focused -> state.isActive = focused } + satellite.onFocusChanged(listener) + onDispose { state.isActive = false } + } + + latestContent() + }, + ) + } +} + +/** + * Settles the *initial* placement of [satellite]. A satellite declared inside + * its parent's content composes in the same frame the parent window is + * created, before the parent's own position effect has run — so the position + * it was given can be anchored to a parent rect that is about to change, or to + * none at all. Re-read real geometry as soon as both windows are mapped; from + * then on the offset the follow logic preserves is the anchored one. + * + * One successful re-anchor is not enough: a window manager can report a real + * frame at the origin and apply the requested position several frames later + * (openbox under Xvfb takes tens of milliseconds; a loaded desktop longer). + * Anchoring to that frame latches an offset measured against a parent that was + * never there, and the follow logic then preserves it forever — the satellite + * trails its parent by exactly the distance the parent moved after the map. So + * keep re-anchoring until the parent's frame has held still for + * [PLACEMENT_SETTLE_STABLE_POLLS] polls, the only signal a WM gives that + * placement is done. + * + * Keyed on the satellite, not on [anchoring]: a reparent swaps the anchoring + * but must leave the satellite where it is on screen. + */ +@Suppress("FunctionNaming") +@Composable +private fun SettleInitialPlacement( + satellite: TaoWindow, + anchoring: SatelliteAnchoring, +) { + val current by rememberUpdatedState(anchoring) + LaunchedEffect(satellite) { + var placedWith: SatelliteAnchoring? = null + var anchoredAgainst: List? = null + var stablePolls = 0 + var pollsSincePlaced = 0 + repeat(PLACEMENT_SETTLE_ATTEMPTS) { + val settling = current + if (!settling.hasParent || !settling.canPlace) return@LaunchedEffect + // Hard stop once the satellite has been placed: this covers the + // window manager's map-time placement, which lands within a few + // frames, and nothing else. A loop still running when the app — + // or the user — moves the owner would re-anchor instead of + // letting the follow logic preserve the offset it captured. + if (placedWith != null && ++pollsSincePlaced > PLACEMENT_SETTLE_POLLS_AFTER_PLACED) { + return@LaunchedEffect + } + // Stop as soon as the anchoring that placed it is no longer the + // live one. Before the first placement the loop still follows the + // swap: a satellite reparented before it ever landed has to be + // placed against whoever owns it now. + if (placedWith != null && placedWith !== settling) return@LaunchedEffect + val frame = settling.parentFramePx() + if (frame != null && frame != anchoredAgainst) { + // Only a parent frame this satellite has not been anchored + // against yet is worth another move. Re-anchoring on every + // poll would keep overriding a placement someone else owns — + // a satellite reopened where the user had dragged it is + // positioned by the workspace, not by the positioner. + if (settling.reanchor()) { + placedWith = settling + anchoredAgainst = frame + stablePolls = 0 + } + } else if (frame != null) { + // The parent's frame has not moved, but the satellite's own can + // still be moved out from under this placement: its + // `WindowState` carries the position resolved before the native + // window existed, and [DecoratedWindow] applies that *after* the + // map — which is after the re-anchor above when the parent was + // itself placed late. Re-assert the anchored offset until the + // satellite holds it, then count the poll as stable. + val holds = placedWith == null || settling.realignToOffset() + stablePolls = if (holds) stablePolls + 1 else 0 + if (holds && stablePolls >= PLACEMENT_SETTLE_STABLE_POLLS) return@LaunchedEffect + } + delay(PLACEMENT_SETTLE_POLL_MILLIS) + } + } +} + +/** + * Puts [satellite] back at its offset after it stepped aside for a maximized + * or fullscreen parent. + * + * Coming back re-shows the window, and the single move the step-back path + * issues races that re-map. When it loses, the satellite stays exactly where + * it was before it stepped aside — right for a parent that never moved, wrong + * for one that was restored somewhere else. Re-assert the offset until it + * holds, the same way the first placement settles. + */ +@Suppress("FunctionNaming") +@Composable +private fun RealignAfterSteppingBack( + satellite: TaoWindow, + anchoring: SatelliteAnchoring, + hiddenByParent: Boolean, +) { + val current by rememberUpdatedState(anchoring) + var steppedAside by remember(satellite) { mutableStateOf(false) } + LaunchedEffect(satellite, hiddenByParent) { + if (hiddenByParent) { + steppedAside = true + return@LaunchedEffect + } + if (!steppedAside) return@LaunchedEffect + repeat(PLACEMENT_SETTLE_ATTEMPTS) { + val settling = current + if (!settling.hasParent || !settling.canPlace || settling.realignToOffset()) { + return@LaunchedEffect + } + delay(PLACEMENT_SETTLE_POLL_MILLIS) + } + } +} + +/** + * Whether [parent] has a real frame to anchor against yet — `true` at once for + * a parentless satellite, and for a parent that is already on screen. + * + * Polled rather than driven by `onMoved` / `onResized`: the frame can be there + * before either fires (a satellite opened over a window that has been up for + * minutes), and the wait is bounded so a parent that never maps — hidden, or + * on a platform that reports no frame at all — still gets its satellite rather + * than none. + */ +@Composable +private fun parentHasFrame(parent: TaoWindow?): Boolean { + if (parent == null) return true + // Not keyed on the parent: this gate is about the *first* placement. A + // satellite that is already on screen must not be taken down and rebuilt + // when it is handed to another owner — reparenting keeps the window. + var placed by remember { mutableStateOf(false) } + LaunchedEffect(parent) { + // A frame is not enough: the parent's own [WindowState] position is + // applied *after* its window is mapped, so a parent asked for one + // corner is reported at the platform's cascade position first. A + // satellite anchored to that frame is placed beside a window that was + // never there — and the stale placement its own WindowState carries + // then lands after this one's correction. Two identical frames in a + // row is the only signal the platform gives that it is done placing. + var last: List? = null + var stable = 0 + var attempt = 0 + while (!placed && attempt < PLACEMENT_SETTLE_ATTEMPTS) { + val frame = parent.outerBoundsPx()?.toList()?.takeIf { parent.hasRealFrame() } + stable = if (frame != null && frame == last) stable + 1 else 0 + last = frame + if (stable >= PARENT_PLACEMENT_STABLE_POLLS) break + delay(PLACEMENT_SETTLE_POLL_MILLIS) + attempt++ + } + // Out of patience: show the satellite anyway, wherever the platform + // puts it, rather than never showing it at all. + placed = true + } + return placed +} + +/** `true` once the platform reports a frame with a real size for this window. */ +private fun TaoWindow.hasRealFrame(): Boolean { + val rect = outerBoundsPx() ?: return false + return rect[2] > 1L && rect[3] > 1L +} + +/** + * Keeps a satellite pinned to its parent. + * + * Everything here runs on the Tao event-loop thread (= the Compose dispatcher), + * so the plain fields need no synchronisation and the Compose state writes are + * on the right thread. + * + * Physical pixels throughout: [TaoWindow.outerBoundsPx] and + * [TaoWindow.setOuterPositionPx] share one coordinate space, which keeps the + * follow arithmetic free of any dp ↔ px round-tripping. + */ +private class SatelliteAnchoring( + private val satellite: TaoWindow, + private val parent: TaoWindow?, + private val state: SatelliteWindowState, + private var hideWhileParentFills: Boolean, +) { + /** Receives the parent once its native window has been destroyed. */ + var onParentDestroyed: (TaoWindow) -> Unit = {} + + val hasParent: Boolean get() = parent != null + + /** + * Whether the satellite can be placed on screen at all. `false` on native + * Wayland, where the follow, the anchoring and the offset capture are all + * skipped: the rects they would read put every window at the screen + * origin, and the moves they would issue are ignored. Ownership, z-order + * and the hide-while-parent-fills rule still apply. + */ + val canPlace: Boolean get() = satellite.canPlaceOnScreen + + private var offsetXPx = 0 + private var offsetYPx = 0 + private var captured = false + + /** Last position we asked the satellite to move to, and whether it landed. */ + private var commandedXPx = 0 + private var commandedYPx = 0 + private var awaitingCommand = false + + /** + * Follow moves issued but not yet observed. A parent drag produces a burst + * of them; only a satellite move seen with the queue empty can be the + * user's own drag. + */ + private var inFlight = 0 + private var detached = false + + /** Whether the parent filled the screen last time it was looked at. */ + private var lastFills: Boolean? = null + + /** + * Set when the satellite is shown again after stepping aside: the parent + * is on its way out of a maximized or fullscreen frame, and the geometry + * read at that instant can still be the old one. Cleared by the first + * parent geometry that lands afterwards, which is the settled one. + */ + private var realignPending = false + + private val parentMoved: (Int, Int) -> Unit = { xPx, yPx -> onParentMoved(xPx, yPx) } + private val parentResized: (Int, Int) -> Unit = { _, _ -> + val wasPending = realignPending + syncSuppression() + if (wasPending) realignAfterSteppingBack() + } + private val parentMinimized: (Boolean) -> Unit = { minimized -> if (!minimized) reassertOwnership() } + private val parentFullscreen: (Int, Int, Boolean) -> Unit = { _, _, entering -> + // Hide before the transition animates so the satellite is never caught + // hovering over a fullscreen window. Leaving fullscreen is resolved by + // the resize that follows, when isFullscreen has actually flipped. + if (entering) syncSuppression(force = true) + } + + // Owner about to be destroyed: step out of the owner link first. Win32 and + // GTK destroy owned windows with their owner, which would kill a satellite + // the app is reparenting in this very frame; whether the satellite then + // closes or moves on is decided from composition (see onParentDestroyed). + private val parentClosing: () -> Unit = { if (!detached) clearWindowOwnerRelationship(satellite) } + private val parentDestroyed: () -> Unit = { if (!detached) parent?.let(onParentDestroyed) } + private val satelliteMoved: (Int, Int) -> Unit = { xPx, yPx -> onSatelliteMoved(xPx, yPx) } + + fun attach() { + val owner = parent ?: return + if (canPlace) { + captureOffset() + owner.onMoved(parentMoved) + satellite.onMoved(satelliteMoved) + } + owner.onResized(parentResized) + owner.onMinimizedChanged(parentMinimized) + owner.onFullscreenPrepare(parentFullscreen) + owner.onClosing(parentClosing) + owner.onDestroyed(parentDestroyed) + syncSuppression() + } + + fun detach() { + detached = true + satellite.removeMovedListener(satelliteMoved) + val owner = parent ?: return + owner.removeMovedListener(parentMoved) + owner.removeResizedListener(parentResized) + owner.removeMinimizedListener(parentMinimized) + owner.removeFullscreenPrepareListener(parentFullscreen) + owner.removeClosingListener(parentClosing) + owner.removeDestroyedListener(parentDestroyed) + } + + /** Updates the suppression rule and re-evaluates it against the parent right away. */ + fun setHideWhileParentFills(hide: Boolean) { + if (hideWhileParentFills == hide) return + hideWhileParentFills = hide + syncSuppression() + } + + /** The parent's frame as `[x, y, w, h]` physical px, or `null` while it has none. */ + fun parentFramePx(): List? = parent?.takeIf { it.hasRealFrame() }?.outerBoundsPx()?.toList() + + /** Reads the parent-relative offset off live geometry. `true` once known. */ + fun captureOffset(): Boolean { + if (captured) return true + if (detached || !canPlace) return false + val owner = parent ?: return false + if (!owner.hasRealFrame() || !satellite.hasRealFrame()) return false + val parentRect = owner.outerBoundsPx() ?: return false + val selfRect = satellite.outerBoundsPx() ?: return false + publishOffset((selfRect[0] - parentRect[0]).toInt(), (selfRect[1] - parentRect[1]).toInt()) + captured = true + return true + } + + /** + * Re-applies the positioner against the parent's current geometry, using + * the satellite's real frame. `false` while either window is not mapped + * yet, so a caller can retry. + */ + fun reanchor(): Boolean { + if (detached || !canPlace) return false + val owner = parent ?: return false + val parentRect = owner.outerBoundsPx() ?: return false + val selfRect = satellite.outerBoundsPx() ?: return false + // GTK maps a window at 1x1 until its first allocation, so "has a size" + // is `> 1`, not `> 0`: anchoring against a 1px-tall satellite centres + // its *top* on the parent instead of its middle, and the wrong offset + // is then latched for the lifetime of the pairing. + if (!satellite.hasRealFrame()) return false + val childSize = Size(selfRect[2].toFloat(), selfRect[3].toFloat()) + val origin = anchoredOriginPx(owner, state, childSize) ?: return false + val xPx = origin.x.toInt() + val yPx = origin.y.toInt() + publishOffset(xPx - parentRect[0].toInt(), yPx - parentRect[1].toInt()) + captured = true + command(xPx, yPx) + return true + } + + private fun onParentMoved( + parentXPx: Int, + parentYPx: Int, + ) { + if (detached) return + if (!captureOffset()) return + // A hidden satellite is repositioned when it comes back, against the + // parent's geometry at that point — no need to chase it meanwhile. + if (state.isHiddenByParent) return + // This *is* the settled geometry the re-show was waiting for. + realignPending = false + command(parentXPx + offsetXPx, parentYPx + offsetYPx) + } + + private fun onSatelliteMoved( + xPx: Int, + yPx: Int, + ) { + if (detached) return + if (!captured) { + captureOffset() + return + } + if (awaitingCommand) { + if (closeEnough(xPx, commandedXPx) && closeEnough(yPx, commandedYPx)) { + // Caught up with the last follow move. + awaitingCommand = false + inFlight = 0 + return + } + if (inFlight > 0) { + // Still travelling to where we put it. A position that is not + // the one we asked for is the platform reporting an + // intermediate frame — or one it had not published yet when + // the move was issued — and not the user moving the window; + // taking it for one latches an offset nobody chose, and the + // follow logic then preserves it for the lifetime of the + // pairing. Bounded, so a move the platform never confirms + // cannot make the satellite deaf to a real drag. + inFlight-- + return + } + } + val parentRect = parent?.outerBoundsPx() ?: return + publishOffset(xPx - parentRect[0].toInt(), yPx - parentRect[1].toInt()) + } + + private fun command( + xPx: Int, + yPx: Int, + ) { + commandedXPx = xPx + commandedYPx = yPx + awaitingCommand = true + inFlight++ + satellite.setOuterPositionPx(xPx, yPx) + } + + /** + * Aligns [SatelliteWindowState.isHiddenByParent] with the parent's + * placement. [force] hides ahead of a fullscreen transition, before the + * platform flag has flipped. + */ + private fun syncSuppression(force: Boolean = false) { + if (detached) return + val owner = parent ?: return + val fills = force || owner.isFullscreen || owner.isMaximized + val fillsChanged = fills != lastFills + lastFills = fills + val hide = hideWhileParentFills && fills + if (hide != state.isHiddenByParent) { + state.isHiddenByParent = hide + if (!hide) { + // AppKit drops a child window's parent link when the child is + // ordered out; re-assert it so the satellite comes back above its + // parent instead of behind it. No-op where the platform keeps the + // relationship across hide/show. + reassertOwnership() + // Re-align while still hidden: the parent may have moved during the + // fullscreen stint, and the position sticks before the show(). + val parentRect = owner.outerBoundsPx() ?: return + if (captured) command(parentRect[0].toInt() + offsetXPx, parentRect[1].toInt() + offsetYPx) + // That frame can still be the maximized one — the platform + // reports the restore in pieces, and every move it made while + // the satellite was away was skipped. Re-align on the next one. + realignPending = true + } + return + } + // Same visibility on both sides of a maximize / fullscreen / restore — + // an app that opted out of hiding. The transition re-stacks the owner, + // which on every platform can leave the satellite *behind* the window + // it belongs to, so put the link back. While the owner fills the + // screen this runs on every resize, not only on the flip: the + // fullscreen prepare hook flips `lastFills` *before* the native + // transition, so the resize that lands afterwards is the one that + // has to re-stack. + if ((fills || fillsChanged) && !state.isHiddenByParent) reassertOwnership() + } + + /** + * Puts the satellite back at its captured offset, and reports whether it + * is there. Unlike [realignAfterSteppingBack] this can be called + * repeatedly: a move issued while the platform is still re-mapping the + * window it just re-showed can be dropped outright — GTK carries a move + * into the map only when it is issued *before* it — so the one command the + * step-back path sends is not always enough. + */ + fun realignToOffset(): Boolean { + if (detached || !canPlace || !captured) return false + if (state.isHiddenByParent) return false + val owner = parent ?: return false + if (owner.isMaximized || owner.isFullscreen) return false + if (!owner.hasRealFrame() || !satellite.hasRealFrame()) return false + val parentRect = owner.outerBoundsPx() ?: return false + val selfRect = satellite.outerBoundsPx() ?: return false + val targetX = parentRect[0].toInt() + offsetXPx + val targetY = parentRect[1].toInt() + offsetYPx + if (closeEnough(selfRect[0].toInt(), targetX) && closeEnough(selfRect[1].toInt(), targetY)) return true + command(targetX, targetY) + return false + } + + /** + * Puts the satellite back at its offset once the parent's frame has + * settled after a maximize / fullscreen stint. A no-op unless the + * satellite has just stepped back in — see [realignPending]. + */ + private fun realignAfterSteppingBack() { + if (!realignPending || detached || !canPlace || !captured) return + if (state.isHiddenByParent) return + val owner = parent ?: return + val parentRect = owner.outerBoundsPx() ?: return + if (owner.isMaximized || owner.isFullscreen) return + realignPending = false + command(parentRect[0].toInt() + offsetXPx, parentRect[1].toInt() + offsetYPx) + } + + /** + * Re-applies the native owner link, which is what keeps the satellite + * above its parent. Idempotent, and the platform calls behind it are + * cheap, so it is safe to run on every state transition. + */ + private fun reassertOwnership() { + if (detached) return + val owner = parent ?: return + applyWindowOwnerRelationship(child = satellite, owner = owner, autoCenter = false, destroyWithOwner = false) + } + + private fun publishOffset( + xPx: Int, + yPx: Int, + ) { + offsetXPx = xPx + offsetYPx = yPx + val scale = satellite.scaleFactor.takeIf { it > 0f } ?: 1f + state.offsetFromParent = DpOffset((xPx / scale).dp, (yPx / scale).dp) + } + + private fun closeEnough( + actual: Int, + expected: Int, + ): Boolean = kotlin.math.abs(actual - expected) <= COMMAND_ECHO_SLOP_PX +} + +/** + * The satellite's anchored top-left corner in physical screen pixels, or `null` + * when the parent's geometry or the monitor work area is unavailable. + */ +private fun anchoredOriginPx( + parent: TaoWindow, + state: SatelliteWindowState, + childSizePx: Size, +): Offset? { + val parentRectPx = parent.outerBoundsPx() ?: return null + // A frame with no size is a window the platform has not laid out yet — + // 1x1 being GTK's placeholder for it, not a size. Anchoring to its right + // edge would put the satellite on its left one; `null` makes the caller + // retry rather than latch onto that. + if (!parent.hasRealFrame()) return null + val workAreaPx = parentMonitorWorkAreaPx(parent) ?: return null + val scale = parent.scaleFactor.takeIf { it > 0f } ?: 1f + val parentRect = parentRectPx.toRect() + val anchorRect = + state.anchorRect?.let { rect -> + Rect( + parentRect.left + rect.left.value * scale, + parentRect.top + rect.top.value * scale, + parentRect.left + rect.right.value * scale, + parentRect.top + rect.bottom.value * scale, + ) + } ?: parentRect + return state.positioner + .placeIn( + childSize = childSizePx, + anchorRect = anchorRect, + parentRect = parentRect, + workArea = workAreaPx.toRect(), + scale = scale, + ).topLeft +} + +/** + * The anchored position as a [WindowPosition.Absolute] for the satellite's + * initial [androidx.compose.ui.window.WindowState], or + * [WindowPosition.PlatformDefault] when the parent isn't on screen yet. + * + * The satellite's native window does not exist at this point, so the placement + * uses the requested size; once mapped, the follow logic re-reads the real + * frame, which is what every later move is based on. + */ +private fun anchoredWindowPosition( + parent: TaoWindow, + state: SatelliteWindowState, +): WindowPosition { + // Native Wayland: the parent rect this would anchor to is the screen + // origin, and the compositor places the window anyway. + if (!parent.canPlaceOnScreen) return WindowPosition.PlatformDefault + val scale = parent.scaleFactor.takeIf { it > 0f } ?: 1f + val childSizePx = Size(state.size.width.value * scale, state.size.height.value * scale) + val origin = anchoredOriginPx(parent, state, childSizePx) ?: return WindowPosition.PlatformDefault + // WindowState.position is applied through Tao's logical set_outer_position, + // which multiplies by the scale the window was created at — the primary + // monitor's on Windows, the window's own elsewhere. Same conversion as + // DecoratedDialog's centring, so a satellite on a second monitor with a + // different DPI still lands where the positioner asked. + val logicalScale = + if (Platform.Current == Platform.Windows && NativeTaoWindowsDecoBridge.isLoaded) { + NativeTaoWindowsDecoBridge.nativeGetPrimaryMonitorScaleMilli().coerceAtLeast(1) / 1000f + } else { + scale + } + return WindowPosition.Absolute((origin.x / logicalScale).dp, (origin.y / logicalScale).dp) +} + +/** + * Work area of the monitor the parent sits on, falling back to the primary + * monitor's. Windows exposes the owner's monitor directly; elsewhere the + * primary work area is the best available answer. + */ +private fun parentMonitorWorkAreaPx(parent: TaoWindow): LongArray? { + if (Platform.Current == Platform.Windows && NativeTaoWindowsDecoBridge.isLoaded) { + val hwnd = parent.nativeHandle + if (hwnd != 0L) { + NativeTaoWindowsDecoBridge.nativeOwnerMonitorWorkArea(hwnd)?.let { return it } + } + } + return TaoScreenGeometry.primaryMonitorWorkAreaPx(parent) +} + +/** `[x, y, w, h]` physical px → a float rect. */ +private fun LongArray.toRect(): Rect = + Rect( + this[0].toFloat(), + this[1].toFloat(), + (this[0] + this[2]).toFloat(), + (this[1] + this[3]).toFloat(), + ) + +/** Physical-pixel slop when matching a follow move against its echo. */ +private const val COMMAND_ECHO_SLOP_PX = 2 + +/** ~1.6 s at 60 Hz — far past any observed map latency, then given up on. */ +private const val PLACEMENT_SETTLE_ATTEMPTS = 100 +private const val PLACEMENT_SETTLE_POLL_MILLIS = 16L + +/** Consecutive identical parent frames that count as "the WM is done placing it". */ +private const val PLACEMENT_SETTLE_STABLE_POLLS = 3 + +/** + * Consecutive identical parent frames before a satellite is composed at all. + * Two, not three: this one is paid before the palette is on screen, and the + * settle loop above corrects whatever a slower platform still gets wrong. + */ +private const val PARENT_PLACEMENT_STABLE_POLLS = 2 + +/** Upper bound on the settle window once the satellite has been placed once (~190 ms). */ +private const val PLACEMENT_SETTLE_POLLS_AFTER_PLACED = 12 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindowState.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindowState.kt new file mode 100644 index 000000000..89a0ba8e8 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWindowState.kt @@ -0,0 +1,99 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.ExperimentalNucleusApi + +/** + * State of a [SatelliteWindow]: the geometry inputs the app owns, plus the + * live anchoring state the window publishes back. + * + * Create it with [rememberSatelliteWindowState] inside composition, or + * directly when it has to outlive a single composition (a palette whose + * position must survive being toggled off and on). + * + * @param size the satellite's requested size. + * @param positioner where the satellite lands relative to its parent, applied + * once when the window is first shown (and again on [reanchor]). + * @param anchorRect the rectangle the [positioner] anchors to, in the parent's + * own coordinate space (top-left of the parent frame = origin). `null` + * anchors to the whole parent frame, decorations included. + */ +@ExperimentalNucleusApi +public class SatelliteWindowState( + size: DpSize = DpSize(DEFAULT_SATELLITE_WIDTH_DP.dp, DEFAULT_SATELLITE_HEIGHT_DP.dp), + positioner: WindowPositioner = WindowPositioner(), + anchorRect: DpRect? = null, +) { + /** Requested satellite size. Reactive: writing it resizes the window. */ + public var size: DpSize by mutableStateOf(size) + + /** + * Placement rule. Deliberately *not* snapshot state: placement is a + * one-shot (see [SatelliteWindow]), so a new rule only takes effect on the + * next [reanchor]. + */ + public var positioner: WindowPositioner = positioner + + /** Anchor rectangle in parent coordinates. Applied on [reanchor], like [positioner]. */ + public var anchorRect: DpRect? = anchorRect + + /** + * The satellite's current offset from its parent's top-left corner, or + * `null` before both windows are on screen. + * + * This is the value the satellite preserves as the parent moves. It is + * re-captured whenever the user drags the satellite, so a palette the user + * has repositioned keeps its *new* relationship to the parent. + */ + public var offsetFromParent: DpOffset? by mutableStateOf(null) + internal set + + /** + * `true` while the satellite is force-hidden because its parent went + * fullscreen or maximized. See [SatelliteWindow]'s + * `hideWhileParentFullscreenOrMaximized`. + */ + public var isHiddenByParent: Boolean by mutableStateOf(false) + internal set + + /** `true` while the satellite itself holds the keyboard focus. */ + public var isActive: Boolean by mutableStateOf(false) + internal set + + internal var reanchorRequest: (() -> Unit)? = null + + /** + * Re-applies [positioner] against the parent's current geometry, discarding + * any offset the user established by dragging the satellite. + * + * Placement is otherwise a one-shot: like Flutter's satellite archetype, + * the satellite keeps whatever offset it has so the user's own positioning + * is never overridden. Call this after changing [positioner] or + * [anchorRect], or when the UI element the satellite documents has moved. + * + * No-op when the satellite is not (yet) on screen. + */ + public fun reanchor() { + reanchorRequest?.invoke() + } +} + +/** Remembers a [SatelliteWindowState] across recompositions. */ +@Composable +@ExperimentalNucleusApi +public fun rememberSatelliteWindowState( + size: DpSize = DpSize(DEFAULT_SATELLITE_WIDTH_DP.dp, DEFAULT_SATELLITE_HEIGHT_DP.dp), + positioner: WindowPositioner = WindowPositioner(), + anchorRect: DpRect? = null, +): SatelliteWindowState = remember { SatelliteWindowState(size, positioner, anchorRect) } + +internal const val DEFAULT_SATELLITE_WIDTH_DP = 320 +internal const val DEFAULT_SATELLITE_HEIGHT_DP = 240 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt new file mode 100644 index 000000000..a6b4e647a --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspace.kt @@ -0,0 +1,1440 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.roundToIntRect +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.workspace.DockDropZone +import dev.nucleusframework.window.tao.workspace.DragController +import dev.nucleusframework.window.tao.workspace.HostGeometry +import dev.nucleusframework.window.tao.workspace.HostGeometryRegistry +import dev.nucleusframework.window.tao.workspace.RelocatableSlot +import dev.nucleusframework.window.tao.workspace.TransferGhostSource +import dev.nucleusframework.window.tao.workspace.WindowGroup +import dev.nucleusframework.window.tao.workspace.clientOriginPx +import dev.nucleusframework.window.tao.workspace.sanitizedOrNull +import dev.nucleusframework.window.tao.workspace.warnScreenPlacementUnsupported +import kotlin.math.abs + +/** + * One satellite known to a [SatelliteWorkspace]: identity, placement and the + * live geometry of its floating window. + * + * Created by [Satellite] on first composition (or by + * [SatelliteWorkspace.restore] ahead of it) and kept for the lifetime of the + * workspace, so a satellite the app takes out of composition and brings back + * resumes where it was. + */ +@ExperimentalNucleusApi +public class SatelliteEntry internal constructor( + /** Stable identity, the key used by every [SatelliteWorkspace] operation. */ + public val id: String, + title: String, + initialPlacement: SatellitePlacement, + isOpen: Boolean, + /** + * The sides this satellite may be docked on. Every other side is neither + * offered to a drag nor accepted by [SatelliteWorkspace.dock]; empty means + * the satellite only ever floats. Declared with [Satellite]. + */ + public val dockSides: Set = DockSide.entries.toSet(), + /** + * Whether this satellite can be a window of its own. `false` is a fixed + * panel: [SatelliteWorkspace.undock] refuses it, a drag can only move it + * within the dock, and a restore never floats it. Declared with [Satellite]. + */ + public val isFloatable: Boolean = true, + /** + * Whether the user may change this satellite's rank on its side. `false` + * pins it to the rank it was declared with: its own drag offers it none, + * and another panel can only be dropped after it, never in front of it. + * Declared with [Satellite]. + */ + public val isReorderable: Boolean = true, + /** + * The thinnest this satellite may be docked — its width on a left or right + * side, its height on a top or bottom one. [SatelliteWorkspace.MinDockExtent] + * by default, and never below it. Declared with [Satellite]. + */ + public val minExtent: Dp = SatelliteWorkspace.MinDockExtent, + /** The thickest this satellite may be docked; unbounded by default. Declared with [Satellite]. */ + public val maxExtent: Dp = Dp.Infinity, +) { + /** [minExtent]..[maxExtent], floored at [SatelliteWorkspace.MinDockExtent]. */ + internal val extentRange: ClosedRange + get() { + val min = maxOf(minExtent, SatelliteWorkspace.MinDockExtent) + return min..maxOf(min, maxExtent) + } + + /** Human-readable title, shown by the default header. */ + public var title: String by mutableStateOf(title) + internal set + + /** Where the satellite currently lives. */ + public var placement: SatellitePlacement by mutableStateOf(initialPlacement) + internal set + + /** `false` once the user (or the app) closed the satellite; reopen with [SatelliteWorkspace.open]. */ + public var isOpen: Boolean by mutableStateOf(isOpen) + internal set + + /** + * The window whose [DockLayout] hosts this satellite while it is docked. + * `null` while floating, and while docked with no workspace member to + * dock into yet — the next window to join picks it up. + */ + public var dockHost: TaoWindow? by mutableStateOf(null) + internal set + + /** `true` while [placement] is [SatellitePlacement.Docked]. */ + public val isDocked: Boolean get() = placement is SatellitePlacement.Docked + + /** `true` while the satellite is open and declared, i.e. a [DockLayout] would show its panel. */ + internal val isShown: Boolean get() = isOpen && content != null + + /** + * The side [SatelliteScope.dock] targets when none is given: the last + * docked side — to begin with the declared one, else the right side when + * [dockSides] allows it, else the first side it allows. + */ + public var preferredDockSide: DockSide by + mutableStateOf( + (initialPlacement as? SatellitePlacement.Docked)?.side + ?: DockSide.Right.takeIf { it in dockSides } + ?: dockSides.firstOrNull() + ?: DockSide.Right, + ) + internal set + + /** + * Geometry of the floating window: size, placement rule and the live + * offset from the owner. Meaningful while [placement] is + * [SatellitePlacement.Floating]; the values are also what + * [SatelliteWorkspace.undock] falls back to. + */ + public val windowState: SatelliteWindowState = + floatingOf(initialPlacement).let { SatelliteWindowState(it.size, it.positioner, it.anchorRect) } + + /** Floating geometry to return to when undocking without a lift-off rect. */ + internal var lastFloating: SatellitePlacement.Floating = floatingOf(initialPlacement) + + /** + * The docked placement this satellite last held on each side it has left + * — the declared one to begin with — so [SatelliteWorkspace.dock] can put + * it back at the rank and the share it had there rather than at the end + * of the stack. + */ + internal val dockMemory: MutableMap = + (initialPlacement as? SatellitePlacement.Docked)?.let { mutableMapOf(it.side to it) } ?: mutableMapOf() + + internal var content: (@Composable SatelliteScope.() -> Unit)? by mutableStateOf(null) + internal var header: (@Composable SatelliteScope.() -> Unit)? by mutableStateOf(null) + + /** `rememberSaveable` values carried across a dock / undock host change. */ + internal val stateSlot: RelocatableSlot = RelocatableSlot() + + /** Last docked panel rect in the host's window coordinates (physical px). */ + internal var dockedBoundsInWindowPx: Rect? = null + + /** The host's content size (physical px) when [dockedBoundsInWindowPx] was captured. */ + internal var dockHostContainerSizePx: IntSize? = null + + private companion object { + fun floatingOf(placement: SatellitePlacement): SatellitePlacement.Floating = + placement as? SatellitePlacement.Floating ?: SatellitePlacement.Floating() + } +} + +/** + * Per-satellite part of a [SatelliteLayoutSnapshot]. + * + * @property placement where the satellite was; a floating placement carries + * the user's last position baked into its positioner. + * @property isOpen whether it was open. + */ +@ExperimentalNucleusApi +public data class SatelliteSnapshot( + val placement: SatellitePlacement, + val isOpen: Boolean, +) + +/** + * Serializable-by-the-app picture of a [SatelliteWorkspace] layout: every + * satellite's placement and open state plus the dock extents. Produce it with + * [SatelliteWorkspace.snapshot], apply it with [SatelliteWorkspace.restore]. + * + * A docked satellite's own size — [SatellitePlacement.Docked.extent] and + * [SatellitePlacement.Docked.weight] — rides in its placement, so the + * per-panel geometry of a layered or split side is part of the picture too. + * + * @property satellites snapshots keyed by satellite id. + * @property dockExtents width (left/right) or height (top/bottom) of each + * split dock side, shared by the panels on it. + */ +@ExperimentalNucleusApi +public data class SatelliteLayoutSnapshot( + val satellites: Map, + val dockExtents: Map, +) + +/** + * The set of satellites shared by a group of windows, and the rules that bind + * them together. + * + * Windows **join** the workspace ([JoinSatelliteWorkspace]); satellites are + * **declared** against it ([Satellite]) and hosted according to their + * [SatellitePlacement]: + * + * - **Owner.** Floating satellites are owned by, anchored to and follow the + * workspace's [owner]: the most recently focused member when [followFocus] + * is on (the default), or the member pinned with [pinTo]. When the owner + * closes, the previously focused member takes over and the satellites move + * on without changing their position on screen. One palette can serve any + * number of document windows this way — no reparenting call needed. + * - **Docking.** [dock] turns a floating satellite into a panel inside the + * owner's [DockLayout]; [undock] lifts it back out as a window placed + * exactly where the panel was. `rememberSaveable` state inside the + * satellite survives both moves. + * - **Collective state.** [visible] hides and restores every satellite at + * once (the "Tab hides all palettes" gesture); [snapshot] / [restore] + * capture the whole layout for the app to persist. + * + * Every member of this class is meant for the Tao event-loop thread, which is + * also the Compose dispatcher. + * + * @param followFocus when `true`, the owner follows keyboard focus between + * members; when `false`, it is the pinned member or the first to have joined. + */ +@Suppress("TooManyFunctions", "LargeClass") +@ExperimentalNucleusApi +public class SatelliteWorkspace( + public val followFocus: Boolean = true, +) { + private val group = + WindowGroup( + followFocus = followFocus, + onJoined = { window -> + // Docked satellites left without a host by an earlier member's + // departure (or restored before any window joined) land here. + for (entry in entryMap.values) { + if (entry.isDocked && entry.dockHost == null) entry.dockHost = window + } + }, + onLeft = { window, fallback -> + for (entry in entryMap.values) { + if (entry.dockHost === window) entry.dockHost = fallback + } + }, + ) + + /** The member [pinTo] selected as owner, or `null` when the owner is chosen by focus. */ + public val pinnedOwner: TaoWindow? get() = group.pinned + + /** + * Windows that have joined, in join order. + * + * A snapshot of the live list, so reading it in composition subscribes to + * it and comparing it with `==` means what it says. + */ + public val members: List get() = group.members + + /** + * The window floating satellites currently belong to, or `null` while no + * member has joined. Pinned member first, then the most recently focused + * member (with [followFocus]), then the first member. + */ + public val owner: TaoWindow? get() = group.owner + + private val entryMap = mutableStateMapOf() + + /** Every satellite declared so far, including closed ones. */ + public val satellites: Collection get() = entryMap.values + + /** The satellite registered under [id], if any. */ + public fun satellite(id: String): SatelliteEntry? = entryMap[id] + + /** Master switch: `false` hides every satellite, floating and docked alike, without closing any. */ + public var visible: Boolean by mutableStateOf(true) + + private val extents = mutableStateMapOf() + private val pendingRestore = HashMap() + + /** Width (left/right) or height (top/bottom) of the panels docked on [side]. */ + public fun dockExtent(side: DockSide): Dp = extents[side] ?: DefaultDockExtent + + /** + * The extent [side] would have once [entry] is docked there: the side's + * own extent when it already has one, else the satellite's floating size, + * which is what the first drop seeds it with — either brought within what + * the panels of the side, [entry] included, allow + * ([SatelliteEntry.minExtent] / [SatelliteEntry.maxExtent]). [DockLayout] + * previews a drop at this width, which is the width the drop produces. + */ + public fun plannedDockExtent( + entry: SatelliteEntry, + side: DockSide, + ): Dp = (extents[side] ?: dockSeedExtent(entry, side)).coerceIn(sideExtentRange(side, joining = entry)) + + /** [extent] within what [entry] allows for its own thickness. */ + internal fun clampExtent( + entry: SatelliteEntry, + extent: Dp, + ): Dp = extent.coerceIn(entry.extentRange) + + /** + * What the shared thickness of the split side [side] may be: no thinner + * than the thickest minimum among its panels — [joining] counted, for a + * panel about to dock there — and no thicker than the thinnest maximum, + * the minimum winning where the two cross. [MinDockExtent] at the least. + */ + internal fun sideExtentRange( + side: DockSide, + joining: SatelliteEntry? = null, + ): ClosedRange { + val panels = + entryMap.values.filter { (it.placement as? SatellitePlacement.Docked)?.side == side } + + listOfNotNull(joining) + val min = panels.fold(MinDockExtent) { acc, entry -> maxOf(acc, entry.extentRange.start) } + val max = panels.fold(Dp.Infinity) { acc, entry -> minOf(acc, entry.extentRange.endInclusive) } + return min..maxOf(min, max) + } + + /** + * The thickness [entry] brings with it when docked on [side]: its own + * extent when it comes from a dock on the same axis, else the size of its + * floating window along that axis. What [dock] gives the panel and, for a + * side with no extent of its own yet, what it seeds the side with — so a + * drop preview drawn at this width shows the width the drop produces. + */ + internal fun dockSeedExtent( + entry: SatelliteEntry, + side: DockSide, + ): Dp { + val docked = entry.placement as? SatellitePlacement.Docked + val seed = + if (docked != null && docked.side.isVertical == side.isVertical) { + docked.extent ?: dockExtent(docked.side) + } else { + entry.windowState.size.let { if (side.isVertical) it.width else it.height } + } + return clampExtent(entry, seed) + } + + /** + * The weight [entry] takes among the panels of a split side once docked on + * [side]: the one it has where it is docked now, else the one it last held + * on [side], else `1`. What [dock] gives the panel, and what the drop + * preview divides the stack with. + */ + internal fun dockSeedWeight( + entry: SatelliteEntry, + side: DockSide, + ): Float = + (entry.placement as? SatellitePlacement.Docked)?.weight + ?: entry.dockMemory[side]?.weight + ?: 1f + + /** + * Sets [dockExtent]; clamped to what the panels on [side] allow + * ([SatelliteEntry.minExtent] / [SatelliteEntry.maxExtent]) and to + * [MinDockExtent]. Driven by the [DockLayout] splitters. + */ + public fun setDockExtent( + side: DockSide, + extent: Dp, + ) { + extents[side] = extent.coerceIn(sideExtentRange(side)) + } + + /** + * Sets the own thickness of the docked satellite [id] + * ([SatellitePlacement.Docked.extent]), clamped to what it allows + * ([SatelliteEntry.minExtent] / [SatelliteEntry.maxExtent]). What the + * splitter of a panel on a *layered* side drags; a no-op for a satellite + * that is not docked. + */ + public fun setDockedExtent( + id: String, + extent: Dp, + ) { + val entry = entryMap[id] ?: return + updateDocked(id) { it.copy(extent = clampExtent(entry, extent)) } + } + + /** + * Sets the share of a split side the docked satellite [id] takes + * ([SatellitePlacement.Docked.weight]); values at or below zero are + * clamped to a small positive share. What the divider between two panels + * on a *split* side drags; a no-op for a satellite that is not docked. + */ + public fun setDockedWeight( + id: String, + weight: Float, + ) { + updateDocked(id) { it.copy(weight = weight.coerceAtLeast(MIN_DOCK_WEIGHT)) } + } + + private fun updateDocked( + id: String, + transform: (SatellitePlacement.Docked) -> SatellitePlacement.Docked, + ) { + val entry = entryMap[id] ?: return + val docked = entry.placement as? SatellitePlacement.Docked ?: return + entry.placement = transform(docked) + } + + // ── Members ────────────────────────────────────────────────────────── + + /** + * Adds [window] to the workspace. Idempotent. Prefer [JoinSatelliteWorkspace] + * from the window's content; it leaves again when that content is disposed. + */ + public fun join(window: TaoWindow) { + group.join(window) + } + + /** + * Removes [window] from the workspace. Called automatically when a member + * is destroyed. Satellites docked into it move to the next [owner]. + */ + public fun leave(window: TaoWindow) { + group.leave(window) + } + + /** Records [window] as the most recently focused member. */ + internal fun noteFocus(window: TaoWindow) { + group.noteFocus(window) + } + + /** + * Makes [window] the [owner] regardless of focus; `null` goes back to the + * focus-driven choice. A pinned window that is not (or no longer) a member + * is ignored. + */ + public fun pinTo(window: TaoWindow?) { + group.pinTo(window) + } + + // ── Satellites ─────────────────────────────────────────────────────── + + /** Shows the satellite [id] again after [close]. */ + public fun open(id: String) { + entryMap[id]?.isOpen = true + } + + /** Hides the satellite [id] until [open]; its placement and state are kept. */ + public fun close(id: String) { + entryMap[id]?.isOpen = false + } + + /** [open] or [close], whichever applies. */ + public fun toggle(id: String) { + entryMap[id]?.let { it.isOpen = !it.isOpen } + } + + /** + * Docks the satellite [id] on [side] of a [DockLayout]: the one in [host] + * when given, else — for a satellite already docked — the host it is in, + * else the current [owner]'s. + * + * [order] is the position the panel takes among the panels docked on that + * side of that layout, closed ones included, counted from the top (left + * and right sides) or the left (top and bottom sides) on a split side and + * from the edge inwards on a layered one; the panels from there on move + * one rank down, and the ranks of the side are kept contiguous from `0`. + * `null` puts the satellite back at the rank it last held on that side — + * the one it was declared with, or the one it left by [undock] or by a + * move to another side — and appends it when it has never sat there, so a + * palette that is floated and docked again lands where it was rather + * than at the end. A re-dock on the side it already occupies keeps its + * rank. + * + * The satellite brings its thickness along ([dockSeedExtent]): its own + * extent when it comes from a dock on the same axis, else the size of its + * floating window. A side with no [dockExtent] of its own yet is seeded + * with it, so the panel keeps the width it had wherever it lands. The + * weight is kept across a move between docks and remembered with the rank. + * + * A side the satellite was not declared for ([SatelliteEntry.dockSides]) + * is refused: nothing changes. [order] is ignored for a pinned satellite + * ([SatelliteEntry.isReorderable] `false`), which keeps its declared + * rank, and is pushed past the pinned panels of the side for any other — + * a drop can join them but never displace one. + */ + public fun dock( + id: String, + side: DockSide, + order: Int? = null, + host: TaoWindow? = null, + ) { + val entry = entryMap[id] ?: return + if (side !in entry.dockSides) return + val current = entry.placement + val extent = dockSeedExtent(entry, side) + val weight = dockSeedWeight(entry, side) + if (current is SatellitePlacement.Floating) entry.lastFloating = currentFloating(entry, current) + leaveStack(entry) + val remembered = entry.dockMemory[side] + if (side !in extents) setDockExtent(side, extent) + entry.dockHost = + host?.takeIf { it in members } + ?: entry.dockHost?.takeIf { it in members } + ?: owner + entry.placement = SatellitePlacement.Docked(side, order = 0, extent, weight) + // A pinned panel takes the rank it was declared with, whatever the + // caller asks: that rank is the whole point of pinning it. + insertInStack(entry, order?.takeIf { entry.isReorderable } ?: remembered?.order) + entry.preferredDockSide = side + // The newcomer's limits now count for the side it joined. + reclampSide(side) + } + + /** + * Docks the satellite [id] where a drag resolved to: [DockTarget.order] + * counts the panels *shown* on the side — what the user aimed between — + * and is turned into the rank among every panel docked there, closed ones + * included, before [dock] applies it. + */ + internal fun dropAt( + id: String, + target: DockTarget, + ) { + val entry = entryMap[id] ?: return + val order = + target.order?.let { slot -> + val stack = stackOf(target.side, target.host, exclude = entry) + val before = stack.filter { it.isShown }.getOrNull(slot) + before?.let(stack::indexOf) ?: stack.size + } + dock(id, target.side, order, target.host) + } + + /** + * The target that drops the docked satellite [entry] back where it is in + * [host]: its side, at its own slot among the panels shown there — `null` + * order when it is alone, which is what a drop on an empty side resolves + * to. `null` for a satellite not docked in [host]. + */ + internal fun ownTarget( + entry: SatelliteEntry, + host: TaoWindow, + ): DockTarget? { + val docked = entry.placement as? SatellitePlacement.Docked ?: return null + if (entry.dockHost !== host) return null + if (!entry.isReorderable) return DockTarget(host, docked.side) + val shown = stackOf(docked.side, host, exclude = null).filter { it.isShown } + return DockTarget(host, docked.side, shown.indexOf(entry).takeIf { shown.size > 1 && it >= 0 }) + } + + /** + * Turns the docked satellite [id] back into a floating window: at + * [placement] when given, else over the panel it just was when the host's + * geometry is known, else at its last floating position. No-op for a + * floating satellite, and for a fixed one + * ([SatelliteEntry.isFloatable] `false`), which never leaves the dock. + */ + public fun undock( + id: String, + placement: SatellitePlacement.Floating? = null, + ) { + val entry = entryMap[id] ?: return + if (!entry.isFloatable) return + val docked = entry.placement as? SatellitePlacement.Docked ?: return + entry.preferredDockSide = docked.side + val floating = placement ?: liftOffPlacement(entry) ?: entry.lastFloating + leaveStack(entry) + applyFloating(entry, floating) + } + + /** + * Bakes where [entry]'s floating window currently is into its placement, so + * a satellite that goes away and comes back — [close] then [open], or the + * [visible] sweep — reappears where the user left it instead of at the rule + * it was declared with. A no-op for a docked satellite, whose placement is + * the dock. + * + * Driven by [Satellite] as the floating window leaves composition, which is + * the last moment the live offset is known. + */ + internal fun recordFloatingPlacement(entry: SatelliteEntry) { + val floating = entry.placement as? SatellitePlacement.Floating ?: return + val current = currentFloating(entry, floating) + entry.lastFloating = current + entry.placement = current + entry.windowState.size = current.size + entry.windowState.positioner = current.positioner + entry.windowState.anchorRect = current.anchorRect + } + + // ── Drag and drop ──────────────────────────────────────────────────── + + /** The [DockLayout] geometry every member publishes, for hit-testing and lift-off placement. */ + internal val dockHosts: HostGeometryRegistry = HostGeometryRegistry() + + private val drags = + DragController { + draggedSatellite = null + dockPreview = null + dragGhost = null + } + + /** + * The satellite being dragged right now, or `null`. While it is set every + * [DockLayout] in the workspace shows where the satellite can be dropped, + * which is what makes the gesture discoverable. + */ + public var draggedSatellite: SatelliteEntry? by mutableStateOf(null) + internal set + + /** + * The dock zone the satellite being dragged would land in if released + * now, or `null`. [DockLayout] highlights it in the target window; custom + * layouts may read it for their own preview. + */ + public var dockPreview: DockTarget? by mutableStateOf(null) + internal set + + /** + * The translucent preview of a panel being dragged out of its dock, or + * `null`. [Satellite] shows it as a borderless window that follows the + * pointer, so tearing a panel out of a window is something you can see + * leaving the window. + */ + public var dragGhost: DragGhost? by mutableStateOf(null) + internal set + + /** The drag currently owning the feedback state, or `null`. */ + internal val activeDragSession: SatelliteDragSession? get() = drags.active + + /** + * How the satellite in flight is being carried, or `null` while none is. + * + * Read it to draw a drag the way it actually behaves: + * [WorkspaceDragKind.Window] moves a real window under the pointer, so + * [dragGhost] is published and a torn-out panel is something the user sees + * leaving; [WorkspaceDragKind.Transfer] carries the satellite in the + * platform's drag-and-drop session — the picture under the pointer is the + * drag icon the compositor draws, no window follows, and [dragGhost] stays + * `null`. [draggedSatellite] and [dockPreview] are published either way. + */ + public val dragKind: WorkspaceDragKind? + get() = + when { + drags.active != null -> WorkspaceDragKind.Window + transferDrag != null -> WorkspaceDragKind.Transfer + else -> null + } + + /** `true` while [session] is the one the workspace is publishing. */ + internal fun isLiveDrag(session: SatelliteDragSession): Boolean = drags.isLive(session) + + /** Ends [session] if it is live (`null`: whichever is) and clears everything a drag publishes. Idempotent. */ + internal fun releaseDrag(session: SatelliteDragSession?) { + drags.release(session) + } + + internal fun dockHostGeometry(host: TaoWindow?): HostGeometry? = dockHosts[host] + + /** + * The dock zone under [screenPx] (physical screen pixels): the strip of + * [DockZoneWidth] inside each edge of a member's [DockLayout], the nearest + * edge winning where two overlap. Where windows overlap on screen, the + * [owner]'s layout is tried first, then the others by focus recency — the + * window the user worked in last is the one most likely on top. A + * minimized member is never a target: its frame is still on record, but + * nothing of it is on screen to drop onto. `null` over content or outside + * every layout. + */ + public fun dockTargetAt(screenPx: Offset): DockTarget? = zoneOf { it.dockHitTest(screenPx, DockZoneWidth) } + + /** + * The dock zone the satellite being dragged would land in, decided from + * **where the satellite is** rather than from where the pointer is: the + * zone [draggedScreenRectPx] — the floating window's frame, or the ghost + * of a panel being torn out — has entered, the nearest edge winning. That + * is what the user sees moving, so a palette whose edge has reached the + * left strip highlights it even though the pointer is still in the middle + * of the palette. + * + * The rect has to overlap the layout at all; a window merely parked beside + * one is no drop. When the rect covers several zones at once — a palette + * larger than the layout — [pointerScreenPx] breaks the tie, so a drop + * still goes where the user is aiming. Overlapping layouts are tried as + * for the pointer overload: the [owner]'s first, then by focus recency, + * stopping at the layout the pointer is over. + */ + public fun dockTargetAt( + draggedScreenRectPx: Rect, + pointerScreenPx: Offset, + ): DockTarget? = zoneOf { it.dockHitTest(draggedScreenRectPx, pointerScreenPx, DockZoneWidth) } + + /** + * Whether dragging [entry] could change anything: it can float, it has + * another side or another window's dock to go to, or it may take another + * rank among the panels shown beside it. `false` makes + * [Modifier.satelliteDragHandle] inert rather than leaving a gesture that + * cannot end anywhere. + */ + internal fun canBeDragged(entry: SatelliteEntry): Boolean { + if (entry.isFloatable) return true + val docked = entry.placement as? SatellitePlacement.Docked ?: return true + if (entry.dockSides.any { it != docked.side }) return true + if (members.size > 1) return true + return entry.isReorderable && stackOf(docked.side, entry.dockHost, exclude = entry).any { it.isShown } + } + + /** [dockTargetAt] resolved for the satellite [entry] — see [targetFor]. */ + internal fun dockTargetFor( + entry: SatelliteEntry, + draggedScreenRectPx: Rect, + pointerScreenPx: Offset, + ): DockTarget? = dockTargetAt(draggedScreenRectPx, pointerScreenPx)?.let { targetFor(entry, it) } + + /** + * [target] as a target for [entry]: `null` on a side [entry] was not + * declared for, and without a rank for a pinned one — [dock] would ignore + * it, so a preview drawn from it would promise a move that does not happen. + */ + internal fun targetFor( + entry: SatelliteEntry, + target: DockTarget, + ): DockTarget? = + when { + target.side !in entry.dockSides -> null + entry.isReorderable -> target + else -> target.copy(order = null) + } + + private inline fun zoneOf(hitTest: (HostGeometry) -> DockHit?): DockTarget? { + val hit = + dockHosts + .ordered(group.membersByRecency) + .asSequence() + .filter { !it.minimized() } + .firstNotNullOfOrNull(hitTest) + return (hit as? DockHit.Zone)?.target + } + + /** + * Starts dragging the satellite [id] from [origin], with the pointer at + * [pointerScreenPx] (physical screen pixels). Feed the session the pointer + * as it moves and release it with [SatelliteDragSession.end]; it moves a + * floating window along, publishes [dockPreview] / [dragGhost], and docks, + * re-docks or undocks on release. `null` when [id] is unknown, the + * origin's geometry is not available, or the origin window has no + * client-side screen placement (native Wayland: no window position to + * drag from, none to drop onto — [dock] and [undock] still work there). + * + * [Modifier.satelliteDragHandle] drives this from a pointer gesture; call + * it directly to drive docking from another input source. + */ + public fun beginDrag( + id: String, + origin: SatelliteDragOrigin, + pointerScreenPx: Offset, + ): SatelliteDragSession? { + val entry = entryMap[id] ?: return null + val start = pointerScreenPx.sanitizedOrNull() ?: return null + val from = + when (origin) { + is SatelliteDragOrigin.FloatingWindow -> origin.window + is SatelliteDragOrigin.DockedPanel -> origin.host + } + if (!from.canPlaceOnScreen) { + from.warnScreenPlacementUnsupported("SatelliteWorkspace.beginDrag") + return null + } + // Whatever was dragging until now is over: two live sessions would + // fight over the same published state. + transferDrag?.cancel() + val session = createDragSession(entry, origin, start) ?: return null + drags.begin(session) + draggedSatellite = entry + return session + } + + // ── Drag and drop without screen placement (native Wayland) ────────── + + /** + * The drag riding the platform's DnD session, or `null`. Started from a + * grip in a window without client-side screen placement; every + * [DockLayout] is a drop target for it and records the outcome on it, and + * the session acts on that record when it ends. Feedback is the same as + * for a pointer drag: [draggedSatellite] and [dockPreview]. + */ + internal var transferDrag: SatelliteTransferDrag? by mutableStateOf(null) + private set + + /** + * Starts the DnD-carried counterpart of [beginDrag] for the satellite + * [id] from [origin]; `null` when [id] is unknown. Supersedes whichever + * drag was live. + */ + internal fun beginTransferDrag( + id: String, + origin: SatelliteDragOrigin, + ): SatelliteTransferDrag? { + val entry = entryMap[id] ?: return null + transferDrag?.cancel() + releaseDrag(null) + val session = + SatelliteTransferDrag( + this, + entry, + origin, + transferGhostSizePx(entry, origin), + transferGhostSource(entry, origin), + ) + transferDrag = session + draggedSatellite = entry + return session + } + + /** `true` while [session] is the transfer drag in flight. */ + internal fun isLiveTransfer(session: SatelliteTransferDrag): Boolean = transferDrag === session + + /** Ends [session] if it is the one in flight and clears the drag feedback. Idempotent. */ + internal fun endTransferDrag(session: SatelliteTransferDrag) { + if (transferDrag !== session) return + transferDrag = null + releaseDrag(null) + } + + /** + * The drag icon's size: the header strip of the dragged satellite, as wide + * as its window or panel. Sizes stay valid where positions do not, so the + * frame is read even on native Wayland. + */ + @Suppress("MagicNumber") // outer frame is [x, y, w, h] + private fun transferGhostSizePx( + entry: SatelliteEntry, + origin: SatelliteDragOrigin, + ): Size { + val window = + when (origin) { + is SatelliteDragOrigin.FloatingWindow -> origin.window + is SatelliteDragOrigin.DockedPanel -> origin.host + } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val width = + when (origin) { + is SatelliteDragOrigin.FloatingWindow -> origin.outerBoundsPx()?.get(2)?.toFloat() + is SatelliteDragOrigin.DockedPanel -> entry.dockedBoundsInWindowPx?.width + } ?: (entry.windowState.size.width.value * scale) + return Size(width, DockPanelHeaderHeight.value * scale) + } + + /** + * What the drag icon pictures: the whole floating window, or the docked + * panel's own rect in its host — header included, since that is what the + * user grabbed — when the layout has published it. + */ + private fun transferGhostSource( + entry: SatelliteEntry, + origin: SatelliteDragOrigin, + ): TransferGhostSource = + when (origin) { + is SatelliteDragOrigin.FloatingWindow -> TransferGhostSource.WholeWindow + is SatelliteDragOrigin.DockedPanel -> + entry.dockedBoundsInWindowPx + ?.takeIf { !it.isEmpty } + ?.let { TransferGhostSource.Region(it.roundToIntRect()) } + ?: TransferGhostSource.None + } + + /** Floating placement whose window's top-left lands at [screenTopLeftPx], relative to the current [owner]. */ + internal fun floatingAtScreen( + screenTopLeftPx: Offset, + sizePx: Size, + ): SatellitePlacement.Floating? { + val owner = owner ?: return null + val outer = dockHosts[owner]?.outerBoundsPx() ?: owner.outerBoundsPx() ?: return null + val scale = (dockHosts[owner]?.scaleFactor() ?: owner.scaleFactor).takeIf { it > 0f } ?: 1f + return SatellitePlacement.Floating( + positioner = + offsetPositioner( + DpOffset(((screenTopLeftPx.x - outer[0]) / scale).dp, ((screenTopLeftPx.y - outer[1]) / scale).dp), + ), + size = DpSize((sizePx.width / scale).dp, (sizePx.height / scale).dp), + ) + } + + // ── Layout persistence ─────────────────────────────────────────────── + + /** Captures every satellite's placement and open state, plus the dock extents. */ + public fun snapshot(): SatelliteLayoutSnapshot = + SatelliteLayoutSnapshot( + satellites = + pendingRestore.toMap() + + entryMap.mapValues { (_, entry) -> + val placement = entry.placement + val stored = + if (placement is SatellitePlacement.Floating) { + currentFloating(entry, placement) + } else { + placement + } + SatelliteSnapshot(stored, entry.isOpen) + }, + dockExtents = extents.toMap(), + ) + + /** + * Applies [snapshot]. Satellites it names that are not declared yet are + * applied when they are; satellites it does not name are left alone. + */ + public fun restore(snapshot: SatelliteLayoutSnapshot) { + extents.clear() + // Placements first: a side's limits are those of the panels the + // snapshot puts on it, not of the ones it is about to move away. + for ((id, saved) in snapshot.satellites) { + val entry = entryMap[id] + if (entry == null) pendingRestore[id] = saved else apply(entry, saved) + } + // Through the setter: a snapshot written by an older version — or by + // hand — must not be able to install an extent below the minimum and + // leave a splitter no one can grab. + for ((side, extent) in snapshot.dockExtents) setDockExtent(side, extent) + DockSide.entries.forEach(::reclampSide) + } + + /** + * Brings [side]'s shared thickness within what its panels allow now: the + * stored one when it has one, else the default when that falls outside. + * A side without a stored extent otherwise keeps none, so the first drop + * on it still seeds it with the panel's own size. + */ + private fun reclampSide(side: DockSide) { + val range = sideExtentRange(side) + val stored = extents[side] + if (stored != null) { + extents[side] = stored.coerceIn(range) + } else if (DefaultDockExtent !in range) { + extents[side] = DefaultDockExtent.coerceIn(range) + } + } + + // ── Registration (driven by the Satellite composable) ──────────────── + + internal fun register( + id: String, + title: String, + initialPlacement: SatellitePlacement, + initiallyOpen: Boolean, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, + minExtent: Dp = MinDockExtent, + maxExtent: Dp = Dp.Infinity, + ): SatelliteEntry { + entryMap[id]?.let { + it.title = title + return it + } + require(minExtent <= maxExtent) { + "satellite '$id' declares minExtent $minExtent above maxExtent $maxExtent" + } + require((initialPlacement as? SatellitePlacement.Docked)?.side?.let { it in dockSides } != false) { + "satellite '$id' is declared docked on ${(initialPlacement as SatellitePlacement.Docked).side}, " + + "a side its dockSides $dockSides do not allow" + } + require(floatable || initialPlacement is SatellitePlacement.Docked) { + "satellite '$id' cannot float and is not declared docked: it would have nowhere to live" + } + require(reorderable || initialPlacement is SatellitePlacement.Docked) { + "satellite '$id' is pinned to a rank and is not declared docked: there is no rank to pin it to" + } + val entry = + SatelliteEntry( + id, + title, + initialPlacement, + initiallyOpen, + dockSides, + floatable, + reorderable, + minExtent, + maxExtent, + ) + if (initialPlacement is SatellitePlacement.Docked) entry.dockHost = owner + entryMap[id] = entry + pendingRestore.remove(id)?.let { apply(entry, it) } + return entry + } + + internal fun unregister(entry: SatelliteEntry) { + entry.content = null + entry.header = null + } + + // ── Internals ──────────────────────────────────────────────────────── + + private fun apply( + entry: SatelliteEntry, + saved: SatelliteSnapshot, + ) { + entry.isOpen = saved.isOpen + // A snapshot is a consistent picture of every side, so the ranks it + // carries are applied as they are; only the memory is kept up to date. + (entry.placement as? SatellitePlacement.Docked)?.let { entry.dockMemory[it.side] = it } + when (val placement = saved.placement) { + is SatellitePlacement.Floating -> { + // A fixed panel has no floating placement to go back to: the + // snapshot predates the declaration, and the dock stands. + if (!entry.isFloatable) return + applyFloating(entry, placement) + // Already on screen: move it, since placement is otherwise one-shot. + entry.windowState.reanchor() + } + is SatellitePlacement.Docked -> { + // A snapshot written before the declaration changed may name a + // side the satellite no longer docks on: its placement is left as it is. + if (placement.side !in entry.dockSides) return + val current = entry.placement + if (current is SatellitePlacement.Floating) entry.lastFloating = currentFloating(entry, current) + // A snapshot written by an older version, or by hand, must not + // install a thickness the panel does not allow. + entry.placement = placement.copy(extent = placement.extent?.let { clampExtent(entry, it) }) + entry.preferredDockSide = placement.side + entry.dockHost = owner + reclampSide(placement.side) + } + } + } + + private fun applyFloating( + entry: SatelliteEntry, + floating: SatellitePlacement.Floating, + ) { + entry.lastFloating = floating + entry.windowState.size = floating.size + entry.windowState.positioner = floating.positioner + entry.windowState.anchorRect = floating.anchorRect + entry.windowState.offsetFromParent = null + entry.placement = floating + entry.dockHost = null + } + + /** + * The floating placement that reproduces where the satellite *is*: the + * user's dragged offset baked into a top-left positioner, else the rule + * it was declared with. + */ + private fun currentFloating( + entry: SatelliteEntry, + declared: SatellitePlacement.Floating, + ): SatellitePlacement.Floating { + val offset = entry.windowState.offsetFromParent + val positioner = + if (offset != null) { + offsetPositioner(offset) + } else { + entry.windowState.positioner + } + return SatellitePlacement.Floating( + positioner = positioner, + size = entry.windowState.size, + anchorRect = if (offset != null) null else declared.anchorRect, + ) + } + + /** + * Where the docked panel sits on screen, as a floating placement, so the + * undocked window appears to lift off the panel. `null` when the host's + * geometry is not available. + * + * The host's client origin is derived from its outer frame and content + * size (side borders split evenly, everything else on top), which is + * exact for Tao's client-side-decorated windows and off by at most a + * shadow margin elsewhere. + */ + private fun liftOffPlacement(entry: SatelliteEntry): SatellitePlacement.Floating? { + val host = entry.dockHost ?: return null + val bounds = entry.dockedBoundsInWindowPx ?: return null + val container = entry.dockHostContainerSizePx ?: return null + val outer = (dockHosts[host]?.outerBoundsPx() ?: host.outerBoundsPx()) ?: return null + val scale = (dockHosts[host]?.scaleFactor() ?: host.scaleFactor).takeIf { it > 0f } ?: 1f + val client = clientOriginPx(outer, container) + val dx = (client.x + bounds.left - outer[0]) / scale + val dy = (client.y + bounds.top - outer[1]) / scale + return SatellitePlacement.Floating( + positioner = offsetPositioner(DpOffset(dx.dp, dy.dp)), + size = DpSize((bounds.width / scale).dp, (bounds.height / scale).dp), + ) + } + + /** + * The panels docked on [side] of [host]'s layout — open or not, every one + * of them holds a rank — in rank order, without [exclude]. + */ + private fun stackOf( + side: DockSide, + host: TaoWindow?, + exclude: SatelliteEntry?, + ): List = + entryMap.values + .filter { + it !== exclude && + it.dockHost === host && + (it.placement as? SatellitePlacement.Docked)?.side == side + }.sortedWith(compareBy({ (it.placement as SatellitePlacement.Docked).order }, { it.id })) + + /** + * Takes [entry] out of the stack it is docked in, remembering the + * placement it held there and closing the rank it leaves behind. A no-op + * for a floating satellite. + */ + private fun leaveStack(entry: SatelliteEntry) { + val docked = entry.placement as? SatellitePlacement.Docked ?: return + entry.dockMemory[docked.side] = docked + renumber(stackOf(docked.side, entry.dockHost, exclude = entry)) + } + + /** + * Puts the freshly docked [entry] at [index] of its side's stack — the + * end when `null` or past it — and renumbers the stack from `0`. + * + * A reorderable [entry] cannot land in front of a pinned panel: the ranks + * are contiguous, so inserting there would shift every pinned panel from + * that rank on. The insertion is pushed past the last of them. A pinned + * [entry] itself is placed at the rank it asks for, which is the one it + * was declared with. + */ + private fun insertInStack( + entry: SatelliteEntry, + index: Int?, + ) { + val docked = entry.placement as SatellitePlacement.Docked + val stack = stackOf(docked.side, entry.dockHost, exclude = entry).toMutableList() + val floor = if (entry.isReorderable) pinnedFloor(stack) else 0 + stack.add((index ?: stack.size).coerceIn(floor, stack.size), entry) + renumber(stack) + } + + /** The first rank of [stack] a reorderable panel may take: past every pinned panel. */ + internal fun pinnedFloor(stack: List): Int = stack.indexOfLast { !it.isReorderable } + 1 + + private fun renumber(stack: List) { + stack.forEachIndexed { rank, member -> + val docked = member.placement as SatellitePlacement.Docked + if (docked.order != rank) member.placement = docked.copy(order = rank) + } + } + + /** Constants shared with [DockLayout]. */ + public companion object { + /** Extent a dock side gets before any satellite seeded it. */ + public val DefaultDockExtent: Dp = 280.dp + + /** Smallest extent a dock side can be dragged or set to. */ + public val MinDockExtent: Dp = 80.dp + + /** Depth of the drop zone inside each edge of a [DockLayout]. */ + public val DockZoneWidth: Dp = 64.dp + + /** Smallest share a split-side panel can be dragged down to; keeps its divider reachable. */ + private const val MIN_DOCK_WEIGHT = 0.05f + + /** Pins the satellite's top-left corner at [offset] from the owner's, sliding on-screen if needed. */ + internal fun offsetPositioner(offset: DpOffset): WindowPositioner = + WindowPositioner( + parentAnchor = WindowAnchor.TopLeft, + childAnchor = WindowAnchor.TopLeft, + offset = offset, + constraintAdjustment = WindowConstraintAdjustment.Slide, + ) + } +} + +/** + * A dock zone: the [side] of the [DockLayout] in [host], and the rank + * ([SatellitePlacement.Docked.order]) the dropped panel takes among the + * panels shown on that side — `null` leaves the choice to + * [SatelliteWorkspace.dock]: the rank the satellite last held there, else the + * end. A drag resolves the rank from where the pointer is over the side's + * stack, so a panel can be dropped between two others. + */ +@ExperimentalNucleusApi +public data class DockTarget( + val host: TaoWindow, + val side: DockSide, + val order: Int? = null, +) + +/** + * The preview of a satellite being dragged out of its dock: which satellite, + * and where it sits on screen right now (physical screen pixels, outer frame + * of the ghost window). + */ +@ExperimentalNucleusApi +public data class DragGhost( + val satellite: SatelliteEntry, + val screenRectPx: Rect, + /** + * Physical pixels per dp on the host the panel came from. The rect is in + * physical screen pixels; a window is placed in logical ones, and the + * application scope the ghost is composed in has no density of its own. + */ + val scaleFactor: Float, + /** + * The layout direction of the dock the panel is torn out of, as the dock + * published it — what the ghost card is laid out in. + */ + val layoutDirection: LayoutDirection = LayoutDirection.Ltr, +) + +/** Where a satellite drag starts; see [SatelliteWorkspace.beginDrag]. */ +@ExperimentalNucleusApi +public sealed interface SatelliteDragOrigin { + /** + * The satellite's own floating window, dragged by its header. The window + * follows the pointer through [move] (outer top-left, physical px). + */ + public class FloatingWindow internal constructor( + public val window: TaoWindow, + internal val outerBoundsPx: () -> LongArray?, + internal val move: (xPx: Int, yPx: Int) -> Unit, + ) : SatelliteDragOrigin { + public constructor(window: TaoWindow) : this(window, window::outerBoundsPx, window::setOuterPositionPx) + } + + /** The satellite's docked panel in [host], dragged by its header. */ + public class DockedPanel( + public val host: TaoWindow, + ) : SatelliteDragOrigin +} + +/** + * A satellite drag in progress. Positions are physical screen pixels. + * Obtained from [SatelliteWorkspace.beginDrag]. + * + * A session stops acting the moment it is no longer the workspace's current + * drag — cancelled, finished, or superseded by another [SatelliteWorkspace.beginDrag]. + * Every method is then a no-op, so a late release from an abandoned gesture + * cannot move a window or re-dock a satellite. All three are safe to call + * repeatedly and in any order. + * + * Positions that are not finite (an `Offset.Unspecified` from a detached + * layout, an infinity) are ignored rather than propagated into window + * geometry; the last usable position stands. + */ +@ExperimentalNucleusApi +public interface SatelliteDragSession { + /** The pointer moved. */ + public fun update(pointerScreenPx: Offset) + + /** The pointer was released: dock, re-dock or undock according to where. */ + public fun end(pointerScreenPx: Offset) + + /** The gesture was abandoned: nothing changes placement. */ + public fun cancel() +} + +/** + * Where [screenPx] falls on this [DockLayout] geometry: `null` outside it, + * [DockHit.Content] inside but clear of the edges, [DockHit.Zone] within + * [zoneWidth] of the nearest edge. + */ +internal fun HostGeometry.dockHitTest( + screenPx: Offset, + zoneWidth: Dp, +): DockHit? { + val rect = layoutScreenRectPx() ?: return null + if (!rect.contains(screenPx)) return null + val side = dockSideAt(rect, screenPx, zoneWidth.value * scaleFactor()) + return if (side != null) DockHit.Zone(DockTarget(host, side)) else DockHit.Content +} + +/** + * Where the dragged satellite [draggedRectPx] falls on this [DockLayout] + * geometry, with the pointer at [pointerPx]: [DockHit.Zone] for the zone it + * has entered, [DockHit.Content] when it is over the layout but clear of every + * zone, `null` when neither it nor the pointer is on this layout at all. + */ +internal fun HostGeometry.dockHitTest( + draggedRectPx: Rect, + pointerPx: Offset, + zoneWidth: Dp, +): DockHit? { + val rect = layoutScreenRectPx() ?: return null + val overlaps = !rect.intersect(draggedRectPx).isEmpty + val onPointer = rect.contains(pointerPx) + if (!overlaps && !onPointer) return null + val zones = zoneScreenRectsPx(zoneWidth.value * scaleFactor()) ?: return null + // Over the layout, in a zone or not: no other layout under it is + // consulted, exactly as for a pointer hit. + val side = dockSideEntered(zones, draggedRectPx, pointerPx) ?: return DockHit.Content + return DockHit.Zone(DockTarget(host, side, zones.getValue(side).slotAt(pointerPx))) +} + +/** + * The zone of [zones] the dragged satellite has brought its edge to, or — + * failing that — the zone [pointer] is in. + * + * [zones] are the rectangles the target actually draws, so the region that + * lights up is the region a drag is measured against: on a layered side that + * is the strip inset behind the layers already docked there, not the window's + * own edge, which sits behind them. + * + * "Brought its edge to" is the satellite's own edge within one zone thickness + * of the zone's outer edge, and the satellite overlapping the zone across the + * other axis. The edge rather than any overlap is what keeps a tear-out + * possible: a panel as tall as the layout overlaps the top and bottom strips + * wherever it is dragged, and treating that as "entered" would pin it to a + * zone for the whole gesture. + * + * The pointer over a side's stack — its [DockDropZone.slots] — is a zone + * entered too: that is how a panel is dropped between two others. Several + * zones at once — a palette larger than the layout reaches all four, a strip + * runs across the corner of a neighbouring stack — are resolved by the + * pointer: the one stack it is over, else the one strip it is in, so an + * ambiguous overlap still drops where the user aims; else the closest edge + * wins. + */ +internal fun dockSideEntered( + zones: Map, + dragged: Rect, + pointer: Offset, +): DockSide? { + val live = zones.filterValues { !it.strip.isEmpty } + val gaps = + live + .filter { (side, zone) -> overlapsAcross(zone.strip, dragged, side) } + .mapValues { (side, zone) -> abs(edgePx(dragged, side) - outerEdgePx(zone.strip, side)) } + .filter { (side, gap) -> gap <= thicknessPx(live.getValue(side).strip, side) } + val overStack = live.filterValues { zone -> zone.slots.any { it.contains(pointer) } }.keys + val underPointer = live.filterValues { it.contains(pointer) }.keys + val candidates = gaps.keys + underPointer + candidates.singleOrNull()?.let { return it } + if (candidates.isEmpty()) return null + overStack.singleOrNull()?.let { return it } + underPointer.singleOrNull()?.let { return it } + return candidates.minBy { gaps[it] ?: Float.MAX_VALUE } +} + +/** Whether [dragged] overlaps [zone] along the axis the zone runs on. */ +private fun overlapsAcross( + zone: Rect, + dragged: Rect, + side: DockSide, +): Boolean = + if (side.isVertical) { + dragged.top < zone.bottom && zone.top < dragged.bottom + } else { + dragged.left < zone.right && zone.left < dragged.right + } + +/** The zone's outer boundary: the one against the layout's [side] edge. */ +private fun outerEdgePx( + zone: Rect, + side: DockSide, +): Float = + when (side) { + DockSide.Left -> zone.left + DockSide.Right -> zone.right + DockSide.Top -> zone.top + DockSide.Bottom -> zone.bottom + } + +/** The zone's own thickness: how far a satellite's edge may sit from it and still count. */ +private fun thicknessPx( + zone: Rect, + side: DockSide, +): Float = if (side.isVertical) zone.width else zone.height + +/** A strip of [widthPx] inside [rect]'s [side] edge: the zone a plain layout offers. */ +internal fun edgeStripPx( + rect: Rect, + side: DockSide, + widthPx: Float, +): Rect = + when (side) { + DockSide.Left -> Rect(rect.left, rect.top, rect.left + widthPx, rect.bottom) + DockSide.Right -> Rect(rect.right - widthPx, rect.top, rect.right, rect.bottom) + DockSide.Top -> Rect(rect.left, rect.top, rect.right, rect.top + widthPx) + DockSide.Bottom -> Rect(rect.left, rect.bottom - widthPx, rect.right, rect.bottom) + } + +/** The edge of [rect] that faces [side]'s zone. */ +private fun edgePx( + rect: Rect, + side: DockSide, +): Float = + when (side) { + DockSide.Left -> rect.left + DockSide.Right -> rect.right + DockSide.Top -> rect.top + DockSide.Bottom -> rect.bottom + } + +/** + * The dock zone of [rect] that [point] falls in: the nearest edge when the + * point is within [zonePx] of it, else `null` (over the content, or outside + * the rect altogether). Coordinate-space agnostic: screen pixels for a pointer + * drag, window pixels for a drop the window itself reports. + */ +internal fun dockSideAt( + rect: Rect, + point: Offset, + zonePx: Float, +): DockSide? { + if (!rect.contains(point)) return null + val (side, distance) = + listOf( + DockSide.Left to point.x - rect.left, + DockSide.Right to rect.right - point.x, + DockSide.Top to point.y - rect.top, + DockSide.Bottom to rect.bottom - point.y, + ).minBy { it.second } + return side.takeIf { distance <= zonePx } +} + +/** Result of [dockHitTest]. */ +internal sealed interface DockHit { + /** Inside the layout, over the content: not a drop target, but no other layout is consulted. */ + data object Content : DockHit + + /** Inside a dock zone. */ + data class Zone( + val target: DockTarget, + ) : DockHit +} + +/** Remembers a [SatelliteWorkspace] for the lifetime of the calling composition. */ +@Composable +@ExperimentalNucleusApi +public fun rememberSatelliteWorkspace(followFocus: Boolean = true): SatelliteWorkspace = + remember { SatelliteWorkspace(followFocus) } + +/** + * Makes the enclosing window (or [window]) a member of [workspace] for as long + * as this composable is in composition. Call it from the window's content, + * typically right under [DecoratedWindow]. + */ +@Composable +@ExperimentalNucleusApi +public fun JoinSatelliteWorkspace( + workspace: SatelliteWorkspace, + window: TaoWindow? = LocalTaoWindow.current, +) { + DisposableEffect(workspace, window) { + if (window == null) return@DisposableEffect onDispose {} + workspace.join(window) + onDispose { workspace.leave(window) } + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabDragSessions.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabDragSessions.kt new file mode 100644 index 000000000..c89011fa3 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabDragSessions.kt @@ -0,0 +1,318 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.roundToIntRect +import dev.nucleusframework.window.tao.workspace.TransferDrag +import dev.nucleusframework.window.tao.workspace.TransferGhostSource +import dev.nucleusframework.window.tao.workspace.sanitizedOrNull +import dev.nucleusframework.window.tao.workspace.toWindowCoordinate + +/** + * The session for a drag of [entry] from [origin], with the pointer at + * [pointerScreenPx]; `null` while the origin's geometry is not available. + * + * Which of the two it is follows the window, exactly as in a browser: the only + * tab of a window has no "out" to be dragged to, so the window itself follows + * the pointer; one of several is lifted out under a ghost. + */ +@Suppress("MagicNumber") // outer frame is [x, y, w, h] +internal fun TabWorkspace.createTabDragSession( + entry: TabEntry, + origin: TabDragOrigin, + pointerScreenPx: Offset, +): TabDragSession? { + val strip = + when (origin) { + is TabDragOrigin.Strip -> origin + } + val group = groupOf(strip.window) ?: return null + val outer = strip.outerBoundsPx() ?: return null + val geometry = stripHosts[strip.window] ?: return null + return if (group.tabIds.size == 1) { + TabWindowDragSession( + workspace = this, + entry = entry, + origin = strip, + grabOffsetPx = pointerScreenPx - Offset(outer[0].toFloat(), outer[1].toFloat()), + pointer = pointerScreenPx, + ) + } else { + val slot = group.slotsInWindowPx.getOrNull(group.tabIds.indexOf(entry.id)) ?: return null + val client = geometry.clientOriginPx() ?: return null + val scale = geometry.scaleOrOne() + TabTearOffDragSession( + workspace = this, + entry = entry, + windowSizePx = tearOffSizePx(strip.window, outer, scale), + grabOffsetPx = pointerScreenPx - (client + slot.topLeft), + tabSizePx = slot.size, + pointer = pointerScreenPx, + scaleFactor = scale, + layoutDirection = geometry.layoutDirection, + ) + } +} + +/** + * The size a window torn off [window] gets: the source window's own, so the + * tab keeps the room it had — unless the source fills the screen, where + * inheriting the frame would hand the user a second screen-sized window + * instead of one they can put somewhere. Then it is the workspace default, + * which is what a browser does with a tab pulled out of a maximized window. + */ +@Suppress("MagicNumber") // outer frame is [x, y, w, h] +internal fun TabWorkspace.tearOffSizePx( + window: TaoWindow, + outer: LongArray, + scale: Float, +): Size = + if (window.isMaximized || window.isFullscreen) { + Size(defaultWindowSize.width.value * scale, defaultWindowSize.height.value * scale) + } else { + Size(outer[2].toFloat(), outer[3].toFloat()) + } + +/** The part every tab drag shares: it acts only while live, and cancelling releases it. */ +private abstract class TabDragSessionBase( + protected val workspace: TabWorkspace, +) : TabDragSession { + /** `true` while this session is the one the workspace is publishing. */ + protected val isLive: Boolean get() = workspace.isLiveDrag(this) + + final override fun cancel() { + workspace.releaseDrag(this) + } +} + +/** + * The only tab of a window, dragged: the window follows the pointer, and + * releasing it over another strip merges the tab into it — which drops this + * window, since it is then empty. + */ +private class TabWindowDragSession( + workspace: TabWorkspace, + private val entry: TabEntry, + private val origin: TabDragOrigin.Strip, + /** Pointer offset from the window's outer top-left at the grab. */ + private val grabOffsetPx: Offset, + /** Where the pointer was last seen; a rejected sample leaves it alone. */ + private var pointer: Offset, +) : TabDragSessionBase(workspace) { + override fun update(pointerScreenPx: Offset) { + if (!isLive) return + pointer = pointerScreenPx.sanitizedOrNull() ?: pointer + val topLeft = pointer - grabOffsetPx + origin.move(topLeft.x.toWindowCoordinate(), topLeft.y.toWindowCoordinate()) + // Its own strip moved with the window and is under the pointer the + // whole time; only another window's strip is a target, and the search + // has to look *past* its own rather than stop at it. + // + // That own strip is also what stands in for the card here: the window + // is what the user is moving, so a merge is previewed as soon as its + // strip reaches another's, before the pointer is over it — the same + // rule as for a tab carried under a ghost. + workspace.dropPreview = + workspace.dropTargetAt(stripScreenRectPx(topLeft), pointer, exclude = entry, excludeGroup = entry.group) + workspace.dragPointerScreenPx = pointer + } + + /** + * Where this window's own strip would be with its frame at [topLeftPx]: + * the band that stands in for the dragged card. `null` before the strip + * has published its geometry. + */ + private fun stripScreenRectPx(topLeftPx: Offset): Rect? { + val geometry = workspace.stripHosts[origin.window] ?: return null + val outer = origin.outerBoundsPx() ?: return null + val clientInset = (geometry.clientOriginPx() ?: return null) - Offset(outer[0].toFloat(), outer[1].toFloat()) + return geometry.layoutBoundsInWindowPx.translate(topLeftPx + clientInset) + } + + override fun end(pointerScreenPx: Offset) { + if (!isLive) return + update(pointerScreenPx) + val target = workspace.dropPreview + cancel() + if (target != null) workspace.move(entry.id, target.group, target.index) + } +} + +/** + * One of several tabs, dragged out: a ghost follows the pointer, and releasing + * either inserts the tab in the strip under it or tears it into a window of + * its own placed where the ghost was. + */ +@Suppress("LongParameterList") +private class TabTearOffDragSession( + workspace: TabWorkspace, + private val entry: TabEntry, + /** The source window's outer size, which the torn-off window inherits. */ + private val windowSizePx: Size, + /** Pointer offset from the dragged tab's top-left at the grab. */ + private val grabOffsetPx: Offset, + private val tabSizePx: Size, + /** Where the pointer was last seen; a rejected sample leaves it alone. */ + private var pointer: Offset, + /** The source window's px-per-dp, carried to the ghost and the new window. */ + private val scaleFactor: Float, + /** The source strip's layout direction, carried to the ghost. */ + private val layoutDirection: LayoutDirection, +) : TabDragSessionBase(workspace) { + private val velocity = HorizontalVelocity() + + override fun update(pointerScreenPx: Offset) { + if (!isLive) return + pointer = pointerScreenPx.sanitizedOrNull() ?: pointer + workspace.dragVelocityPxPerSecond = velocity.sample(pointer.x) + // Resolved from the card as well as from the pointer: a tab whose top + // edge has come up into a strip is previewed there before the pointer + // reaches it, so the drop reads while the card is still below the + // strip rather than over it. + val card = ghostRectPx() + val target = workspace.dropTargetAt(card, pointer, exclude = entry) + workspace.dropPreview = target + workspace.dragPointerScreenPx = pointer + // Over its own strip the tab has not left: the strip holds it under the + // pointer and its neighbours make room, the way a browser's do. Over + // another window's strip, or clear of every strip, it *is* leaving — + // and seeing it hover is what makes the move and the tear-out read. + val inOwnStrip = target != null && target.group === entry.group + workspace.dragGhost = if (inOwnStrip) null else TabDragGhost(entry, card, scaleFactor, layoutDirection) + } + + /** Where the card is on screen: the grabbed tab, carried at the grab offset. */ + private fun ghostRectPx(): Rect = Rect(pointer - grabOffsetPx, tabSizePx) + + override fun end(pointerScreenPx: Offset) { + if (!isLive) return + pointer = pointerScreenPx.sanitizedOrNull() ?: pointer + val drop = pointer + val target = workspace.dropTargetAt(ghostRectPx(), drop, exclude = entry) + val group = entry.group + // Read before the release clears the drag: the slide home starts with + // the speed the pointer had, so a flick carries through. + val speed = workspace.dragVelocityPxPerSecond + cancel() + if (target != null) { + if (target.group === group && group != null) { + // Inside its own strip: the strip slides the tab into its new + // place and applies the reorder itself, so nothing jumps. + workspace.pendingReorder = TabReorderSettle(entry, group, target.index, speed) + } else { + workspace.move(entry.id, target.group, target.index) + } + return + } + // A window the size of the one it came from, with the grabbed tab + // still under the pointer: the strip lands where the ghost was. + workspace.tearOff(entry.id, Rect(drop - grabOffsetPx, windowSizePx), scaleFactor) + } +} + +/** + * How fast the pointer is travelling along one axis, from the samples the + * session is fed: the strip hands it to the spring that slides a released tab + * home, so a flick carries through and a slow move does not overshoot. + * + * Smoothed over the last samples rather than taken from the last pair: one + * pointer report can land a millisecond after the one before it and read as + * thousands of px per second. + */ +private class HorizontalVelocity { + private var lastX = Float.NaN + private var lastNanos = 0L + private var smoothed = 0f + + fun sample(x: Float): Float { + val now = System.nanoTime() + val elapsed = now - lastNanos + if (!lastX.isNaN() && elapsed in 1..MAX_GAP_NANOS) { + val instant = (x - lastX) / (elapsed / NANOS_PER_SECOND) + smoothed = smoothed * (1f - SMOOTHING) + instant * SMOOTHING + } else if (lastX.isNaN() || elapsed > MAX_GAP_NANOS) { + // A first sample, or a pause long enough that the pointer has + // stopped: no speed to carry. + smoothed = 0f + } + lastX = x + lastNanos = now + return smoothed + } + + private companion object { + const val NANOS_PER_SECOND = 1_000_000_000f + + /** Longer than this between samples and the pointer was at rest, not travelling. */ + const val MAX_GAP_NANOS = 100_000_000L + + /** How much of the newest sample the estimate takes: enough to follow a flick, not a jitter. */ + const val SMOOTHING = 0.4f + } +} + +/** + * The DnD-carried tab drag (native Wayland, see [TransferDrag]) of [entry] out + * of [group]'s strip in [window]. Sizes are still readable there, so the + * torn-off window gets the size a pointer drag would give it; its position is + * the compositor's. + */ +@Suppress("MagicNumber") // outer frame is [x, y, w, h] +internal fun TabWorkspace.createTabTransferDrag( + entry: TabEntry, + group: TabWindowGroup, + window: TaoWindow, +): TabTransferDrag { + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val outer = window.outerBoundsPx() + val windowSizePx = + outer?.let { tearOffSizePx(window, it, scale) } + ?: Size(defaultWindowSize.width.value * scale, defaultWindowSize.height.value * scale) + val slot = group.slotsInWindowPx.getOrNull(group.tabIds.indexOf(entry.id))?.takeIf { !it.isEmpty } + val ghostSizePx = slot?.size ?: Size(TabMaxWidth.value * scale, TAB_GHOST_HEIGHT_DP * scale) + // The tab itself is the picture; without a published slot, its title card. + val ghostSource = slot?.let { TransferGhostSource.Region(it.roundToIntRect()) } ?: TransferGhostSource.None + return TabTransferDrag(this, entry, ghostSizePx, ghostSource, windowSizePx, scale) +} + +/** Ghost height when the dragged tab published no slot yet — roughly a title bar's worth. */ +private const val TAB_GHOST_HEIGHT_DP = 32f + +/** + * A tab drag carried by the platform's DnD session. The strip under the + * release records the insertion in [drop]; [end] then applies it — or, with + * no record, tears the tab into a window of its own (one of several) and + * leaves the only tab of a window where it is. + */ +internal class TabTransferDrag( + private val workspace: TabWorkspace, + val entry: TabEntry, + override val ghostSizePx: Size, + override val ghostSource: TransferGhostSource, + /** The size a torn-off window gets, physical px. */ + private val windowSizePx: Size, + private val scaleFactor: Float, +) : TransferDrag { + override val title: String get() = entry.title + + /** Written by the strip that took the drop, read once the session ends. */ + var drop: TabDropTarget? = null + + override fun end() { + if (!workspace.isLiveTransfer(this)) return + val target = drop + workspace.endTransferDrag(this) + when { + target != null -> workspace.move(entry.id, target.group, target.index) + (entry.group?.tabIds?.size ?: 0) > 1 -> + workspace.tearOff(entry.id, Rect(Offset.Zero, windowSizePx), scaleFactor) + else -> Unit + } + } + + override fun cancel() { + workspace.endTransferDrag(this) + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabHoverPreview.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabHoverPreview.kt new file mode 100644 index 000000000..6ce401d0f --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabHoverPreview.kt @@ -0,0 +1,464 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.graphics.layer.GraphicsLayer +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.graphics.rememberGraphicsLayer +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.math.max +import kotlin.math.roundToInt +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * What the card of a hovered tab gets to see: the tab, its workspace, and the + * last picture taken of its body. + */ +@ExperimentalNucleusApi +public interface TabHoverPreviewScope { + /** The workspace the tab belongs to. */ + public val workspace: TabWorkspace + + /** The group whose strip the pointer is over. */ + public val group: TabWindowGroup + + /** The tab under the pointer. */ + public val tab: TabEntry + + /** + * The picture of [tab]'s body, or `null` when there is none: the last one + * the workspace took, or the one the app assigned — see + * [TabEntry.thumbnail] for who writes it and when. + */ + public val thumbnail: ImageBitmap? get() = tab.thumbnail +} + +internal class TabHoverPreviewScopeImpl( + override val workspace: TabWorkspace, + override val group: TabWindowGroup, + override val tab: TabEntry, +) : TabHoverPreviewScope + +/** + * How a strip previews the tab under the pointer: a browser's hover card, + * shown under the tab after a pause and gone as soon as the pointer leaves it. + * + * Never for the selected tab, whose body is on screen anyway — see + * [TabStripScope.hoveredTab] for every case a card is withheld. + * + * The whole card is [content], so an app draws its own — the title, the path, + * a picture of the page, whatever it knows about the tab — and the stock + * [TabHoverPreviewCard] is one composable it can build on or replace outright: + * + * ```kotlin + * TabStrip( + * hoverPreview = + * TabHoverPreview(delay = 400.milliseconds) { + * TabHoverPreviewCard(subtitle = { Text(documents[tab.id]?.path.orEmpty()) }) + * }, + * ) + * ``` + * + * Pass it to [TabStrip], or compose [TabHoverPreviewPopup] with it in a strip + * written from scratch. + * + * @property delay how long the pointer has to rest on a tab before the first + * card appears. Moving to another tab while one is shown switches at once, + * the way a browser does. + * @property offset where the card sits relative to the tab's bottom-left + * corner — its bottom-*right* in a right-to-left strip, so the card grows + * into the reading direction on both. + * @property nativeLayer whether the card is hosted on a native popup surface + * ([NativePopupLayers]), which is what lets it hang below the window like a + * browser's. `false` draws it inside the window's own scene, where it is + * kept within the window's bounds and clipped by them. + * @property content the card. Composed with the hovered tab as receiver. + */ +@Immutable +@ExperimentalNucleusApi +public class TabHoverPreview( + public val delay: Duration = HoverPreviewDelay, + public val offset: DpOffset = HoverPreviewOffset, + public val nativeLayer: Boolean = true, + public val content: @Composable TabHoverPreviewScope.() -> Unit = { TabHoverPreviewCard() }, +) { + /** The stock hover card, for a strip that wants a browser's behaviour and nothing else. */ + public companion object { + /** [TabHoverPreview] with every default: the stock card, after the stock pause. */ + public val Default: TabHoverPreview = TabHoverPreview() + } +} + +/** + * The tab the pointer is resting on in this strip, which is what a hover card + * follows. + * + * `null` in every case where a card would be wrong: + * + * - the pointer is over no tab of this strip; + * - the tab under it is the *selected* one — its body is on screen already, + * and a card of what is being read is nothing but in the way; + * - a tab of the workspace is being dragged, which passes it over every + * neighbour in turn without pointing at any of them; + * - a press is in flight on the hovered tab, until the pointer has moved on. + * + * Published by [Modifier.tabSlot], so a strip written from scratch has it as + * soon as it marks its slots. + */ +@ExperimentalNucleusApi +public val TabStripScope.hoveredTab: TabEntry? + get() { + if (workspace.draggedTab != null || group.hoverBlocked) return null + val id = group.hoveredId ?: return null + if (id == group.selectedId) return null + if (id !in group.ids) return null + return workspace.tab(id) + } + +/** + * The hover card of this strip: [preview]'s content under the tab the pointer + * rests on, at the place [Modifier.tabSlot] published for it. + * + * [TabStrip] composes it for its `hoverPreview`; a strip written from scratch + * composes it once, next to its tabs, and needs nothing else — the tab is + * [hoveredTab] and the anchor is the slot the strip already marks. + * + * The card is never a hover target itself: reaching it with the pointer puts + * it away, since reaching it means having left the tab. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Suppress("FunctionNaming") +@Composable +@ExperimentalNucleusApi +public fun TabStripScope.TabHoverPreviewPopup(preview: TabHoverPreview = TabHoverPreview.Default) { + val candidate = hoveredTab + // The card waits out `delay` on the first tab and then follows the pointer + // from tab to tab without a pause, as a browser's does. + var shown by remember(group) { mutableStateOf(null) } + LaunchedEffect(candidate, preview.delay) { + if (candidate == null) { + shown = null + return@LaunchedEffect + } + if (shown == null) delay(preview.delay) + shown = candidate + } + + val tab = shown ?: return + // Read off the settled layout the strip publishes, re-read when the strip + // order changes: the slots are written from layout and are not snapshot + // state, so `ids` is what says the anchor may have moved. + val order = group.ids + val density = LocalDensity.current + val position = + remember(tab, order, preview.offset, density) { + val slot = group.slotInWindowPx(tab.id) ?: return@remember null + TabHoverPreviewPosition( + anchorPx = slot, + offsetPx = + with(density) { + IntOffset(preview.offset.x.roundToPx(), preview.offset.y.roundToPx()) + }, + ) + } ?: return + val scope = remember(workspace, group, tab) { TabHoverPreviewScopeImpl(workspace, group, tab) } + + val card = + @Composable { + Popup( + popupPositionProvider = position, + properties = + PopupProperties( + // Never takes focus and never eats a pointer event: + // the card appears while the strip is being used, and + // the click that follows belongs to the tab. + focusable = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + // On a native surface the card may hang below the + // window, which is where a browser's sits; drawn + // in-scene it has to stay inside the window or it is + // cut off at its edge. + clippingEnabled = !preview.nativeLayer, + ), + ) { + // The card is no target of its own: the moment the pointer + // reaches it, the tab it belongs to has been left behind, and + // a browser's card goes away. It has to be said here — a popup + // surface takes the pointer off the window beneath it, so the + // tab never hears the pointer leave and the card would sit + // over the content it covers until something else moved. + Box(Modifier.onPointerEvent(PointerEventType.Enter) { group.noteHoverExit(tab.id) }) { + preview.content(scope) + } + } + } + if (preview.nativeLayer) NativePopupLayers { card() } else card() +} + +/** + * Where a hover card goes: under the tab it belongs to. + * + * The anchor is the tab's own slot in window pixels — the rect + * [Modifier.tabSlot] publishes — and not the `anchorBounds` handed in, which + * is the strip's whole width: the card is composed once for the strip, not per + * tab, so the tab it points at is the one the strip picked. + */ +internal class TabHoverPreviewPosition( + private val anchorPx: Rect, + private val offsetPx: IntOffset, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset { + // The card grows into the reading direction: from the tab's leading + // edge, which is its right in a right-to-left strip. + val x = + if (layoutDirection == LayoutDirection.Rtl) { + anchorPx.right.roundToInt() - popupContentSize.width - offsetPx.x + } else { + anchorPx.left.roundToInt() + offsetPx.x + } + val y = anchorPx.bottom.roundToInt() + offsetPx.y + // Kept within the window across the strip: a card that runs past the + // last tab would otherwise hang off the side of the window. + val maxX = (windowSize.width - popupContentSize.width).coerceAtLeast(0) + return IntOffset(x.coerceIn(0, maxX), y) + } +} + +/** + * The stock hover card: the tab's full title, whatever [subtitle] adds under + * it, and the last picture taken of the tab's body when there is one + * ([TabHoverPreviewScope.thumbnail]). + * + * Colours come from the window and title-bar styles, so the card matches the + * chrome the app installed. Anything else is the app's own card — + * [TabHoverPreview] takes it whole. + * + * @param modifier applied to the card itself, which is where a fixed width or + * a different padding goes. + * @param subtitle a second line under the title: the path of a file, the host + * of a page. Nothing by default, since the workspace knows only the title. + */ +@Composable +@ExperimentalNucleusApi +public fun TabHoverPreviewScope.TabHoverPreviewCard( + modifier: Modifier = Modifier, + subtitle: (@Composable () -> Unit)? = null, +) { + val titleColors = LocalTitleBarStyle.current.colors + val background = LocalDecoratedWindowStyle.current.colors.background + val shape = RoundedCornerShape(HoverCardCornerRadius) + Column( + modifier = + modifier + .widthIn(min = HoverCardMinWidth, max = HoverCardMaxWidth) + .background(background, shape) + .border(HoverCardBorderWidth, titleColors.border, shape) + .padding(HoverCardPadding), + ) { + BasicText( + text = tab.title, + style = + TextStyle( + color = titleColors.content, + fontSize = HOVER_CARD_TITLE_SP.sp, + fontWeight = FontWeight.Medium, + ), + maxLines = HOVER_CARD_TITLE_LINES, + overflow = TextOverflow.Ellipsis, + ) + if (subtitle != null) { + Spacer(Modifier.height(HoverCardGap)) + subtitle() + } + if (thumbnail != null) { + Spacer(Modifier.height(HoverCardGap)) + TabPreview(tab, Modifier.fillMaxWidth().clip(RoundedCornerShape(HoverCardPictureRadius))) + } + } +} + +/** + * The picture of [tab]'s body ([TabEntry.thumbnail]) drawn as an image, or + * [placeholder] while there is none: the one composable for a tab's preview + * wherever it goes — a hover card, an overview of every tab, a drag ghost — + * so an app draws it in one line and animates it like anything else, through + * [modifier] or by wrapping it. Sized by [modifier]; given one dimension it + * takes the other from the picture's aspect ratio. The stock + * [TabHoverPreviewCard] is built on it. + * + * @param contentScale how the picture fills the bounds [modifier] gives it. + * @param placeholder what stands in while the tab has no picture: nothing by + * default, so the preview takes no room until it has something to show. + */ +@Composable +@ExperimentalNucleusApi +public fun TabPreview( + tab: TabEntry, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + placeholder: @Composable () -> Unit = {}, +) { + val picture = tab.thumbnail + if (picture == null) { + Box(modifier) { placeholder() } + } else { + Image(bitmap = picture, contentDescription = null, modifier = modifier, contentScale = contentScale) + } +} + +/** + * Records the tab's body into a layer of its own and keeps a reduced picture + * of it on the entry, which is what a hover card of a tab that is not the + * selected one has to draw. + * + * Composed by [TabWindows] around the selected tab's body, and only for a + * workspace built with `captureThumbnails` — it sits *above* the relocation + * anchor, so the path from that anchor down to the content is the same in + * every window and `rememberSaveable` state still follows a tab across. + */ +@Suppress("FunctionNaming") +@Composable +internal fun TabThumbnailRecorder( + tab: TabEntry, + content: @Composable () -> Unit, +) { + val recorded = rememberGraphicsLayer() + val reduced = rememberGraphicsLayer() + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + Box( + modifier = + Modifier.fillMaxSize().drawWithContent { + recorded.record { this@drawWithContent.drawContent() } + drawLayer(recorded) + }, + ) { + content() + } + LaunchedEffect(tab, recorded, reduced, density, layoutDirection) { + snapshotFlow { tab.thumbnailRequest }.collectLatest { + // The body has to have drawn once for the layer to hold anything, + // and a picture taken the frame a tab arrives catches it mid + // animation: one settle, then the readback. `collectLatest` + // collapses a burst of requests into the last one. + delay(ThumbnailSettleMillis) + reducedPicture(recorded, reduced, density, layoutDirection)?.let { tab.thumbnail = it } + } + } +} + +/** + * [source] drawn into [into] at a size no larger than [THUMBNAIL_MAX_SIDE_PX] + * on its longest side, and read back. + * + * Reduced rather than read back whole: a hover card is a couple of hundred dp + * across, and keeping a window-sized bitmap per tab would cost megabytes for + * something that is never drawn at that size. + */ +@Suppress("TooGenericExceptionCaught") +private suspend fun reducedPicture( + source: GraphicsLayer, + into: GraphicsLayer, + density: Density, + layoutDirection: LayoutDirection, +): ImageBitmap? { + val size = source.size + if (size.width <= 0 || size.height <= 0) return null + val factor = (THUMBNAIL_MAX_SIDE_PX.toFloat() / max(size.width, size.height)).coerceAtMost(1f) + val target = + IntSize( + (size.width * factor).roundToInt().coerceAtLeast(1), + (size.height * factor).roundToInt().coerceAtLeast(1), + ) + // A picture is cosmetic: a readback that fails must leave the last one in + // place, never take the window with it. + return try { + into.record(density, layoutDirection, target) { + scale(factor, factor, Offset.Zero) { drawLayer(source) } + } + into.toImageBitmap() + } catch (error: Exception) { + thumbnailLogger.log(Level.FINE, "tab thumbnail readback failed", error) + null + } +} + +private val thumbnailLogger: Logger = Logger.getLogger("dev.nucleusframework.window.tao.tabthumbnail") + +/** How long a body is given to draw and settle before its picture is taken. */ +private val ThumbnailSettleMillis: Duration = THUMBNAIL_SETTLE_MILLIS.milliseconds + +private val HoverPreviewDelay: Duration = HOVER_PREVIEW_DELAY_MILLIS.milliseconds +private val HoverPreviewOffset: DpOffset = DpOffset(0.dp, 4.dp) +private val HoverCardMinWidth: Dp = 160.dp +private val HoverCardMaxWidth: Dp = 280.dp +private val HoverCardPadding: Dp = 10.dp +private val HoverCardGap: Dp = 6.dp +private val HoverCardCornerRadius: Dp = 8.dp +private val HoverCardPictureRadius: Dp = 4.dp +private val HoverCardBorderWidth: Dp = 1.dp +private const val HOVER_PREVIEW_DELAY_MILLIS = 650 +private const val HOVER_CARD_TITLE_SP = 12 +private const val HOVER_CARD_TITLE_LINES = 2 +private const val THUMBNAIL_SETTLE_MILLIS = 400 +private const val THUMBNAIL_MAX_SIDE_PX = 512 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt new file mode 100644 index 000000000..77f39c32c --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStrip.kt @@ -0,0 +1,575 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.workspace.positionInWindowPx +import dev.nucleusframework.window.tao.workspace.publishHostGeometry +import dev.nucleusframework.window.tao.workspace.rememberHostGeometry + +/** What tab-strip chrome gets to see: the workspace and the group this strip belongs to. */ +@ExperimentalNucleusApi +public interface TabStripScope { + /** The workspace the strip belongs to. */ + public val workspace: TabWorkspace + + /** The group whose tabs this strip shows. */ + public val group: TabWindowGroup + + /** The tabs to show, in strip order. */ + public val tabs: List get() = workspace.tabsOf(group) +} + +internal class TabStripScopeImpl( + override val workspace: TabWorkspace, + override val group: TabWindowGroup, +) : TabStripScope + +/** + * The stock tab strip: one tab per entry of the group, the selected one + * highlighted, each draggable between windows ([Modifier.tabDragHandle]) and + * closable. + * + * The strip publishes its own geometry to the workspace, which is what lets a + * tab dragged out of *another* window be dropped into this one — so custom + * chrome should either build on this composable or publish the same geometry + * with [Modifier.tabStripGeometry]. + * + * Colours come from [LocalTitleBarStyle], so the strip matches whatever + * title-bar theme the app installed. + * + * A tab dragged along its own strip stays in the strip's hands: it is drawn + * under the pointer, its neighbours slide aside as its edge crosses their + * centres, and on release it slides into the slot it was over before the + * order changes — the motion of a browser's tab strip. Taken out of the strip + * it becomes a ghost window, as a tab dragged to another window does, and the + * strip it is carried over opens a slot of its width where it would land, + * showing the same card ([dropGhost]), so the tab is seen taking its place + * before it is let go. + * + * @param reorderAnimation how a tab travels along the strip — pushed aside, + * or sliding home; `null` moves it at once. Only the drawing is animated: + * the strip's published geometry is the settled layout throughout, so a + * drop resolved mid-motion still lands where the strip says it will. + * @param hoverPreview the card shown under the tab the pointer rests on; + * `null`, the default, shows none. [TabHoverPreview.Default] is a browser's + * behaviour, and [TabHoverPreview] takes the card whole for an app that + * wants to draw its own. + * @param tabLeading chrome placed before the title of every tab, composed + * with the tab it belongs to — a favicon, a file-type icon. `null`, the + * default, leaves the title at the tab's edge. + * @param tabTrailing chrome placed after the title of every tab, before its + * close button — a modified dot, an unread badge. `null` by default. + * @param dropGhostCard the card drawn in the slot a tab dragged from another + * window would fill, a slot already sized to that tab's width and the + * strip's height; [TabDropGhostCard] by default. An app that draws its own + * `dragGhost` in [TabWindows] draws this with the same composable — + * [TabGhostCard]'s shape, a tab and a modifier — so the tab lands as it + * travelled. + * @param trailing chrome placed right after the last tab — a new-tab button, + * typically. It sits inside the strip, so the strip stays a single drop + * target and a tab released over it is appended. + */ +@Composable +@ExperimentalNucleusApi +public fun TabStripScope.TabStrip( + modifier: Modifier = Modifier, + reorderAnimation: AnimationSpec? = TabReorderAnimation, + hoverPreview: TabHoverPreview? = null, + tabLeading: (@Composable TabStripScope.(TabEntry) -> Unit)? = null, + tabTrailing: (@Composable TabStripScope.(TabEntry) -> Unit)? = null, + dropGhostCard: @Composable TabStripScope.(TabDropGhost) -> Unit = { TabDropGhostCard(it) }, + trailing: @Composable TabStripScope.() -> Unit = {}, +) { + val entries = tabs + val motion = rememberTabStripMotion(reorderAnimation) + // A tab still sliding home after being let go: the tabs themselves show + // where it lands, and a slot would say it twice. + val ghost = dropGhost?.takeIf { motion.animating == null } + // The slot shuts with a slide when the tab moves on — but not when the tab + // lands in it. The tab then takes the slot's place in the very frame the + // drag ends, so the card is seen becoming the tab rather than shutting + // beside it: the slots are keyed on a generation that turns over at the + // landing, which drops the open one from the composition at once. + val landing = remember(group) { TabLandingMemo() } + workspace.draggedTab?.let { dragged -> if (ghost != null) landing.expect(dragged, ghost.index) } + if (ghost == null) landing.settle(entries) + val closing = remember(group) { mutableStateListOf() } + Row( + modifier = modifier.fillMaxWidth().tabStripGeometry(workspace, group), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + entries.forEachIndexed { index, entry -> + // The slot a tab coming from *another* window would take. + key(landing.generation) { TabDropGhostSlot(ghost, index, dropGhostCard) } + // Keyed on the tab, not on its place in the strip: Compose + // otherwise identifies the items by position, so a reorder would + // hand the arriving tab the state of the one that left — its hover + // for a start — and no item would have moved for an animation to + // follow. + key(entry.id) { + TabStripItem( + scope = this@TabStrip, + entry = entry, + index = index, + motion = motion, + closing = closing, + leading = tabLeading, + trailing = tabTrailing, + slotModifier = Modifier.weight(1f, fill = false).fillMaxHeight(), + ) + } + } + key(landing.generation) { TabDropGhostSlot(ghost, entries.size, dropGhostCard) } + trailing() + } + // Outside the Row: the card is a popup anchored to the tab's own slot, so + // it belongs to the strip rather than to any one tab, and nothing about it + // takes part in the strip's layout. + hoverPreview?.let { TabHoverPreviewPopup(it) } +} + +/** + * Which tab the strip's open slot stands for, and where — so the frame that + * shows the tab landed there can tell a landing from a drag that moved on. + * Plain fields: bookkeeping read in the composition that writes it, never a + * reason to recompose. + */ +private class TabLandingMemo { + private var entry: TabEntry? = null + private var index = -1 + + /** Turned over at every landing; the slots are keyed on it. */ + var generation = 0 + private set + + fun expect( + entry: TabEntry, + index: Int, + ) { + this.entry = entry + this.index = index + } + + /** The slot has closed: if the tab it stood for is now at its place, it landed — snap the slot away. */ + fun settle(entries: List) { + val expected = entry ?: return + if (entries.getOrNull(index) === expected) generation++ + entry = null + index = -1 + } +} + +/** + * The slot a tab dragged from another window would fill in this strip: the + * place it lands, the width it brings and the tab itself — drawn with the + * strip's `dropGhostCard` ([TabDropGhostCard] by default) where [TabStrip]'s + * own layout puts it, or by a strip written from scratch at [index] among its + * tabs (`tabs.size` is after the last one). + * + * `null` while nothing is dragged over this strip, and for a tab of this very + * strip in the strip's own hands: its neighbours moving aside already show + * where it lands. + */ +@ExperimentalNucleusApi +public val TabStripScope.dropGhost: TabDropGhost? + get() { + val preview = workspace.dropPreview?.takeIf { it.group === group } ?: return null + val dragged = workspace.draggedTab ?: return null + if (dragged.group === group && workspace.dragGhost == null) return null + return TabDropGhost(preview.index.coerceIn(0, tabs.size), workspace.draggedTabWidth(dragged), dragged) + } + +/** + * Where a tab dragged from another window would land in a strip, and what it + * looks like there — see [TabStripScope.dropGhost]. + * + * @property index the place among the strip's tabs; `tabs.size` is after the last. + * @property width the width the tab has in the strip it comes from. + * @property tab the tab being dragged, for a card that draws more than its title. + */ +@ExperimentalNucleusApi +public data class TabDropGhost( + val index: Int, + val width: Dp, + val tab: TabEntry, +) + +/** + * The card a [TabDropGhost] is drawn as: [TabGhostCard] at [TabDropGhost.width] + * wide and the strip's height, the same card the tab travels under. A strip + * written from scratch composes it at [TabDropGhost.index] among its tabs. + */ +@Composable +@ExperimentalNucleusApi +public fun TabDropGhostCard( + ghost: TabDropGhost, + modifier: Modifier = Modifier, +) { + TabGhostCard(ghost.tab, modifier.width(ghost.width).fillMaxHeight()) +} + +/** + * The card a [TabDragGhost] is drawn as unless the app draws its own: + * [TabGhostCard] filling the ghost window. The default of the `dragGhost` slot + * of [TabWindows], and what an app's own ghost falls back on for a tab it has + * no picture of. + */ +@Composable +@ExperimentalNucleusApi +public fun TabDragGhostCard( + ghost: TabDragGhost, + modifier: Modifier = Modifier, +) { + TabGhostCard(ghost.tab, modifier.fillMaxSize()) +} + +/** + * One of the strip's gaps — before the tab at [index], or after the last — + * opening to [ghost]'s width while [ghost] lands there and shutting when it + * moves on, so the tabs slide aside for it as they do for one of their own. + */ +@Composable +private fun TabStripScope.TabDropGhostSlot( + ghost: TabDropGhost?, + index: Int, + card: @Composable TabStripScope.(TabDropGhost) -> Unit, +) { + val shown = ghost?.takeIf { it.index == index } + // Kept through the exit, which still needs a width and a title to shut. + var last by remember { mutableStateOf(shown) } + if (shown != null) last = shown + AnimatedVisibility( + visible = shown != null, + enter = expandHorizontally(TabEnterAnimation, clip = false), + exit = shrinkHorizontally(TabExitAnimation, clip = false), + ) { + // Sized here, not by the card: the slot must open to the travelling + // tab's width whatever the app draws in it. + last?.let { Box(Modifier.width(it.width).fillMaxHeight()) { card(it) } } + } +} + +/** + * Publishes this element as [group]'s tab strip: the drop target a tab dragged + * from any window of [workspace] can be released on. + * + * [TabStrip] applies it already; use it directly when writing a strip from + * scratch, on the element that spans the whole strip, and mark each tab's own + * slot with [Modifier.tabSlot] so the insertion index can be worked out. + */ +@ExperimentalNucleusApi +public fun Modifier.tabStripGeometry( + workspace: TabWorkspace, + group: TabWindowGroup, +): Modifier = + composed { + val containerSize = LocalWindowInfo.current.containerSize + val geometry = rememberHostGeometry(workspace.stripHosts, group.window) + // The strip's direction rides on its geometry: a tab torn out of it + // travels under a card laid out the way the strip drew it. + val direction = LocalLayoutDirection.current + Modifier + .publishHostGeometry(geometry, containerSize, direction) + .tabTransferTarget(workspace, group) + } + +/** + * Makes the strip the drop target of a [TabWorkspace.transferDrag]: the drag + * that rides the platform's DnD session where strips cannot be hit-tested + * from the source (native Wayland). The insertion index is resolved here, in + * this window's coordinates — previewed while hovering, recorded on the + * session at the drop for the source to act on when the session ends. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun Modifier.tabTransferTarget( + workspace: TabWorkspace, + group: TabWindowGroup, +): Modifier { + val target = remember(workspace, group) { TabTransferTarget(workspace, group) } + return dragAndDropTarget( + shouldStartDragAndDrop = { workspace.transferDrag != null }, + target = target, + ) +} + +private class TabTransferTarget( + private val workspace: TabWorkspace, + private val group: TabWindowGroup, +) : DragAndDropTarget { + override fun onEntered(event: DragAndDropEvent) = preview(event) + + override fun onMoved(event: DragAndDropEvent) = preview(event) + + override fun onExited(event: DragAndDropEvent) = clearPreview() + + override fun onEnded(event: DragAndDropEvent) = clearPreview() + + override fun onDrop(event: DragAndDropEvent): Boolean { + val drag = workspace.transferDrag ?: return false + drag.drop = insertion(drag, event) ?: return false + clearPreview() + return true + } + + /** + * Where the dragged tab would land in this strip; `null` for the only tab + * of this very window, which has no "in" here — its own strip moves with + * it on the other platforms and is no target there either. + */ + private fun insertion( + drag: TabTransferDrag, + event: DragAndDropEvent, + ): TabDropTarget? { + if (drag.entry.group === group && group.tabIds.size == 1) return null + return TabDropTarget(group, workspace.insertionIndex(group, event.positionInWindowPx().x, exclude = drag.entry)) + } + + private fun preview(event: DragAndDropEvent) { + val drag = workspace.transferDrag ?: return + workspace.dropPreview = insertion(drag, event) + } + + private fun clearPreview() { + if (workspace.dropPreview?.group === group) workspace.dropPreview = null + } +} + +/** + * Marks this element as the slot of the tab at [index] in [group], which is + * what turns a pointer position into an insertion index. + * + * It is also what publishes the tab under the pointer + * ([TabStripScope.hoveredTab]) and the rect a hover card is anchored to, so a + * strip that marks its slots gets [TabHoverPreviewPopup] for nothing. + * + * [TabStrip] applies it already; a strip written from scratch must apply it to + * every tab, in strip order. + */ +@OptIn(ExperimentalComposeUiApi::class) +@ExperimentalNucleusApi +public fun Modifier.tabSlot( + group: TabWindowGroup, + index: Int, +): Modifier = + onPositionChanged { coordinates -> + val slots = group.slotsInWindowPx.toMutableList() + while (slots.size <= index) slots += Rect.Zero + slots[index] = coordinates.boundsInWindow() + // Trailing slots of tabs that have left: the list is rebuilt from the + // ones still placed, so a stale rect cannot shift an insertion index. + group.slotsInWindowPx = slots.take(group.ids.size.coerceAtLeast(index + 1)) + } + // The id is resolved at event time, not captured: the slot at an index + // is whichever tab the strip has put there. + .onPointerEvent(PointerEventType.Enter) { group.noteHoverEnter(group.ids.getOrNull(index)) } + .onPointerEvent(PointerEventType.Exit) { group.noteHoverExit(group.ids.getOrNull(index)) } + .onPointerEvent(PointerEventType.Press) { group.noteHoverPress(group.ids.getOrNull(index)) } + +/** One tab: its title, a close button, and the whole thing a drag handle. */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +@Suppress("LongParameterList") +internal fun TabItem( + scope: TabStripScope, + tab: TabEntry, + selected: Boolean, + leaving: Boolean, + held: Boolean, + /** `true` while a tab of this strip is in hand: the others stop reacting to the pointer. */ + hoverSuppressed: Boolean, + leading: (@Composable TabStripScope.(TabEntry) -> Unit)?, + trailing: (@Composable TabStripScope.(TabEntry) -> Unit)?, + modifier: Modifier, + onClose: () -> Unit, +) { + val colors = LocalTitleBarStyle.current.colors + var hovered by remember { mutableStateOf(false) } + val shape = RoundedCornerShape(topStart = TabCornerRadius, topEnd = TabCornerRadius) + // A tab in hand is faded, and it fades rather than switches, so picking one + // up and putting it down again is one motion. Held inside its own strip it + // stays nearly solid — it is a card being carried, not a tab on its way out. + val targetAlpha = + when { + held -> TAB_HELD_ALPHA + leaving -> TAB_LEAVING_ALPHA + else -> 1f + } + val leavingAlpha by animateFloatAsState(targetAlpha, TabFadeAnimation) + val background = + when { + // Carried, it needs a body of its own: a tab whose background is + // the strip's would travel as a bare title and read as nothing. + held -> colors.content.copy(alpha = TAB_HELD_BACKGROUND_ALPHA) + selected -> colors.content.copy(alpha = TAB_SELECTED_ALPHA) + // A tab under the pointer while another is being carried over it is + // not being pointed at, it is being passed: highlighting it would + // light up every tab the carried one crosses. + hovered && !hoverSuppressed -> colors.content.copy(alpha = TAB_HOVER_ALPHA) + else -> Color.Transparent + } + Row( + modifier = + modifier + .widthIn(max = TabMaxWidth) + .fillMaxHeight() + .alpha(leavingAlpha) + .background(background, shape) + .clickable { scope.workspace.select(tab.id) } + .onPointerEvent(PointerEventType.Enter) { hovered = true } + .onPointerEvent(PointerEventType.Exit) { hovered = false } + .padding(horizontal = TabHorizontalPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + leading?.let { Box(Modifier.slotGap(before = false)) { it(scope, tab) } } + BasicText( + text = tab.title, + modifier = Modifier.weight(1f), + style = + TextStyle( + color = colors.content, + fontSize = TAB_TITLE_SP.sp, + fontWeight = if (selected) FontWeight.Medium else FontWeight.Normal, + ), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + trailing?.let { Box(Modifier.slotGap(before = true)) { it(scope, tab) } } + TabCloseButton(colors.content, onClose) + } +} + +@Composable +private fun TabCloseButton( + color: Color, + onClick: () -> Unit, +) { + // `clickable` consumes the press, which is what opts this out of both the + // tab drag and the title bar's native window move. + Box( + modifier = Modifier.clickable(onClick = onClick).padding(TabCloseInset), + contentAlignment = Alignment.Center, + ) { + BasicText(text = "×", style = TextStyle(color = color, fontSize = TAB_CLOSE_SP.sp)) + } +} + +/** + * The card a tab is previewed as while it is dragged — following the pointer + * out of its strip ([TabDragGhostCard]) and drawn on the slot it would take in + * another ([TabDropGhostCard]): [tab]'s title on the shared drop-preview + * surface, sized by [modifier]. Both default cards are this one, and an app's + * own card takes the same shape — a tab and a modifier — so one composable + * serves the `dragGhost` slot of [TabWindows] and the `dropGhostCard` slot of + * [TabStrip] alike. + */ +@Composable +@ExperimentalNucleusApi +public fun TabGhostCard( + tab: TabEntry, + modifier: Modifier = Modifier, +) { + val accent = LocalTitleBarStyle.current.colors.content + Box(modifier = modifier, contentAlignment = Alignment.CenterStart) { + DragPreviewSurface(Modifier.matchParentSize()) + BasicText( + text = tab.title, + modifier = Modifier.padding(horizontal = TabHorizontalPadding), + style = TextStyle(color = accent, fontSize = TAB_TITLE_SP.sp, fontWeight = FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +internal val TabMaxWidth: Dp = 220.dp +private val TabHorizontalPadding: Dp = 8.dp + +/** Between a tab's leading or trailing slot and its title. */ +private val TabSlotGap: Dp = 6.dp + +/** + * Room for [TabSlotGap] beside a slot, charged only when the slot drew + * something: a slot that composes nothing for this tab costs it nothing, and + * a strip without slots is the stock chip to the pixel. + */ +private fun Modifier.slotGap(before: Boolean): Modifier = + layout { measurable, constraints -> + // The gap is reserved out of the room the slot is given, so a squeezed + // tab never reports more than its constraints allow. + val reserved = TabSlotGap.roundToPx() + val placeable = measurable.measure(constraints.offset(horizontal = -reserved)) + val gap = if (placeable.width > 0) reserved else 0 + layout(placeable.width + gap, placeable.height) { + placeable.placeRelative(if (before) gap else 0, 0) + } + } + +private val TabCornerRadius: Dp = 8.dp +private val TabCloseInset: Dp = 3.dp +private const val TAB_SELECTED_ALPHA = 0.16f +private const val TAB_HOVER_ALPHA = 0.08f +private const val TAB_LEAVING_ALPHA = 0.35f + +/** A tab held under the pointer in its own strip: almost solid, and clearly in hand. */ +private const val TAB_HELD_ALPHA = 0.7f + +/** The body a carried tab is given, so it travels as a card rather than as a title. */ +private const val TAB_HELD_BACKGROUND_ALPHA = 0.16f +private const val TAB_TITLE_SP = 12 +private const val TAB_CLOSE_SP = 14 + +private const val TAB_REORDER_MILLIS = 180 +private const val TAB_ENTER_MILLIS = 200 +private const val TAB_EXIT_MILLIS = 200 +private const val TAB_FADE_MILLIS = 150 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripAnimation.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripAnimation.kt new file mode 100644 index 000000000..2f0c3d634 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripAnimation.kt @@ -0,0 +1,352 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.snapshots.SnapshotStateList +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.zIndex +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.noWindowDrag +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * How a tab travels along its strip by default — pushed aside by the one in + * hand, or sliding into its new place on release: a soft spring, the motion of + * a browser's tab strip. + */ +@ExperimentalNucleusApi +public val TabReorderAnimation: AnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) + +/** How a tab opens: its width grows into the strip. */ +internal val TabEnterAnimation: FiniteAnimationSpec = + tween(durationMillis = TAB_ENTER_MILLIS, easing = FastOutSlowInEasing) + +/** How a tab closes: its width shuts, taking the strip with it. */ +internal val TabExitAnimation: FiniteAnimationSpec = + tween(durationMillis = TAB_EXIT_MILLIS, easing = FastOutSlowInEasing) + +/** The fade that goes with a tab closing, and with one being picked up. */ +internal val TabFadeAnimation: FiniteAnimationSpec = tween(durationMillis = TAB_FADE_MILLIS) + +/** + * The motion of one strip's tabs while one of them is in hand, after the + * pattern of a reorderable row: every tab has a draw-time offset, the tab in + * hand is drawn at the pointer's travel since the grab, and a neighbour + * slides a whole tab aside the moment the carried tab's leading edge crosses + * its centre — back again when it uncrosses. On release the carried tab + * slides into the slot it was over, and only then does the order change, so + * nothing is ever seen jumping. + * + * Offsets are drawn through `graphicsLayer`, so none of this moves a layout: + * the slots the workspace resolves a drop against stay where the settled + * layout put them, and the neighbours' shifts read from those same slots. + */ +internal class TabStripMotion( + private val scope: CoroutineScope, +) { + private val offsets = HashMap>() + + /** Each tab's slot in window px, from its last placement. */ + private val slots = HashMap() + + /** How far the tab [id] is drawn from its slot right now; `0` for one at rest. */ + fun drawnOffsetOf(id: String): Float = offsets[id]?.value ?: 0f + + /** Where the tab [id]'s slot is, in window px, or `null` before its first placement. */ + fun slotOf(id: String): Rect? = slots[id] + + /** The tab in hand, or the one still sliding home after a release. */ + var animating: String? by mutableStateOf(null) + private set + + /** The tab under the pointer; `null` once it has been let go. */ + var held: String? by mutableStateOf(null) + private set + + var spec: AnimationSpec? = TabReorderAnimation + + fun offsetOf(id: String): Animatable = offsets.getOrPut(id) { Animatable(0f) } + + fun placed( + id: String, + slot: Rect, + ) { + slots[id] = slot + } + + /** + * The tab [id] has been carried [slidePx] from where it was grabbed. Its + * own offset snaps there — it is the pointer — and every other tab of + * [order] is pushed a slot aside or let back, by where the carried tab's + * edges now are against their centres. + */ + fun carry( + id: String, + order: List, + slidePx: Float, + ) { + held = id + animating = id + val own = slots[id] ?: return + scope.launch { offsetOf(id).snapTo(slidePx) } + val currentStart = own.left + slidePx + val currentEnd = own.right + slidePx + val neighbours = order.filter { it != id }.mapNotNull { other -> slots[other]?.let { other to it.center.x } } + for ((other, centre) in neighbours) { + val target = + when { + currentStart < own.left && centre in currentStart..own.left -> own.width + currentStart > own.left && centre in own.right..currentEnd -> -own.width + else -> 0f + } + moveTo(other, target) + } + } + + /** The pointer let go, but the carried tab keeps its offset: the settle slides it from there. */ + fun letHold() { + held = null + } + + /** + * The tab in hand has left the strip's hands — a ghost took it out of the + * window, or the drag was abandoned: everything slides back where it was. + */ + fun letGo(order: List) { + held = null + for (id in order) moveTo(id, 0f) + animating = null + } + + /** + * The tab [id], released, slides into the slot of rank [target] in + * [order]; suspends until it has arrived. The caller then changes the + * order and calls [rest], in that sequence, so the frame that shows the + * new order shows every tab at zero offset exactly where it already was. + */ + suspend fun settle( + id: String, + order: List, + target: Int, + velocityPxPerSecond: Float = 0f, + ) { + held = null + animating = id + val from = order.indexOf(id) + val own = slots[id] + val into = order.getOrNull(target)?.let(slots::get) + val destination = + if (own == null || into == null || from < 0) { + 0f + } else if (target > from) { + into.right - own.right + } else { + into.left - own.left + } + val animate = spec + if (animate == null) { + offsetOf(id).snapTo(destination) + } else { + offsetOf(id).animateTo(destination, animate, initialVelocity = velocityPxPerSecond) + } + } + + /** Every offset back to zero at once: the order has just changed under the tabs. */ + suspend fun rest() { + for (animatable in offsets.values) animatable.snapTo(0f) + animating = null + } + + private fun moveTo( + id: String, + target: Float, + ) { + val animatable = offsetOf(id) + if (animatable.targetValue == target) return + val animate = spec + scope.launch { + if (animate == null) animatable.snapTo(target) else animatable.animateTo(target, animate) + } + } +} + +@Composable +internal fun TabStripScope.rememberTabStripMotion(spec: AnimationSpec?): TabStripMotion { + val scope = rememberCoroutineScope() + val motion = remember(group) { workspace.motionFor(group, scope) } + motion.spec = spec + val workspace = workspace + // Where the app places its own windows the drag is the workspace's, and + // the pointer it publishes is what the strip animates from; the local + // gesture of a compositor-placed window drives the motion itself. + LaunchedEffect(motion, workspace, group) { + snapshotFlow { + val tab = workspace.draggedTab + val pointer = workspace.dragPointerScreenPx + val grab = workspace.dragGrabScreenPx + val inHand = + tab != null && + tab.group === group && + pointer != null && + grab != null && + workspace.dragGhost == null && + workspace.dropPreview?.group === group + if (inHand) Triple(tab!!.id, pointer!!.x - grab!!.x, workspace.tabsOf(group).map { it.id }) else null + }.collect { sample -> + if (sample != null) { + motion.carry(sample.first, sample.third, sample.second) + } else if (motion.held != null && workspace.pendingReorder == null) { + motion.letGo(workspace.tabsOf(group).map { it.id }) + } + } + } + // The release inside this strip: slide home, then reorder. + val settle = workspace.pendingReorder?.takeIf { it.group === group } + LaunchedEffect(settle) { + if (settle == null) return@LaunchedEffect + val order = workspace.tabsOf(group).map { it.id } + motion.settle(settle.tab.id, order, settle.index, settle.velocityPxPerSecond) + workspace.reorder(settle.tab.id, settle.index) + motion.rest() + if (workspace.pendingReorder === settle) workspace.pendingReorder = null + } + return motion +} + +/** + * One tab of the strip: its slot, which is the geometry a drop resolves + * against, and inside it the tab as it is drawn — carried, pushed aside, + * sliding home, opening or closing. + * + * @param slotModifier the share of the strip the caller gives this tab. + */ +@Suppress("LongParameterList") +@Composable +internal fun TabStripItem( + scope: TabStripScope, + entry: TabEntry, + index: Int, + motion: TabStripMotion, + closing: SnapshotStateList, + leading: (@Composable TabStripScope.(TabEntry) -> Unit)?, + trailing: (@Composable TabStripScope.(TabEntry) -> Unit)?, + slotModifier: Modifier, +) { + val workspace = scope.workspace + val group = scope.group + val held = motion.held == entry.id + val coroutineScope = rememberCoroutineScope() + + // A tab the strip has not shown yet opens; one the close button took + // shuts, and only then leaves the workspace. + val visibleState = remember { MutableTransitionState(!entry.isEntering) } + LaunchedEffect(entry) { + entry.isEntering = false + visibleState.targetState = true + } + if (entry.id in closing) visibleState.targetState = false + + AnimatedVisibility( + visibleState = visibleState, + modifier = + slotModifier + // In hand or sliding home, it is drawn over its neighbours: a + // Row draws its children in order, so a tab carried past the + // ones after it would otherwise slide underneath them. + .zIndex(if (motion.animating == entry.id) 1f else 0f) + // Clipped to the slot while it opens or shuts, and only then: + // the title is revealed with the width and nothing is drawn + // over the "+" beside it. A tab in hand is drawn outside its + // slot, which is why AnimatedVisibility's own clip — on for + // good once asked for — stays off below. + .graphicsLayer { clip = !visibleState.isIdle }, + // A tab opens and closes by width, so the strip never jumps. + enter = expandHorizontally(TabEnterAnimation, clip = false), + exit = shrinkHorizontally(TabExitAnimation, clip = false) + fadeOut(TabFadeAnimation), + ) { + Box( + modifier = + Modifier + .widthIn(max = TabMaxWidth) + .fillMaxHeight() + // The slot is this box, and it is never animated: what the + // workspace resolves a drop against is the settled layout, + // whatever the drawing is doing. + .tabSlot(group, index) + .onPlaced { motion.placed(entry.id, it.boundsInWindow()) } + // The grip is the slot, not the card: the card is drawn + // translated under the pointer, and a gesture on a node + // that follows the pointer reads no movement at all — + // in its own coordinates the pointer never moves. + // Never the window's move: a tab is dragged by the pointer, + // and on a compositor-placed surface the title bar's move is + // a grab that swallows the whole gesture. The grip claims the + // press on Main, but the bar arms on Final for *any* + // unclaimed press — a press this gesture is not ready for + // (the one that lands while the previous is winding down) + // would take the window with it. + .noWindowDrag() + .tabStripGripFor(workspace, entry, motion), + ) { + val offset = motion.offsetOf(entry.id) + TabItem( + scope = scope, + tab = entry, + selected = entry.id == group.selectedId, + // On its way out of this window, which dims it right down; + // held inside the strip, which draws it as a card in hand. + leaving = entry === workspace.draggedTab && workspace.dragGhost != null, + held = held, + hoverSuppressed = motion.held != null, + leading = leading, + trailing = trailing, + // Drawn where the motion puts it — at draw time, so a layer + // translation moves no layout and recomposes nothing. + modifier = Modifier.fillMaxSize().graphicsLayer { translationX = offset.value }, + ) { + if (entry.id !in closing) { + closing += entry.id + coroutineScope.launch { + delay(TAB_EXIT_MILLIS.toLong()) + closing -= entry.id + workspace.close(entry.id) + } + } + } + } + } +} + +private const val TAB_ENTER_MILLIS = 200 +private const val TAB_EXIT_MILLIS = 200 +private const val TAB_FADE_MILLIS = 150 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripDrag.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripDrag.kt new file mode 100644 index 000000000..cb314beb9 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabStripDrag.kt @@ -0,0 +1,261 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.workspace.ScreenDrag +import dev.nucleusframework.window.tao.workspace.TransferDragGesture +import dev.nucleusframework.window.tao.workspace.screenDragHandle +import dev.nucleusframework.window.tao.workspace.transferDragHandle + +/** + * Makes this element the grip that drags [tab]: a reorder inside its own + * strip, and — where the platform allows it — a move to another window or a + * window of its own. + * + * While the tab is in hand the strip draws it under the pointer and its + * neighbours step aside; see [TabStrip], which applies this already. + * + * The gesture a window can carry depends on one thing, whether the app is the + * one placing its windows ([TaoWindow.canPlaceOnScreen]): + * + * - where it is, the drag is the workspace's ([TabWorkspace.beginDrag]) and + * speaks screen pixels: the strip animates the reorder from the pointer + * that drag publishes, another window's strip can be dropped on, and a + * release clear of every strip tears the tab off under a ghost; + * - where it is not — a native Wayland surface — the reorder is a *local* + * gesture ([tabStripLocalDragHandle]), driven by the pointer's travel + * inside the window and resolved against the strip's own slots, because + * that is the only thing a client is told. A release clear of the strip + * defers the drop to the window the compositor hands the pointer to next, + * which is how a merge into another window still resolves there. + * + * No-op outside a Tao window. + */ +@ExperimentalNucleusApi +public fun Modifier.tabDragHandle( + workspace: TabWorkspace, + tab: TabEntry, +): Modifier = + composed { + val group = tab.group ?: return@composed Modifier + val scope = rememberCoroutineScope() + val motion = remember(workspace, group) { workspace.motionFor(group, scope) } + tabStripGripFor(workspace, tab, motion) + } + +/** [tabDragHandle] with the strip's own motion in hand — see it for the two paths. */ +internal fun Modifier.tabStripGripFor( + workspace: TabWorkspace, + tab: TabEntry, + motion: TabStripMotion, +): Modifier = + composed { + val window = LocalTaoWindow.current ?: return@composed Modifier + if (window.canPlaceOnScreen) { + screenDragHandle( + key = tab, + isDragging = { workspace.draggedTab === tab }, + beginTransfer = { host -> workspace.beginTransferDrag(tab.id, host) }, + ) { host, pointerScreenPx -> + workspace.beginDrag(tab.id, TabDragOrigin.Strip(host), pointerScreenPx)?.asScreenDrag() + } + } else { + tabStripLocalDragHandle(workspace, tab, motion) + } + } + +/** + * The strip's own grip, for a window the app cannot place. + * + * Reordering is *local*: driven by the pointer's travel inside the window and + * resolved against the strip's own slots, so it needs no screen coordinate and + * no window to move. The moment the pointer leaves the strip the gesture is + * handed to the platform's drag-and-drop session + * ([Modifier.transferDragHandle]), and that is the only reason it can be: no + * other window of the app hears a thing about a pointer another window holds, + * so until that session exists no strip can show where a drop would land. With + * it, every window's strip gets the drag in its own coordinates and previews + * the drop, and the release resolves there. + */ +internal fun Modifier.tabStripLocalDragHandle( + workspace: TabWorkspace, + tab: TabEntry, + motion: TabStripMotion, +): Modifier = + composed { + val window = LocalTaoWindow.current ?: return@composed Modifier + var coordinates by remember { mutableStateOf(null) } + val gesture = + remember(workspace, tab, motion) { + TabStripTransferGesture(workspace, motion, tab) { coordinates } + } + Modifier + .pointerHoverIcon( + if (workspace.draggedTab === tab) TaoPointerIcons.Grabbing else TaoPointerIcons.Grab, + ).onPlaced { coordinates = it } + .transferDragHandle( + key = tab, + window = window, + begin = { workspace.beginTransferDrag(tab.id, window) }, + gesture = gesture, + ) + } + +/** + * The strip's half of the gesture: it reorders while the pointer is over the + * strip, and hands over the moment it leaves. + */ +private class TabStripTransferGesture( + private val workspace: TabWorkspace, + private val motion: TabStripMotion, + private val tab: TabEntry, + private val coordinates: () -> LayoutCoordinates?, +) : TransferDragGesture { + private var carry: TabStripCarry? = null + private var origin = 0f + + override fun onStart(pressPosition: Offset) { + origin = pressPosition.x + val group = tab.group ?: return + workspace.takeInStrip(tab.id) + carry = TabStripCarry(workspace, motion, tab) { workspace.tabsOf(group).map { it.id } } + carry?.travel(0f) + } + + override fun onDrag(position: Offset): Boolean { + val live = carry ?: return true + val inWindow = coordinates()?.takeIf { it.isAttached }?.localToWindow(position) + if (live.leftTheStrip(inWindow)) { + // The tab is leaving: the strip lets go of it, and the platform + // session carries it from here — the drag icon under the pointer, + // every window's strip previewing the drop. + live.abandon() + carry = null + return true + } + live.travel(position.x - origin, sampleVelocity = true) + return false + } + + override fun onEnd(released: Boolean) { + val live = carry ?: return + carry = null + if (released) live.release() else live.abandon() + } +} + +/** + * One in-strip reorder in flight: how far the tab has travelled, the motion it + * drives, and the speed it carries into the slide home. + */ +private class TabStripCarry( + private val workspace: TabWorkspace, + private val motion: TabStripMotion, + private val tab: TabEntry, + private val order: () -> List, +) { + private var live = true + private val velocity = CarryVelocity() + + fun travel( + slidePx: Float, + sampleVelocity: Boolean = false, + ) { + if (!live) return + if (sampleVelocity) velocity.sample(slidePx) + motion.carry(tab.id, order(), slidePx) + workspace.carryInStrip(tab.id, slidePx) + } + + /** + * Whether the pointer has left the strip's own rectangle — the only + * question this gesture can ask, since it is told nothing about the + * screen. `false` before the grip has been placed. + */ + fun leftTheStrip(pointerInWindowPx: Offset?): Boolean { + val group = tab.group ?: return false + val strip = workspace.stripGeometry(group)?.layoutBoundsInWindowPx ?: return false + val pointer = pointerInWindowPx ?: return false + return !strip.inflate(STRIP_SLACK_PX).contains(pointer) + } + + /** Let go inside the strip: it slides into the place the strip is showing. */ + fun release() { + if (!live) return + live = false + motion.letHold() + workspace.dropInStrip(tab.id, velocity.perSecond()) + } + + /** The gesture was abandoned: everything back to its slot. */ + fun abandon() { + if (!live) return + live = false + motion.letGo(order()) + workspace.cancelInStrip() + } + + private companion object { + /** A press right on the strip's edge should not read as leaving it. */ + const val STRIP_SLACK_PX = 2f + } +} + +/** + * How fast the tab is travelling along the strip, from the travels the gesture + * reports: what the slide home starts with, so a flick carries through. + * Smoothed, since one change can land a millisecond after the one before it. + */ +private class CarryVelocity { + private var smoothed = 0f + private var lastNanos = 0L + private var lastTravel = Float.NaN + + fun sample(travelPx: Float) { + val now = System.nanoTime() + val elapsed = now - lastNanos + val previous = lastTravel + lastNanos = now + lastTravel = travelPx + if (previous.isNaN() || elapsed !in 1..MAX_GAP_NANOS) { + smoothed = 0f + return + } + val instant = (travelPx - previous) / (elapsed / NANOS_PER_SECOND) + smoothed = smoothed * (1f - SMOOTHING) + instant * SMOOTHING + } + + fun perSecond(): Float = smoothed.coerceIn(-MAX_SPEED, MAX_SPEED) + + private companion object { + const val NANOS_PER_SECOND = 1_000_000_000f + + /** Longer than this between samples and the pointer was at rest, not travelling. */ + const val MAX_GAP_NANOS = 100_000_000L + + /** How much of the newest sample the estimate takes: enough to follow a flick, not a jitter. */ + const val SMOOTHING = 0.4f + + /** A flick harder than this is the pointer teleporting, not a throw. */ + const val MAX_SPEED = 6_000f + } +} + +private fun TabDragSession.asScreenDrag(): ScreenDrag = + object : ScreenDrag { + override fun update(pointerScreenPx: Offset) = this@asScreenDrag.update(pointerScreenPx) + + override fun end(pointerScreenPx: Offset) = this@asScreenDrag.end(pointerScreenPx) + + override fun cancel() = this@asScreenDrag.cancel() + } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWindows.kt new file mode 100644 index 000000000..6ce23c4c1 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWindows.kt @@ -0,0 +1,361 @@ +// #636: the window openers below are `@ComposableOpenTarget(-1)` with +// `@UiComposable` content lambdas — callable from any applier, always composing +// UI — so a non-UI composable called in the caller's scope cannot reclassify +// the window content. ktlint's `annotation` and `function-type-modifier-spacing` +// rules contradict each other on the resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation") + +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.UiComposable +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.window.BasicTitleBar +import dev.nucleusframework.window.DecoratedWindowScope +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.TitleBarLayoutPolicy +import dev.nucleusframework.window.WindowScaffold +import dev.nucleusframework.window.tao.workspace.DragGhostWindow +import dev.nucleusframework.window.tao.workspace.RelocatedContentHost + +/** + * What a tab's body gets to see: the tab, its workspace, and the actions tab + * chrome needs. + */ +@ExperimentalNucleusApi +public interface TabScope { + /** The workspace the tab belongs to. */ + public val workspace: TabWorkspace + + /** The tab being composed. */ + public val tab: TabEntry + + /** Makes this tab the visible one of its group. */ + public fun select() { + workspace.select(tab.id) + } + + /** Removes the tab; its window closes with it when it was the last one. */ + public fun close() { + workspace.close(tab.id) + } +} + +internal class TabScopeImpl( + override val workspace: TabWorkspace, + override val tab: TabEntry, +) : TabScope + +/** + * Declares a tab of [workspace]. Where it is shown is the workspace's + * business: [TabWindows] composes it in whichever window's group holds it. + * + * Declare every tab once, at application scope, next to [TabWindows]: + * + * ```kotlin + * val workspace = rememberTabWorkspace() + * TabWindows(workspace, onLastWindowClosed = ::exitApplication) + * for (document in documents) { + * Tab(workspace, id = document.id, title = document.name) { Editor(document) } + * } + * ``` + * + * On first declaration the tab joins [group] when given — created if it does + * not exist yet — else the window that was focused last, else a new one. After + * that the workspace owns its placement, so an id already known only has its + * title and body refreshed. `rememberSaveable` state inside [content] survives + * every move between windows; plain `remember` state does not. + * + * @param id stable identity within the workspace. + * @param title shown on the tab and, for the selected tab, as the window title. + * @param group the group to open in on first declaration. + * @param content the tab's body. + */ +@Suppress("FunctionNaming") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun ApplicationScope.Tab( + workspace: TabWorkspace, + id: String, + title: String, + group: String? = null, + content: @Composable @UiComposable TabScope.() -> Unit, +) { + val entry = remember(workspace, id) { workspace.register(id, title, group) } + // Published as snapshot state so the window hosting the tab picks up a new + // lambda without this composable knowing which window that is. + SideEffect { + entry.title = title + entry.content = content + } + DisposableEffect(workspace, entry) { + onDispose { workspace.unregister(entry) } + } +} + +/** + * Composes one [DecoratedWindow] per group of [workspace] — the windows the + * user has pulled tabs into — with a [TabStrip] in each title bar and the + * group's selected tab as its content. + * + * A group appears when a tab is torn off and disappears when its last tab + * leaves, so windows follow the tabs without the app opening or closing any. + * The strip is the top of the window and the selected tab fills the rest; + * [windowBodyWrapper] is where an app puts chrome of its own between the two — + * `examples/reader-dock-demo` hangs a whole `DockLayout` of satellites there. + * [onLastWindowClosed] fires when the final group goes, which is where an app + * calls `exitApplication`. + * + * `rememberSaveable` state inside a tab survives the move from one window to + * the next: the workspace carries it across, and the body is composed from one + * shared call site here so the two compositions agree on its keys. + * + * @param strip the chrome of one window's tab strip; [TabStrip] by default. + * Composed inside the window's title bar. + * @param titleBar the title bar of each window, handed the strip to place in + * it — the hook for an app whose windows wear a title bar of their own + * (a gradient, fullscreen controls, the platform order of the window + * buttons). [DefaultTabTitleBar] by default: a [BasicTitleBar] giving the + * strip all the width between the platform controls. + * @param dragGhost what a tab being dragged out of its strip looks like under + * the pointer: composed in a borderless window covering + * [TabDragGhost.screenRectPx], the size the tab had in its strip, laid out + * in the direction of the strip it was grabbed from + * ([TabDragGhost.layoutDirection]). [TabDragGhostCard] by default — the + * title on the stock drop-preview surface; an app draws its own, + * [TabEntry.thumbnail] included if it likes, and draws the strip's + * `dropGhostCard` with the same composable — [TabGhostCard] is the shape + * both take, a tab and a modifier — so the tab lands as it travelled. It is + * composed in the ghost's own scene, with that window's scope as receiver + * and [compositionLocalContext] bridged in; neither [windowContentWrapper] + * nor [windowBodyWrapper] wraps it — they dress a window, background + * included, and a ghost is translucent — so a framework layer that needs + * its locals in the ghost wraps this slot itself, as `nucleus-application` + * does. Never composed where the app + * cannot place windows (native Wayland): there the tab travels as the + * compositor's drag icon, a picture of it in its strip, and + * [TabWorkspace.dragKind] says which is in effect. + * @param compositionLocalContext parent locals bridged into every window's own + * scene, as for [DecoratedWindow]. + * @param windowContentWrapper composed around each window's chrome and + * content, inside that window's scene — the hook framework layers use to + * provide their per-window locals. Must invoke the lambda it is given. + * @param windowBodyWrapper composed *inside* each window, below the tab strip, + * around the selected tab's body: where chrome that belongs to the window + * rather than to a tab goes — a `DockLayout` and its satellites, an activity + * bar, a status bar. The strip stays at the very top of the window, and the + * wrapper is one call site for every window, so nothing a tab change does + * rebuilds it. Must invoke the lambda it is given. + * @param onLastWindowClosed called every time the workspace goes from holding + * groups to holding none — never for the empty workspace this composable + * first sees, since the tabs are declared after it. + */ +@Suppress("LongParameterList", "FunctionNaming") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun ApplicationScope.TabWindows( + workspace: TabWorkspace, + compositionLocalContext: CompositionLocalContext? = null, + strip: @Composable @UiComposable TabStripScope.() -> Unit = { TabStrip() }, + titleBar: @Composable @UiComposable TaoDecoratedWindowScope.(strip: @Composable () -> Unit) -> Unit = + { DefaultTabTitleBar(it) }, + dragGhost: @Composable @UiComposable TaoDecoratedWindowScope.(TabDragGhost) -> Unit = { TabDragGhostCard(it) }, + windowContentWrapper: @Composable @UiComposable TaoDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit = + { it() }, + windowBodyWrapper: @Composable @UiComposable TaoDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit = + { it() }, + onLastWindowClosed: () -> Unit = {}, +) { + val ghost = workspace.dragGhost + if (ghost != null) { + DragGhostWindow( + screenRectPx = ghost.screenRectPx, + scaleFactor = ghost.scaleFactor, + title = ghost.tab.title, + compositionLocalContext = compositionLocalContext, + layoutDirection = ghost.layoutDirection, + ) { + dragGhost(ghost) + } + } + val currentOnLastClosed = rememberUpdatedState(onLastWindowClosed) + + // The groups to compose, mirrored out of the workspace by an effect rather + // than read straight from it. + // + // The tabs are declared next to this call, so the first group is created by + // a write that lands *during* the composition that has already read the + // list here — and Compose drops an invalidation aimed at a scope it has + // just composed, taking it for an imminent one. Read directly, the very + // first window would then never be composed at all: an application whose + // only windows come from the workspace would never open one. Written from + // an effect, outside composition, every change lands. + var groups by remember(workspace) { mutableStateOf(workspace.groups.toList()) } + LaunchedEffect(workspace) { + snapshotFlow { workspace.groups.toList() }.collect { groups = it } + } + + // Only a real close fires the callback: the workspace is empty on the + // first composition too, and firing then would close an application that + // has not opened a window yet. + val empty = groups.isEmpty() + val everOpened = remember { mutableStateOf(false) } + LaunchedEffect(empty) { + if (!empty) { + everOpened.value = true + } else if (everOpened.value) { + currentOnLastClosed.value() + } + } + + for (group in groups) { + key(group.id) { + TabWindow( + workspace, + group, + compositionLocalContext, + strip, + titleBar, + windowContentWrapper, + windowBodyWrapper, + ) + } + } +} + +/** One group's window: its strip in the title bar, its selected tab as content. */ +@Suppress("FunctionNaming") +@Composable +private fun ApplicationScope.TabWindow( + workspace: TabWorkspace, + group: TabWindowGroup, + compositionLocalContext: CompositionLocalContext?, + strip: @Composable TabStripScope.() -> Unit, + titleBar: @Composable TaoDecoratedWindowScope.(strip: @Composable () -> Unit) -> Unit, + windowContentWrapper: @Composable TaoDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit, + windowBodyWrapper: @Composable TaoDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit, +) { + val state = + rememberWindowState( + position = group.position?.toWindowPosition() ?: WindowPosition.PlatformDefault, + size = group.size, + ) + // A restore moves a window that is already open; a user drag does not go + // through the group, so nothing here fights the pointer. + LaunchedEffect(group.placementRevision) { + if (group.placementRevision == 0) return@LaunchedEffect + group.position?.let { state.position = WindowPosition.Absolute(it.x, it.y) } + state.size = group.size + } + val selected = workspace.selectedTab(group) + DecoratedWindow( + // Closing a window closes the tabs it holds — the group goes with its + // last tab, so this composable leaves on its own. + onCloseRequest = { group.ids.toList().forEach(workspace::close) }, + state = state, + title = selected?.title.orEmpty(), + compositionLocalContext = compositionLocalContext, + ) { + val windowScope: TaoDecoratedWindowScope = this + val window = windowScope.window + DisposableEffect(workspace, group, window) { + // A system quit must not close the tabs: the workspace is the session (TaoWindow.closesOnQuit). + window.closesOnQuit = false + workspace.attachWindow(group, window) + onDispose { workspace.detachWindow(group) } + } + val stripScope = remember(workspace, group) { TabStripScopeImpl(workspace, group) } + windowContentWrapper { + with(windowScope) { + WindowScaffold( + titleBar = { windowScope.titleBar { strip(stripScope) } }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + // The app's window-level chrome sits here, under the + // strip: one call site for every window, so a tab + // change neither rebuilds it nor moves the body's + // relocation keys. + windowScope.windowBodyWrapper { TabBody(workspace, selected) } + } + } + } + } + } +} + +/** + * The selected tab's body, composed from this one call site in every window. + * + * That is what makes `rememberSaveable` state survive a move: the relocation + * matches keys between two hosts whose path to the content is identical, and + * routing every window through here is how the paths stay identical. Wrapping + * the call per window — or per group — would break it. + * + * Keyed on the tab, and it has to be. Compose identifies what it remembers by + * position, so without the key a change of selection would hand the arriving + * body the slots of the one that left: its `remember` values, its effects, and + * its `rememberSaveable` registry entries. The key is above the relocation + * anchor, not below it, so the path from the anchor down to the content is + * still identical in every window. + * + * A workspace that keeps pictures of its tabs for its hover cards + * ([TabWorkspace.captureThumbnails]) has the body wrapped in a recorder — + * above the anchor too, and the same wrapper in every window, so it changes + * nothing about what follows a tab across. + */ +@Suppress("FunctionNaming") +@Composable +private fun TabBody( + workspace: TabWorkspace, + tab: TabEntry?, +) { + if (tab == null) return + key(tab.id) { + val scope = remember(workspace, tab) { TabScopeImpl(workspace, tab) } + val body = @Composable { RelocatedContentHost(tab.stateSlot, scope, tab.content) } + if (workspace.captureThumbnails) { + TabThumbnailRecorder(tab) { body() } + } else { + body() + } + } +} + +/** + * The stock title bar of a tab window: a [BasicTitleBar] whose centre is the + * strip. `FillCenter` hands its single centre child exactly the width left + * between the platform controls, which is where a tab strip belongs: a strip, + * not a title. The default of [TabWindows]' `titleBar`, and what an app's own + * title bar is measured against. + */ +@Suppress("FunctionNaming") +@Composable +@ExperimentalNucleusApi +public fun DecoratedWindowScope.DefaultTabTitleBar(strip: @Composable () -> Unit) { + BasicTitleBar(layoutPolicy = TitleBarLayoutPolicy.FillCenter) { + Box(Modifier.fillMaxWidth()) { strip() } + } +} + +private fun DpOffset.toWindowPosition(): WindowPosition = WindowPosition.Absolute(x, y) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWorkspace.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWorkspace.kt new file mode 100644 index 000000000..3ad02b899 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TabWorkspace.kt @@ -0,0 +1,1204 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.workspace.DragController +import dev.nucleusframework.window.tao.workspace.HostGeometry +import dev.nucleusframework.window.tao.workspace.HostGeometryRegistry +import dev.nucleusframework.window.tao.workspace.RelocatableSlot +import dev.nucleusframework.window.tao.workspace.WindowGroup +import dev.nucleusframework.window.tao.workspace.sanitizedOrNull +import dev.nucleusframework.window.tao.workspace.warnScreenPlacementUnsupported +import kotlinx.coroutines.CoroutineScope + +/** + * One tab known to a [TabWorkspace]: its identity, title and body. + * + * Created by [Tab] on first composition and kept for the lifetime of the + * workspace, so a tab the app takes out of composition and brings back resumes + * where it was. A [TabWorkspace.restore] that names a tab before then only + * remembers where to put it: the entry exists once [Tab] has composed. + */ +@ExperimentalNucleusApi +public class TabEntry internal constructor( + /** Stable identity, the key used by every [TabWorkspace] operation. */ + public val id: String, + title: String, +) { + /** Human-readable title, shown on the tab. */ + public var title: String by mutableStateOf(title) + internal set + + /** The window group this tab currently belongs to. */ + public var group: TabWindowGroup? by mutableStateOf(null) + internal set + + /** `true` while this tab is the selected one of its group. */ + public val isSelected: Boolean get() = group?.selectedId == id + + /** + * The picture of this tab's body a hover card draws + * ([TabHoverPreviewScope.thumbnail]), or `null`. + * + * Two things write it. A workspace built with `captureThumbnails` takes + * one of the selected tab of every window, and again on + * [TabWorkspace.captureThumbnail]. And the app assigns whatever it has — + * the picture it saved with the layout it is restoring, a render of its + * own — for a tab not on screen, since only the selected tab of a window + * is composed. Assign it *before* the tab is shown: the workspace's + * capture replaces it then, and an assignment landing after that capture + * stands until the next one, stale or not. The entry exists once [Tab] has + * composed — [TabWorkspace.restore] creates none — so a restored picture + * goes on from an effect next to the declaration: + * + * ```kotlin + * Tab(workspace, id = doc.id, title = doc.name) { Editor(doc) } + * LaunchedEffect(doc.id) { workspace.tab(doc.id)?.thumbnail = saved[doc.id] } + * ``` + */ + public var thumbnail: ImageBitmap? by mutableStateOf(null) + + /** + * Bumped to ask for a new [thumbnail]; the window showing the tab takes + * one and stores it. Starts at 0, which is the first capture. + */ + internal var thumbnailRequest: Int by mutableStateOf(0) + private set + + internal fun requestThumbnail() { + thumbnailRequest++ + } + + internal var content: (@Composable TabScope.() -> Unit)? by mutableStateOf(null) + + /** + * `true` until a strip has drawn this tab once: what tells the chrome to + * open it with an animation instead of having it appear at full width. + * Cleared by the first strip that shows it. + */ + internal var isEntering: Boolean = true + + /** `rememberSaveable` values carried across a move between groups. */ + internal val stateSlot: RelocatableSlot = RelocatableSlot() +} + +/** + * One window's worth of tabs: the tabs it holds in strip order, which of them + * is selected, and the geometry of the window showing them. + * + * A group exists exactly as long as it holds at least one tab — tearing the + * last tab out of a window closes that window, and dropping a tab in empty + * space opens a new one. [TabWindows] composes one [DecoratedWindow] per group. + */ +@ExperimentalNucleusApi +public class TabWindowGroup internal constructor( + /** Stable identity, unique within the workspace and stable across a restore. */ + public val id: String, + initialPosition: DpOffset?, + initialSize: DpSize, +) { + internal val tabIds = mutableStateListOf() + + /** The id of the selected tab, or `null` while the group is empty. */ + public var selectedId: String? by mutableStateOf(null) + internal set + + /** Where the group's window is; `null` lets the platform place it. */ + public var position: DpOffset? by mutableStateOf(initialPosition) + internal set + + /** The size of the group's window. */ + public var size: DpSize by mutableStateOf(initialSize) + internal set + + /** The group's native window, once [TabWindows] has mapped it. */ + public var window: TaoWindow? by mutableStateOf(null) + internal set + + /** + * The tab ids this group holds, in strip order. + * + * A snapshot of the live list, so reading it in composition subscribes to + * it and comparing it with `==` means what it says — the observable list + * Compose keeps underneath compares by identity. + */ + public val ids: List get() = tabIds.toList() + + /** Rect of each tab in [ids], in window coordinates (physical px), published by the strip. */ + internal var slotsInWindowPx: List = emptyList() + + /** + * The slot of the tab [id], in window coordinates (physical px), or `null` + * before the strip has placed it. What a hover card is anchored to. + */ + internal fun slotInWindowPx(id: String): Rect? { + val index = tabIds.indexOf(id).takeIf { it >= 0 } ?: return null + return slotsInWindowPx.getOrNull(index)?.takeUnless { it.isEmpty } + } + + /** + * The tab the pointer is over in this group's strip, published by + * `Modifier.tabSlot` — see [TabStripScope.hoveredTab]. + */ + internal var hoveredId: String? by mutableStateOf(null) + private set + + /** + * `true` from a press on the hovered tab until the pointer leaves it: a + * hover card must not sit under a tab being clicked, and must not come + * back until the pointer has been away, which is what a browser does. + */ + internal var hoverBlocked: Boolean by mutableStateOf(false) + private set + + internal fun noteHoverEnter(id: String?) { + hoveredId = id + hoverBlocked = false + } + + internal fun noteHoverExit(id: String?) { + if (hoveredId != id) return + hoveredId = null + hoverBlocked = false + } + + internal fun noteHoverPress(id: String?) { + if (hoveredId == id) hoverBlocked = true + } + + /** + * Bumped every time [position] / [size] are set by the workspace rather + * than by the user. [TabWindows] pushes the new placement onto its window + * when it changes, and only then — a window the user is dragging must not + * be snapped back by a recomposition. + */ + internal var placementRevision: Int by mutableStateOf(0) + private set + + internal fun requestPlacement( + position: DpOffset?, + size: DpSize, + ) { + this.position = position + this.size = size + placementRevision++ + } +} + +/** + * Per-group part of a [TabLayoutSnapshot]. + * + * @property id the group's identity, restored as-is so a snapshot round trip + * keeps the same windows. + * @property tabIds the tabs it held, in strip order. + * @property selectedId which of them was selected. + * @property position where its window was, `null` when the platform placed it. + * @property size the size of its window. + */ +@ExperimentalNucleusApi +public data class TabGroupSnapshot( + val id: String, + val tabIds: List, + val selectedId: String?, + val position: DpOffset?, + val size: DpSize, +) + +/** + * Serializable-by-the-app picture of a [TabWorkspace]: every window, the tabs + * it holds and where it sits. Produce it with [TabWorkspace.snapshot], apply it + * with [TabWorkspace.restore]. + * + * @property groups the groups, in the order their windows were created. + */ +@ExperimentalNucleusApi +public data class TabLayoutSnapshot( + val groups: List, +) + +/** + * A set of tabs spread over however many windows the user has pulled them + * into — the Chrome tab model. + * + * Tabs are **declared** once against the workspace ([Tab]) and the workspace + * decides which window shows each of them; [TabWindows] composes one window + * per non-empty [TabWindowGroup]: + * + * - **Moving.** [move] puts a tab in another group at a given index, [reorder] + * moves it within its own, [tearOff] pulls it into a group of its own at a + * screen rect. A group that loses its last tab is dropped, and its window + * goes with it; a tear-off adds one, and a window appears. + * - **Dragging.** [Modifier.tabDragHandle] — installed on every tab of the + * default strip — drives it: dragging a tab out of a multi-tab window + * previews it under the pointer and lands it in whichever strip it is + * dropped on, or in a new window; dragging the *only* tab of a window moves + * that window instead, exactly as Chrome does, and merges it into the strip + * it is dropped on. + * - **Selection.** [select] picks the visible tab of a group; a tab arriving + * from a drag is selected in its new group, and a group whose selected tab + * leaves selects its neighbour. + * + * `rememberSaveable` state inside a tab's body survives every move; plain + * `remember` state does not, exactly as when any composable moves between + * windows — hoist it or make it saveable. + * + * Every member of this class is meant for the Tao event-loop thread, which is + * also the Compose dispatcher. + * + * @param defaultWindowSize the size a group's window gets when nothing else + * determines it: the first group, and any group restored without a size. + * @param captureThumbnails whether a picture of the selected tab's body is + * kept for a hover card to draw ([TabEntry.thumbnail]). Off by default: it + * records the body into a layer of its own and reads it back, which is a + * cost a workspace should only pay when its chrome shows the pictures. A + * `NativeView` or a `TextureView` in the body draws through a native surface + * of its own rather than into the scene, so it is missing from the picture — + * a body built around one is better off without captures. + */ +@Suppress("TooManyFunctions") +@ExperimentalNucleusApi +public class TabWorkspace( + public val defaultWindowSize: DpSize = DefaultWindowSize, + public val captureThumbnails: Boolean = false, +) { + private val windows = WindowGroup(followFocus = true) + + private val entryMap = mutableStateMapOf() + private val groupList = mutableStateListOf() + private var nextGroupId = 0 + private val pendingRestore = ArrayList() + + /** Every tab declared so far, in declaration order. */ + public val tabs: Collection get() = entryMap.values + + /** The tab registered under [id], if any. */ + public fun tab(id: String): TabEntry? = entryMap[id] + + /** The groups holding tabs, in the order their windows were created. */ + public val groups: List get() = groupList + + /** The group with [id], if any. */ + public fun group(id: String): TabWindowGroup? = groupList.firstOrNull { it.id == id } + + /** The group whose window is [window], if any. */ + public fun groupOf(window: TaoWindow?): TabWindowGroup? = + window?.let { groupList.firstOrNull { group -> group.window === it } } + + /** + * The group whose window was focused most recently, or the first one; the + * window a new tab opens in when none is named. `null` while empty. + */ + public val activeGroup: TabWindowGroup? + get() = groupOf(windows.owner) ?: groupList.firstOrNull() + + /** The tabs of [group], in strip order. */ + public fun tabsOf(group: TabWindowGroup): List = group.tabIds.mapNotNull(entryMap::get) + + /** The selected tab of [group], or `null` while it holds none. */ + public fun selectedTab(group: TabWindowGroup): TabEntry? = group.selectedId?.let(entryMap::get) + + // ── Windows ────────────────────────────────────────────────────────── + + /** Records the window of [group] and makes it a member for focus tracking. Driven by [TabWindows]. */ + internal fun attachWindow( + group: TabWindowGroup, + window: TaoWindow, + ) { + group.window = window + windows.join(window) + } + + /** Forgets the window of [group]. Driven by [TabWindows] when the window leaves composition. */ + internal fun detachWindow(group: TabWindowGroup) { + group.window?.let(windows::leave) + group.window = null + } + + /** Records [window] as the most recently focused group window. */ + internal fun noteWindowFocus(window: TaoWindow) { + windows.noteFocus(window) + } + + // ── Tabs ───────────────────────────────────────────────────────────── + + /** Makes [tabId] the visible tab of its group; a no-op for an unknown tab. */ + public fun select(tabId: String) { + val entry = entryMap[tabId] ?: return + entry.group?.selectedId = tabId + } + + /** + * Takes a fresh picture of [tabId]'s body for its hover card + * ([TabEntry.thumbnail]). + * + * Only the selected tab of a window is composed, so this reaches a tab + * that is on screen right now; for any other it does nothing and the + * picture stays the one taken while it was shown. A no-op altogether + * unless the workspace was built with `captureThumbnails`. + * + * Call it when the tab's content has changed enough for its old picture to + * be misleading — nothing else refreshes it, since the workspace cannot + * know what a body draws. + */ + public fun captureThumbnail(tabId: String) { + if (!captureThumbnails) return + entryMap[tabId]?.requestThumbnail() + } + + /** + * Removes the tab [tabId] from the workspace: its group selects a + * neighbour, and a group left empty is dropped along with its window. + * + * The tab is forgotten entirely, state included — a closed tab is gone, + * unlike a satellite, which is only hidden. + */ + public fun close(tabId: String) { + val entry = entryMap.remove(tabId) ?: return + entry.group?.let { detach(it, tabId) } + entry.group = null + } + + /** + * Moves [tabId] into [group] at [index] (clamped; `null` appends), and + * selects it there. Within its own group this is a [reorder]. A group left + * empty by the move is dropped. + */ + public fun move( + tabId: String, + group: TabWindowGroup, + index: Int? = null, + ) { + val entry = entryMap[tabId] ?: return + if (group !in groupList) return + val from = entry.group + if (from === group) { + reorder(tabId, index ?: group.tabIds.lastIndex) + return + } + from?.let { detach(it, tabId) } + val at = (index ?: group.tabIds.size).coerceIn(0, group.tabIds.size) + group.tabIds.add(at, tabId) + entry.group = group + group.selectedId = tabId + } + + /** Moves [tabId] to [index] within its own group (clamped). */ + public fun reorder( + tabId: String, + index: Int, + ) { + val group = entryMap[tabId]?.group ?: return + val current = group.tabIds.indexOf(tabId) + if (current < 0) return + val at = index.coerceIn(0, group.tabIds.lastIndex) + if (at == current) return + group.tabIds.removeAt(current) + group.tabIds.add(at, tabId) + } + + /** + * Pulls [tabId] into a group of its own whose window covers + * [screenRectPx] (physical screen pixels, outer frame), and returns that + * group — or the tab's existing group when it is already alone in one, + * which is then moved rather than duplicated. + * + * [scaleFactor] is the px-per-dp the rect was measured at. Windows are + * placed in logical pixels, so on a mixed-DPI desktop a rect measured on + * one display and applied on another is off by the ratio of their scales; + * the drop lands where the pointer is either way. + * + * A drag sizes the rect from the window the tab came from, except when + * that window fills the screen — a tab pulled out of a maximized window + * gets [defaultWindowSize] rather than a second screen-sized window. + */ + public fun tearOff( + tabId: String, + screenRectPx: Rect, + scaleFactor: Float, + ): TabWindowGroup? { + val entry = entryMap[tabId] ?: return null + val scale = scaleFactor.takeIf { it > 0f } ?: 1f + val position = DpOffset((screenRectPx.left / scale).dp, (screenRectPx.top / scale).dp) + val size = contentSizeDp(screenRectPx, scale, entry.group) + entry.group?.takeIf { it.tabIds.size == 1 }?.let { alone -> + // Already a window of its own: this is a move, not a tear-off. + // Requested rather than merely recorded, so a caller driving the + // gesture itself really moves the window — a drag never reaches + // here, since the only tab of a window is dragged by moving that + // window ([TabDragOrigin.Strip] takes the window-drag path). + alone.requestPlacement(position, size) + return alone + } + val group = TabWindowGroup(nextGroupId(), position, size) + groupList += group + move(tabId, group) + return group + } + + /** + * The size to request for a window whose *frame* should cover [rectPx]. + * + * A window is sized in content pixels while a tear-off rect is an outer + * frame, so a window created straight from the rect is one chrome too big. + * On Win32 that is the invisible resize border, and it compounds: a tab + * dragged out, merged back and dragged out again gains it every round. + * + * [source] is the group the rect was measured on; the difference between + * its own frame and the content its strip published is the best estimate + * of the chrome the new window will get. Without one — nothing composed + * yet, no screen placement — the rect is taken as-is, which is what this + * always did. + */ + @Suppress("MagicNumber") // outer frame is [x, y, w, h] + private fun contentSizeDp( + rectPx: Rect, + scale: Float, + source: TabWindowGroup?, + ): DpSize { + val geometry = source?.let { stripHosts[it.window] } + val content = geometry?.containerSizePx?.takeIf { it.width > 0 && it.height > 0 } + val outer = source?.window?.outerBoundsPx() + if (content == null || outer == null) { + return DpSize((rectPx.width / scale).dp, (rectPx.height / scale).dp) + } + val chromeW = (outer[2] - content.width).coerceAtLeast(0L) + val chromeH = (outer[3] - content.height).coerceAtLeast(0L) + return DpSize( + ((rectPx.width - chromeW) / scale).dp, + ((rectPx.height - chromeH) / scale).dp, + ) + } + + /** Removes [tabId] from [group], reselecting and dropping the group as needed. */ + private fun detach( + group: TabWindowGroup, + tabId: String, + ) { + val index = group.tabIds.indexOf(tabId) + if (index < 0) return + group.tabIds.removeAt(index) + if (group.selectedId == tabId) { + // The neighbour to the right, else to the left — what a browser does. + group.selectedId = group.tabIds.getOrNull(index) ?: group.tabIds.lastOrNull() + } + if (group.tabIds.isEmpty()) { + detachWindow(group) + groupList -= group + } + } + + /** + * A fresh group id. The counter restarts with the process while a [restore] brings back the + * ids a previous one handed out, so an id already taken — by a group, or by a restored group + * still waiting for its tabs — is skipped. + */ + private fun nextGroupId(): String { + var id: String + do { + id = "group-${nextGroupId++}" + } while (group(id) != null || pendingRestore.any { it.id == id }) + return id + } + + // ── Drag and drop ──────────────────────────────────────────────────── + + /** The strip geometry every group's window publishes, for hit-testing drops. */ + internal val stripHosts: HostGeometryRegistry = HostGeometryRegistry() + + /** The published strip geometry of [group], or `null` before its first layout. */ + internal fun stripGeometry(group: TabWindowGroup): HostGeometry? = stripHosts[group.window] + + private val drags = + DragController { + draggedTab = null + dragPointerScreenPx = null + dragGrabScreenPx = null + dragVelocityPxPerSecond = 0f + dropPreview = null + dragGhost = null + } + + /** + * Where the pointer of the live tab drag is, in physical screen px, or + * `null` while none is dragging — what a strip needs to hold the dragged + * tab under the pointer. Absent on the drag-and-drop path (native + * Wayland), where the source is never told where the pointer is. + */ + internal var dragPointerScreenPx: Offset? by mutableStateOf(null) + + /** Where the pointer was when the live tab drag started, in physical screen px; `null` while none is dragging. */ + internal var dragGrabScreenPx: Offset? by mutableStateOf(null) + + /** + * How fast the pointer of the live drag is travelling along the strip, in + * px per second — what the strip hands the spring that slides a released + * tab home, so a flick carries and a slow move does not overshoot. + */ + internal var dragVelocityPxPerSecond: Float = 0f + + /** + * A tab released inside its own strip, waiting for that strip to slide it + * into its new place before the order changes: the strip animates, then + * applies [reorder] and clears this. Set by the drag session, which does + * not reorder itself on that path, so that the tab is never seen jumping + * from under the pointer to its slot. + */ + internal var pendingReorder: TabReorderSettle? by mutableStateOf(null) + + private val stripMotions = HashMap() + + /** + * Takes [entry] in hand for a drag: it becomes the dragged tab, and the + * selected tab of its group. + * + * Selecting here is what stops an accidental drag from swallowing a click. + * The grip claims the press before the tab's own click gesture does, so a + * click whose pointer drifts past the touch slop becomes a drag — and a + * drag that ends where it started leaves the strip exactly as it was, with + * the click lost and the tab having wobbled for nothing. A browser selects + * a tab on the press for this reason, which also means the tab being + * carried is always the one on screen. + */ + private fun holdForDrag(entry: TabEntry) { + draggedTab = entry + entry.group?.selectedId = entry.id + } + + /** + * Takes the tab [tabId] in hand for a reorder inside its own strip, with + * no coordinate space but the strip's own: this is the gesture that has to + * work where a client is told nothing about the screen (native Wayland), + * so it is driven by [carryInStrip] with the pointer's travel in window px + * and resolved by the same edge-crossing rule the strip animates with. + * + * `null` when the tab is not in a group. Ends with [dropInStrip] or + * [releaseDrag]; a drag that leaves the strip hands over to [beginDrag] or + * [beginTransferDrag] instead. + */ + internal fun takeInStrip(tabId: String): TabWindowGroup? { + val entry = entryMap[tabId] ?: return null + val group = entry.group ?: return null + transferDrag?.cancel() + releaseDrag(null) + holdForDrag(entry) + dropPreview = TabDropTarget(group, group.tabIds.indexOf(tabId)) + return group + } + + /** + * The tab in hand has travelled [slidePx] along its strip: publishes the + * place it would take, by the rule of [reorderTarget]. + */ + internal fun carryInStrip( + tabId: String, + slidePx: Float, + ) { + val entry = entryMap[tabId] ?: return + val group = entry.group ?: return + val index = reorderTarget(group, entry, slidePx) ?: group.tabIds.indexOf(tabId) + dropPreview = TabDropTarget(group, index) + } + + /** + * The tab in hand has been let go inside its strip: records the place for + * the strip to slide it into, at [velocityPxPerSecond], and clears the + * drag. The strip applies the reorder once the tab has arrived. + */ + internal fun dropInStrip( + tabId: String, + velocityPxPerSecond: Float, + ) { + val entry = entryMap[tabId] ?: return + val group = entry.group ?: return + val index = dropPreview?.takeIf { it.group === group }?.index ?: group.tabIds.indexOf(tabId) + draggedTab = null + dropPreview = null + pendingReorder = TabReorderSettle(entry, group, index, velocityPxPerSecond) + } + + /** + * Tears the tab [tabId] out of [window] into a window of its own, at the + * size a pointer drag would give it and wherever the compositor puts it: + * the release of the local strip gesture, on a window the app cannot place. + */ + internal fun tearOffWhereverTheCompositorPuts( + tabId: String, + window: TaoWindow, + ) { + val entry = entryMap[tabId] ?: return + if (entry.group?.tabIds?.size == 1) return + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val outer = window.outerBoundsPx() + val size = + outer?.let { tearOffSizePx(window, it, scale) } + ?: Size(defaultWindowSize.width.value * scale, defaultWindowSize.height.value * scale) + // A rect at the origin: the position is the compositor's and only the + // size survives — see TaoWindow.canPlaceOnScreen. + tearOff(tabId, Rect(Offset.Zero, size), scale) + } + + /** The tab in hand is put back where it was: no reorder, no feedback. */ + internal fun cancelInStrip() { + draggedTab = null + dropPreview = null + } + + /** + * The motion of [group]'s strip — which tab is in hand and how far every + * tab of the strip is drawn from its slot. Created by the strip on its + * first composition; readable from here so a test can assert the motion + * the same way the drawing does. + */ + internal fun motionOf(group: TabWindowGroup): TabStripMotion? = stripMotions[group.id] + + internal fun motionFor( + group: TabWindowGroup, + scope: CoroutineScope, + ): TabStripMotion = stripMotions.getOrPut(group.id) { TabStripMotion(scope) } + + /** + * The tab being dragged right now, or `null`. While it is set every strip + * in the workspace shows where the tab can be dropped. + */ + public var draggedTab: TabEntry? by mutableStateOf(null) + internal set + + /** + * Where the tab being dragged would land if released now, or `null` when + * releasing would tear it into a window of its own. Strips highlight the + * insertion point. + */ + public var dropPreview: TabDropTarget? by mutableStateOf(null) + internal set + + /** + * The preview of a tab being dragged out of its strip, or `null`. + * [TabWindows] shows it as a borderless window that follows the pointer, + * so pulling a tab out of a window is something you can see leaving it. + * + * `null` for a single-tab window: there the window itself follows the + * pointer, and a ghost on top of it would be a second copy of the tab. + */ + public var dragGhost: TabDragGhost? by mutableStateOf(null) + internal set + + /** + * How the tab in flight is being carried, or `null` while none is. + * + * [WorkspaceDragKind.Window] is a drag the app drives, from its first + * sample: a window follows the pointer — the tab's own, when it is the + * only one in it — or [dragGhost] does once a tab leaves a strip of + * several, drawn by the `dragGhost` slot of [TabWindows]; over its own + * strip [dragGhost] is still `null`, the strip holding the tab itself. + * [WorkspaceDragKind.Transfer] carries the tab in the platform's + * drag-and-drop session: the picture under the pointer is the drag icon + * the compositor draws, [dragGhost] stays `null` and the slot never + * composes. On that path a tab held inside its own strip has not been + * handed to the platform yet, so [draggedTab] is set while this is still + * `null`. [draggedTab] and [dropPreview] are published on every path. + */ + public val dragKind: WorkspaceDragKind? + get() = + when { + drags.active != null -> WorkspaceDragKind.Window + transferDrag != null -> WorkspaceDragKind.Transfer + else -> null + } + + /** The drag currently owning the feedback state, or `null`. */ + internal val activeDragSession: TabDragSession? get() = drags.active + + /** `true` while [session] is the one the workspace is publishing. */ + internal fun isLiveDrag(session: TabDragSession): Boolean = drags.isLive(session) + + /** Ends [session] if it is live (`null`: whichever is) and clears the drag feedback. Idempotent. */ + internal fun releaseDrag(session: TabDragSession?) { + drags.release(session) + } + + /** + * Where [screenPx] (physical screen pixels) would insert a tab: the group + * whose strip is under it and the index it would take, or `null` when no + * strip is. Where windows overlap, the focused group is tried first, then + * the others by focus recency; a minimized window is never a target. + * + * [exclude] is left out of the search — the tab being dragged, so hovering + * its own position is not an insertion. + * + * [excludeGroup] is skipped entirely, and the search carries on to the + * strip below it. That is what a single-tab window being dragged needs: + * its own strip travels with the pointer and covers whatever it is being + * dropped on, and it is also the focused window, so it would otherwise + * answer every query and no merge could ever resolve. + */ + public fun dropTargetAt( + screenPx: Offset, + exclude: TabEntry? = null, + excludeGroup: TabWindowGroup? = null, + ): TabDropTarget? = dropTargetAt(null, screenPx, exclude, excludeGroup) + + /** + * The strip the tab being dragged would land in, decided from **where the + * tab is** as well as from where the pointer is: the strip + * [draggedScreenRectPx] — the ghost card following the pointer — has + * reached counts as entered, so a tab whose top edge has come up into a + * strip is previewed there before the pointer itself arrives. That is what + * the user sees moving, and it is the rule the dock zones already follow. + * + * The pointer still wins where both answer: a strip it is actually in is + * the target, whatever the card overlaps. Otherwise the first strip the + * card has reached, by the same order as the pointer overload. A `null` + * rect is the pointer alone. + * + * [exclude] and [excludeGroup] are as in the pointer overload. + */ + public fun dropTargetAt( + draggedScreenRectPx: Rect?, + screenPx: Offset, + exclude: TabEntry? = null, + excludeGroup: TabWindowGroup? = null, + ): TabDropTarget? { + // The excluded group is dropped from the search rather than ending it: + // a single-tab window's own strip travels with the pointer and covers + // whatever it is being dropped on, so the search has to look past it. + val candidates = + stripHosts + .ordered(windows.membersByRecency) + .filterNot { it.minimized() } + .mapNotNull { geometry -> + val group = groupOf(geometry.host)?.takeIf { it !== excludeGroup } ?: return@mapNotNull null + geometry.layoutScreenRectPx()?.let { Triple(geometry, group, it) } + } + val hit = + candidates.firstOrNull { (_, _, strip) -> strip.contains(screenPx) } + ?: draggedScreenRectPx?.let { card -> + candidates.firstOrNull { (_, _, strip) -> !strip.intersect(card).isEmpty } + } + ?: return null + val (geometry, group, _) = hit + val client = geometry.clientOriginPx() ?: return null + val ownSlide = exclude?.takeIf { it.group === group }?.let { slideIn(group, it, screenPx) } + val index = + if (ownSlide != null) { + reorderTarget(group, exclude, ownSlide) ?: group.tabIds.indexOf(exclude.id) + } else { + insertionIndex(group, screenPx.x - client.x, exclude) + } + return TabDropTarget(group, index) + } + + /** + * How far the tab in hand has been carried along its own strip: the + * pointer's travel since the grab, in px — the same in screen and window + * space. `null` before a grab is on record. + */ + private fun slideIn( + group: TabWindowGroup, + entry: TabEntry, + pointerScreenPx: Offset, + ): Float? { + if (group.tabIds.indexOf(entry.id) < 0) return null + val grab = dragGrabScreenPx ?: return null + return pointerScreenPx.x - grab.x + } + + /** + * The place a tab carried [slidePx] along its own strip would take, or + * `null` for the one it has: the last neighbour whose centre its leading + * edge has crossed. Which end of the crossed run counts is the reading + * direction's business, read from the slots as in [insertionIndex]. + * + * This is the rule of the strip's own animation, so what the drop preview + * says and where the tab settles are one and the same. + */ + internal fun reorderTarget( + group: TabWindowGroup, + entry: TabEntry, + slidePx: Float, + ): Int? { + val index = group.tabIds.indexOf(entry.id).takeIf { it >= 0 } ?: return null + val slots = group.slotsInWindowPx + val own = slots.getOrNull(index)?.takeIf { !it.isEmpty } ?: return null + val currentStart = own.left + slidePx + val currentEnd = own.right + slidePx + val placed = slots.filter { !it.isEmpty } + val rightToLeft = isRightToLeft(group, placed) + val crossed: (Int) -> Boolean = + when { + currentStart < own.left -> { j -> + j != index && + slots + .getOrNull(j) + ?.center + ?.x + ?.let { it in currentStart.. own.left -> { j -> + j != index && + slots + .getOrNull(j) + ?.center + ?.x + ?.let { it in own.right.. return null + } + val indices = slots.indices.filter(crossed) + if (indices.isEmpty()) return null + // Moving towards low x: the farthest crossed neighbour is the first + // in strip order, unless the strip runs right to left, where it is the last. + val towardsLowX = currentStart < own.left + return if (towardsLowX == !rightToLeft) indices.first() else indices.last() + } + + /** + * The width [entry] has in the strip it is dragged from, in dp of that + * strip's window — what the slot it lands in elsewhere opens to. Its slot + * is still published while it is in flight (dimmed, or moving with its + * window); before the strip ever placed it, the widest a tab gets. + */ + internal fun draggedTabWidth(entry: TabEntry): Dp { + val group = entry.group + val slot = group?.slotsInWindowPx?.getOrNull(group.tabIds.indexOf(entry.id))?.takeIf { !it.isEmpty } + val scale = group?.window?.scaleFactor?.takeIf { it > 0f } ?: 1f + return slot?.let { (it.width / scale).dp } ?: TabMaxWidth + } + + /** + * Whether [group]'s strip runs right to left: what the strip published + * with its geometry ([Modifier.tabStripGeometry]), else — a strip that + * has published none — inferred from the order of its [placed] slots, + * which takes two of them. A single tab cannot tell, and a right-to-left + * strip resolved left to right opens the drop preview on the wrong side + * of it: the preview moves the tab under the pointer, the answer flips, + * and two cards slide about under a pointer that has not moved. + */ + private fun isRightToLeft( + group: TabWindowGroup, + placed: List, + ): Boolean = + stripHosts[group.window]?.let { it.layoutDirection == LayoutDirection.Rtl } + ?: (placed.size >= 2 && placed.first().left > placed.last().left) + + /** + * The index [xInWindowPx] falls at in [group]'s strip: the number of tabs + * whose midpoint the pointer has passed, counting the dragged tab's own + * slot out so the index it would land at is the one it already has. + * + * "Passed" is a question of reading direction, and the direction is the + * strip's own ([isRightToLeft]): a right-to-left strip puts its first tab + * at the *right*, so its slots run from high x to low, and the pointer + * passes a midpoint by going left. Without that, every drop on a Hebrew or + * Arabic strip resolves mirrored. + */ + internal fun insertionIndex( + group: TabWindowGroup, + xInWindowPx: Float, + exclude: TabEntry?, + ): Int { + val slots = group.slotsInWindowPx.zip(group.tabIds) + val placed = slots.filterNot { (slot, _) -> slot.isEmpty } + val rightToLeft = isRightToLeft(group, placed.map { (slot, _) -> slot }) + return slots + .filterNot { (_, id) -> id == exclude?.id } + .takeWhile { (slot, _) -> + if (rightToLeft) xInWindowPx <= slot.center.x else xInWindowPx >= slot.center.x + }.size + } + + /** + * Starts dragging the tab [tabId] from [origin], with the pointer at + * [pointerScreenPx] (physical screen pixels). Feed the session the pointer + * as it moves and release it with [TabDragSession.end]; it publishes + * [dropPreview] / [dragGhost] and moves, reorders or tears the tab off on + * release. `null` when [tabId] is unknown, the origin's geometry is not + * available, or the origin window has no client-side screen placement + * (native Wayland: no window position to drag from, no strip to drop + * onto — [move] and the snapshot API still work there). + * + * [Modifier.tabDragHandle] drives this from a pointer gesture; call it + * directly to drive the same moves from another input source. + */ + public fun beginDrag( + tabId: String, + origin: TabDragOrigin, + pointerScreenPx: Offset, + ): TabDragSession? { + val entry = entryMap[tabId] ?: return null + val start = pointerScreenPx.sanitizedOrNull() ?: return null + val from = + when (origin) { + is TabDragOrigin.Strip -> origin.window + } + if (!from.canPlaceOnScreen) { + from.warnScreenPlacementUnsupported("TabWorkspace.beginDrag") + return null + } + transferDrag?.cancel() + val session = createTabDragSession(entry, origin, start) ?: return null + drags.begin(session) + holdForDrag(entry) + dragGrabScreenPx = start + dragPointerScreenPx = start + return session + } + + // ── Drag and drop without screen placement (native Wayland) ────────── + + /** + * The drag riding the platform's DnD session, or `null`. Started from a + * tab in a window without client-side screen placement; every strip is a + * drop target for it and records the insertion on it, and the session + * acts on that record when it ends. Feedback is the same as for a pointer + * drag: [draggedTab] and [dropPreview]. + */ + internal var transferDrag: TabTransferDrag? by mutableStateOf(null) + private set + + /** + * Starts the DnD-carried counterpart of [beginDrag] for [tabId], dragged + * from its strip in [window]; `null` when the tab or its group is unknown. + * Supersedes whichever drag was live. + */ + internal fun beginTransferDrag( + tabId: String, + window: TaoWindow, + ): TabTransferDrag? { + val entry = entryMap[tabId] ?: return null + val group = groupOf(window) ?: return null + transferDrag?.cancel() + releaseDrag(null) + val session = createTabTransferDrag(entry, group, window) + transferDrag = session + holdForDrag(entry) + return session + } + + /** `true` while [session] is the transfer drag in flight. */ + internal fun isLiveTransfer(session: TabTransferDrag): Boolean = transferDrag === session + + /** Ends [session] if it is the one in flight and clears the drag feedback. Idempotent. */ + internal fun endTransferDrag(session: TabTransferDrag) { + if (transferDrag !== session) return + transferDrag = null + releaseDrag(null) + } + + // ── Layout persistence ─────────────────────────────────────────────── + + /** Captures every group, the tabs it holds and where its window sits. */ + public fun snapshot(): TabLayoutSnapshot = + TabLayoutSnapshot( + groups = + groupList.map { group -> + TabGroupSnapshot( + id = group.id, + tabIds = group.tabIds.toList(), + selectedId = group.selectedId, + position = liveWindowPosition(group) ?: group.position, + size = liveWindowSize(group) ?: group.size, + ) + }, + ) + + /** + * Applies [snapshot]: tabs it names are moved into the groups it + * describes, and groups whose tabs are all still to be declared are + * applied as those tabs appear. Tabs the snapshot does not name keep + * whichever group they are in — or, if that group is dropped, follow it to + * the first restored one. + * + * A snapshot applies once. A tab it named that is closed and declared + * again afterwards is a new tab, and opens in the active window like any + * other; call [restore] again to put the saved layout back. + */ + public fun restore(snapshot: TabLayoutSnapshot) { + pendingRestore.clear() + for (saved in snapshot.groups) { + val known = saved.tabIds.filter(entryMap::containsKey) + if (known.isEmpty()) { + pendingRestore += saved + continue + } + val group = group(saved.id) ?: TabWindowGroup(saved.id, saved.position, saved.size).also { groupList += it } + group.requestPlacement(saved.position, saved.size) + for (id in known) move(id, group) + // After the moves: a tab arriving selects itself, and the snapshot + // has the last word on which one shows. + group.selectedId = saved.selectedId?.takeIf { it in group.tabIds } ?: group.tabIds.lastOrNull() + val undeclared = saved.tabIds - known.toSet() + if (undeclared.isNotEmpty()) pendingRestore += saved.copy(tabIds = undeclared) + } + } + + @Suppress("MagicNumber") // outer frame is [x, y, w, h] + private fun liveWindowPosition(group: TabWindowGroup): DpOffset? { + val window = group.window ?: return null + val outer = window.outerBoundsPx() ?: return null + val scale = window.scaleFactor.takeIf { it > 0f } ?: return null + return DpOffset((outer[0] / scale).dp, (outer[1] / scale).dp) + } + + @Suppress("MagicNumber") // outer frame is [x, y, w, h] + private fun liveWindowSize(group: TabWindowGroup): DpSize? { + val window = group.window ?: return null + val outer = window.outerBoundsPx() ?: return null + val scale = window.scaleFactor.takeIf { it > 0f } ?: return null + return DpSize((outer[2] / scale).dp, (outer[3] / scale).dp) + } + + // ── Registration (driven by the Tab composable) ─────────────────────── + + internal fun register( + id: String, + title: String, + groupId: String?, + ): TabEntry { + entryMap[id]?.let { + it.title = title + return it + } + val entry = TabEntry(id, title) + entryMap[id] = entry + placeOnFirstDeclaration(entry, groupId) + return entry + } + + /** + * Puts a freshly declared tab where it belongs: the group a pending + * restore names, else the one the app asked for, else the active window, + * else a new one. + */ + private fun placeOnFirstDeclaration( + entry: TabEntry, + groupId: String?, + ) { + val restored = pendingRestore.firstOrNull { entry.id in it.tabIds } + if (restored != null) { + val group = + group(restored.id) + ?: TabWindowGroup(restored.id, restored.position, restored.size).also { groupList += it } + // At the index the snapshot had it, as far as the tabs declared so + // far allow: restoring in declaration order must not shuffle them. + val index = restored.tabIds.filter { it in group.tabIds || it == entry.id }.indexOf(entry.id) + move(entry.id, group, index) + // `move` selects what arrives, which is right for a drag and wrong + // here: the snapshot decides, as soon as the tab it names is in. + restored.selectedId?.takeIf { it in group.tabIds }?.let { group.selectedId = it } + return + } + val target = + groupId?.let { id -> group(id) ?: TabWindowGroup(id, null, defaultWindowSize).also { groupList += it } } + ?: activeGroup + ?: TabWindowGroup(nextGroupId(), null, defaultWindowSize).also { groupList += it } + move(entry.id, target) + } + + internal fun unregister(entry: TabEntry) { + entry.content = null + } + + /** Defaults shared with [TabWindows] and [TabStrip]. */ + public companion object { + /** Size a group's window gets when nothing else determines it. */ + public val DefaultWindowSize: DpSize = DpSize(960.dp, 640.dp) + } +} + +/** A tab released inside its own strip, and the place it is sliding to — see [TabWorkspace.pendingReorder]. */ +internal class TabReorderSettle( + val tab: TabEntry, + val group: TabWindowGroup, + val index: Int, + /** The pointer's speed along the strip at the release; the slide home starts with it. */ + val velocityPxPerSecond: Float, +) + +/** Where a tab drag would insert the tab: at [index] in [group]'s strip. */ +@ExperimentalNucleusApi +public data class TabDropTarget( + val group: TabWindowGroup, + val index: Int, +) + +/** + * The preview of a tab being dragged out of its strip: which tab, and where it + * sits on screen right now (physical screen pixels, outer frame of the ghost + * window), with the px-per-dp of the window it came from. + * + * @property layoutDirection the layout direction of the strip the tab was + * grabbed from, as the strip published it ([Modifier.tabStripGeometry]) — + * what the tab was drawn with, and what its ghost card is laid out in. + */ +@ExperimentalNucleusApi +public data class TabDragGhost( + val tab: TabEntry, + val screenRectPx: Rect, + val scaleFactor: Float, + val layoutDirection: LayoutDirection = LayoutDirection.Ltr, +) + +/** Where a tab drag starts; see [TabWorkspace.beginDrag]. */ +@ExperimentalNucleusApi +public sealed interface TabDragOrigin { + /** + * The tab's own strip in [window]. Geometry is read through lambdas so + * tests can stand in for the native window. + */ + public class Strip internal constructor( + public val window: TaoWindow, + internal val outerBoundsPx: () -> LongArray?, + internal val move: (xPx: Int, yPx: Int) -> Unit, + ) : TabDragOrigin { + public constructor(window: TaoWindow) : this(window, window::outerBoundsPx, window::setOuterPositionPx) + } +} + +/** + * A tab drag in progress. Positions are physical screen pixels. Obtained from + * [TabWorkspace.beginDrag]. + * + * A session stops acting the moment it is no longer the workspace's current + * drag — cancelled, finished, or superseded by another [TabWorkspace.beginDrag]. + * Every method is then a no-op, so a late release from an abandoned gesture + * cannot move a window or a tab. All three are safe to call repeatedly and in + * any order. + * + * Positions that are not finite (an `Offset.Unspecified` from a detached + * layout, an infinity) are ignored rather than propagated into window + * geometry; the last usable position stands. + */ +@ExperimentalNucleusApi +public interface TabDragSession { + /** The pointer moved. */ + public fun update(pointerScreenPx: Offset) + + /** The pointer was released: move, reorder or tear off according to where. */ + public fun end(pointerScreenPx: Offset) + + /** The gesture was abandoned: nothing changes. */ + public fun cancel() +} + +/** Remembers a [TabWorkspace] for the lifetime of the calling composition. */ +@Composable +@ExperimentalNucleusApi +public fun rememberTabWorkspace( + defaultWindowSize: DpSize = TabWorkspace.DefaultWindowSize, + captureThumbnails: Boolean = false, +): TabWorkspace = remember { TabWorkspace(defaultWindowSize, captureThumbnails) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt index a48167fbc..6178a17d2 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplication.kt @@ -1,15 +1,19 @@ package dev.nucleusframework.window.tao +import dev.nucleusframework.core.runtime.NucleusUiThread +import dev.nucleusframework.core.runtime.WindowBackend import dev.nucleusframework.window.tao.dispatch.LifecycleMainDispatcherPriming import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import kotlinx.coroutines.CoroutineExceptionHandler import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Executor import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicReference import java.util.logging.Level import java.util.logging.Logger +import kotlin.coroutines.EmptyCoroutineContext /** * Phase 1 entry point for the Tao backend. @@ -28,6 +32,7 @@ import java.util.logging.Logger * The lambda runs once Tao has finished launching, on the macOS main thread. * [run] does **not** return until [TaoApplication.exit] is called. */ +@Suppress("TooManyFunctions") public object TaoApplication { private val logger = Logger.getLogger(TaoApplication::class.java.name) private val handleSeq = AtomicLong(1L) @@ -66,6 +71,7 @@ public object TaoApplication { // make a genuine new fatal take the log-only branch. fatalError.set(null) fatalDialogShown.set(false) + resetQuit() // Capture the Tao main thread eagerly, before the native event loop // takes over this thread. Required so `Dispatchers.Main` consumers // (notably AndroidX Lifecycle's synchronous `MainDispatcherChecker`) @@ -73,6 +79,19 @@ public object TaoApplication { // pump would race the very first `NavHost.setGraph` → `addObserver` // call on real apps. TaoMainDispatcher.taoMainThread = Thread.currentThread() + // Record the backend for libraries that branch on it without depending + // on Compose or Tao. `nucleusApplication` sets it earlier in its own + // bootstrap (before the loop exists); setting it again here is + // idempotent and covers a bare `TaoApplication.run` app, which would + // otherwise keep reporting the `Awt` fallback. + WindowBackend.setActive(WindowBackend.Tao) + // Route native integrations (notifications, launchers, media keys, …) + // to this thread instead of the AWT EDT, which is not Compose's UI + // thread under Tao (issue #310). Registered here rather than in + // `nucleusApplication` so a bare `TaoApplication.run` app gets it too. + NucleusUiThread.setExecutor( + Executor { runnable -> TaoMainDispatcher.dispatch(EmptyCoroutineContext, runnable) }, + ) // Hand queue draining over to the native loop: from here `dispatch` // wakes Tao and `pump()` drains `pending`, instead of the pre-loop // fallback thread (see TaoMainDispatcher, issue #337). Done *before* @@ -85,7 +104,15 @@ public object TaoApplication { // first `NavController.setGraph` call. LifecycleMainDispatcherPriming.primeWithCurrentThread() onLaunched = block - NativeTaoBridge.nativeRunBlocking(EventDispatcher) + // Watch the loop from the outside (#643): a stall deadlocks this + // thread, so nothing downstream of `nativeRunBlocking` — including + // `rethrowPendingFatal` below — can ever report it. + TaoEventLoopWatchdog.start() + try { + NativeTaoBridge.nativeRunBlocking(EventDispatcher) + } finally { + TaoEventLoopWatchdog.stop() + } // The loop has exited (reportFatal posted the exit) and every tao // callback frame is unwound — only now is it safe to block in the // app-modal native dialog (a modal pump inside a tao callback @@ -97,7 +124,7 @@ public object TaoApplication { /** * Shows the native error dialog (once) and rethrows the recorded fatal, * if any. [run] calls it right after the loop exits; [taoApplication] - * calls it again just before its clean `exitProcess(0)` to catch a fatal + * calls it again just before finishing (exit or return) to catch a fatal * reported from a non-main thread (the coroutine exception handler runs * on the failing coroutine's thread) after [run]'s check already passed — * without the recheck such a crash would end the process with exit @@ -155,6 +182,213 @@ public object TaoApplication { } } + /** + * `true` from the moment the OS asks the app to quit — macOS Cmd+Q, Dock → + * Quit, logout / restart / shutdown — while the windows are being asked to + * close, and for good once they all did. Reset when a window keeps itself + * open, which cancels the quit. Electron's `before-quit` flag: an + * `onCloseRequest` that normally hides to the tray checks it to let a real + * quit through (`if (isQuitting) exitApplication() else hide()`). + */ + @Volatile + public var isQuitting: Boolean = false + private set + + /** How a completed quit ends the app; the Compose loop routes it through `exitApplication`. */ + internal var quitExit: () -> Unit = ::exit + + /** Runs its argument once the close requests have taken effect; the Compose loop waits for recomposition. */ + internal var afterQuitRequests: (() -> Unit) -> Unit = { it() } + + /** + * System quit (#696), Electron's `Browser::Quit`: every app window gets its + * cancelable close request, newest first; the app exits once they all + * closed, and a window that stays open cancels the quit. No app window → + * exit at once. Repeated requests while one is in flight are ignored; a + * window opened meanwhile defers the exit until it closes, and a new + * request asks every window again. + */ + internal fun requestQuit(open: Collection = windows.values) { + if (quitInFlight) return + quitScope = open + waitingForLastWindow = false + val targets = open.filter { it.closesOnQuit && !it.isClosing }.sortedByDescending { it.handle } + isQuitting = true + if (targets.isEmpty()) { + quitExit() + return + } + quitInFlight = true + quitConsented.clear() + targets.forEach { window -> + askingWindow = window + try { + window.requestUserClose() + } finally { + askingWindow = null + } + if (quitConsent) quitConsented += window + quitConsent = false + } + afterQuitRequests { + quitInFlight = false + when { + targets.any { !it.isClosing && it !in quitConsented } -> isQuitting = false + // A window opened meanwhile (a "Save?" dialog) keeps the app alive; + // the quit completes once it is gone — Electron's OnWindowAllClosed. + openAppWindows().isEmpty() -> quitExit() + else -> waitingForLastWindow = true + } + } + } + + /** App windows still open that have not agreed to the quit in flight. */ + private fun openAppWindows(): List = + quitScope.filter { it.closesOnQuit && !it.isClosing && it !in quitConsented } + + /** Called as a window goes away: completes a quit that was waiting for the last one. */ + private fun completeQuitIfLastWindow() { + if (waitingForLastWindow && openAppWindows().isEmpty()) { + waitingForLastWindow = false + quitExit() + } + } + + private var quitInFlight = false + private var waitingForLastWindow = false + private var quitScope: Collection = emptyList() + private val quitConsented = HashSet() + + /** The window whose close request [requestQuit] is running, or `null`. */ + private var askingWindow: TaoWindow? = null + private var quitConsent = false + + /** + * `exitApplication()` called from a window's close request during a quit + * is that window's *consent* (Electron's `app.quit()` while quitting), not + * an exit that would override another window's veto: `true` when the call + * was absorbed that way. + */ + internal fun consentToQuit(): Boolean { + if (askingWindow == null) return false + quitConsent = true + return true + } + + /** Fresh-run quit state; [run] starts with it, tests reset through it. */ + internal fun resetQuit() { + isQuitting = false + quitInFlight = false + waitingForLastWindow = false + quitScope = emptyList() + quitConsented.clear() + askingWindow = null + quitConsent = false + quitExit = ::exit + afterQuitRequests = { it() } + } + + /** + * Handlers for [onUnresponsive] / [onResponsive]. One each, replaced on + * registration rather than appended — `nucleusApplication`'s block is + * `@Composable`, so an appending registry would grow by one copy per + * recomposition and fire the app's crash reporter N times for one stall. + * `onDeepLink` has the same replace semantics for the same reason. + * Volatile: written on the loop thread, read from the watchdog thread. + */ + @Volatile + private var unresponsiveHandler: (() -> Unit)? = null + + @Volatile + private var responsiveHandler: (() -> Unit)? = null + + /** + * Registers [listener] for "the UI stopped responding", Electron's + * `webContents` `unresponsive` event (#643). Fires once per stall, after + * the OS has flagged the window and the watchdog's grace period on top of + * it; [onResponsive] closes the episode. + * + * One handler at a time: a second call replaces the first, like + * [onDeepLink]'s sink. That is what makes it safe to call straight from + * the `@Composable` application block, which recomposes. + * + * Nucleus itself only logs `SEVERE` with a thread dump — like Chromium's + * HangWatcher or IntelliJ's PerformanceWatcher, and like Electron it ships + * no built-in UI. What to do with the event is the app's call: report it + * to a crash backend, or offer the user the browsers' "wait or quit" + * choice. + * + * **[listener] runs on `nucleus-tao-watchdog-events`, not the UI thread** + * — the UI thread is the one that is stuck, so anything posted to it + * (Compose state, `Dispatchers.Main`) would only run once the stall is + * over, if ever. That thread is the callbacks' own: it is neither the + * sampling thread nor the UI thread, so a listener that blocks — a "wait + * or quit" prompt is the expected use — delays only the next callback, + * never the detection. Callbacks are serialized in order. A throwing + * listener is logged and ignored: the watchdog must survive it. + */ + public fun onUnresponsive(listener: () -> Unit) { + unresponsiveHandler = listener + } + + /** + * Registers [listener] for "the UI is responding again", Electron's + * `responsive` event — the counterpart of [onUnresponsive], fired only + * after a stall that was reported. Same threading and replace semantics. + */ + public fun onResponsive(listener: () -> Unit) { + responsiveHandler = listener + } + + /** + * Runs [block] with the hang watchdog told that a stall is *expected* + * (#643) — Chromium's `HangWatcher::InvalidateActiveExpectations()`. + * + * The watchdog reports any UI thread that stops pumping, which includes an + * operation the app knows is long and synchronous. Wrap that operation and + * neither the `SEVERE` report nor [onUnresponsive] fires for it; everything + * else stays watched, unlike the `nucleus.tao.watchdog=false` switch, which + * gives up on the whole process. + * + * ```kotlin + * expectUnresponsive { importHugeProjectSynchronously() } + * ``` + * + * Reentrant, and thread-safe: the scope is the app's, not one thread's. A + * stall already reported when the scope opens still gets its + * [onResponsive], so the two events stay paired. + * + * Prefer moving the work off the UI thread. This is for the cases where + * that is not an option — a native call that must run on the loop, a + * shutdown flush — not a way to make a slow UI quiet. + */ + public fun expectUnresponsive(block: () -> T): T { + TaoEventLoopWatchdog.beginExpectedStall() + try { + return block() + } finally { + TaoEventLoopWatchdog.endExpectedStall() + } + } + + /** Fires the [onUnresponsive] handler; called by the watchdog thread. */ + internal fun notifyUnresponsive(): Unit = notify(unresponsiveHandler, "unresponsive") + + /** Fires the [onResponsive] handler; called by the watchdog thread. */ + internal fun notifyResponsive(): Unit = notify(responsiveHandler, "responsive") + + @Suppress("TooGenericExceptionCaught") + private fun notify( + handler: (() -> Unit)?, + event: String, + ) { + try { + handler?.invoke() + } catch (t: Throwable) { + logger.log(Level.SEVERE, "Unhandled exception in the '$event' handler", t) + } + } + /** Posts an exit request and unblocks [run]. */ public fun exit() { NativeTaoBridge.nativeExit() @@ -223,8 +457,12 @@ public object TaoApplication { internal fun lookup(handle: Long): TaoWindow? = windows[handle] + /** Live native windows, by handle. Used by tests to catch leaked windows. */ + internal fun liveWindowCount(): Int = windows.size + internal fun remove(handle: Long) { windows.remove(handle) + completeQuitIfLastWindow() } private object EventDispatcher : NativeTaoBridge.EventCallback { @@ -241,6 +479,7 @@ public object TaoApplication { onLaunched = null cb?.invoke(this@TaoApplication) } + TaoEventCode.QUIT_REQUESTED -> requestQuit() TaoEventCode.MAIN_EVENTS_CLEARED -> TaoMainDispatcher.pump() else -> lookup(handle)?.dispatch(code, a, b) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplicationCompose.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplicationCompose.kt index 66035b744..1d7850a64 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplicationCompose.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoApplicationCompose.kt @@ -19,6 +19,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Level import java.util.logging.Logger @@ -37,17 +38,24 @@ import kotlin.system.exitProcess * `LaunchedEffect`/`DisposableEffect`, observe `MutableState`, etc. The * composition lives until [ApplicationScope.exitApplication] is called. * - * The JVM is terminated once the Tao event loop returns: `exitProcess(0)` - * on a normal quit, `exitProcess(1)` after a fatal error (#622 — already - * logged at SEVERE and shown in the native error dialog by then). A forced - * exit is required because Compose/Skiko initialisation indirectly touches - * AWT, which spawns the non-daemon EDT, and that thread keeps the JVM alive - * long after the Tao loop has shut down. Mirrors Compose Desktop's - * `application { … }` (which also force-exits the process). + * Once the Tao event loop returns, the default is to terminate the JVM: + * `exitProcess(0)` on a normal quit, `exitProcess(1)` after a fatal error + * (#622 — already logged at SEVERE and shown in the native error dialog by + * then). A forced exit is the default because Compose/Skiko initialisation + * indirectly touches AWT, which spawns the non-daemon EDT, and that thread + * keeps the JVM alive long after the Tao loop has shut down. Mirrors Compose + * Desktop's `application { … }` (which also force-exits the process). + * + * Pass [exitProcessOnExit] `false` to return normally instead, matching + * Compose Desktop's `application(exitProcessOnExit = false)`. A fatal error + * is then rethrown to the caller after the same SEVERE log. */ @OptIn(ExperimentalFoundationApi::class) @Suppress("TooGenericExceptionCaught", "SwallowedException") -public fun taoApplication(content: @Composable ApplicationScope.() -> Unit) { +public fun taoApplication( + exitProcessOnExit: Boolean = true, + content: @Composable ApplicationScope.() -> Unit, +) { check(NativeTaoBridge.isLoaded) { "nucleus_tao native library is not available — supported targets: " + "macOS (arm64/x86_64), Windows (x64/aarch64), Linux (x64/aarch64)." @@ -55,30 +63,56 @@ public fun taoApplication(content: @Composable ApplicationScope.() -> Unit) { // A fatal dispatch failure (#622) is rethrown by TaoApplication.run after // the loop exits — it was already logged at SEVERE and shown in the native - // error dialog, so here it only needs to become a non-zero exit. A plain - // rethrow would skip exitProcess(0) below and the non-daemon AWT EDT would - // keep the dead process alive. + // error dialog, so here it only needs to become a non-zero exit when + // [exitProcessOnExit] is true. A plain rethrow would skip exitProcess(0) + // below and the non-daemon AWT EDT would keep the dead process alive. try { runTaoComposeLoop(content) // Recheck: reportFatal can fire from a non-main thread (the coroutine // exception handler runs on the failing coroutine's thread) after // run()'s own post-loop check already passed — without this a genuine - // fatal would fall through to exitProcess(0) below. + // fatal would fall through to a clean finish. TaoApplication.rethrowPendingFatal() } catch (t: Throwable) { + finishTaoApplication(exitProcessOnExit, failure = t) + return + } + finishTaoApplication(exitProcessOnExit, failure = null) +} + +/** + * After the Tao loop has stopped: force-exit the process, return to the + * caller, or rethrow [failure]. [exit] is `exitProcess` in production; tests + * inject a recorder so this path can run inside the test JVM. + */ +internal fun finishTaoApplication( + exitProcessOnExit: Boolean, + failure: Throwable?, + exit: (Int) -> Unit = { exitProcess(it) }, +) { + if (failure != null) { // Anything that is NOT the already-handled fatal (broken native lib, // wrong-thread init failure, …) would otherwise vanish with exit - // code 1 and zero output — log it before exiting. - if (!TaoApplication.isReportedFatal(t)) { - composeEntryLogger.log(Level.SEVERE, "taoApplication failed", t) + // code 1 and zero output — log it before exiting or rethrowing. + if (!TaoApplication.isReportedFatal(failure)) { + composeEntryLogger.log(Level.SEVERE, "taoApplication failed", failure) + } + if (exitProcessOnExit) { + exit(1) + return } - exitProcess(1) + throw failure + } + if (exitProcessOnExit) { + exit(0) } - exitProcess(0) } private val composeEntryLogger: Logger = Logger.getLogger(TaoApplication::class.java.name) +/** Upper bound on waiting for the close requests of a system quit to recompose. */ +private const val QUIT_SETTLE_TIMEOUT_MS = 500L + @OptIn(ExperimentalFoundationApi::class) private fun runTaoComposeLoop(content: @Composable ApplicationScope.() -> Unit) { TaoApplication.run { app -> @@ -105,6 +139,22 @@ private fun runTaoComposeLoop(content: @Composable ApplicationScope.() -> Unit) coroutineScope.launch { recomposer.runRecomposeAndApplyChanges() } + // A quit completes through exitApplication (composition disposed first), + // and is judged only once the close requests' state writes have been + // recomposed — a window that accepted has been disposed by then. + // ponytail: the timeout is a liveness guard only — a recomposer that never + // reports Idle would otherwise leave isQuitting stuck and swallow every later quit. + app.quitExit = scope::exitApplication + app.afterQuitRequests = { then -> + coroutineScope.launch { + Snapshot.sendApplyNotifications() + withTimeoutOrNull(QUIT_SETTLE_TIMEOUT_MS) { + recomposer.currentState.first { it == Recomposer.State.Idle || it <= Recomposer.State.ShuttingDown } + } + then() + } + } + coroutineScope.launch { try { composition.setContent { diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoContentMeasurers.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoContentMeasurers.kt new file mode 100644 index 000000000..0c1072438 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoContentMeasurers.kt @@ -0,0 +1,30 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize +import java.util.concurrent.ConcurrentHashMap + +/** + * Per-window hook into the live scene's `ComposeScene.measureContent`, the + * real re-measure behind + * [dev.nucleusframework.window.tao.v2.WindowGeometryProviderScope.measureWindowContent]. + * + * Registered by the scene host for the window's lifetime and looked up by + * handle — the same shape as [WindowSizePolicy], and for the same reason: the + * window API must not grow a parameter for something only the host can do. + * Calls run on the Tao main thread (the Compose dispatcher), where the scene + * may be measured. Returns `null` while the window has no scene yet. + */ +internal typealias ContentMeasurer = (Constraints) -> IntSize? + +private val contentMeasurers = ConcurrentHashMap() + +internal fun TaoWindow.installContentMeasurer(measurer: ContentMeasurer) { + contentMeasurers[handle] = measurer +} + +internal fun TaoWindow.clearContentMeasurer() { + contentMeasurers.remove(handle) +} + +internal fun TaoWindow.contentMeasurerOrNull(): ContentMeasurer? = contentMeasurers[handle] diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt index d31f4c62e..22b15d95c 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventConstants.kt @@ -18,6 +18,12 @@ public object TaoCursorIcon { public const val NS_RESIZE: Int = 10 public const val NESW_RESIZE: Int = 11 public const val NWSE_RESIZE: Int = 12 + + /** Open hand: this can be picked up and dragged. */ + public const val GRAB: Int = 13 + + /** Closed hand: it is being dragged. */ + public const val GRABBING: Int = 14 } /** Mirrors the event constants in `nucleus_tao` (`lib.rs`). */ @@ -79,6 +85,9 @@ public object TaoEventCode { * VSync while active so border-drag frames don't block on VBlank. */ public const val SIZE_MOVE: Int = 25 + + /** macOS requested application termination; route it through each window's close callback. */ + internal const val QUIT_REQUESTED: Int = 26 } /** Trackpad gesture kind reported by [NativeTaoBridge.EventCallback.onTrackpadGesture]. */ @@ -160,5 +169,7 @@ public object TaoMouseButton { public const val LEFT: Int = 0 public const val RIGHT: Int = 1 public const val MIDDLE: Int = 2 - public const val OTHER: Int = 3 + public const val BACK: Int = 3 + public const val FORWARD: Int = 4 + public const val OTHER: Int = 5 } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdog.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdog.kt new file mode 100644 index 000000000..c61f946a3 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdog.kt @@ -0,0 +1,746 @@ +package dev.nucleusframework.window.tao + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher +import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import java.lang.management.ManagementFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.locks.ReentrantLock +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.concurrent.withLock + +/** Milliseconds between two liveness samples. */ +private const val POLL_INTERVAL_MS = 2_000L + +/** Default extra time a window must stay hung before the watchdog reports it. */ +private const val DEFAULT_GRACE_MS = 5_000L + +private const val NANOS_PER_MILLI = 1_000_000L + +/** A poll that overshot by this much means the machine was suspended, not slow. */ +private const val SUSPEND_OVERSHOOT_MS = 10_000L + +/** How long samples are ignored after a resume — Electron's `kHungRendererDelay` rule. */ +private const val RESUME_GRACE_MS = 30_000L + +/** Longest a watchdog parks with nothing to watch; a bound, not a schedule. */ +private const val PARK_TIMEOUT_MS = 30_000L + +/** An overshoot is a GC pause when collection explains more than this fraction of it. */ +private const val GC_PAUSE_SHARE_DIVISOR = 2 + +/** + * Test seams for the watchdog's concurrency monkey, all `null` in production. + * + * The watchdog is a thread that asks the OS about a real window every two + * seconds — none of which a race hunt can wait for. With a fake probe and a + * millisecond poll, the same state machine, lifecycle and callback plumbing run + * thousands of times a second with no window and no native library, which is + * what makes `TaoEventLoopWatchdogMonkeyTest` possible. + */ +internal object WatchdogTestHooks { + /** Replaces the native probe, keyed by the fake HWND the monkey registers. */ + @Volatile + var probe: ((Long) -> Boolean)? = null + + /** Shortens the poll interval; the real one is [POLL_INTERVAL_MS]. */ + @Volatile + var pollIntervalMs: Long? = null + + /** + * Transitions the watchdog *produced*, as opposed to callbacks the app + * *received*. A monkey that only counts callbacks cannot say whether a + * missing `responsive` was never produced (a detector or lifecycle bug) or + * produced and never delivered (the callback path); these two say which. + */ + val stallsProduced: AtomicInteger = AtomicInteger() + val recoveriesProduced: AtomicInteger = AtomicInteger() + + /** + * Last lifecycle steps of each watchdog run, recorded only while a test + * probe is installed. A count that ends one short says a recovery was never + * produced; this says which run opened the episode and how it left. + */ + val trace: ConcurrentLinkedDeque = ConcurrentLinkedDeque() + + /** Records [step] when under test; a no-op in production. */ + fun trace(step: () -> String) { + if (probe == null) return + trace.addLast(step()) + while (trace.size > TRACE_DEPTH) trace.pollFirst() + } + + private const val TRACE_DEPTH = 60 + + /** Back to production behaviour; a test must always land here. */ + fun reset() { + probe = null + pollIntervalMs = null + stallsProduced.set(0) + recoveriesProduced.set(0) + } +} + +/** + * Watches the Tao event loop and reports a stall instead of letting the app + * freeze silently (#643). + * + * A deadlocked loop produces no exception, no panic and no error code — to the + * JVM the thread is a perfectly healthy `RUNNABLE` / `_thread_in_native`, so + * the fatal path ([TaoApplication.reportFatal]) has nothing to report, and its + * reporting point sits *after* `nativeRunBlocking` returns, which a stalled + * loop never does. Only the OS notices, and its only way of saying so is to + * ghost the window. + * + * So the watchdog asks the OS: a daemon thread polls + * [NativeTaoBridge.nativeIsWindowHung] (`IsHungAppWindow`) every + * [POLL_INTERVAL_MS] and, once a window has been hung for the grace period on + * top of the OS's own ~5 s threshold, logs `SEVERE` with every thread's stack + * — which alone would have pointed straight at `main` sitting in + * `nativeRunBlocking` for #640. + * + * The probe is a pure query of state the OS already maintains: it sends + * nothing to the event-loop thread, so it costs that thread nothing and cannot + * inject the inline sent message that caused #640 in the first place. + * + * What the app does about it is the app's call, as in Electron: the framework + * logs and raises [TaoApplication.onUnresponsive] / [TaoApplication.onResponsive] + * (`unresponsive` / `responsive` on a `webContents`), and ships no UI of its + * own. The browsers' "wait or quit" dialog is the app's to build — Chromium's + * HangWatcher, IntelliJ's PerformanceWatcher and Unreal's `FThreadHeartBeat` + * all stop at the report too. + * + * ### Configuration + * - `nucleus.tao.watchdog=false` — disable entirely (also `true` to force it + * on under a debugger, where it is off by default: a breakpoint on the UI + * thread is indistinguishable from a stall, which is why Unreal ships its + * own hang detector disabled). + * - `nucleus.tao.watchdogGraceMs=` — extra time before reporting + * (default [DEFAULT_GRACE_MS]). + * - `nucleus.tao.watchdogDialog=true` — also show the native error dialog on + * detection (opt-in: a stall is not always fatal, and the report is a + * developer signal first). Shown from the watchdog thread, never from the + * event loop — that is precisely the thread that is stuck (#622's + * constraint). + * + * ### Platforms + * Windows only for now. macOS exposes no public "not responding" query, and + * the X11 `_NET_WM_PING` equivalent perturbs the loop it observes — which the + * probe must not do. Elsewhere the watchdog simply never starts. + */ + +@Suppress("TooManyFunctions") +internal object TaoEventLoopWatchdog { + private val logger = Logger.getLogger(TaoEventLoopWatchdog::class.java.name) + + /** Window handle → HWND, cached from the event-loop thread. */ + private val hwnds = ConcurrentHashMap() + + private val running = AtomicBoolean(false) + + /** Run counter; a watchdog thread acts only while it owns the current one. */ + private val generations = AtomicInteger() + + /** Guards against stacking one not-responding dialog per stall episode. */ + private val dialogShowing = AtomicBoolean(false) + + /** Wait target of the watchdog thread; signalled when a window appears or on stop. */ + private val lock = ReentrantLock() + private val wakeUp = lock.newCondition() + + @Volatile + private var thread: Thread? = null + + /** Runs the app's `unresponsive` / `responsive` callbacks; see [postEvent]. */ + @Volatile + private var eventExecutor: ExecutorService? = null + + /** `true` on a platform that has a non-perturbing liveness probe. */ + private val isSupported: Boolean + get() = + WatchdogTestHooks.probe != null || + (Platform.Current == Platform.Windows && NativeTaoBridge.isLoaded) + + private val isEnabled: Boolean + get() = System.getProperty("nucleus.tao.watchdog", "true").toBoolean() + + /** `true` when the app asked for the watchdog explicitly, debugger or not. */ + private val isForced: Boolean + get() = System.getProperty("nucleus.tao.watchdog")?.toBoolean() == true + + /** + * `true` when this JVM runs under a debug agent. A breakpoint on the UI + * thread is indistinguishable from a stall — Unreal ships its own hang + * detector off by default for exactly that reason — so the watchdog stays + * out of debug sessions unless `-Dnucleus.tao.watchdog=true` asks for it. + * Guarded: `ManagementFactory` is not guaranteed under native-image, where + * there is no debug agent to find anyway. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private val isDebuggerAttached: Boolean by lazy { + try { + ManagementFactory.getRuntimeMXBean().inputArguments.any { + it.startsWith("-agentlib:jdwp") || it.startsWith("-Xrunjdwp") + } + } catch (t: Throwable) { + false + } + } + + private val pollIntervalMs: Long + get() = WatchdogTestHooks.pollIntervalMs ?: POLL_INTERVAL_MS + + private val graceMs: Long + get() = System.getProperty("nucleus.tao.watchdogGraceMs")?.toLongOrNull() ?: DEFAULT_GRACE_MS + + private val showsDialog: Boolean + get() = System.getProperty("nucleus.tao.watchdogDialog", "false").toBoolean() + + /** + * Caches [handle]'s HWND so the watchdog thread never has to resolve it + * later — resolving goes through the native window map, whose lock is + * exactly what a stalled loop may be holding. Call from the event-loop + * thread once the window is realized (`WINDOW_READY`). + */ + fun registerWindow(handle: Long) { + // `running` covers every off state — unsupported platform, the + // property, a debug agent, a stopped loop. Off means the event loop + // pays nothing per window: no JNI round-trip, no signal, no map. + if (!running.get()) return + // The monkey registers windows that do not exist; its probe is keyed + // by the handle itself, so there is nothing native to resolve. + if (WatchdogTestHooks.probe != null) { + hwnds[handle] = handle + wakeWatchdog() + return + } + val hwnd = NativeTaoBridge.nativeHwndHandle(handle) + if (hwnd == 0L) { + // Silence here would be the very failure mode this watchdog + // exists to remove: with no HWND it has nothing to probe. + guarded { logger.warning("Event-loop watchdog: no HWND for window $handle, it will not be watched") } + return + } + hwnds[handle] = hwnd + wakeWatchdog() + } + + /** Forgets a window that is gone (`DESTROYED`). */ + fun unregisterWindow(handle: Long) { + hwnds.remove(handle) + } + + /** + * Nesting depth of [TaoApplication.expectUnresponsive] blocks. While it is + * non-zero the loop is *expected* to be unresponsive, so the watchdog + * treats every sample as healthy — Chromium's + * `HangWatcher::InvalidateActiveExpectations()`. + */ + private val expectedStalls = AtomicInteger() + + /** `true` while an [TaoApplication.expectUnresponsive] block is in flight. */ + private val isStallExpected: Boolean + get() = expectedStalls.get() > 0 + + /** Opens an expected-stall scope. */ + fun beginExpectedStall() { + expectedStalls.incrementAndGet() + } + + /** Closes an expected-stall scope; never goes below zero. */ + fun endExpectedStall() { + expectedStalls.updateAndGet { depth -> if (depth > 0) depth - 1 else 0 } + } + + /** Starts the daemon watchdog thread; no-op when unsupported or disabled. */ + fun start() { + if (!isSupported || !isEnabled) return + if (!running.compareAndSet(false, true)) return + // A scope whose `finally` never ran (a fatal thrown inside + // `expectUnresponsive`, a forced exit) would otherwise leave the next + // run permanently disarmed. + expectedStalls.set(0) + // `stop()` does not join, so the previous run's thread may still be on + // its way out. Each run takes a generation and a thread only touches + // shared state while it owns the current one — otherwise a straggler + // would disarm the run that just started, or keep sampling beside it + // and report every stall twice. + val generation = generations.incrementAndGet() + thread = + Thread({ watch(generation) }, "nucleus-tao-watchdog").apply { + isDaemon = true + // Below the event loop: the watchdog must never compete with + // the thread whose health it is measuring. + priority = Thread.MIN_PRIORITY + start() + } + // The generation this run just took retires every previous thread, but + // a parked one only learns that when something wakes it. + wakeWatchdog() + } + + /** Stops the watchdog and drops the window cache; safe to call twice. */ + fun stop() { + // Cleanup runs even when `watch()` already cleared `running` itself (a + // debug agent, an interrupt): `run()` supports being called again, and + // a second run must not inherit the first one's HWNDs — Windows + // recycles them, and a non-empty map would also defeat the parking. + val wasRunning = running.getAndSet(false) + if (wasRunning) thread?.interrupt() + thread = null + hwnds.clear() + // The stall still open, if any, is closed by the watchdog thread on its + // way out — it owns its detector, so nobody else has to race it for the + // right to close the episode. + wakeWatchdog() + } + + @Suppress("ReturnCount") + private fun watch(generation: Int) { + // Asked here rather than in `start()`: the first + // `ManagementFactory.getRuntimeMXBean()` call initialises the + // management subsystem and measures ~6 ms, which `start()` would spend + // on the main thread with the event loop not yet running. Off the + // startup path it costs the app nothing. + if (isDebuggerAttached && !isForced) { + guarded { logger.fine("Event-loop watchdog disabled: a debug agent is attached") } + if (owns(generation)) running.set(false) + return + } + // The detector belongs to this thread. A shared one has to be raced + // against on every teardown — a straggler could report a stall onto the + // detector `start()` had just drained, and that episode was then never + // closed (concurrency monkey, profile Thrash, seed 467221, after 261 + // episodes). Thread-owned, the run that opened an episode is the run + // that closes it, on whichever path it leaves by. + val detector = EventLoopHangDetector(graceMs) + // Not 0: `nanoTime`'s origin is arbitrary and may be negative, and a + // deadline of 0 would then gate every sample until the clock crossed it. + var resumeDeadlineNanos = Long.MIN_VALUE + while (running.get() && owns(generation)) { + // Stamped around the wait only: a `report()` that takes seconds + // (a listener uploading, a thread dump on a large app) must not + // make the next iteration look like a system suspend. + // The watch list can drain while a stall is still open (the user + // closed the frozen window). Close the episode before parking, or + // the app's prompt and telemetry span stay open forever. + if (hwnds.isEmpty()) guarded { handle(detector.reset(System.nanoTime())) } + val waitStartNanos = System.nanoTime() + val gcBefore = gcMillis + val wait = awaitNextSample(generation, detector) + if (wait == WatchWait.Interrupted && running.get()) { + if (!owns(generation)) { + if (detector.hasOpenEpisode) { + WatchdogTestHooks.trace { "gen$generation exit=interrupted-stale WITH OPEN EPISODE" } + } + return drain(detector) + } + // Interrupted by something other than `stop()` — a shutdown + // hook or a test harness sweeping threads. Leave, but leave + // the door open: `running` stays consistent so a later + // `start()` can bring the watchdog back, and say so once. + // Guarded like every other log here: an app's JUL handler that + // throws would otherwise kill this thread between the stall it + // reported and the drain that closes it, stranding the app's + // `unresponsive` for good. Found by the concurrency monkey after + // ~1.4M events, and only visible once the trace bracketed the + // report: "report end" and then nothing at all. + guarded { logger.warning("Event-loop watchdog stopped: its thread was interrupted") } + running.set(false) + if (detector.hasOpenEpisode) { + WatchdogTestHooks.trace { "gen$generation exit=interrupted WITH OPEN EPISODE" } + } + return drain(detector) + } + if (wait == WatchWait.Stopped || wait == WatchWait.Interrupted || !running.get()) { + if (detector.hasOpenEpisode) { + WatchdogTestHooks.trace { "gen$generation exit=$wait WITH OPEN EPISODE" } + } + return drain(detector) + } + val now = System.nanoTime() + // An untimed park tells nothing about elapsed time, so the suspend + // heuristic below would read it as one. Re-baseline and sample on + // the next tick instead. + if (wait == WatchWait.Parked) continue + val overslept = now - waitStartNanos - pollIntervalMs * NANOS_PER_MILLI + // The machine was suspended (Electron #53529): every process + // stopped, and on wake the window is briefly flagged while the + // system pages back in. A sleep that overshot by far is the only + // signal a plain JVM gets — `base::PowerMonitor` without the + // platform hookup. Drop the episode and ignore what follows for + // one hang delay, exactly as Electron does after a resume. + resumeDeadlineNanos = step(detector, now, overslept, gcMillis - gcBefore, resumeDeadlineNanos) + } + if (detector.hasOpenEpisode) { + WatchdogTestHooks.trace { + "gen$generation exit=loop running=${running.get()} owns=${owns(generation)} WITH OPEN EPISODE" + } + } + drain(detector) + } + + /** + * Closes the episode this thread opened, on whatever path it is leaving by: + * an app holding a prompt or a telemetry span on the strength of + * `unresponsive` must always hear the end. + */ + private fun drain(detector: EventLoopHangDetector) { + val open = detector.hasOpenEpisode + guarded { handle(detector.reset(System.nanoTime())) } + if (open) WatchdogTestHooks.trace { "drained open episode, closed=${!detector.hasOpenEpisode}" } + } + + /** + * One sample and its consequences, guarded: `Thread.getAllStackTraces()` + * can fail on a huge heap, JUL propagates a throwing `Handler.publish` + * (apps and our own tests attach handlers), and the event executor can + * refuse a task. Any of those escaping would kill the watchdog thread with + * `running` still true — unrevivable, and silent, which is precisely the + * failure mode this class exists to remove. Returns the resume deadline. + */ + @Suppress("TooGenericExceptionCaught") + private fun step( + detector: EventLoopHangDetector, + now: Long, + oversleptNanos: Long, + gcMillisDuringWait: Long, + resumeDeadlineNanos: Long, + ): Long { + try { + if (oversleptNanos > SUSPEND_OVERSHOOT_MS * NANOS_PER_MILLI && + !isGcPause(gcMillisDuringWait, oversleptNanos) + ) { + // A stall reported before the suspend still gets its recovery: + // an app that opened a telemetry span or a prompt on + // `unresponsive` must never be left waiting for the close. + handle(detector.reset(now)) + return now + RESUME_GRACE_MS * NANOS_PER_MILLI + } + if (now >= resumeDeadlineNanos) { + // An expected stall counts as healthy rather than skipping the + // sample: a stall reported before the scope opened still gets + // its recovery, so every `unresponsive` keeps its `responsive`. + handle(detector.sample(!isStallExpected && isAnyWindowHung(), now)) + } + } catch (t: Throwable) { + logSafely(t) + } + return resumeDeadlineNanos + } + + /** `true` while this thread is the run's current watchdog — see [start]. */ + private fun owns(generation: Int): Boolean = generations.get() == generation + + /** Runs [block], swallowing anything it throws — see [step] for why. */ + @Suppress("TooGenericExceptionCaught") + private inline fun guarded(block: () -> Unit) { + try { + block() + } catch (t: Throwable) { + logSafely(t) + } + } + + /** Last-resort logging: the failure of a log call must not end the watch. */ + @Suppress("TooGenericExceptionCaught", "EmptyCatchBlock", "SwallowedException") + private fun logSafely(t: Throwable) { + try { + logger.log(Level.WARNING, "Event-loop watchdog sample failed; still watching", t) + } catch (_: Throwable) { + // Nothing left to report with. Keep watching. + } + } + + /** + * `true` when a stop-the-world pause, not a suspended machine, explains an + * overshot wait. The watchdog is an ordinary min-priority Java thread, so a + * long full GC parks it too — and a GC long enough to freeze the UI is one + * of the freezes most worth reporting. Treating it as a resume would drop + * the very episode the user felt. + */ + private fun isGcPause( + gcMillisDuringWait: Long, + oversleptNanos: Long, + ): Boolean = gcMillisDuringWait * NANOS_PER_MILLI * GC_PAUSE_SHARE_DIVISOR > oversleptNanos + + /** + * Total time this JVM has spent collecting, or 0 when the management beans + * are unavailable (possible under native-image), which keeps the plain + * suspend rule. + */ + @Suppress("TooGenericExceptionCaught", "SwallowedException") + private val gcMillis: Long + get() = + try { + ManagementFactory.getGarbageCollectorMXBeans().sumOf { it.collectionTime.coerceAtLeast(0) } + } catch (t: Throwable) { + 0L + } + + /** + * Waits for the next sample. With no window registered there is nothing to + * probe and nothing can hang, so the thread parks until one appears rather + * than waking every [POLL_INTERVAL_MS] — Chromium's HangWatcher parks the + * same way while its watch list is empty, and it is what keeps an app that + * is merely sitting in the tray free of a timer it does not need. + */ + private fun awaitNextSample( + generation: Int, + detector: EventLoopHangDetector, + ): WatchWait = + lock.withLock { + try { + // Re-checked here, under the lock the signal is sent with: a + // thread that read these outside it could decide to park an + // instant after the last `signalAll` and never be woken again. + // The concurrency monkey found 150 such threads alive at once + // (profile Thrash) — one leaked per run, for the process's life. + if (!running.get() || !owns(generation)) return@withLock WatchWait.Stopped + // Never park on an open episode. The drain above this call + // runs outside the lock, so the last window can be unregistered + // in between — and parking then holds the app's `responsive` + // for the whole park. One more timed wait closes it instead. + if (hwnds.isEmpty() && !detector.hasOpenEpisode) { + // Bounded even so: a missed signal must cost one late + // wakeup, never a thread that never leaves. + wakeUp.await(PARK_TIMEOUT_MS, TimeUnit.MILLISECONDS) + if (running.get()) WatchWait.Parked else WatchWait.Stopped + } else { + wakeUp.await(pollIntervalMs, TimeUnit.MILLISECONDS) + WatchWait.Sampled + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + WatchWait.Interrupted + } + } + + /** Wakes a parked watchdog — a window appeared, or the loop is shutting down. */ + private fun wakeWatchdog() { + lock.withLock { wakeUp.signalAll() } + } + + /** Outcome of one [awaitNextSample] wait. */ + private enum class WatchWait { + /** Waited the poll interval: the elapsed time is known, so sample. */ + Sampled, + + /** Parked with nothing to watch: elapsed time means nothing. */ + Parked, + + /** The watchdog was stopped. */ + Stopped, + + /** The wait was interrupted; only [stop] is a legitimate source. */ + Interrupted, + } + + private fun handle(transition: HangTransition?) { + when (transition) { + is HangTransition.Stalled -> { + WatchdogTestHooks.stallsProduced.incrementAndGet() + WatchdogTestHooks.trace { "stalled #${WatchdogTestHooks.stallsProduced.get()}" } + report(transition.durationMs) + } + is HangTransition.Recovered -> { + WatchdogTestHooks.recoveriesProduced.incrementAndGet() + WatchdogTestHooks.trace { "recovered #${WatchdogTestHooks.recoveriesProduced.get()}" } + // Same order as [report], for the same reason. + postEvent(TaoApplication::notifyResponsive) + guarded { + logger.log(Level.INFO, "Tao event loop responded again after ${transition.durationMs} ms") + } + } + null -> Unit + } + } + + /** + * `true` when at least one live window is hung. Any single one is enough: + * every window of the app shares the one event-loop thread, so a stall on + * one is the stall of all — and a window whose HWND is already gone simply + * probes healthy. + */ + private fun isAnyWindowHung(): Boolean { + val probe = WatchdogTestHooks.probe ?: NativeTaoBridge::nativeIsWindowHung + return hwnds.values.any(probe) + } + + private fun report(durationMs: Long) { + WatchdogTestHooks.trace { "report begin" } + // The app hears first, and unconditionally. Logging came first here + // until the concurrency monkey (seed 4242) caught what that costs: JUL + // propagates a throwing `Handler.publish`, so a hostile log handler + // skipped the notification while the detector had already marked the + // stall reported — the app then got a `responsive` for a stall it was + // never told about. Diagnostics must never outrank the contract. + // + // Off the watchdog thread: the documented use of this callback is a + // "wait or quit" prompt, which blocks until the user answers. Run + // inline it would stop the sampling loop for the whole episode — no + // recovery, no `onResponsive`, the next stall missed. + postEvent(TaoApplication::notifyUnresponsive) + val detail = runCatching { allThreadStacks() }.getOrElse { "thread dump unavailable: $it" } + guarded { + logger.log( + Level.SEVERE, + "Tao event loop has not pumped messages for at least $durationMs ms — the UI is frozen. " + + "Thread dump follows.\n$detail", + ) + } + if (showsDialog) showNotRespondingDialog(detail) + WatchdogTestHooks.trace { "report end" } + } + + /** + * Runs an app callback on the event thread, created on first use. One + * thread, so `unresponsive` and `responsive` keep their order; a listener + * that blocks delays the next callback but never the detection. + */ + private fun postEvent(event: () -> Unit) { + // One per process, created on the first event and never shut down: a + // daemon thread parked on an empty queue costs nothing, while tearing it + // down per run meant racing its teardown and dropping the very callback + // that closes an episode. + val executor = + lock.withLock { + eventExecutor ?: Executors + .newSingleThreadExecutor { runnable -> + Thread(runnable, "nucleus-tao-watchdog-events").apply { isDaemon = true } + }.also { eventExecutor = it } + } + executor.execute(event) + } + + /** + * Opens the native dialog on a thread of its own. Not on the event loop — + * that is the stuck thread (#622's constraint) — but not on the watchdog + * thread either: the dialog blocks until dismissed, and a watchdog parked + * in it stops sampling, so the recovery would only be noticed (and + * [TaoApplication.onResponsive] only fire) once the user clicked OK. + */ + private fun showNotRespondingDialog(detail: String) { + // One at a time, like `fatalDialogShown`: an app stalling repeatedly + // would otherwise leave a pile of modals for the user to dismiss. + if (!dialogShowing.compareAndSet(false, true)) return + Thread( + { + showNativeErrorDialog( + title = "Application Not Responding", + message = "The user interface has stopped responding.", + detail = detail, + ) + dialogShowing.set(false) + }, + "nucleus-tao-watchdog-dialog", + ).apply { isDaemon = true }.start() + } + + /** + * Every thread's stack, the event-loop thread first — it is the one under + * suspicion, and the reader should not have to hunt for it. + */ + private fun allThreadStacks(): String { + val loopThread = TaoMainDispatcher.taoMainThread + return Thread + .getAllStackTraces() + .entries + .sortedByDescending { it.key === loopThread } + .joinToString("\n\n") { (thread, stack) -> + val marker = if (thread === loopThread) " (Tao event loop)" else "" + buildString { + append("\"").append(thread.name).append("\"").append(marker) + append(" ").append(thread.state) + stack.forEach { append("\n\tat ").append(it) } + } + } + } +} + +/** What a liveness sample means for the watchdog, or `null` for "no change". */ +internal sealed interface HangTransition { + /** The loop has been hung past the grace period; reported once per stall. */ + data class Stalled( + val durationMs: Long, + ) : HangTransition + + /** The loop pumped again after a reported stall. */ + data class Recovered( + val durationMs: Long, + ) : HangTransition +} + +/** + * Turns a stream of "is it hung?" samples into at most one + * [HangTransition.Stalled] per stall and one [HangTransition.Recovered] when it + * ends. Separate from the polling thread so the state machine is testable + * without a window, a native library or wall-clock waiting. + * + * The OS flag already means "~5 s without pumping"; [graceMs] is the extra time + * on top of it, which keeps a merely slow frame — a long synchronous operation + * that does come back — out of the log. + */ +internal class EventLoopHangDetector( + private val graceMs: Long, +) { + // Nullable rather than a 0 sentinel: `System.nanoTime` has an arbitrary + // origin and 0 is one of its legal readings. + private var hangStartNanos: Long? = null + private var reported = false + + /** + * `true` once a stall has been reported and not yet closed. The watchdog + * reads it to decide whether it may park: parking on an open episode would + * hold the app's `responsive` for the length of the park. + */ + val hasOpenEpisode: Boolean + get() = reported + + /** Feeds one sample taken at [nowNanos] (a [System.nanoTime] reading). */ + fun sample( + hung: Boolean, + nowNanos: Long, + ): HangTransition? { + if (!hung) { + val since = hangStartNanos.takeIf { reported } + hangStartNanos = null + reported = false + return since?.let { HangTransition.Recovered(millisSince(it, nowNanos)) } + } + val start = hangStartNanos ?: nowNanos.also { hangStartNanos = it } + if (reported) return null + val duration = millisSince(start, nowNanos) + if (duration < graceMs) return null + reported = true + return HangTransition.Stalled(duration) + } + + /** + * Forgets the episode in flight — for samples that cannot be trusted at + * all, such as the ones straddling a system suspend. + * + * Returns a [HangTransition.Recovered] when a stall had already been + * reported: the duration is a lower bound (the suspend swallowed the rest), + * but an app that opened a prompt or a telemetry span on the report must + * get its close, so every `unresponsive` keeps its `responsive`. + */ + fun reset(nowNanos: Long): HangTransition? { + val since = hangStartNanos.takeIf { reported } + hangStartNanos = null + reported = false + return since?.let { HangTransition.Recovered(millisSince(it, nowNanos)) } + } + + private fun millisSince( + startNanos: Long, + nowNanos: Long, + ): Long = (nowNanos - startNanos) / NANOS_PER_MILLI +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoMonitors.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoMonitors.kt new file mode 100644 index 000000000..de0f3068f --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoMonitors.kt @@ -0,0 +1,310 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsDecoBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge + +// Wire format: id, name, then 4 bounds + 4 work-area numbers, scaleMilli, primary. +private const val FIELD_ID = 0 +private const val FIELD_NAME = 1 +private const val FIRST_NUMERIC_FIELD = 2 + +/** 4 bounds + 4 work-area numbers + scaleMilli; `primary` is read as a flag. */ +private const val NUMERIC_FIELD_COUNT = 9 +private const val SCALE_MILLI_INDEX = 8 +private const val FIELD_PRIMARY = 11 +private const val MONITOR_FIELD_COUNT = 12 + +// Indices into an [x, y, width, height] rectangle, native or wire. +private const val RECT_X = 0 +private const val RECT_Y = 1 +private const val RECT_WIDTH = 2 +private const val RECT_HEIGHT = 3 +private const val RECT_LENGTH = 4 + +private const val SCALE_MILLI = 1000f +private const val FALLBACK_WIDTH_PX = 1920 +private const val FALLBACK_HEIGHT_PX = 1080 + +/** + * A display attached to the machine, as reported by the platform's own monitor + * enumeration — `EnumDisplayMonitors` on Windows, `NSScreen.screens` on macOS, + * GDK monitors on Linux. + * + * This is the no-AWT counterpart of `java.awt.GraphicsDevice`: the Tao backend + * never initializes the AWT toolkit, so `GraphicsEnvironment` is not an option + * (and would report a DPI-scaled coordinate space that does not match Tao's + * physical pixels on mixed-DPI Windows setups). + * + * ### Native wire format + * + * Every platform bridge encodes one monitor per tab-separated string, so a + * single JNI call carries the whole enumeration: + * + * ``` + * id \t name \t x \t y \t width \t height \t + * workX \t workY \t workWidth \t workHeight \t scaleMilli \t primary + * ``` + * + * Geometry is **physical pixels with a top-left origin** in the global + * multi-monitor space, matching [TaoWindow.outerBoundsPx]. `scaleMilli` is the + * scale factor times 1000 and `primary` is `1` or `0`. + */ +public class TaoMonitor internal constructor( + /** + * Platform identifier, stable for as long as the monitor stays attached: + * the GDI device name on Windows (`\\.\DISPLAY1`), `display-` + * on macOS, the EDID model (or `monitor-`) on Linux. + */ + public val id: String, + /** Human-readable display name, for a monitor picker UI. */ + public val name: String, + /** Full monitor rectangle in physical pixels. */ + public val boundsPx: IntRect, + /** Monitor rectangle minus taskbar / menu bar / dock / panels, in physical pixels. */ + public val workAreaPx: IntRect, + /** The monitor's own scale factor (`1.0` on non-HiDPI displays). */ + public val scaleFactor: Float, + /** + * Whether this is the primary monitor — the one owning the origin. + * + * Exactly one monitor of [TaoMonitors.all] carries it: where the platform + * names no primary (GDK's Wayland backend does not), the first monitor is + * flagged, so filtering the list by this always finds one. + */ + public val isPrimary: Boolean, +) { + /** + * [boundsPx] converted to density-independent pixels. + * + * [scale] defaults to the monitor's own [scaleFactor], which is the right + * answer for a single-monitor or uniform-DPI setup. Pass the scale of the + * window being positioned when the result feeds window geometry: Tao's + * window coordinates are physical pixels divided by *one* scale, so mixing + * per-monitor scales would misplace windows on mixed-DPI setups. + */ + public fun boundsDp(scale: Float = scaleFactor): DpRect = boundsPx.toDpRect(scale) + + /** [workAreaPx] converted to density-independent pixels. See [boundsDp]. */ + public fun workAreaDp(scale: Float = scaleFactor): DpRect = workAreaPx.toDpRect(scale) + + /** Whether [xPx] / [yPx] (physical pixels) fall inside [boundsPx]. */ + public fun containsPx( + xPx: Int, + yPx: Int, + ): Boolean = xPx >= boundsPx.left && xPx < boundsPx.right && yPx >= boundsPx.top && yPx < boundsPx.bottom + + override fun equals(other: Any?): Boolean = this === other || (other is TaoMonitor && other.id == id) + + override fun hashCode(): Int = id.hashCode() + + override fun toString(): String = "TaoMonitor($id, $name, $boundsPx, scale=$scaleFactor, primary=$isPrimary)" +} + +/** + * Multi-monitor enumeration for the Tao backend. + * + * The AWT-free counterpart of `GraphicsEnvironment.getScreenDevices()`, and the + * data source behind [dev.nucleusframework.window.tao.v2.Screen]. + * + * Queries hit the platform bridge on every call rather than caching: monitors + * come and go (a laptop docking, a projector unplugged) and the underlying + * calls are cheap. [all] never returns an empty list — without a platform + * bridge it synthesizes one monitor from [TaoScreenGeometry] so a screen picker + * always has something to show. + */ +public object TaoMonitors { + /** + * Every attached monitor, primary first on macOS and in platform order + * elsewhere. + * + * [window] is only used on Linux, where GDK resolves monitors through a + * display reachable from a realized window; `null` falls back to the + * default GDK display. Ignored on Windows and macOS. + */ + public fun all(window: TaoWindow? = null): List = + reported(window).ifEmpty { listOf(syntheticMonitor(window)) }.withOnePrimary() + + /** + * The monitors the platform actually named — empty when it named none. + * + * [all] papers over that with [syntheticMonitor], which is right for a + * screen picker and wrong for anything that treats a work area as the truth + * about the display: the synthetic monitor falls back to a fixed + * [FALLBACK_WIDTH_PX] × [FALLBACK_HEIGHT_PX] rectangle at the origin, and a + * popup clamped into *that* would be dragged onto a display that does not + * exist. Callers who would rather do nothing than act on a guess ask here + * and treat empty as "no geometry" — see `PopupScreenGeometry`. + */ + internal fun reported(window: TaoWindow? = null): List { + val rows = + when (Platform.Current) { + Platform.Windows -> + if (NativeTaoWindowsDecoBridge.isLoaded) NativeTaoWindowsDecoBridge.nativeGetMonitors() else null + Platform.MacOS -> + if (NativeTaoMacOsDecoBridge.isLoaded) NativeTaoMacOsDecoBridge.nativeGetMonitors() else null + Platform.Linux -> + if (NativeTaoBridge.isLoaded) NativeTaoBridge.nativeLinuxMonitors(window?.handle ?: 0L) else null + else -> null + } + return rows?.mapNotNull(::parseMonitor).orEmpty() + } + + /** + * Exactly one monitor carrying [TaoMonitor.isPrimary]: the one the platform + * named, else the first. + * + * Not every platform names one — GDK's Wayland backend reports no primary + * monitor at all — and a list where the flag is nowhere makes + * `all().first { it.isPrimary }` throw for a caller doing the obvious + * thing. The fallback is the same one [primary] already applies; applying + * it here makes the flag mean something on every platform. + */ + private fun List.withOnePrimary(): List { + if (any { it.isPrimary }) return this + val chosen = first() + return listOf( + TaoMonitor( + id = chosen.id, + name = chosen.name, + boundsPx = chosen.boundsPx, + workAreaPx = chosen.workAreaPx, + scaleFactor = chosen.scaleFactor, + isPrimary = true, + ), + ) + drop(1) + } + + /** The primary monitor — see [TaoMonitor.isPrimary]. */ + public fun primary(window: TaoWindow? = null): TaoMonitor { + val monitors = all(window) + return monitors.firstOrNull { it.isPrimary } ?: monitors.first() + } + + /** The monitor with the given [id], or `null` when it is no longer attached. */ + public fun byId( + id: String, + window: TaoWindow? = null, + ): TaoMonitor? = all(window).firstOrNull { it.id == id } + + /** + * The monitor hosting [window] — the one containing the centre of its outer + * rectangle, falling back to the largest-overlap monitor and finally to + * [primary] (which also covers a window that is not realized yet). + */ + public fun forWindow(window: TaoWindow?): TaoMonitor { + val monitors = all(window) + val rect = window?.outerBoundsPx()?.takeIf { it.size == RECT_LENGTH } ?: return primary(window) + val left = rect[RECT_X].toInt() + val top = rect[RECT_Y].toInt() + val width = rect[RECT_WIDTH].toInt() + val height = rect[RECT_HEIGHT].toInt() + val centreX = left + width / 2 + val centreY = top + height / 2 + monitors.firstOrNull { it.containsPx(centreX, centreY) }?.let { return it } + val bounds = IntRect(left, top, left + width, top + height) + return monitors.maxByOrNull { overlapArea(it.boundsPx, bounds) } + ?: primary(window) + } + + /** + * The scale factor to interpret window geometry with: the window's own when + * it is realized, otherwise its monitor's. + * + * Every Dp rectangle the window API produces has to share one scale — see + * [TaoMonitor.boundsDp]. + */ + internal fun referenceScale(window: TaoWindow?): Float { + val windowScale = window?.scaleFactor ?: 0f + if (windowScale > 0f) return windowScale + return primary(window).scaleFactor + } + + private fun overlapArea( + a: IntRect, + b: IntRect, + ): Long { + val width = (minOf(a.right, b.right) - maxOf(a.left, b.left)).coerceAtLeast(0) + val height = (minOf(a.bottom, b.bottom) - maxOf(a.top, b.top)).coerceAtLeast(0) + return width.toLong() * height.toLong() + } + + /** + * Single monitor derived from the primary work area, for a runtime without + * the platform bridge (or a headless CI box). The work area doubles as the + * full bounds — the taskbar inset is unknowable here. + */ + private fun syntheticMonitor(window: TaoWindow?): TaoMonitor { + val work = TaoScreenGeometry.primaryMonitorWorkAreaPx(window)?.takeIf { it.size == RECT_LENGTH } + val scale = TaoScreenGeometry.primaryMonitorScaleFactor(window) + val rect = + if (work != null) { + IntRect( + left = work[RECT_X].toInt(), + top = work[RECT_Y].toInt(), + right = (work[RECT_X] + work[RECT_WIDTH]).toInt(), + bottom = (work[RECT_Y] + work[RECT_HEIGHT]).toInt(), + ) + } else { + IntRect(0, 0, (FALLBACK_WIDTH_PX * scale).toInt(), (FALLBACK_HEIGHT_PX * scale).toInt()) + } + return TaoMonitor( + id = "primary", + name = "Primary", + boundsPx = rect, + workAreaPx = rect, + scaleFactor = scale, + isPrimary = true, + ) + } + + internal fun parseMonitor(row: String): TaoMonitor? { + val fields = row.split('\t') + if (fields.size != MONITOR_FIELD_COUNT) return null + val id = fields[FIELD_ID] + val name = fields[FIELD_NAME] + val numbers = IntArray(NUMERIC_FIELD_COUNT) + for (index in numbers.indices) { + numbers[index] = fields[FIRST_NUMERIC_FIELD + index].toIntOrNull() ?: return null + } + val bounds = rectOrNull(numbers, offset = 0) ?: return null + val scale = (numbers[SCALE_MILLI_INDEX] / SCALE_MILLI).takeIf { it > 0f } ?: 1f + return TaoMonitor( + id = id.ifEmpty { "monitor" }, + name = name.ifEmpty { id }, + boundsPx = bounds, + // Some Wayland compositors report no work area; the full monitor is + // the honest answer there, not a zero-sized rectangle. + workAreaPx = rectOrNull(numbers, offset = RECT_LENGTH) ?: bounds, + scaleFactor = scale, + isPrimary = fields[FIELD_PRIMARY] == "1", + ) + } + + /** `[x, y, width, height]` at [offset], or `null` when the size is empty. */ + private fun rectOrNull( + numbers: IntArray, + offset: Int, + ): IntRect? { + val x = numbers[offset + RECT_X] + val y = numbers[offset + RECT_Y] + val width = numbers[offset + RECT_WIDTH] + val height = numbers[offset + RECT_HEIGHT] + if (width <= 0 || height <= 0) return null + return IntRect(left = x, top = y, right = x + width, bottom = y + height) + } +} + +private fun IntRect.toDpRect(scale: Float): DpRect { + val safeScale = if (scale > 0f) scale else 1f + return DpRect( + left = (left / safeScale).dp, + top = (top / safeScale).dp, + right = (right / safeScale).dp, + bottom = (bottom / safeScale).dp, + ) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoPointerIcons.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoPointerIcons.kt new file mode 100644 index 000000000..50fa3be28 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoPointerIcons.kt @@ -0,0 +1,56 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.input.pointer.PointerIcon + +/** + * A [PointerIcon] backed by a native Tao cursor, recognised by the Tao scene + * hosts and passed straight to `Window::set_cursor_icon`. + */ +internal class TaoPointerIcon( + val code: Int, +) : PointerIcon + +/** + * Pointer icons beyond the four Compose defines in common code + * (`Default`, `Text`, `Hand`, `Crosshair`). + * + * Use them with `Modifier.pointerHoverIcon` like any other icon: + * + * ```kotlin + * Modifier.pointerHoverIcon(TaoPointerIcons.Grab) + * ``` + * + * They resolve to the platform's own shapes (AppKit `openHandCursor` / + * `closedHandCursor`, the freedesktop `grab` / `grabbing` themed cursors, the + * Win32 equivalents), and fall back to the arrow where a platform has none. + * Compose Desktop's AWT-based `PointerIcon(Cursor(…))` is not usable on this + * backend — the process runs without AWT. + */ +public object TaoPointerIcons { + /** Open hand: this element can be picked up. The hover state of a drag handle. */ + public val Grab: PointerIcon = TaoPointerIcon(TaoCursorIcon.GRAB) + + /** Closed hand: the element is being dragged. */ + public val Grabbing: PointerIcon = TaoPointerIcon(TaoCursorIcon.GRABBING) + + /** Four arrows: the element will be moved. */ + public val Move: PointerIcon = TaoPointerIcon(TaoCursorIcon.MOVE) + + /** The drop here is refused. */ + public val NotAllowed: PointerIcon = TaoPointerIcon(TaoCursorIcon.NOT_ALLOWED) + + /** Wait cursor: the app is busy and does not take input. */ + public val Wait: PointerIcon = TaoPointerIcon(TaoCursorIcon.WAIT) + + /** Progress cursor: busy, but still interactive. */ + public val Progress: PointerIcon = TaoPointerIcon(TaoCursorIcon.PROGRESS) + + /** Help cursor, usually a question mark. */ + public val Help: PointerIcon = TaoPointerIcon(TaoCursorIcon.HELP) + + /** Horizontal resize: a vertical splitter or a left/right window edge. */ + public val ResizeEastWest: PointerIcon = TaoPointerIcon(TaoCursorIcon.EW_RESIZE) + + /** Vertical resize: a horizontal splitter or a top/bottom window edge. */ + public val ResizeNorthSouth: PointerIcon = TaoPointerIcon(TaoCursorIcon.NS_RESIZE) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt index f183feecf..6f1594384 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TaoWindow.kt @@ -2,8 +2,13 @@ package dev.nucleusframework.window.tao +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntRect import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.UpdateHandoff import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxTouchBridge @@ -13,9 +18,19 @@ import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Logger +import kotlin.math.roundToInt import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION as SHARED_AWT_PIXEL_TO_ROTATION import dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT as SHARED_MACOS_AWT_SCROLL_AMOUNT +/** + * How long an unanswered redraw request may stay latched before + * [TaoWindow.requestRedraw] assumes the OS dropped it and asks again. Far above + * a frame, far below anything a user would call a freeze. File-level and + * private: a `const val` in the private companion would still land on the + * validated ABI. + */ +private const val STALE_REDRAW_NANOS: Long = 1_000_000_000L + /** * Phase 2 handle to a window owned by the Tao event loop. * @@ -27,6 +42,8 @@ import dev.nucleusframework.window.tao.event.MACOS_AWT_SCROLL_AMOUNT as SHARED_M public class TaoWindow internal constructor( public val handle: Long, isResizable: Boolean = true, + isMinimizable: Boolean = true, + isMaximizable: Boolean = true, /** * `true` when the window was created as a popup overlay of another window * (`openWindow(popupOf = …)` — GTK_WINDOW_POPUP, mapped as a `wl_subsurface` @@ -61,7 +78,7 @@ public class TaoWindow internal constructor( * the `resizable` flag the window was created with; tracks runtime * [setResizable] calls. Surfaced to Compose so [WindowControlsLinux] / * [WindowControlsWindows] can hide the maximize button on non-resizable - * windows (matches the `decorated-window-jni` behaviour). + * windows (matches the legacy AWT backend's behaviour). */ public val isResizable: Boolean get() = resizableState.value @@ -73,6 +90,58 @@ public class TaoWindow internal constructor( NativeTaoBridge.nativeSetResizable(handle, resizable) } + // Same snapshot-backed shape as [resizableState]: the Compose chromes drop + // the minimize slot, the native side greys the affordance (#504). + private val minimizableState = mutableStateOf(isMinimizable) + + /** + * `true` when the user can minimize the window. Initially the + * `minimizable` flag the window was created with; tracks runtime + * [setMinimizable] calls. Surfaced to Compose so [WindowControlsLinux] / + * [WindowControlsWindows] can drop the minimize button (#504). + */ + public val isMinimizable: Boolean + get() = minimizableState.value + + /** + * Enables/disables user minimizing at runtime. macOS clears + * `NSWindowStyleMaskMiniaturizable` (the yellow traffic-light greys out, + * Cmd+M and the Window menu follow); Windows drops `WS_MINIMIZEBOX` + * (taskbar click, Win+Down, system menu). Linux has no client-side hint + * in tao, so only the title-bar button disappears — the window manager's + * own shortcuts can still iconify the window. + */ + public fun setMinimizable(minimizable: Boolean) { + if (minimizableState.value == minimizable) return + minimizableState.value = minimizable + NativeTaoBridge.nativeSetMinimizable(handle, minimizable) + } + + private val maximizableState = mutableStateOf(isMaximizable) + + /** + * `true` when the user can maximize the window. Initially the + * `maximizable` flag the window was created with; tracks runtime + * [setMaximizable] calls. The Compose chromes drop the maximize slot and + * the title-bar double-click when this is `false`. Orthogonal to + * [isResizable]: a palette stays resizable without ever filling the screen. + */ + public val isMaximizable: Boolean + get() = maximizableState.value + + /** + * Enables/disables user maximizing at runtime. macOS clears the zoom + * button (Window > Zoom follows); Windows drops `WS_MAXIMIZEBOX` (caption + * button, Win+Up, Aero Snap to the top edge). Linux has no client-side + * hint in tao, so only the title-bar button and double-click disappear — + * the window manager's own shortcuts can still maximize the window. + */ + public fun setMaximizable(maximizable: Boolean) { + if (maximizableState.value == maximizable) return + maximizableState.value = maximizable + NativeTaoBridge.nativeSetMaximizable(handle, maximizable) + } + @Volatile private var readyListener: ((Int, Int) -> Unit)? = null @@ -93,6 +162,19 @@ public class TaoWindow internal constructor( @Volatile private var closeRequestedListener: (() -> Unit)? = null + /** + * `false` for windows the framework owns (workspace satellites, tab + * windows, drag ghosts): a system quit leaves them alone instead of + * closing them, so a cancelled quit keeps the layout and an accepted one + * still snapshots it whole. See [TaoApplication.requestQuit]. + */ + @Volatile + internal var closesOnQuit: Boolean = true + + /** Set once [requestClose] started destroying this window. */ + @Volatile + internal var isClosing: Boolean = false + /** * Fires synchronously at the start of [requestClose] — before the native * destroy — so the host can present an opaque last frame (backdrop @@ -104,6 +186,14 @@ public class TaoWindow internal constructor( */ private val prepareCloseListeners = CopyOnWriteArrayList<() -> Unit>() + /** + * Fires synchronously at the start of [requestClose], right after + * [prepareCloseListeners]: windows *owned* by this one (satellites) sever + * their native owner link here, so Win32 / GTK don't destroy them together + * with their former owner while the app is handing them a new one. + */ + private val closingListeners = CopyOnWriteArrayList<() -> Unit>() + private val destroyedListeners = CopyOnWriteArrayList<() -> Unit>() @Volatile @@ -122,12 +212,24 @@ public class TaoWindow internal constructor( // the listener runs, so a redraw posted *during* render still gets through. private val redrawPending = AtomicBoolean(false) + /** When the in-flight redraw was asked for; see [requestRedraw]'s staleness re-issue. */ + @Volatile + private var redrawRequestedAtNanos = 0L + // Startup white-flash workaround: the themed WM_ERASEBKGND fill is armed on // show() and disabled once — on the first native redraw after show. Gating // on this flag keeps the disable off the per-frame redraw path. private var startupEraseActive = false private val focusListeners = CopyOnWriteArrayList<(Boolean) -> Unit>() + /** + * `true` while this window holds the keyboard focus, as last reported by + * the native FOCUSED / UNFOCUSED events. Snapshot-backed, so Compose + * readers recompose on change. + */ + public var isFocused: Boolean by mutableStateOf(false) + private set + @Volatile private var willHideListener: (() -> Unit)? = null private var shownListener: (() -> Unit)? = null @@ -208,7 +310,29 @@ public class TaoWindow internal constructor( } public fun requestRedraw() { - if (!redrawPending.compareAndSet(false, true)) return + val now = System.nanoTime() + if (redrawPending.compareAndSet(false, true)) { + redrawRequestedAtNanos = now + NativeTaoBridge.nativeRequestRedraw(handle) + return + } + // A request is already in flight. The latch is a *coalescing* device, so + // it only ever holds until the matching REDRAW_REQUESTED comes back — and + // when the OS swallows that event instead, the latch suppresses every + // later request and the window silently stops painting for good. Two such + // cases are patched by hand already ([resetRedrawLatch] for nested modal + // pumps, the FOCUSED branch of [dispatch] for an occluding modal child), + // and the #643 monkeys found a third: an app frozen long enough for + // Windows to ghost its window can come back with a live event loop and a + // dead picture. + // + // Rather than enumerate the ways an invalidation can be lost, treat a + // request the OS has not answered within [STALE_REDRAW_NANOS] as lost and + // ask again. No frame is lost either way: a genuinely in-flight redraw + // just yields one extra, idempotent request, at most once per second. + if (now - redrawRequestedAtNanos < STALE_REDRAW_NANOS) return + redrawRequestedAtNanos = now + logger.fine { "redraw for window $handle unanswered, re-issuing" } NativeTaoBridge.nativeRequestRedraw(handle) } @@ -233,6 +357,7 @@ public class TaoWindow internal constructor( } public fun requestClose() { + isClosing = true // Actual destroy path (not the cancelable close-*request*). Present an // opaque themed frame first: a live backdrop's translucent clear would // composite towards black in the close animation. The host listener @@ -246,6 +371,7 @@ public class TaoWindow internal constructor( } else { for (listener in prepareCloseListeners) listener.invoke() } + for (listener in closingListeners) listener.invoke() NativeTaoBridge.nativeRequestClose(handle) } @@ -805,18 +931,62 @@ public class TaoWindow internal constructor( * a window opened with `forceX11` reports `false` inside an app whose other * windows are Wayland. Only meaningful once the native window exists (after * `WINDOW_READY`). + * + * Cheap to poll: the kind is resolved through JNI once and cached, since a + * surface never changes backend for the life of its window. Cross-window + * gestures read it on every pointer move. */ public val isNativeWaylandSurface: Boolean - get() { - if (Platform.Current != Platform.Linux || !NativeTaoBridge.isLoaded) return false - val handles = NativeTaoBridge.nativeLinuxHandles(handle) ?: return false - return handles.isNotEmpty() && handles[0] == WAYLAND_HANDLE_KIND - } + get() = linuxSurfaceKind() == WAYLAND_HANDLE_KIND + + /** + * `true` when this window's position on screen is the client's to know and + * to set — every platform but a native Wayland surface, where xdg-shell + * gives the compositor full authority over toplevel placement: GDK reports + * every toplevel at `(0, 0)` there and ignores a move. + * + * This is the capability to branch on, rather than the platform + * ([isNativeWaylandSurface]): [outerBoundsPx] still carries a valid *size* + * where this is `false`, so a caller that needs only the size keeps using + * it, while anything that would treat its origin as a screen coordinate, + * move the window, or place another window against it must check here + * first. + * + * What it changes for an app: where it is `false`, moving the window is + * the compositor's gesture ([Modifier.windowDragArea]) and a cross-window + * drag rides the platform's drag-and-drop session instead of the window + * itself, so chrome that carries both has to give each one its own area — + * see [Satellite]'s `floatingCaption` and [SatelliteScope.isCompositorPlaced]. + */ + public val canPlaceOnScreen: Boolean + get() = !isNativeWaylandSurface + + /** + * `nativeLinuxHandles` slot 0, cached from the first call that returns a + * realized surface: `0` while the native window does not exist yet (not + * cached, so the next read asks again), `1` for Xlib, `2` for Wayland. + */ + @Volatile + private var cachedLinuxSurfaceKind = 0L + + private fun linuxSurfaceKind(): Long { + val cached = cachedLinuxSurfaceKind + if (cached != 0L) return cached + if (Platform.Current != Platform.Linux || !NativeTaoBridge.isLoaded) return 0L + val handles = NativeTaoBridge.nativeLinuxHandles(handle) ?: return 0L + val kind = if (handles.isNotEmpty()) handles[0] else 0L + if (kind != 0L) cachedLinuxSurfaceKind = kind + return kind + } /** Features already reported through [warnIfNativeWayland] for this window. */ private val waylandWarnings = ConcurrentHashMap.newKeySet() - /** Logical pixels. Pass `null` to clear the minimum. */ + /** + * Logical pixels. The constraint is per-window, not per-axis: a `null` on + * either axis clears the whole minimum, so pass `null` for **both** to + * clear it and two real values to set it. + */ public fun setMinimumSize( widthDp: Double?, heightDp: Double?, @@ -826,6 +996,20 @@ public class TaoWindow internal constructor( NativeTaoBridge.nativeSetMinInnerSize(handle, w, h) } + /** + * Logical pixels. The constraint is per-window, not per-axis: a `null` on + * either axis clears the whole maximum, so pass `null` for **both** to + * clear it and two real values to set it. + */ + public fun setMaximumSize( + widthDp: Double?, + heightDp: Double?, + ) { + val w = widthDp ?: -1.0 + val h = heightDp ?: -1.0 + NativeTaoBridge.nativeSetMaxInnerSize(handle, w, h) + } + /** [pixels] must be row-major premultiplied RGBA. Empty array clears. */ public fun setIcon( width: Int, @@ -873,8 +1057,81 @@ public class TaoWindow internal constructor( NativeTaoBridge.nativeSetOuterPosition(handle, x, y) } + /** + * Linux native Wayland only, for a popup overlay (`openWindow(popupOf = …)`): + * anchors the popup's content at a point of the parent's content area + * through GDK's `move_to_rect`, so it maps as an `xdg_popup` the compositor + * keeps on screen — flipped above the point when there is no room below, + * slid along an edge — instead of a `wl_subsurface` the compositor cannot + * constrain. The shadow margins are the transparent border the surface + * carries around its content; the compositor constrains the content, not + * the margin. The surface size is applied here too, because GDK builds the + * positioner from the window's current geometry — a popup still sized 1×1 + * asks the compositor to constrain a 1×1 rectangle and is never flipped. + * GDK positions a popup once, at map: call before [show], and never + * [setOuterPosition] or [setInnerSize] afterwards (either one re-maps it as + * a plain subsurface). + */ + internal fun anchorPopupInParent( + contentXDp: Double, + contentYDp: Double, + widthDp: Double, + heightDp: Double, + shadowLeftDp: Int, + shadowTopDp: Int, + shadowRightDp: Int, + shadowBottomDp: Int, + ) { + var x = contentXDp + var y = contentYDp + // Same content-area → parent-surface conversion as setOuterPosition. + if (isPopup && popupParentHandle != 0L && parentIsNativeWayland()) { + val packed = NativeTaoBridge.nativeLinuxContentOrigin(popupParentHandle) + x += (packed shr 32).toInt() + y += packed.toInt() + } + NativeTaoBridge.nativeLinuxPopupAnchor( + handle, + x.roundToInt(), + y.roundToInt(), + widthDp.roundToInt(), + heightDp.roundToInt(), + shadowLeftDp, + shadowTopDp, + shadowRightDp, + shadowBottomDp, + ) + } + + /** + * [setOuterPosition] in physical screen pixels — the coordinate space + * [outerBoundsPx] reports in, so a caller that computes a target from live + * window rects never has to guess a scale factor. + * + * On Windows this goes straight to `SetWindowPos(SWP_NOSIZE)`: Tao's + * logical `set_outer_position` multiplies by the scale the window was + * *created* at, which is the wrong factor as soon as the window lives on a + * second monitor with a different DPI. macOS and Linux convert with the + * window's own scale factor, where logical units and the native frame + * (AppKit points / GTK logical pixels) line up. + */ + internal fun setOuterPositionPx( + xPx: Int, + yPx: Int, + ) { + if (Platform.Current == Platform.Windows && NativeTaoWindowsDecoBridge.isLoaded) { + val hwnd = NativeTaoBridge.nativeHwndHandle(handle) + if (hwnd != 0L) { + NativeTaoWindowsDecoBridge.nativeSetWindowOuterPositionPx(hwnd, xPx, yPx) + return + } + } + val scale = scaleFactor.takeIf { it > 0f } ?: 1f + setOuterPosition(xPx / scale.toDouble(), yPx / scale.toDouble()) + } + /** `true` when the popup parent is a native Wayland surface (kind == 2). */ - private fun parentIsNativeWayland(): Boolean { + internal fun parentIsNativeWayland(): Boolean { if (Platform.Current != Platform.Linux || !NativeTaoBridge.isLoaded) return false val handles = NativeTaoBridge.nativeLinuxHandles(popupParentHandle) ?: return false return handles.isNotEmpty() && handles[0] == WAYLAND_HANDLE_KIND @@ -955,6 +1212,45 @@ public class TaoWindow internal constructor( resizedListeners += block } + // ── Multi-cast unsubscribe ──────────────────────────────────────────────── + // A window that observes *another* window (a satellite following its + // parent) has a shorter lifetime than the window it listens to, so it must + // be able to detach. Windows that only listen to themselves don't need + // this: their listener lists die with the native window. + + /** Detaches a listener registered with [onResized]. */ + internal fun removeResizedListener(block: (Int, Int) -> Unit) { + resizedListeners -= block + } + + /** Detaches a listener registered with [onMoved]. */ + internal fun removeMovedListener(block: (Int, Int) -> Unit) { + movedListeners -= block + } + + /** Detaches a listener registered with [onDestroyed]. */ + internal fun removeDestroyedListener(block: () -> Unit) { + destroyedListeners -= block + } + + /** Detaches a listener registered with [onClosing]. */ + internal fun removeClosingListener(block: () -> Unit) { + closingListeners -= block + } + + /** Detaches a listener registered with [onFullscreenPrepare]. */ + internal fun removeFullscreenPrepareListener(block: (Int, Int, Boolean) -> Unit) { + fullscreenPrepareListeners -= block + } + + internal fun removeFocusListener(block: (Boolean) -> Unit) { + focusListeners -= block + } + + internal fun removeMinimizedListener(block: (Boolean) -> Unit) { + minimizedListeners -= block + } + public fun onScaleFactorChanged(block: (scale: Float) -> Unit) { scaleFactorListener = block } @@ -972,6 +1268,14 @@ public class TaoWindow internal constructor( prepareCloseListeners += block } + /** + * Owned-window hook: runs at the start of [requestClose], before the native + * destroy. Multi-cast; detach with [removeClosingListener]. + */ + internal fun onClosing(block: () -> Unit) { + closingListeners += block + } + /** Multi-cast: every call adds a listener; all of them fire when the window is destroyed. */ public fun onDestroyed(block: () -> Unit) { destroyedListeners += block @@ -1154,6 +1458,34 @@ public class TaoWindow internal constructor( * See [NativeTaoBridge.EventCallback.onImePreedit]. */ @Volatile + /** + * Renders the current composition of this window's scene into a bitmap — + * the whole content area, or the given region of it in physical content + * pixels. Installed by the scene host while it is attached; `null` before + * and after, and on hosts that do not offer it. + * + * What a platform drag-and-drop session shows under the pointer where the + * window itself cannot follow (native Wayland): a picture of the palette + * or panel being dragged rather than a window the client cannot move. + */ + internal var contentSnapshot: ((IntRect?) -> ImageBitmap?)? = null + + /** See [contentSnapshot]; `null` when the host offers none or the scene has no size yet. */ + internal fun snapshotContent(rectPx: IntRect?): ImageBitmap? = contentSnapshot?.invoke(rectPx) + + /** + * This window's scene root as a drag-and-drop target, installed by the + * scene host while it is attached; `null` before and after. + * + * The platform inbound callbacks (`NativeTao*DndBridge.Callback`) resolve + * the node through the very same lambda, so a driver inside the process — + * the headful suite — can hand a drag to + * [dev.nucleusframework.window.tao.dnd.TaoSceneDnD] along the path the OS + * takes, rather than a parallel one that could drift from it. + */ + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + internal var inboundDragAndDropNode: (() -> androidx.compose.ui.scene.ComposeSceneDragAndDropNode?)? = null + internal var imePreedit: ((String) -> Unit)? = null internal fun dispatchImePreedit(text: String) { @@ -1177,7 +1509,13 @@ public class TaoWindow internal constructor( b: Int, ) { when (code) { - TaoEventCode.WINDOW_READY -> readyListener?.invoke(a, b) + TaoEventCode.WINDOW_READY -> { + // Cache the HWND for the hang watchdog while we are on the + // event-loop thread: resolving it later goes through the + // native window map, whose lock a stalled loop may hold (#643). + TaoEventLoopWatchdog.registerWindow(handle) + readyListener?.invoke(a, b) + } TaoEventCode.RESIZED -> { // Win32 emits WM_SIZE/SIZE_MINIMIZED as 0x0. Keep resize // listeners on the last real content size while minimized. @@ -1192,6 +1530,7 @@ public class TaoWindow internal constructor( TaoEventCode.SCALE_FACTOR_CHANGED -> scaleFactorListener?.invoke(a / 1000f) TaoEventCode.CLOSE_REQUESTED -> closeRequestedListener?.invoke() TaoEventCode.DESTROYED -> { + TaoEventLoopWatchdog.unregisterWindow(handle) destroyedListeners.forEach { it.invoke() } TaoApplication.remove(handle) } @@ -1206,6 +1545,9 @@ public class TaoWindow internal constructor( if (startupEraseActive) { startupEraseActive = false setStartupBackgroundEraseEnabled(false) + // A window is on screen with content: after a hot update, the + // version that launched this one may now exit (no-op otherwise). + UpdateHandoff.signalReady() } } TaoEventCode.FOCUSED -> { @@ -1219,9 +1561,13 @@ public class TaoWindow internal constructor( // just yields one extra, idempotent request. redrawPending.set(false) requestRedraw() + isFocused = true focusListeners.forEach { it.invoke(true) } } - TaoEventCode.UNFOCUSED -> focusListeners.forEach { it.invoke(false) } + TaoEventCode.UNFOCUSED -> { + isFocused = false + focusListeners.forEach { it.invoke(false) } + } TaoEventCode.MINIMIZED -> { val minimized = a != 0 isMinimized = minimized @@ -1294,6 +1640,8 @@ public class TaoWindow internal constructor( const val WAYLAND_HANDLE_KIND: Long = 2L val waylandLogger: Logger = Logger.getLogger("dev.nucleusframework.window.tao.wayland") + + val logger: Logger = Logger.getLogger(TaoWindow::class.java.name) } } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureView.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureView.kt index cf230b33b..a5c3560e5 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureView.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureView.kt @@ -88,12 +88,16 @@ internal data class D3D11SharedTextureSource( * finish their writes (`commit` + `waitUntilCompleted`, or double buffering) * *before* calling [TextureViewController.markFrameAvailable]; a producer * still writing while the compositor copies can tear, never crash. + * + * The returned source retains [ioSurface] for as long as it is reachable, so + * a producer that releases its own hold (the "close under a live view" + * case) cannot free the surface out from under a later remount. */ public fun nucleusIOSurfaceTextureSource( ioSurface: Long, widthPx: Int, heightPx: Int, -): TextureViewSource = IOSurfaceTextureSource(ioSurface, widthPx, heightPx) +): TextureViewSource = IOSurfaceTextureSource(ioSurface, widthPx, heightPx).also(::retainIoSurfaceForSource) internal data class IOSurfaceTextureSource( val ioSurface: Long, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureViewMac.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureViewMac.kt index 25da2dc3c..fc5be10a2 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureViewMac.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/TextureViewMac.kt @@ -15,11 +15,13 @@ import dev.nucleusframework.window.tao.scene.TaoMetalTextureHost import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.ColorSpace import org.jetbrains.skia.ContentChangeMode +import org.jetbrains.skia.DirectContext import org.jetbrains.skia.Image import org.jetbrains.skia.Rect import org.jetbrains.skia.Surface import org.jetbrains.skia.SurfaceColorFormat import org.jetbrains.skia.SurfaceOrigin +import java.lang.ref.Cleaner /** * macOS implementation of [TextureView]. The producer's `IOSurface` (or @@ -170,6 +172,9 @@ private val metalTextureImports = closeImport = { it.close() }, ) +/** Whether any `TextureView` import is currently alive on [context] — the headful suite's leak probe. */ +internal fun hasMetalTextureImports(context: DirectContext): Boolean = metalTextureImports.hasImportsFor(context) + private fun importTexture( host: TaoMetalTextureHost, source: TextureViewSource, @@ -240,3 +245,29 @@ private fun importTexture( MacImportedTexture(handle, host, renderTarget, surface, widthPx, heightPx) } } + +/** + * Keeps [IOSurfaceTextureSource.ioSurface] alive for the source's lifetime: + * the producer may `CFRelease` on close while a `TextureView` still holds + * the source (and may remount it). The matching release runs when the + * source is collected. No-op when the Metal bridge is not loaded. + */ +internal fun retainIoSurfaceForSource(source: IOSurfaceTextureSource) { + val ptr = source.ioSurface + if (ptr == 0L || !NativeTaoMacOsTextureBridge.isLoaded) return + if (!NativeTaoMacOsTextureBridge.nativeRetainIOSurface(ptr)) return + ioSurfaceCleaner.register(source, IoSurfaceRelease(ptr)) +} + +private val ioSurfaceCleaner: Cleaner = Cleaner.create() + +/** Must not capture the source, or the Cleaner would never run. */ +private class IoSurfaceRelease( + private val ptr: Long, +) : Runnable { + override fun run() { + if (NativeTaoMacOsTextureBridge.isLoaded) { + NativeTaoMacOsTextureBridge.nativeReleaseIOSurface(ptr) + } + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WindowPositioner.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WindowPositioner.kt new file mode 100644 index 000000000..3847560bb --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WindowPositioner.kt @@ -0,0 +1,359 @@ +@file:Suppress("MagicNumber") + +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp + +/** + * A point on a rectangle, used to pin one window to another. + * + * Corner values ([TopLeft], [BottomRight], …) resolve to that corner; edge + * values ([Top], [Left], …) resolve to the middle of that edge; [Center] + * resolves to the middle of the rectangle. + * + * Used twice by a [WindowPositioner]: once on the parent's anchor rectangle + * ([WindowPositioner.parentAnchor]) and once on the child window + * ([WindowPositioner.childAnchor]). + */ +public enum class WindowAnchor { + /** The middle of the rectangle. */ + Center, + + /** The middle of the top edge. */ + Top, + + /** The middle of the bottom edge. */ + Bottom, + + /** The middle of the left edge. */ + Left, + + /** The middle of the right edge. */ + Right, + + /** The top-left corner. */ + TopLeft, + + /** The top-right corner. */ + TopRight, + + /** The bottom-left corner. */ + BottomLeft, + + /** The bottom-right corner. */ + BottomRight, +} + +/** + * How a window may be nudged when the position a [WindowPositioner] computes + * would put it (partly) outside the monitor work area. + * + * Adjustments are tried in a fixed precedence and the first one that lands the + * whole window inside the work area wins: + * + * 1. [flipHorizontal] / [flipVertical] — mirror both anchors and the offset to + * the opposite side of the anchor rectangle. + * 2. [slideHorizontal] / [slideVertical] — translate along the axis until the + * window fits. + * 3. [resizeHorizontal] / [resizeVertical] — shrink along the axis until the + * window fits. + * + * When none of the enabled adjustments fits, the unadjusted position is used. + */ +public data class WindowConstraintAdjustment( + val flipHorizontal: Boolean = false, + val flipVertical: Boolean = false, + val slideHorizontal: Boolean = false, + val slideVertical: Boolean = false, + val resizeHorizontal: Boolean = false, + val resizeVertical: Boolean = false, +) { + /** Ready-made combinations, in increasing order of how far they'll go. */ + public companion object { + /** No adjustment: the anchored position is used verbatim. */ + public val None: WindowConstraintAdjustment = WindowConstraintAdjustment() + + /** Slide along both axes until the window fits. */ + public val Slide: WindowConstraintAdjustment = + WindowConstraintAdjustment(slideHorizontal = true, slideVertical = true) + + /** Mirror to the opposite side of the anchor rectangle on both axes. */ + public val Flip: WindowConstraintAdjustment = + WindowConstraintAdjustment(flipHorizontal = true, flipVertical = true) + + /** Flip first, then slide — the sensible default for tool palettes. */ + public val FlipAndSlide: WindowConstraintAdjustment = + WindowConstraintAdjustment( + flipHorizontal = true, + flipVertical = true, + slideHorizontal = true, + slideVertical = true, + ) + + /** Every adjustment, shrinking the window as a last resort. */ + public val All: WindowConstraintAdjustment = + WindowConstraintAdjustment( + flipHorizontal = true, + flipVertical = true, + slideHorizontal = true, + slideVertical = true, + resizeHorizontal = true, + resizeVertical = true, + ) + } +} + +/** + * Declarative placement rule for a child window relative to its parent. + * + * The child is placed by putting its [childAnchor] on top of the parent's + * [parentAnchor] and then translating by [offset]. For example + * `WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left)` + * hangs the child off the parent's right edge, vertically centred; adding + * `offset = DpOffset(8.dp, 0.dp)` leaves an 8 dp gap. + * + * The anchor point is clamped to the parent's own rectangle before the child + * anchor is applied, so a child can never be flung far away by an anchor + * rectangle that sticks out of its parent. + * + * Used by [SatelliteWindow] for the satellite's initial placement. + * + * @property parentAnchor the point on the parent's anchor rectangle to pin to. + * @property childAnchor the point on the child window pinned to [parentAnchor]. + * @property offset translation applied after the two anchors meet — typically + * the gap between the parent and a palette hanging off its edge. Applied + * *after* the anchor point is clamped to [parentRect][place], unlike + * Flutter's positioner, which clamps the offset anchor point and therefore + * swallows any offset pointing away from the parent. + * @property constraintAdjustment how to keep the child inside the work area. + * Defaults to [WindowConstraintAdjustment.FlipAndSlide] so an anchored window + * near a screen edge stays reachable; pass [WindowConstraintAdjustment.None] + * for raw anchoring. + */ +public data class WindowPositioner( + val parentAnchor: WindowAnchor = WindowAnchor.Center, + val childAnchor: WindowAnchor = WindowAnchor.Center, + val offset: DpOffset = DpOffset.Zero, + val constraintAdjustment: WindowConstraintAdjustment = WindowConstraintAdjustment.FlipAndSlide, +) { + /** + * Resolves the screen rectangle for a child window of [childSize]. + * + * All rectangles are in the same coordinate space — screen dp with a + * top-left origin — and the result is too: + * + * @param childSize the child window's outer (frame) size. + * @param anchorRect the rectangle the child is anchored to. Usually the + * parent window's frame, or a sub-rectangle of it (a toolbar button). + * @param parentRect the parent window's frame; bounds the anchor point. + * @param workArea the monitor work area the child must stay inside + * (screen minus taskbar / menu bar / dock). + */ + public fun place( + childSize: DpSize, + anchorRect: DpRect, + parentRect: DpRect, + workArea: DpRect, + ): DpRect = + placeIn( + childSize = childSize.toSize(), + anchorRect = anchorRect.toRect(), + parentRect = parentRect.toRect(), + workArea = workArea.toRect(), + ).toDpRect() + + /** + * [place] in raw floats, so callers that already work in physical pixels + * (the satellite follow path) don't round-trip through [DpRect]. + * + * [scale] converts [offset] — the only dp-valued input — into the unit the + * rectangles are expressed in: `1f` for dp, the monitor scale factor for + * physical pixels. + */ + @Suppress("ReturnCount", "CyclomaticComplexMethod") + internal fun placeIn( + childSize: Size, + anchorRect: Rect, + parentRect: Rect, + workArea: Rect, + scale: Float = 1f, + ): Rect { + val delta = Offset(offset.x.value * scale, offset.y.value * scale) + + fun candidate( + parent: WindowAnchor, + child: WindowAnchor, + translation: Offset, + ): Rect { + // Clamp the anchor *point*, then translate: an anchor rectangle + // that sticks out of its parent can't fling the child across the + // screen, while an [offset] meant to open a gap on the outside of + // the parent survives. See the note on [offset]. + val anchorPoint = parent.pointOn(anchorRect).clampTo(parentRect) + translation + val origin = anchorPoint + child.originShiftFor(childSize) + return Rect(origin, childSize) + } + + val unadjusted = candidate(parentAnchor, childAnchor, delta) + if (workArea.covers(unadjusted)) return unadjusted + + if (constraintAdjustment.flipHorizontal) { + val flipped = + candidate( + parentAnchor.flippedHorizontally(), + childAnchor.flippedHorizontally(), + Offset(-delta.x, delta.y), + ) + if (workArea.covers(flipped)) return flipped + } + if (constraintAdjustment.flipVertical) { + val flipped = + candidate( + parentAnchor.flippedVertically(), + childAnchor.flippedVertically(), + Offset(delta.x, -delta.y), + ) + if (workArea.covers(flipped)) return flipped + } + if (constraintAdjustment.flipHorizontal && constraintAdjustment.flipVertical) { + val flipped = + candidate( + parentAnchor.flippedHorizontally().flippedVertically(), + childAnchor.flippedHorizontally().flippedVertically(), + Offset(-delta.x, -delta.y), + ) + if (workArea.covers(flipped)) return flipped + } + + if (constraintAdjustment.slideHorizontal || constraintAdjustment.slideVertical) { + var origin = unadjusted.topLeft + if (constraintAdjustment.slideHorizontal) { + origin = Offset(slideInto(origin.x, childSize.width, workArea.left, workArea.right), origin.y) + } + if (constraintAdjustment.slideVertical) { + origin = Offset(origin.x, slideInto(origin.y, childSize.height, workArea.top, workArea.bottom)) + } + val slid = Rect(origin, childSize) + if (workArea.covers(slid)) return slid + } + + if (constraintAdjustment.resizeHorizontal || constraintAdjustment.resizeVertical) { + // Clip the overhanging axis to the work area — the window shrinks + // to what fits and is never grown past what was asked for. + val resized = + Rect( + left = + if (constraintAdjustment.resizeHorizontal) { + maxOf(unadjusted.left, workArea.left) + } else { + unadjusted.left + }, + top = + if (constraintAdjustment.resizeVertical) { + maxOf(unadjusted.top, workArea.top) + } else { + unadjusted.top + }, + right = + if (constraintAdjustment.resizeHorizontal) { + minOf(unadjusted.right, workArea.right) + } else { + unadjusted.right + }, + bottom = + if (constraintAdjustment.resizeVertical) { + minOf(unadjusted.bottom, workArea.bottom) + } else { + unadjusted.bottom + }, + ) + if (workArea.covers(resized)) return resized + } + + return unadjusted + } +} + +/** Translation that keeps a span of [extent] starting at [start] inside `[min, max]`. */ +private fun slideInto( + start: Float, + extent: Float, + min: Float, + max: Float, +): Float { + val leadingOverhang = start - min + val trailingOverhang = start + extent - max + return when { + leadingOverhang < 0f -> start - leadingOverhang + trailingOverhang > 0f -> start - trailingOverhang + else -> start + } +} + +private fun WindowAnchor.pointOn(rect: Rect): Offset = + when (this) { + WindowAnchor.Center -> rect.center + WindowAnchor.Top -> rect.topCenter + WindowAnchor.Bottom -> rect.bottomCenter + WindowAnchor.Left -> rect.centerLeft + WindowAnchor.Right -> rect.centerRight + WindowAnchor.TopLeft -> rect.topLeft + WindowAnchor.TopRight -> rect.topRight + WindowAnchor.BottomLeft -> rect.bottomLeft + WindowAnchor.BottomRight -> rect.bottomRight + } + +/** Shift from the anchor point to the child's top-left corner. */ +private fun WindowAnchor.originShiftFor(size: Size): Offset = + when (this) { + WindowAnchor.Center -> Offset(-size.width / 2f, -size.height / 2f) + WindowAnchor.Top -> Offset(-size.width / 2f, 0f) + WindowAnchor.Bottom -> Offset(-size.width / 2f, -size.height) + WindowAnchor.Left -> Offset(0f, -size.height / 2f) + WindowAnchor.Right -> Offset(-size.width, -size.height / 2f) + WindowAnchor.TopLeft -> Offset.Zero + WindowAnchor.TopRight -> Offset(-size.width, 0f) + WindowAnchor.BottomLeft -> Offset(0f, -size.height) + WindowAnchor.BottomRight -> Offset(-size.width, -size.height) + } + +private fun WindowAnchor.flippedHorizontally(): WindowAnchor = + when (this) { + WindowAnchor.Left -> WindowAnchor.Right + WindowAnchor.Right -> WindowAnchor.Left + WindowAnchor.TopLeft -> WindowAnchor.TopRight + WindowAnchor.TopRight -> WindowAnchor.TopLeft + WindowAnchor.BottomLeft -> WindowAnchor.BottomRight + WindowAnchor.BottomRight -> WindowAnchor.BottomLeft + WindowAnchor.Center, WindowAnchor.Top, WindowAnchor.Bottom -> this + } + +private fun WindowAnchor.flippedVertically(): WindowAnchor = + when (this) { + WindowAnchor.Top -> WindowAnchor.Bottom + WindowAnchor.Bottom -> WindowAnchor.Top + WindowAnchor.TopLeft -> WindowAnchor.BottomLeft + WindowAnchor.BottomLeft -> WindowAnchor.TopLeft + WindowAnchor.TopRight -> WindowAnchor.BottomRight + WindowAnchor.BottomRight -> WindowAnchor.TopRight + WindowAnchor.Center, WindowAnchor.Left, WindowAnchor.Right -> this + } + +private fun Offset.clampTo(rect: Rect): Offset = + Offset(x.coerceIn(rect.left, rect.right), y.coerceIn(rect.top, rect.bottom)) + +/** True when [other] lies entirely inside this rectangle. */ +private fun Rect.covers(other: Rect): Boolean = + left <= other.left && right >= other.right && top <= other.top && bottom >= other.bottom + +private fun DpSize.toSize(): Size = Size(width.value, height.value) + +private fun DpRect.toRect(): Rect = Rect(left.value, top.value, right.value, bottom.value) + +private fun Rect.toDpRect(): DpRect = DpRect(left.dp, top.dp, right.dp, bottom.dp) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WindowSizePolicy.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WindowSizePolicy.kt index 475ad40d8..d681e8848 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WindowSizePolicy.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WindowSizePolicy.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.Dp @@ -35,6 +37,12 @@ internal fun TaoWindow.resolvedSizePolicy(): WindowSizePolicy = sizePolicies[han /** * Wrap-content axes for a [DecoratedWindow] whose [androidx.compose.ui.window.WindowState.size] * has [Dp.Unspecified] on one or both dimensions (#532). + * + * [settled] flips once the measured size has been applied to the native + * window: the scene then fills it like any other window's. The wrap + * modifiers hand children an unbounded axis, under which `fillMaxWidth` / + * `fillMaxHeight` collapse to content — a `TitleBar` shrank to its buttons + * (#546). */ internal class WindowSizePolicy( val wrapWidth: Boolean = false, @@ -42,6 +50,7 @@ internal class WindowSizePolicy( val onContentMeasured: ((IntSize) -> Unit)? = null, ) { val wraps: Boolean get() = wrapWidth || wrapHeight + val settled: MutableState = mutableStateOf(false) } internal fun Dp.toWindowCreationDp(fallback: Double): Double = @@ -58,7 +67,7 @@ internal fun Dp.toWindowCreationDp(fallback: Double): Double = internal fun WindowSceneColumn(content: @Composable ColumnScope.() -> Unit) { val policy = LocalTaoWindow.current?.resolvedSizePolicy() ?: WindowSizePolicy() val modifier = - if (!policy.wraps) { + if (!policy.wraps || policy.settled.value) { Modifier.fillMaxSize() } else { Modifier diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WorkspaceDragKind.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WorkspaceDragKind.kt new file mode 100644 index 000000000..752dfa2b4 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/WorkspaceDragKind.kt @@ -0,0 +1,28 @@ +package dev.nucleusframework.window.tao + +import dev.nucleusframework.window.ExperimentalNucleusApi + +/** + * How a cross-window drag in flight is carried — see [SatelliteWorkspace.dragKind] + * and [TabWorkspace.dragKind]. + */ +@ExperimentalNucleusApi +public enum class WorkspaceDragKind { + /** + * A window follows the pointer: the satellite's own, the tab's own when it + * is the only one in it, or a ghost window standing in for a docked panel + * or a tab leaving its strip — [SatelliteWorkspace.dragGhost] and + * [TabWorkspace.dragGhost] are published for the latter. + */ + Window, + + /** + * The platform's drag-and-drop session carries it, because the window + * cannot be placed by the app ([TaoWindow.canPlaceOnScreen] `false`). The + * compositor draws a picture of the dragged panel or tab as the drag icon, + * nothing of the workspace's follows the pointer, and the source is not + * told where it is: the window under it resolves the drop and the source + * acts on that record. + */ + Transfer, +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/FullscreenTitleBarHolder.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/FullscreenTitleBarHolder.kt index 42846ee16..12185738d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/FullscreenTitleBarHolder.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/FullscreenTitleBarHolder.kt @@ -31,7 +31,7 @@ import androidx.compose.ui.unit.dp * CompositionLocals from the original call site so user-provided values * (themes, etc.) remain accessible inside the overlay. * - * Mirrors `decorated-window-jni`'s `FullscreenTitleBarHolder`. + * Mirrors the legacy AWT backend's `FullscreenTitleBarHolder`. */ internal class FullscreenTitleBarHolder { var content: (@Composable () -> Unit)? by mutableStateOf(null) @@ -46,7 +46,7 @@ internal val LocalFullscreenTitleBarHolder = compositionLocalOf = LinkedHashMap() + private val focusSinkKey: Any = object {} + + /** + * Puts an invisible, focusable EventBox first in the overlay's focus + * chain, before any embed is added. GTK hands a newly focused window + * with no focus widget to its *first* focusable child — which used to be + * the embed, so a `WebKitWebView` or a `GtkEntry` held GTK focus (and a + * caret) from the moment the window mapped, next to Compose's own. The + * sink takes that default focus instead; being one of our boxes, keys + * then route to Tao's toplevel handler and on to Compose. Parked at + * (-1, -1) 1×1, it never catches a click. Idempotent; call before the + * first attach. + */ + fun ensureFocusSink() { + if (focusSinkKey in boxes) return + registerRegion(focusSinkKey, -1, -1, 1, 1) + } + /** * Translates the EventBox's logical pixel reports back into * Compose's physical pixel space (matching what Tao's diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/UndecoratedWindowBorder.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/UndecoratedWindowBorder.kt index 43f44bce5..077dd0f79 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/UndecoratedWindowBorder.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/UndecoratedWindowBorder.kt @@ -32,7 +32,7 @@ private val DialogElevationStroke = Color(0x1F000000) /** * Returns the [Modifier.insideBorder] used by Tao `DecoratedWindow` on * Linux + Windows for the **default custom-chrome** look (no native frame — - * same role as `decorated-window-jni`'s `DecoratedWindowBody` border). macOS + * same role as the legacy AWT backend's `DecoratedWindowBody` border). macOS * keeps native decorations and does not need this. * * Callers that pass `undecorated = true` (fully borderless overlays / ghosts) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsLinux.kt index e8bc70285..17d8a94c3 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsLinux.kt @@ -56,6 +56,8 @@ internal fun TitleBarScope.WindowControlsLinux( win: TaoWindow, state: DecoratedWindowState, isResizable: Boolean, + isMinimizable: Boolean, + isMaximizable: Boolean, style: TitleBarStyle, layout: LinuxButtonLayout = rememberLinuxButtonLayout(), isFullscreen: Boolean = false, @@ -67,7 +69,7 @@ internal fun TitleBarScope.WindowControlsLinux( // Iterate over `layout.buttons` in natural order — `layout.buttons[0]` is // "closest to the edge". Core's `TitleBarMeasurePolicy` places End items // first-declared = rightmost (controls-on-right) and Start items - // first-declared = leftmost. Mirrors `decorated-window-jni`'s + // first-declared = leftmost. Mirrors the legacy AWT backend's // `WindowControlArea.kt` exactly. for (button in layout.buttons) { when (button) { @@ -98,8 +100,9 @@ internal fun TitleBarScope.WindowControlsLinux( ) continue } - if (!isResizable) continue if (state.isMaximized) { + // Restore is never gated: the WM can maximize a window tao + // has no client-side maximizable hint for. LinuxControlButton( onClick = { win.setMaximized(false) }, icon = icons.restore, @@ -109,7 +112,7 @@ internal fun TitleBarScope.WindowControlsLinux( style = style, modifier = Modifier.align(buttonAlignment), ) - } else { + } else if (isResizable && isMaximizable) { LinuxControlButton( onClick = { win.setMaximized(true) }, icon = icons.maximize, @@ -122,6 +125,7 @@ internal fun TitleBarScope.WindowControlsLinux( } } LinuxTitleBarButton.MINIMIZE -> { + if (!isMinimizable) continue LinuxControlButton( onClick = { win.minimize() }, icon = icons.minimize, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsWindows.kt index fcdfb69cc..7785eae36 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/deco/WindowControlsWindows.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.dp import dev.nucleusframework.window.DecoratedWindowState @@ -64,8 +63,9 @@ import dev.nucleusframework.window.resolveWindowControl import dev.nucleusframework.window.styling.TitleBarStyle import dev.nucleusframework.window.tao.LocalTaoWindow import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.onPositionChanged -// Mirrors `decorated-window-awt/WindowsWindowControlArea.kt` so the visual +// Mirrors the legacy AWT backend's `WindowsWindowControlArea` so the visual // output is identical between the AWT-based backend and the Tao backend. private val WINDOWS_BUTTON_WIDTH = 46.dp @@ -98,7 +98,7 @@ internal fun WindowControlsWindows( isFullscreen: Boolean = false, onExitFullscreen: (() -> Unit)? = null, ) { - // Match decorated-window-jni's WindowsWindowControlArea: LTR renders + // Match the legacy AWT backend's window controls: LTR renders // Minimize/Maximize/Close, RTL mirrors it to Close/Maximize/Minimize. CompositionLocalProvider(LocalLayoutDirection provides LocalControlButtonsDirection.current) { Row(modifier = modifier.fillMaxHeight()) { @@ -136,7 +136,7 @@ internal fun WindowsWindowControl( // button leaves the composition. val positionModifier = if (window != null) { - Modifier.onGloballyPositioned { coordinates -> + Modifier.onPositionChanged { coordinates -> CaptionButtonHitZones.publish(window, type, coordinates.boundsInWindow()) } } else { @@ -163,7 +163,7 @@ internal fun WindowsWindowControl( /** * Icon artwork per control, in the four active/inactive x light/dark variants * `decorated-window-core`'s `WindowsWindowControlArea` uses. Exit-fullscreen - * has its own set (the "collapse" glyph), matching decorated-window-jni. + * has its own set (the "collapse" glyph), matching the legacy AWT backend. */ private fun windowsControlIcon( type: WindowControlType, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dispatch/TaoMainCoroutineDispatcher.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dispatch/TaoMainCoroutineDispatcher.kt index 74730d81f..9ad5d13d3 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dispatch/TaoMainCoroutineDispatcher.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dispatch/TaoMainCoroutineDispatcher.kt @@ -99,7 +99,7 @@ internal object ImmediateTaoMainDispatcher : TaoMainCoroutineDispatcher() { * thread via [TaoMainCoroutineDispatcher.dispatch]. The scheduler thread * itself only schedules — it never runs user code. */ -private object DelayScheduler { +internal object DelayScheduler { private val executor: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "Nucleus-Tao-Delay").apply { isDaemon = true } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoDragAndDropManager.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoDragAndDropManager.kt index c5df4d476..12e14808a 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoDragAndDropManager.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoDragAndDropManager.kt @@ -35,6 +35,13 @@ internal class TaoDragAndDropManager( @Suppress("unused") // wired in stage 2+ for inbound proxy through the manager private val getRootNode: () -> ComposeSceneDragAndDropNode, private val outboundLauncher: OutboundLauncher? = null, + /** + * Whether [outboundLauncher] can run a session whose only payload is a + * [TaoPrivateTransfer] token. Only the Linux host does: the cross-window + * gestures ride the DnD session there on native Wayland. Elsewhere such a + * request is refused like any other with nothing to export. + */ + private val acceptsPrivateData: Boolean = false, ) : PlatformDragAndDropManager { /** * Per-platform implementation of the actual OS drag session. Receives the @@ -67,9 +74,18 @@ internal class TaoDragAndDropManager( class OutboundRequest internal constructor( val files: List, val text: String?, + /** In-process token, see [TaoPrivateTransfer]; `null` for an ordinary data drag. */ + val privateData: String?, val supportedActions: List, val decorationSize: Size, val drawDragDecoration: DrawScope.() -> Unit, + /** + * Where the pointer sits inside the decoration, in the decoration's + * own pixels. Compose (and AWT's `DragSource.startDrag`) place the + * decoration's origin at the pointer *plus* the transfer's + * `dragDecorationOffset`, so the pointer is at minus that offset. + */ + val decorationHotspot: Offset, ) init { @@ -111,7 +127,8 @@ internal class TaoDragAndDropManager( } val files = awt.extractFiles() val text = awt.extractText() - if (files.isEmpty() && text == null) { + val privateData = TaoPrivateTransfer.tokenOf(awt)?.takeIf { acceptsPrivateData } + if (files.isEmpty() && text == null && privateData == null) { TaoDnDDiagnostics.log("startDragAndDropTransfer skipped — no exportable data") return false } @@ -120,11 +137,15 @@ internal class TaoDragAndDropManager( OutboundRequest( files = files, text = text, + privateData = privateData, supportedActions = transferData.supportedActions.toList(), decorationSize = decorationSize, drawDragDecoration = drawDragDecoration, + decorationHotspot = -transferData.dragDecorationOffset, ) - TaoDnDDiagnostics.log("starting OS drag files=${files.size} text=${text != null}") + TaoDnDDiagnostics.log( + "starting OS drag files=${files.size} text=${text != null} private=${privateData != null}", + ) inProgress = true val launched = launcher.launch(request) { result -> diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoPrivateTransfer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoPrivateTransfer.kt new file mode 100644 index 000000000..98d242256 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoPrivateTransfer.kt @@ -0,0 +1,46 @@ +package dev.nucleusframework.window.tao.dnd + +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.Transferable +import java.awt.datatransfer.UnsupportedFlavorException + +/** + * A drag payload that never leaves the process. + * + * The cross-window gestures — docking a satellite, tearing a tab off — ride + * the platform's drag-and-drop session on native Wayland, where it is the only + * pointer grab that crosses windows and reports coordinates. What travels is a + * token, not data: the session's meaning lives in the workspace that started + * it, and every target is in this process. The native side offers the token + * under [MIME] to this application only, so a foreign drop target never sees + * a stray string and a foreign source can never spoof one. + */ +internal object TaoPrivateTransfer { + /** Must match the Rust `PRIVATE_TARGET` in `dnd.rs`. */ + const val MIME: String = "application/x-nucleus-private" + + /** The AWT flavor the token is carried under, so it fits Compose's `DragAndDropTransferable`. */ + val FLAVOR: DataFlavor = DataFlavor("$MIME; class=java.lang.String") + + /** A transferable offering only [token] under [FLAVOR]. */ + fun transferable(token: String): Transferable = PrivateTransferable(token) + + /** The token a transferable carries under [FLAVOR], or `null` when it carries none. */ + fun tokenOf(transferable: Transferable): String? = + if (transferable.isDataFlavorSupported(FLAVOR)) { + runCatching { transferable.getTransferData(FLAVOR) as? String }.getOrNull() + } else { + null + } + + private class PrivateTransferable( + private val token: String, + ) : Transferable { + override fun getTransferDataFlavors(): Array = arrayOf(FLAVOR) + + override fun isDataFlavorSupported(flavor: DataFlavor?): Boolean = flavor == FLAVOR + + override fun getTransferData(flavor: DataFlavor?): Any = + if (flavor == FLAVOR) token else throw UnsupportedFlavorException(flavor) + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoSceneDnD.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoSceneDnD.kt index 0b067c7ca..8a2630851 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoSceneDnD.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/dnd/TaoSceneDnD.kt @@ -100,6 +100,13 @@ internal object TaoSceneDnD { if (accepted) { node.onStarted(ev) node.onEntered(ev) + // The entry event carries a position, and only `onMoved` makes the + // root resolve the target under it. Without this, the target the + // pointer entered on is not entered until the next motion event — + // so its highlight lags a frame, and a platform that delivers + // enter → drop with no motion in between (or a drop right after a + // re-entry) finds no target and refuses perfectly good files. + node.onMoved(ev) } return accepted } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoCursorMapping.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoCursorMapping.kt index 6be0d900b..e799fe3d4 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoCursorMapping.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoCursorMapping.kt @@ -2,6 +2,7 @@ package dev.nucleusframework.window.tao.event import androidx.compose.ui.input.pointer.PointerIcon import dev.nucleusframework.window.tao.TaoCursorIcon +import dev.nucleusframework.window.tao.TaoPointerIcon import java.awt.Cursor /** @@ -15,6 +16,9 @@ import java.awt.Cursor * trick. */ internal fun PointerIcon.toTaoCursorIconCode(): Int { + // Nucleus' own icons ([TaoPointerIcons]) carry the native code directly; + // everything else is a Compose singleton or an AWT-backed cursor. + if (this is TaoPointerIcon) return code when (this) { PointerIcon.Default -> return TaoCursorIcon.DEFAULT PointerIcon.Text -> return TaoCursorIcon.TEXT diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScale.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScale.kt new file mode 100644 index 000000000..baa5bc30f --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScale.kt @@ -0,0 +1,106 @@ +package dev.nucleusframework.window.tao.event + +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerKeyboardModifiers +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.scene.ComposeScene + +/** + * Feeds one step of a platform-recognized pinch into the scene as Compose's + * `ScaleStart` / `ScaleChange` / `ScaleEnd` (#660). [scaleFactor] is a + * multiplicative per-event ratio (`1f` = no change, `> 1f` zoom in, `< 1f` + * zoom out) — the same shape as `NSEvent.magnification` after `1 + delta`, + * and as GDK's per-event pinch ratio. Foundation's `transformable` and + * apps that listen for `PointerEventType.Scale*` consume it directly, so + * unlike the previous two-finger Touch synthesis there is no second pass + * through touch slop, span thresholds or release momentum. + */ +@OptIn(InternalComposeUiApi::class) +internal fun ComposeScene.dispatchTrackpadScale( + x: Float, + y: Float, + type: PointerEventType, + scaleFactor: Float, + keyboardModifiers: PointerKeyboardModifiers = PointerKeyboardModifiers(), +) { + sendPointerEvent( + eventType = type, + position = Offset(x, y), + type = PointerType.Mouse, + keyboardModifiers = keyboardModifiers, + scaleGestureFactor = scaleFactor, + ) +} + +/** + * Open/move/close a Compose scale gesture from a platform pinch stream + * (`TaoTrackpadPhase` on macOS/Linux, a debounced tick stream on + * Windows / Linux Ctrl+wheel). UI thread only. + */ +internal class TaoTrackpadScaleSession( + private val send: (type: PointerEventType, scaleFactor: Float) -> Unit, +) { + var active: Boolean = false + private set + + /** Opens the scale gesture if it is not already open. */ + fun start() { + if (active) return + active = true + send(PointerEventType.ScaleStart, 1f) + } + + /** + * Opens the gesture if needed and reports a multiplicative [scaleFactor]. + * A `1f` factor is not a move (Began / Ended ticks, a zero wheel delta). + */ + fun change(scaleFactor: Float) { + if (scaleFactor == 1f) return + start() + send(PointerEventType.ScaleChange, scaleFactor) + } + + /** + * [delta] is `NSEvent.magnification` / GDK's equivalent: the next factor + * is `1 + delta`, floored so a collapse cannot invert the scale. + */ + fun magnifyBy(delta: Float) { + change((1f + delta).coerceAtLeast(MIN_GESTURE_SCALE)) + } + + /** One-shot smart-magnify: a discrete zoom step, then the gesture closes. */ + fun smartMagnify() { + start() + change(SMART_MAGNIFY_FACTOR) + end() + } + + fun end() { + if (!active) return + active = false + send(PointerEventType.ScaleEnd, 1f) + } + + internal companion object { + const val SMART_MAGNIFY_FACTOR: Float = 1.5f + const val MIN_GESTURE_SCALE: Float = 0.05f + } +} + +/** + * The two Touch contacts the macOS and Linux hosts synthesise for a trackpad + * rotation (Compose has no rotation event). Chrome that reacts to touch — the + * title bar's window drag — must tell them from a real finger. + */ +internal object TaoTrackpadRotationContacts { + private const val ID_A: Long = 0xA001L + private const val ID_B: Long = 0xA002L + + val A: PointerId = PointerId(ID_A) + val B: PointerId = PointerId(ID_B) + + fun isContact(id: PointerId): Boolean = id == A || id == B +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt index 870bbe1da..db2bc139d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/event/TaoWheelPinchZoom.kt @@ -4,9 +4,9 @@ import kotlin.math.pow /** * Maps a Ctrl+wheel / precision-touchpad wheel delta to a multiplicative zoom step. - * Shared by the Windows and Linux hosts, which both synthesise a magnify gesture from - * Ctrl+wheel so it zooms (never scrolls) — the AWT backend has no pinch-zoom, so this - * gives Windows/Linux the same behaviour. + * Shared by the Windows and Linux hosts, which both turn Ctrl+wheel into a + * Compose scale gesture so it zooms (never scrolls) — the AWT backend has no + * pinch-zoom, so this gives Windows/Linux the same behaviour. */ internal object TaoWheelPinchZoom { private const val WHEEL_DELTAS_PER_DOUBLING: Float = 12f diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt index f5fa6468f..37089090f 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeMetalBridge.kt @@ -36,7 +36,7 @@ internal object NativeMetalBridge { // ── Menu bar offset (event-driven via native NSEvent monitor) ── // - // Mirrors `decorated-window-jni`'s JniMacTitleBarBridge. Keyed by NSView + // Mirrors the legacy AWT backend's mac title-bar bridge. Keyed by NSView // pointer for consistency with the rest of this bridge (the JNI sibling // keys by NSWindow pointer because it owns AWT windows directly). @@ -393,7 +393,7 @@ internal object NativeMetalBridge { * be called after [nativeApplyButtonLayout] has stashed the title-bar * height (otherwise this is a no-op until the height is published). * - * Mirrors `decorated-window-jni`'s `JniMacTitleBarBridge.nativeSetRTL`. + * Mirrors the legacy AWT backend's `nativeSetRTL`. */ @JvmStatic external fun nativeSetButtonLayoutRtl( @@ -409,7 +409,7 @@ internal object NativeMetalBridge { * * If the window is already in fullscreen, the menu bar event monitor is * installed/removed to match the new flag. Mirrors - * `decorated-window-jni`'s `JniMacTitleBarBridge.nativeSetNewFullscreenControls`. + * the legacy AWT backend's `nativeSetNewFullscreenControls`. */ @JvmStatic external fun nativeSetNewFullscreenControls( @@ -423,7 +423,7 @@ internal object NativeMetalBridge { * changes, the native side calls [onMenuBarOffsetChanged] via JNI so the * Compose layer can animate the title-bar offset. * - * Mirrors `decorated-window-jni`'s `nativeInstallMenuBarMonitor`. + * Mirrors the legacy AWT backend's `nativeInstallMenuBarMonitor`. */ @JvmStatic external fun nativeInstallMenuBarMonitor(nsViewPtr: Long) @@ -438,7 +438,7 @@ internal object NativeMetalBridge { * Compose animates the offset. Triggers an immediate * `updateFullScreenButtonsPosition` on the macOS main thread. * - * Mirrors `decorated-window-jni`'s `nativeSetMenuBarOffset`. + * Mirrors the legacy AWT backend's `nativeSetMenuBarOffset`. */ @JvmStatic external fun nativeSetMenuBarOffset( @@ -451,7 +451,7 @@ internal object NativeMetalBridge { * frame from the stored title-bar height + menu-bar offset. Useful after * a layout pass that may have moved the contentView. * - * Mirrors `decorated-window-jni`'s `nativeUpdateFullScreenButtons`. + * Mirrors the legacy AWT backend's `nativeUpdateFullScreenButtons`. */ @JvmStatic external fun nativeUpdateFullScreenButtons(nsViewPtr: Long) @@ -546,6 +546,28 @@ internal object NativeMetalBridge { momentumPhase: Int, ): Boolean + /** + * Headful e2e only (#660): feeds a synthetic magnify / rotate / + * smart-magnify NSEvent on `NSApp`'s queue (`postEvent`, delivered after + * the current callback returns), so the trackpad gesture monitor handles + * it as a real trackpad pinch. [kind] is the + * `touchpad_gestures.m` wire (0 magnify, 1 rotate, 2 smart-magnify); + * [phase] the IOHID encoding (1 began, 2 changed, 4 ended, 8 cancelled, + * `0` = unset); [x] / [y] content-local points, top-left origin; [value] + * the magnification delta or the rotation in degrees. `false` when + * injection is disabled or the view or its window is gone. + */ + @JvmStatic + @Suppress("LongParameterList") + external fun nativeDiagInjectTrackpadGesture( + nsViewPtr: Long, + kind: Int, + phase: Int, + x: Float, + y: Float, + value: Double, + ): Boolean + /** * Disables native → JVM callbacks and removes any active menu bar * monitors. Called from a JVM shutdown hook so AppKit can't fire a diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt index bd7174633..3b8686211 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoBridge.kt @@ -404,6 +404,19 @@ internal object NativeTaoBridge { rangesOut: LongArray, ): String + /** + * macOS only, headful e2e: the rect TaoView answers + * `firstRectForCharacterRange:` with, filled into [rectOut] (length ≥ 4) + * as `[x, y, width, height]` in Cocoa screen coordinates. An all-zero rect + * is the client reporting no insertion point — what AppKit needs to hear + * once the focused field is gone. + */ + @JvmStatic + external fun nativeMacOsQueryImeRect( + handle: Long, + rectOut: DoubleArray, + ): Boolean + /** * macOS only, headful e2e: invoke `setMarkedText:selectedRange:replacementRange:` * on TaoView (the same entry IMKit uses). @@ -439,6 +452,21 @@ internal object NativeTaoBridge { @JvmStatic external fun nativeHwndHandle(handle: Long): Long + /** + * Windows only (#643): `true` when the OS considers [hwnd]'s owning thread + * to have stopped pumping messages — the very state the shell reads to + * ghost a window as "(Not Responding)". `IsHungAppWindow` is a pure query: + * it sends nothing to the event loop, so the watchdog that calls it every + * few seconds costs the loop nothing and cannot inject the inline sent + * message that deadlocked #640. + * + * Takes the HWND by value and touches no crate state, so it is safe to + * call from a thread other than the event loop — which is the whole point, + * the event loop being the thread under suspicion. + */ + @JvmStatic + external fun nativeIsWindowHung(hwnd: Long): Boolean + /** * Linux counterpart: returns `[kind, display, nativeWindow]` so the JVM can * attach an EGL context. `kind` is 0 = unavailable, 1 = Xlib, 2 = Wayland. @@ -512,6 +540,30 @@ internal object NativeTaoBridge { y: Int, ): Boolean + /** + * Linux only, headful e2e: delivers a synthetic `GdkEventTouchpadPinch` + * through the GtkWindow's `event` signal — the handler a real touchpad + * pinch reaches (`touch.rs`), so GDK's absolute scale and radian angle + * are converted exactly as for a real gesture. + * + * [phase] is a `GdkTouchpadGesturePhase` (`0=BEGIN`, `1=UPDATE`, `2=END`, + * `3=CANCEL`), [scaleMicro] GDK's absolute scale × 1 000 000 (1 000 000 at + * BEGIN), [angleDeltaMicro] the per-event angle in micro-radians. + * Coordinates are widget-local logical px. + * + * Must run on the Tao / GTK main thread. Returns `false` when the handle + * is unknown, the window is not realized, or [phase] is out of range. + */ + @JvmStatic + external fun nativeLinuxInjectGdkTouchpadPinch( + handle: Long, + phase: Int, + x: Int, + y: Int, + scaleMicro: Int, + angleDeltaMicro: Int, + ): Boolean + /** * Linux only: origin of the content area (the child GTK allocated inside * any client-side decorations) in logical toplevel coordinates, packed as @@ -557,17 +609,35 @@ internal object NativeTaoBridge { @JvmStatic external fun nativeLinuxPrimaryMonitorScaleMilli(handle: Long): Int + /** + * Linux only: returns one descriptor per GDK monitor, encoded as documented + * in [dev.nucleusframework.window.tao.TaoMonitor]. + * + * [handle] may be `0` — monitors are a display-wide property, so the + * default GDK display is used when no window is available. `null` when GDK + * has no display. + */ + @JvmStatic + external fun nativeLinuxMonitors(handle: Long): Array? + /** * Linux only: wires [childHandle] as a GTK transient of [ownerHandle] via - * `gtk_window_set_transient_for` (+ `skip_taskbar_hint` and - * `destroy_with_parent`). Mirrors the Win32 `GWLP_HWNDPARENT` and AppKit - * `addChildWindow:` paths used by `DecoratedDialog`. Pass `0` for - * [ownerHandle] to clear the relationship. + * `gtk_window_set_transient_for` (+ `skip_taskbar_hint`). Mirrors the Win32 + * `GWLP_HWNDPARENT` and AppKit `addChildWindow:` paths used by + * `DecoratedDialog`. Pass `0` for [ownerHandle] to clear the relationship. + * + * [destroyWithOwner] adds `gtk_window_set_destroy_with_parent`, which is + * the JDialog behaviour a dialog wants and the opposite of what a + * satellite wants: a satellite outlives the window it is anchored to (the + * workspace hands it to another one). GTK destroying it behind tao's back + * leaves a live `TaoWindow` whose toplevel is gone — a window that reports + * no geometry and can never be shown again. */ @JvmStatic external fun nativeLinuxSetDialogOwner( childHandle: Long, ownerHandle: Long, + destroyWithOwner: Boolean, ) /** @@ -620,6 +690,18 @@ internal object NativeTaoBridge { resizable: Boolean, ) + @JvmStatic + external fun nativeSetMinimizable( + handle: Long, + minimizable: Boolean, + ) + + @JvmStatic + external fun nativeSetMaximizable( + handle: Long, + maximizable: Boolean, + ) + @JvmStatic external fun nativeSetMinimized( handle: Long, @@ -673,6 +755,14 @@ internal object NativeTaoBridge { height: Double, ) + /** [width]/[height] in logical pixels; pass negative values to clear. */ + @JvmStatic + external fun nativeSetMaxInnerSize( + handle: Long, + width: Double, + height: Double, + ) + /** [pixels] is row-major premultiplied RGBA. Empty array clears the icon. */ @JvmStatic external fun nativeSetWindowIcon( @@ -698,6 +788,24 @@ internal object NativeTaoBridge { y: Double, ) + /** + * Linux only: anchors a popup overlay (`popupOf`) at a logical point of + * its parent window through GDK's `move_to_rect`, so GDK maps it as a + * compositor-positioned `xdg_popup` — see [TaoWindow.anchorPopupInParent]. + */ + @JvmStatic + external fun nativeLinuxPopupAnchor( + handle: Long, + x: Int, + y: Int, + width: Int, + height: Int, + shadowLeft: Int, + shadowTop: Int, + shadowRight: Int, + shadowBottom: Int, + ) + @JvmStatic external fun nativeIsFullscreen(handle: Long): Boolean @@ -707,13 +815,35 @@ internal object NativeTaoBridge { fullscreen: Boolean, ) - /** Sets the OS cursor for the window. [code] follows [TaoCursorIcon]. */ + /** + * Sets the OS cursor for the window. [code] follows [TaoCursorIcon]. + * Callers go through [setCursorIcon], which records the request first. + */ @JvmStatic external fun nativeSetCursorIcon( handle: Long, code: Int, ) + /** + * The last cursor code requested per window handle, exactly as it was + * handed to [nativeSetCursorIcon]. The platform cursor itself cannot be + * read back portably (and never under Xvfb), so this is what the headful + * suite asserts against: a `BasicTextField` under a still pointer must + * have left a `TEXT` here, and a native view under it must not have + * flipped it back. + */ + val lastCursorIcon: java.util.concurrent.ConcurrentHashMap = java.util.concurrent.ConcurrentHashMap() + + /** Records the request in [lastCursorIcon] and applies it. */ + fun setCursorIcon( + handle: Long, + code: Int, + ) { + lastCursorIcon[handle] = code + nativeSetCursorIcon(handle, code) + } + /** * Anchors the platform IME UI at the given window-local rect in *physical * pixels* (top-left origin), so preedit and candidate windows follow the @@ -753,9 +883,29 @@ internal object NativeTaoBridge { selectionEnd: Long, ) - /** Calls `[view.inputContext activate]` for TaoView's NSTextInputClient. */ + /** + * Calls `[view.inputContext activate]` for TaoView's NSTextInputClient and + * returns the token identifying the text-input session it opens (0 when the + * window is gone). Hand it back to [nativeDeactivateInputContext]. + */ @JvmStatic - external fun nativeActivateInputContext(handle: Long) + external fun nativeActivateInputContext(handle: Long): Long + + /** + * Ends the session [token] opened: `[view.inputContext deactivate]` plus + * the drop of the cached caret rect. Both matter — an input context left + * active over a caret rect that outlived its field keeps AppKit anchoring + * the input-source indicator (the badge Caps Lock raises when it is bound + * to keyboard-layout switching) to a field that no longer exists. + * + * A [token] the newest activation superseded is ignored, so the teardown of + * an outgoing session cannot undo the incoming one. + */ + @JvmStatic + external fun nativeDeactivateInputContext( + handle: Long, + token: Long, + ) // ── Accessibility (macOS) ────────────────────────────────────────────── // diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoEglBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoEglBridge.kt index 3e2806d7d..7e8a1e254 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoEglBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoEglBridge.kt @@ -119,7 +119,7 @@ internal object NativeTaoEglBridge { handle: Long, xLogical: Int, yLogical: Int, - ) + ): Boolean /** * Wayland only: declares which part of the content surface is fully opaque, @@ -160,6 +160,36 @@ internal object NativeTaoEglBridge { interval: Int, ) + /** + * Forces the driver to acquire — and, with a pending + * `wl_egl_window_resize`, reallocate — the buffer behind the default + * framebuffer, so [nativeQueryDrawableSize] describes the buffer this + * frame will actually land in rather than whatever the driver has not + * got round to yet. Touches the GL binding behind Skia's back: reset the + * cached state after calling it. + */ + @JvmStatic + external fun nativeTouchDrawable(handle: Long) + + /** + * The real size of the buffer behind the default framebuffer, packed as + * `(width shl 32) or height`, or 0 when `eglQuerySurface` is unavailable. + * [nativeWidth] / [nativeHeight] report the last *requested* size instead, + * which on Wayland is not the same thing until the buffer catches up. + * Call [nativeTouchDrawable] first on a frame that pushed a resize. + */ + @JvmStatic + external fun nativeQueryDrawableSize(handle: Long): Long + + /** + * Size of the buffer currently attached to the content surface as + * libwayland-egl tracks it — what the compositor holds, as opposed to + * the size last requested through `wl_egl_window_resize`. Packed as + * `(width shl 32) or height`; 0 on X11 or when unavailable. + */ + @JvmStatic + external fun nativeAttachedSize(handle: Long): Long + @JvmStatic external fun nativeWidth(handle: Long): Int @@ -209,4 +239,16 @@ internal object NativeTaoEglBridge { */ @JvmStatic external fun nativeGetProcAddrFunctionPointer(): Long + + /** + * Wayland only: puts the content sub-surface in `set_sync` (buffers apply + * with GTK's toplevel commit, atomically with the positions of embedded + * native views) or back in `set_desync` (buffers apply on their own). + * No-op on X11. + */ + @JvmStatic + external fun nativeSetSubsurfaceSync( + handle: Long, + sync: Boolean, + ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxDndBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxDndBridge.kt index 81094a67b..5453cad9d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxDndBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxDndBridge.kt @@ -100,15 +100,33 @@ internal object NativeTaoLinuxDndBridge { * loop — this call is made from inside one of its callbacks — so without * [pump] the host paints nothing for the whole session. * + * @param privateData an in-process payload offered under + * [dev.nucleusframework.window.tao.dnd.TaoPrivateTransfer.MIME] to this + * application's own windows only (`SAME_APP`), or `null`. A session may + * carry it alone: the cross-window gestures ride the DnD session on + * native Wayland with nothing a foreign target could take. + * @param iconArgb the drag icon under the pointer as premultiplied ARGB + * (`0xAARRGGBB`) device pixels, row-major, `iconWidth × iconHeight`; + * `null` for GTK's default icon. [iconScale] is the device pixels per + * logical pixel it was rendered at, [iconHotX] / [iconHotY] the pointer's + * position inside it in device pixels. * @param pump invoked repeatedly during the drag so the suppressed Tao tick * can still drain and render; see [DragPump]. `null` disables it. */ + @Suppress("LongParameterList") @JvmStatic external fun nativeStartDrag( handle: Long, files: Array?, text: String?, + privateData: String?, allowedEffects: Int, + iconArgb: IntArray?, + iconWidth: Int, + iconHeight: Int, + iconScale: Float, + iconHotX: Int, + iconHotY: Int, pump: DragPump?, ): Int diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt index 85dc330c2..92591dd7d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxTouchBridge.kt @@ -25,9 +25,9 @@ import dev.nucleusframework.core.runtime.NativeLibraryLoader * single `Release` carrying the final position. * - **Trackpad gesture**: matches [NativeTaoBridge.EventCallback.onTrackpadGesture] * exactly — same kind / phase / fixed-point scaling. The Rust side has - * already converted GDK's absolute pinch scale into per-event ratio - * deltas and GDK's radian angle deltas into degrees, so the JVM-side - * synth math is platform-independent. + * already converted GDK's absolute pinch scale into a per-event ratio + * (forwarded as Compose Scale events, #660) and GDK's radian angle + * deltas into degrees, so the JVM-side math is platform-independent. * * Coordinates passed to [Callback.onTouchEvent] are physical pixels in the * GtkWindow's bin-child coordinate space, encoded as fixed-point ×1024 @@ -71,8 +71,8 @@ internal object NativeTaoLinuxTouchBridge { /** * Trackpad pinch / rotate. Same wire format as * [NativeTaoBridge.EventCallback.onTrackpadGesture] so the JVM-side - * synth math (`TaoComposeSceneHost.onTrackpadGesture`) is reused - * verbatim across macOS and Linux. Wayland-only on Linux. + * scale / rotate dispatch is reused across macOS and Linux. + * Wayland-only on Linux. */ @Suppress("LongParameterList", "FunctionParameterNaming") fun onTrackpadGesture( diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxWidgetBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxWidgetBridge.kt index 8ba471055..e252c1ef9 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxWidgetBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoLinuxWidgetBridge.kt @@ -111,6 +111,35 @@ internal object NativeTaoLinuxWidgetBridge { @JvmStatic external fun nativeRemoveInputBox(boxPtr: Long) + /** Receives the toplevel GtkWindow's `draw` signal — see [nativeConnectToplevelDraw]. */ + interface ToplevelDrawCallback { + fun onToplevelDraw() + } + + /** + * Connects [callback] to the toplevel's `draw` signal, after GTK's own + * handler and still inside the frame clock's paint phase — i.e. *before* + * GDK's after-paint commits the toplevel surface (#444). A frame rendered + * and swapped from that callback, with the content sub-surface in sync + * mode, is applied by the compositor together with the geometry that + * commit carries. Returns the handler id, 0 if unavailable; the handler + * is owned by the GtkWindow and goes with it. + */ + @JvmStatic + external fun nativeConnectToplevelDraw( + gtkWindowPtr: Long, + callback: ToplevelDrawCallback, + ): Long + + /** + * The toplevel's client size in logical units (`gtk_window_get_size`), + * packed `(width shl 32) or height`, 0 when unavailable. Inside the `draw` + * signal this is the size of the configure GTK is painting — which Tao's + * `configure-event` only reports once that paint has been committed (#444). + */ + @JvmStatic + external fun nativeToplevelClientSize(gtkWindowPtr: Long): Long + /** * Receives motion / press / release events forwarded from the * native EventBox handlers. Coords are **logical pixels** in the @@ -189,4 +218,61 @@ internal object NativeTaoLinuxWidgetBridge { dx: Float, dy: Float, ) + + /** + * Gives the keyboard back to Compose after a press Compose kept: clears + * the GTK focus widget when it is an embed (not one of the suite's own + * input boxes), so keys route to Tao's toplevel handler again. `true` + * when it did. + */ + @JvmStatic + external fun nativeClaimKeyboardForCompose(gtkWindowPtr: Long): Boolean + + /** + * GDK's live pointer button mask (`GDK_BUTTON1_MASK = 1 shl 8`, + * `GDK_BUTTON3_MASK = 1 shl 10`, …), or -1 when unavailable. + */ + @JvmStatic + external fun nativeQueryPointerButtons(gtkWindowPtr: Long): Int + + /** `gtk_widget_queue_draw` on the toplevel: GTK paints and commits it on its next frame. */ + @JvmStatic + external fun nativeQueueToplevelDraw(gtkWindowPtr: Long) + + // ── Diagnostics for the headful suite ───────────────────────────── + + /** + * A fresh, unparented `GtkEntry` for a headful case to embed through + * `NativeView` — the test module cannot fabricate a `GtkWidget*` on + * its own. 0 when GTK is unavailable. Destroy with + * [nativeDiagDestroyWidget]. + */ + @JvmStatic + external fun nativeDiagCreateEntry(): Long + + /** Detaches and destroys a widget from [nativeDiagCreateEntry]. */ + @JvmStatic + external fun nativeDiagDestroyWidget(widgetPtr: Long) + + /** The widget [gtkWindowPtr] routes keys to (`gtk_window_get_focus`), or 0. */ + @JvmStatic + external fun nativeDiagFocusWidget(gtkWindowPtr: Long): Long + + /** Whether [widgetPtr] itself holds GTK focus. */ + @JvmStatic + external fun nativeDiagWidgetHasFocus(widgetPtr: Long): Boolean + + /** The text of an entry from [nativeDiagCreateEntry], or null. */ + @JvmStatic + external fun nativeDiagEntryText(widgetPtr: Long): String? + + /** + * Where a widget sits, in Tao's content-box coordinates and logical px, + * as `[x, y, w, h]` — null while it is not mapped. + */ + @JvmStatic + external fun nativeDiagWidgetFrame( + gtkWindowPtr: Long, + widgetPtr: Long, + ): IntArray? } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt index 2955e3f7e..dc8fb8449 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsDecoBridge.kt @@ -55,6 +55,21 @@ internal object NativeTaoMacOsDecoBridge { @JvmStatic external fun nativeGetWindowRect(nsView: Long): LongArray? + /** + * Returns the view's own rect on screen as `[x, y, width, height]` in + * physical pixels with a top-left origin — same convention as + * [nativeGetWindowRect] and [nativeGetMonitors]. + * + * This is the origin window-rooted Compose coordinates are relative to, + * which is *not* the window frame origin when the window has a native + * title bar. Used by the popup screen clamp + * ([dev.nucleusframework.window.tao.popup.popupScreenClampOffset], #569) to + * turn a popup's window-rooted frame into screen coordinates. Returns + * `null` if the view is not attached to an NSWindow. + */ + @JvmStatic + external fun nativeGetContentRect(nsView: Long): LongArray? + /** * Returns the primary screen's `visibleFrame` (full screen minus menu bar * and Dock) as `[x, y, width, height]` in physical pixels with a top-left @@ -63,6 +78,14 @@ internal object NativeTaoMacOsDecoBridge { @JvmStatic external fun nativeGetPrimaryMonitorWorkArea(): LongArray? + /** + * Returns one descriptor per `NSScreen`, encoded as documented in + * [dev.nucleusframework.window.tao.TaoMonitor]. Index 0 is the primary + * screen. `null` when AppKit reports no screen. + */ + @JvmStatic + external fun nativeGetMonitors(): Array? + /** * Returns the primary screen's `backingScaleFactor` encoded as * `(scale * 1000)`. Used as a scale source while a Tao window's own scale diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt index ec0048edb..b9c771006 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsNativeViewBridge.kt @@ -99,6 +99,24 @@ internal object NativeTaoMacOsNativeViewBridge { @JvmStatic external fun nativeMakeContentViewFirstResponder(contentNsView: Long) + /** + * Delivers a Tao key event to the window's first responder when that + * responder is an embedded native view (or its field editor), not the + * Tao content view. Synthetic keys never enter AppKit's responder chain, + * so an `NSTextField` that holds first responder would otherwise never + * see a letter typed through the in-process driver. + * + * [type] is a `TaoEventCode` (`KEY_DOWN` / `KEY_UP` / `KEY_TYPED`). + * Returns `true` when the embed took the event. + */ + @JvmStatic + external fun nativeDispatchKeyToFirstResponder( + contentNsView: Long, + type: Int, + vkCode: Int, + codePoint: Int, + ): Boolean + // ── Sibling overlay NSView ──────────────────────────────────────── /** @@ -199,4 +217,37 @@ internal object NativeTaoMacOsNativeViewBridge { */ @JvmStatic external fun nativeIsFirstResponder(overlayNsView: Long): Boolean + + // ── Diagnostics for the headful suite ───────────────────────────── + + /** + * A retained, unparented `NSTextField` for a headful case to embed + * through `NativeView`. 0 on failure. Release with [nativeDiagReleaseView]. + */ + @JvmStatic + external fun nativeDiagCreateTextField(): Long + + /** Removes a view from [nativeDiagCreateTextField] from its superview and releases it. */ + @JvmStatic + external fun nativeDiagReleaseView(nsView: Long) + + /** + * Whether [nsView] is editing: its window's first responder is the view + * or the field editor working on its behalf — the AppKit shape of + * "keystrokes go to the embed". + */ + @JvmStatic + external fun nativeDiagViewIsEditing(nsView: Long): Boolean + + /** Whether [contentNsView] itself is its window's first responder — keystrokes go to Compose. */ + @JvmStatic + external fun nativeDiagViewIsFirstResponder(contentNsView: Long): Boolean + + /** The string value of a field from [nativeDiagCreateTextField], or null. */ + @JvmStatic + external fun nativeDiagTextFieldString(nsView: Long): String? + + /** A subview's frame in physical px with a top-left origin, as `[x, y, w, h]`, or null. */ + @JvmStatic + external fun nativeDiagViewFrame(nsView: Long): IntArray? } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsTextureBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsTextureBridge.kt index 99c281e83..9a4082280 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsTextureBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoMacOsTextureBridge.kt @@ -81,6 +81,19 @@ internal object NativeTaoMacOsTextureBridge { @JvmStatic external fun nativeDestroy(handle: Long) + /** + * `CFRetain` on a live `IOSurfaceRef`. Used by + * [dev.nucleusframework.window.tao.nucleusIOSurfaceTextureSource] so a + * producer close cannot free the surface while the source is still + * reachable. False when [ioSurfacePtr] is 0 or not an IOSurface. + */ + @JvmStatic + external fun nativeRetainIOSurface(ioSurfacePtr: Long): Boolean + + /** `CFRelease` matching a successful [nativeRetainIOSurface]. */ + @JvmStatic + external fun nativeReleaseIOSurface(ioSurfacePtr: Long) + // ---- Metal test producer (demos / smoke tests) -------------------- /** diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsDecoBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsDecoBridge.kt index a0b1a1101..7a5f4a0c5 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsDecoBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsDecoBridge.kt @@ -9,7 +9,7 @@ private const val LIBRARY_NAME = "nucleus_tao_windows_deco" * (client-area extension via `WM_NCCALCSIZE`, hit-test routing via * `WM_NCHITTEST`, DWM shadow via `DwmExtendFrameIntoClientArea`). * - * Mirrors the API of `decorated-window-jni`'s `JniWindowsDecorationBridge`, + * Mirrors the API of the legacy AWT backend's Windows decoration bridge, * minus the Skiko-AWT child-window plumbing (Tao renders into the HWND * directly via ANGLE). */ @@ -266,6 +266,15 @@ internal object NativeTaoWindowsDecoBridge { @JvmStatic external fun nativeGetPrimaryMonitorWorkArea(): LongArray? + /** + * Returns one descriptor per attached monitor + * (`EnumDisplayMonitors` + `GetMonitorInfoW`), encoded as documented in + * [dev.nucleusframework.window.tao.TaoMonitor]. `null` when the + * enumeration fails. + */ + @JvmStatic + external fun nativeGetMonitors(): Array? + /** * Returns the primary monitor's scale factor encoded as `(scale * 1000)`. * Falls back gracefully when `GetDpiForSystem` is unavailable. Used as a @@ -330,4 +339,16 @@ internal object NativeTaoWindowsDecoBridge { startScreenX: Int, startScreenY: Int, ): LongArray? + + /** + * Windows ClearType pixel geometry for Skia LCD text. + * + * `0` = font smoothing off or not ClearType, `1` = RGB_H, `2` = BGR_H. + */ + @JvmStatic + external fun nativeFontSmoothingPixelGeometry(): Int + + const val FONT_SMOOTHING_UNKNOWN: Int = 0 + const val FONT_SMOOTHING_RGB: Int = 1 + const val FONT_SMOOTHING_BGR: Int = 2 } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsNativeViewBridge.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsNativeViewBridge.kt index 948a2a9e4..ec5184900 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsNativeViewBridge.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/ffi/NativeTaoWindowsNativeViewBridge.kt @@ -14,6 +14,7 @@ private const val LIBRARY_NAME = "nucleus_tao_windows_native_view" * HWNDs instead of NSViews. All entry points must run on the Tao * main UI thread (= the thread that owns the parent HWND). */ +@Suppress("TooManyFunctions") internal object NativeTaoWindowsNativeViewBridge { val isLoaded: Boolean = NativeLibraryLoader.load(LIBRARY_NAME, NativeTaoWindowsNativeViewBridge::class.java) @@ -87,4 +88,65 @@ internal object NativeTaoWindowsNativeViewBridge { dx: Float, dy: Float, ) + + /** + * Hands Win32 keyboard focus back to [parentHwnd] when a descendant (an + * embedded child) holds it, and returns whether it did. Called after a + * press Compose kept, so the keyboard follows the click. + */ + @JvmStatic + external fun nativeClaimKeyboardForCompose(parentHwnd: Long): Boolean + + /** + * The mouse buttons this thread's queue holds down, as a mask: bit 0 + * left, bit 1 right, bit 2 middle. The truth behind a release a child + * HWND captured and Compose never saw. + */ + @JvmStatic + external fun nativeQueryPointerButtons(): Int + + /** + * Takes the mouse capture back from an embedded child of [parentHwnd], + * and returns whether it had one. A child that captures on a forwarded + * press would otherwise keep every later mouse message, leaving the whole + * Compose window unable to see the pointer. + */ + @JvmStatic + external fun nativeReleaseChildCapture(parentHwnd: Long): Boolean + + /** + * The pointer's position in [parentHwnd]'s client pixels, packed as + * `(x shl 32) or (y and 0xffffffff)`, or [Long.MIN_VALUE] when it cannot + * be read. Tao reports a button without one, and the move that would + * have carried it may never have reached the window. + */ + @JvmStatic + external fun nativeCursorPosInClient(parentHwnd: Long): Long + + // ── Diagnostics for the headful suite ───────────────────────────── + + /** + * A single-line `EDIT` control created as a hidden top-level window, + * for a headful case to embed through `NativeView` (whose attach + * turns it into a child of the Tao HWND). 0 on failure. Destroy with + * [nativeDiagDestroyWindow]. + */ + @JvmStatic + external fun nativeDiagCreateEdit(): Long + + /** `DestroyWindow` on a control from [nativeDiagCreateEdit]. */ + @JvmStatic + external fun nativeDiagDestroyWindow(hwnd: Long) + + /** The HWND holding Win32 keyboard focus on this thread's queue (`GetFocus`), or 0. */ + @JvmStatic + external fun nativeDiagFocusedHwnd(): Long + + /** The text of a control from [nativeDiagCreateEdit], or null. */ + @JvmStatic + external fun nativeDiagWindowText(hwnd: Long): String? + + /** A child's rect in its parent's client px, top-left origin, as `[x, y, w, h]`, or null. */ + @JvmStatic + external fun nativeDiagWindowFrame(hwnd: Long): IntArray? } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflate.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflate.kt new file mode 100644 index 000000000..b057659b9 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflate.kt @@ -0,0 +1,72 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntRect +import org.jetbrains.skia.Rect +import kotlin.math.ceil + +/** + * Margin, in dp, that a native popup layer's surface extends past + * `boundsInWindow` on every side, so that what Compose draws outside the + * layout rectangle is not clipped at the surface edge. + * + * `boundsInWindow` is the popup's *layout* rectangle. What Compose draws is + * routinely larger: a Material dialog or menu carries an elevation shadow + * (6 dp for an `AlertDialog`, 8 dp for a `DropdownMenu`, whose blur and + * offset reach roughly twice that), and `Dialog.skiko.kt` animates the dialog + * in from 10 dp below, scaled down and faded. An in-scene layer overflows into + * the window canvas for free; a separate OS surface clips at its own edge. + * + * The margin is a constant rather than a measurement. Compose Desktop's + * `WindowComposeSceneLayer` measures the drawn bounds with a picture + * recorder's R-tree, but since Compose 1.12 a scene draws through skiko + * `RenderNode`s — a single `drawDrawable` op whose bounds are unbounded — so + * that measurement only ever reports the whole canvas. 32 dp covers every + * Material elevation and the appearance animation with room to spare, and + * costs a constant fraction of the surface. + */ +internal const val POPUP_DRAW_MARGIN_DP: Float = 32f + +/** [POPUP_DRAW_MARGIN_DP] in physical pixels at [density] (px per dp). */ +internal fun popupDrawMarginPx(density: Float): Int = ceil(POPUP_DRAW_MARGIN_DP * density.coerceAtLeast(1f)).toInt() + +/** [bounds] inflated by [popupDrawMarginPx]: the rectangle the layer's surface must cover. */ +internal fun popupDrawBounds( + bounds: IntRect, + density: Float, +): IntRect { + val margin = popupDrawMarginPx(density) + return IntRect( + left = bounds.left - margin, + top = bounds.top - margin, + right = bounds.right + margin, + bottom = bounds.bottom + margin, + ) +} + +/** + * Cull rect for the picture the macOS layer records, in **scene** coordinates. + * + * The layer draws its inner scene in owner-window coordinates + * (`calculateLocalPosition` is the identity) and defers the translation into + * the surface to replay time, so the recorded content sits at + * [drawBounds]`.topLeft` — not at the picture's origin. `SkCanvas::drawPicture` + * quick-rejects against the picture's cull rect mapped by the current matrix, + * and the replay matrix is `translate(-drawBounds.topLeft)`: a cull rect rooted + * at the origin therefore maps to `-drawBounds.topLeft`, entirely off the + * drawable, and the whole picture is dropped. The rect has to follow the + * content. + * + * Skia only takes that path for a picture of more than one op, and a Compose + * scene records as exactly one (a skiko `RenderNode` drawable), so a bare popup + * happened to survive an origin-rooted rect. One dimmed by a dialog above it + * does not: the layer paints those scrims into the same picture + * ([PopupScrimRegistry.paintAbove]) and the frame is dropped whole. See + * `MacPopupPictureCullTest`. + */ +internal fun popupPictureCullRect(drawBounds: IntRect): Rect = + Rect.makeLTRB( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + drawBounds.right.toFloat(), + drawBounds.bottom.toFloat(), + ) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClamp.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClamp.kt new file mode 100644 index 000000000..137569207 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClamp.kt @@ -0,0 +1,70 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect + +/** + * Offset to add to [frameInParentPx] so the popup lands fully inside the work + * area of the display it belongs to, in the owner-window coordinate space the + * native `setFrame` calls take. + * + * Clamp, not flip: a popup pushed past an edge slides back in rather than + * re-opening on the other side of its anchor — the behaviour of most native + * menus, and the only one reachable without intercepting + * `PopupPositionProvider.calculatePosition` (which receives the anchor in + * window coordinates and cannot be told about a screen origin; see #569). + * + * Returns [IntOffset.Zero] — i.e. exactly the pre-#569 behaviour — whenever + * the platform cannot resolve the geometry ([geometry] is `null`, as on + * Wayland where popups are parent-relative subsurfaces with no global + * position), or the frame has no area yet. + */ +internal fun popupScreenClampOffset( + frameInParentPx: IntRect, + geometry: PopupScreenGeometry?, +): IntOffset { + if (geometry == null) return IntOffset.Zero + val width = frameInParentPx.width + val height = frameInParentPx.height + if (width <= 0 || height <= 0) return IntOffset.Zero + + val origin = geometry.parentContentOriginPx + val left = origin.x + frameInParentPx.left + val top = origin.y + frameInParentPx.top + val onScreen = IntRect(left = left, top = top, right = left + width, bottom = top + height) + val work = pickWorkArea(onScreen, origin, geometry.workAreasPx) ?: return IntOffset.Zero + + // `coerceAtMost` before `coerceAtLeast`: a popup taller or wider than the + // work area keeps its top-left visible (where a menu's first items and a + // tooltip's text are) instead of its bottom-right. + val clampedLeft = onScreen.left.coerceAtMost(work.right - width).coerceAtLeast(work.left) + val clampedTop = onScreen.top.coerceAtMost(work.bottom - height).coerceAtLeast(work.top) + return IntOffset(clampedLeft - onScreen.left, clampedTop - onScreen.top) +} + +/** + * The display [frame] belongs to: the one it overlaps most. A frame that + * overlaps nothing — the very case the clamp exists for — is attributed to the + * display hosting the owner window's content origin, so the popup slides back + * onto the display the user is looking at instead of the first one enumerated. + */ +private fun pickWorkArea( + frame: IntRect, + parentOrigin: IntOffset, + areas: List, +): IntRect? { + val usable = areas.filter { it.width > 0 && it.height > 0 } + if (usable.size <= 1) return usable.firstOrNull() + val best = usable.maxBy { overlapArea(it, frame) } + if (overlapArea(best, frame) > 0L) return best + return usable.firstOrNull { it.contains(parentOrigin) } ?: usable.first() +} + +private fun overlapArea( + a: IntRect, + b: IntRect, +): Long { + val width = (minOf(a.right, b.right) - maxOf(a.left, b.left)).coerceAtLeast(0) + val height = (minOf(a.bottom, b.bottom) - maxOf(a.top, b.top)).coerceAtLeast(0) + return width.toLong() * height.toLong() +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenGeometry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenGeometry.kt new file mode 100644 index 000000000..87d38731a --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenGeometry.kt @@ -0,0 +1,47 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect + +/** + * Screen geometry a native popup layer needs to place itself against the + * *display* rather than against its owner window (#569). + * + * Compose decides a popup's position entirely in window-rooted coordinates. + * `Popup.skiko.kt` flips and clips inside `[0, containerSize]`, where + * `containerSize` is whatever the layer's own composition reports through + * `LocalWindowInfo` — the layers answer with the work area, so a popup lays out + * at full size and flips against a screen-sized box. But that box is *rooted at + * the window's content top-left*: a virtual screen, correct only while the + * content origin happens to coincide with the work-area origin (roughly: + * maximized on the primary display). Everywhere else a `DropdownMenu` near the + * real screen edge lands offscreen. + * + * [popupScreenClampOffset] closes that gap at the single choke point where each + * layer pushes its native frame, using the two pieces of information the + * platform has but Compose never sees: where the owner's content sits on + * screen, and where the displays' work areas are. + * + * The window-rooted box has one consequence the clamp cannot undo: a popup can + * never be placed *above or left of* the owner's content origin, because + * `clipPosition` coerces the position into `[0, …]` there. Fixing that means + * intercepting `PopupPositionProvider.calculatePosition` (which receives the + * anchor in window coordinates), i.e. owning the `Popup` composable the way + * Jewel's `LocalPopupRenderer` does — see #569. + */ +internal class PopupScreenGeometry( + /** + * Owner window's **content** origin in global screen physical pixels, + * top-left origin — the same space [workAreasPx] is expressed in. This is + * the origin the layers' window-rooted frames are implicitly relative to. + */ + val parentContentOriginPx: IntOffset, + /** + * Work area (display minus taskbar / menu bar / dock / panels) of every + * attached display, in global screen physical pixels. A list rather than + * the owner's display alone: a popup anchored near the edge of a window + * that straddles two displays belongs to the display *it* lands on, which + * is not necessarily the one hosting the window's centre. + */ + val workAreasPx: List, +) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistry.kt new file mode 100644 index 000000000..a615fbc2f --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistry.kt @@ -0,0 +1,120 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import org.jetbrains.skia.BlendMode +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Rect + +/** + * The dialog scrims of a host window's native popup layers, in stacking order. + * + * A Compose `Dialog` never paints its own scrim: `Dialog.skiko.kt` writes + * `ComposeSceneLayer.scrimColor` and leaves the painting to whoever renders + * *underneath* the layer. Compose Desktop's `ComposeContainer.onRenderOverlay` + * paints every layer's scrim over the main window after the main scene, and + * `WindowComposeSceneLayer` paints the scrims of the layers above it into its + * own window, so a popup open under a dialog is dimmed too. With native popup + * layers each layer is a separate OS surface, so the same two passes are + * needed here: [paintAll] from the owner window's scene, [paintAbove] from + * each layer's scene. + * + * Registration order is stacking order: Compose creates layers bottom-up, and + * a layer registers itself in its constructor. + * + * Threading: main / event-loop thread only, like the layers themselves. Colors + * are read through a provider at paint time so a scrim set after registration + * (which is always: `scrimColor` is written during the dialog's composition) + * is picked up without re-registering. + */ +internal class PopupScrimRegistry( + /** + * Invoked when a layer's scrim changed. The host repaints the owner window + * — and marks its scene visually dirty: a scrim fade alone raises no layout + * or draw invalidation in that scene, and a host that skips presenting + * clean frames would otherwise never show it. + */ + private val onChanged: () -> Unit, +) { + private val scrims = LinkedHashMap Color?>() + + /** A layer's `scrimColor` changed; see [onChanged]. */ + fun notifyChanged() = onChanged() + + /** Adds [token]'s layer on top of the stack. Re-registering moves it to the top. */ + fun register( + token: Any, + color: () -> Color?, + ) { + scrims.remove(token) + scrims[token] = color + } + + /** + * Drops [token]'s layer. A layer that was still dimming when it went away + * changed the scrim stack, so this reports it like any other change: nobody + * below observes the registry, and a host that skips clean frames would + * otherwise leave the owner window dark until an unrelated invalidation. + */ + fun unregister(token: Any) { + val dimmed = scrims.remove(token)?.invoke() != null + if (dimmed) onChanged() + } + + /** The scrims of every registered layer, bottom-up. */ + fun all(): List = scrims.values.mapNotNull { it() } + + /** The scrims of the layers stacked above [token], bottom-up. */ + fun above(token: Any): List { + val out = ArrayList() + var seen = false + for ((key, color) in scrims) { + if (seen) color()?.let(out::add) + if (key == token) seen = true + } + return out + } + + /** + * Paints every scrim over [rect] — the owner window's whole surface. + * [transparent] selects the blend mode exactly as Compose's + * `getDialogScrimBlendMode` does: a per-pixel-alpha window must only darken + * what it drew (`SrcAtop`), an opaque one darkens everything (`SrcOver`). + */ + fun paintAll( + canvas: Canvas, + rect: Rect, + transparent: Boolean, + ) = paint(canvas, rect, transparent, all()) + + /** + * Paints the scrims of the layers above [token] over [rect] — the visible + * part of that layer's own surface. Popup surfaces are always per-pixel + * transparent, so the blend is `SrcAtop`. + */ + fun paintAbove( + token: Any, + canvas: Canvas, + rect: Rect, + ) = paint(canvas, rect, transparent = true, above(token)) + + private fun paint( + canvas: Canvas, + rect: Rect, + transparent: Boolean, + colors: List, + ) { + if (colors.isEmpty()) return + val paint = Paint() + try { + paint.blendMode = if (transparent) BlendMode.SRC_ATOP else BlendMode.SRC_OVER + for (color in colors) { + paint.color = color.toArgb() + canvas.drawRect(rect, paint) + } + } finally { + paint.close() + } + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt new file mode 100644 index 000000000..9c7f8ccaa --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupDiagnostics.kt @@ -0,0 +1,102 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import java.util.concurrent.atomic.AtomicReference + +/** + * One popup layer's positioning decision, as pushed to the platform. + * + * Carries both sides of the #569 split — what Compose decided + * ([boundsInWindowPx], window-rooted) and where the popup actually went + * ([frameOnScreenPx], global screen physical pixels) — so a test can assert + * not only that the popup is on screen but that the clamp is what put it + * there. + */ +internal class PopupFrameRecord( + /** `boundsInWindow` as Compose computed it, unclamped. Window-rooted physical px. */ + val boundsInWindowPx: IntRect, + /** + * The native surface's frame, in global screen physical px. Inflated past + * [contentOnScreenPx] by whatever the popup draws outside its layout + * bounds (shadows, the dialog appearance animation) — see + * [PopupDrawInflate]. + */ + val frameOnScreenPx: IntRect, + /** Where [boundsInWindowPx] landed, in global screen physical px: the popup as the user sees it. */ + val contentOnScreenPx: IntRect, + /** [popupScreenClampOffset]'s verdict — [IntOffset.Zero] when nothing had to move. */ + val clampOffsetPx: IntOffset, + /** + * The layer's native popup handle: a `PopupState*` on Windows, an + * `NSPanel*` on macOS, a [dev.nucleusframework.window.tao.TaoWindow] handle + * on Linux. Opaque here; a platform-specific test dereferences it to read + * the real on-screen rect back from the OS. + */ + val panelHandle: Long, +) + +/** + * Last frame every native popup layer pushed — the seam the headful suite + * asserts the #569 placement contract through ("a popup never lands outside + * the work area of the display it belongs to"). + * + * A native popup layer is not reachable from a test: Compose creates it inside + * the scene's render pass, and it owns a `WS_POPUP` HWND / `NSPanel` / + * override-redirect window nobody publishes. Recording the pushed frame at the + * choke point is the smallest seam that makes the real placement observable — + * and the *only* one that can tell an offscreen popup from a popup that just + * happened to be anchored somewhere safe, since `boundsInWindow` is + * deliberately left unclamped. + * + * Not reactive Compose state (unlike [dev.nucleusframework.window.tao.TaoDnDDiagnostics]): + * these writes happen on the popup's frame path, where a snapshot write would + * invalidate the very composition producing them. + */ +internal object TaoPopupDiagnostics { + private val last = AtomicReference(null) + + /** + * Most recently positioned popup layer. `null` until one pushes a real + * frame; never cleared by the layers, so a test can read it after the + * popup was dismissed. + */ + val lastFrame: PopupFrameRecord? get() = last.get() + + /** Frames pushed since the last [reset], clamped or not. */ + @Volatile + var frameCount: Int = 0 + private set + + fun record(record: PopupFrameRecord) { + last.set(record) + frameCount++ + } + + /** + * Whether the most recently placed Linux popup layer let the *compositor* + * position it (an `xdg_popup`, native Wayland) rather than placing itself. + * `null` until one is placed. The Wayland half of the #569 contract: there + * is no screen geometry to assert against there, so the placement decision + * is what a test can hold on to. + */ + @Volatile + var lastCompositorPlaced: Boolean? = null + + /** + * How many times the most recent run's compositor-placed layers anchored + * (`xdg_positioner`). More than one means a popup was re-mapped because its + * size changed after it was already on screen — the only way to keep the + * `xdg_surface` geometry and the EGL buffer agreeing, since GDK positions a + * popup once. + */ + @Volatile + var compositorAnchorCount: Int = 0 + + fun reset() { + last.set(null) + frameCount = 0 + lastCompositorPlaced = null + compositorAnchorCount = 0 + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt index cc184a7a9..981f959dd 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHost.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.WindowExceptionHandler @@ -34,6 +35,14 @@ internal interface TaoPopupHost { */ val parentWindowSize: IntSize + /** + * The owner window's live `WindowInfo`. Its `containerSize` is snapshot + * state, so a dialog that centres itself in it (`Dialog.skiko.kt` reads + * `LocalWindowInfo.current.containerSize`) re-measures when the window is + * resized — [parentWindowSize] is a plain read and would leave it frozen. + */ + val parentWindowInfo: WindowInfo + /** * Visible-frame size (screen minus menu bar + dock) of the NSScreen * hosting the owner window, in **physical pixels**. Used by popup @@ -51,6 +60,21 @@ internal interface TaoPopupHost { */ val workAreaSize: IntSize get() = parentWindowSize + /** + * Where the owner window sits on screen, and where the displays' work + * areas are — the origin [workAreaSize] deliberately throws away. + * + * [workAreaSize] gives the popup room to lay out at full size, but Compose + * then flips and clips inside that size *rooted at the window*, so the + * decision is made against a virtual screen rather than the real one. + * Layers use this to clamp their native frame back into the display's work + * area at the point they push it. `null` when the platform cannot resolve + * it (early init, no screen), which restores the unclamped behaviour. + * + * Read on every frame push; implementations must stay cheap. + */ + val popupScreenGeometry: PopupScreenGeometry? get() = null + /** Coroutine context to feed inner scenes (parent context + frame clock + flushing dispatcher). */ val sceneCoroutineContext: CoroutineContext @@ -81,6 +105,14 @@ internal interface TaoPopupHost { */ val isOwnerWindowTransparent: Boolean get() = false + /** + * The dialog scrims of this host's layers. A layer registers its + * `scrimColor` here for its whole lifetime; the host paints them all over + * the owner window's scene, and every layer paints the ones above it into + * its own surface — see [PopupScrimRegistry]. + */ + val popupScrims: PopupScrimRegistry + fun requestRedraw() /** @@ -97,6 +129,16 @@ internal interface TaoPopupHost { fun unregisterRenderer(token: Any) + /** + * A layer this host handed out has closed and must leave the host's live + * set. Compose closes a native popup layer only when the layer's own + * disappearance animation finishes; an owner window torn down before + * that would otherwise leave the layer's window mapped for good, so the + * host tracks its layers and closes the survivors on detach. + */ + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + fun onLayerClosed(layer: androidx.compose.ui.scene.ComposeSceneLayer) {} + /** * Runs [block] on the host's dedicated Metal render thread and blocks until * it returns. Overlay/popup surfaces must create, use, and close their Skia diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt index f6497d20f..9967a635d 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostLinux.kt @@ -1,8 +1,11 @@ package dev.nucleusframework.window.tao.popup import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.WindowExceptionHandler @@ -22,6 +25,7 @@ import kotlin.coroutines.CoroutineContext * Threading: every call must run on the Tao event-loop thread. */ @OptIn(ExperimentalComposeUiApi::class) +@Suppress("TooManyFunctions") internal interface TaoPopupHostLinux { /** Tao window hosting the main scene — the popup windows' `popupOf` parent. */ val parentWindow: TaoWindow @@ -39,6 +43,9 @@ internal interface TaoPopupHostLinux { /** Host window's content size in physical pixels. */ val parentWindowSize: IntSize + /** The owner window's live `WindowInfo` — see [TaoPopupHost.parentWindowInfo]. */ + val parentWindowInfo: WindowInfo + /** * Screen work area in physical pixels. Used as the inner scene's * layout size so a tall popup (DropdownMenu, expanded Tooltip) in a @@ -59,6 +66,17 @@ internal interface TaoPopupHostLinux { */ val parentScreenOriginPx: IntOffset + /** + * [parentScreenOriginPx] paired with every display's work area, so a layer + * can clamp its native frame into the real screen instead of the + * window-rooted virtual one Compose positions against. See + * [TaoPopupHost.popupScreenGeometry]. + * + * `null` on Wayland: a popup there is a `wl_subsurface` placed relative to + * the parent surface, and no global position exists to clamp against. + */ + val popupScreenGeometry: PopupScreenGeometry? get() = null + /** Coroutine context to feed inner scenes. */ val sceneCoroutineContext: CoroutineContext @@ -73,6 +91,9 @@ internal interface TaoPopupHostLinux { */ val coordinateOffset: IntOffset get() = IntOffset.Zero + /** The dialog scrims of this host's layers — see [TaoPopupHost.popupScrims]. */ + val popupScrims: PopupScrimRegistry + fun requestRedraw() /** @@ -89,6 +110,16 @@ internal interface TaoPopupHostLinux { fun unregisterRenderer(token: Any) + /** + * A layer this host handed out has closed and must leave the host's live + * set. Compose closes a native popup layer only when the layer's own + * disappearance animation finishes; an owner window torn down before + * that would otherwise leave the layer's window mapped for good, so the + * host tracks its layers and closes the survivors on detach. + */ + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + fun onLayerClosed(layer: androidx.compose.ui.scene.ComposeSceneLayer) {} + /** * Registers a key handler consulted by the host's `onKeyEvent` before * the main scene's dispatch. Popup windows never own keyboard focus on @@ -131,4 +162,41 @@ internal interface TaoPopupHostLinux { ) fun unregisterOutsidePressListener(token: Any) + + /** + * Delivers a pointer event that landed on a layer's **draw margin** to the + * owner window's scene, at [positionPx] in owner-window physical pixels. + * + * A layer's window is inflated past the popup's layout bounds so shadows + * and the appearance animation are not clipped ([popupDrawBounds]). That + * margin is transparent, but on Linux it is still the popup's window as far + * as the display server is concerned, so the press never reaches the owner + * — a click on a button beside an open menu would dismiss the menu and + * never press the button, and hovering past the menu's edge would freeze + * the owner's hover state. Windows and macOS get the pass-through from the + * OS (the layer hands it the *content* rect); GTK's own input shaping does + * not take on a popup toplevel, so the layer routes the event here instead. + * + * A [PointerEventType.Press] is expected to behave exactly like a press + * that reached the owner natively — including the outside-press listeners + * and the recompose between them and the dispatch. + */ + fun forwardMarginPointer( + eventType: PointerEventType, + positionPx: Offset, + button: PointerButton?, + ) + + /** + * Claims the parent's compositor-positioned popup for [token]. On native + * Wayland a popup layer that gets it maps as an `xdg_popup` the compositor + * keeps on screen ([TaoWindow.anchorPopupInParent]); an `xdg_popup` must be + * its parent's topmost popup and GDK refuses to map a second one, so only + * one layer at a time may take that path — the others stay subsurfaces. + * Returns `false` while another layer holds it. + */ + fun acquireCompositorPopup(token: Any): Boolean + + /** Releases [acquireCompositorPopup]'s claim; a no-op for a token that never held it. */ + fun releaseCompositorPopup(token: Any) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt index c45b40cec..3fa2cc921 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupHostWindows.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.compositionLocalOf import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.window.WindowExceptionHandler @@ -32,6 +33,9 @@ internal interface TaoPopupHostWindows { /** Host window's content size in physical pixels. */ val parentWindowSize: IntSize + /** The owner window's live `WindowInfo` — see [TaoPopupHost.parentWindowInfo]. */ + val parentWindowInfo: WindowInfo + /** * Screen work area in physical pixels. Used as the inner scene's * layout size so a tall popup (DropdownMenu, expanded Tooltip) in a @@ -42,6 +46,14 @@ internal interface TaoPopupHostWindows { */ val workAreaSize: IntSize get() = parentWindowSize + /** + * Owner client origin on screen + every display's work area, so a layer + * can clamp its native frame into the real screen instead of the + * window-rooted virtual one Compose positions against. See + * [TaoPopupHost.popupScreenGeometry]. + */ + val popupScreenGeometry: PopupScreenGeometry? get() = null + /** Coroutine context to feed inner scenes. */ val sceneCoroutineContext: CoroutineContext @@ -80,6 +92,9 @@ internal interface TaoPopupHostWindows { */ val hostDirectContext: DirectContext + /** The dialog scrims of this host's layers — see [TaoPopupHost.popupScrims]. */ + val popupScrims: PopupScrimRegistry + fun requestRedraw() /** @@ -133,6 +148,16 @@ internal interface TaoPopupHostWindows { fun unregisterRenderer(token: Any) + /** + * A layer this host handed out has closed and must leave the host's live + * set. Compose closes a native popup layer only when the layer's own + * disappearance animation finishes; an owner window torn down before + * that would otherwise leave the layer's window mapped for good, so the + * host tracks its layers and closes the survivors on detach. + */ + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + fun onLayerClosed(layer: androidx.compose.ui.scene.ComposeSceneLayer) {} + /** * Notify the host that a popup [TaoPopupSceneLayerWindows] is about * to close. Lets parent scenes (e.g., the [NativeView] overlay) clear diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt index feb709d90..f618d7639 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayer.kt @@ -4,6 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.CompositionLocalContext import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color @@ -12,6 +14,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -36,6 +39,7 @@ import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.catchExceptions import dev.nucleusframework.window.tao.scene.recordSceneToPicture import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Rect /** * `ComposeSceneLayer` implementation used by macOS overlay scenes to back @@ -64,13 +68,16 @@ import org.jetbrains.skia.DirectContext * what makes `MaterialTheme.colorScheme` etc. flow into the popup * content automatically. * - * Phase 3 deliberately omits: - * - `setOutsidePointerEventListener` — outside-click dismissal lands - * in Phase 4 (NSEvent local monitor on the parent window). - * - `setKeyEventListener` — key forwarding lands in Phase 4 too. - * - `scrimColor` — would need a third surface (full-window-sized - * overlay between main scene and popup). Not relevant for context - * menus / dropdowns. + * The frame pushed to the panel is clamped into the hosting display's work + * area ([popupScreenClampOffset], #569) every time `boundsInWindow` changes. + * Unlike the Windows and Linux layers there is no re-clamp on owner move: the + * panel is an AppKit child window and rides along with the NSWindow, so a + * window dragged past a screen edge with a popup already open takes it along + * — the same thing AppKit's own menus avoid by closing on window move. + * + * `scrimColor` is not painted here: a dialog's scrim covers what lies *under* + * the layer, so the owner window's scene paints every layer's scrim and each + * layer paints the ones of the layers above it — see [PopupScrimRegistry]. * * Threading: every method must run on the macOS main thread. */ @@ -86,7 +93,7 @@ internal class TaoPopupSceneLayer( private var _layoutDirection = initialLayoutDirection private var _focusable = initialFocusable private var _bounds: IntRect = IntRect.Zero - private var _scrimColor: Color? = null + private val scrimColorState: MutableState = mutableStateOf(null) private var _compositionLocalContext: CompositionLocalContext? = null private val rendererToken: Any = Any() @@ -107,6 +114,58 @@ internal class TaoPopupSceneLayer( IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) } + /** + * Compose's box for placing this layer's content, as reported through + * `LocalWindowInfo` inside the layer's own composition (#569). + * + * Two answers, because two very different things end up in a scene layer: + * + * - A **popup** (`Popup`, `DropdownMenu`, context menu, tooltip, Jewel's + * combo-box flyout) belongs to the *display*. It gets the work area + * ([sceneLayoutSize]), so `Popup.skiko.kt` lays it out at full size and + * flips it against a screen-sized box instead of against the owner + * window — the point of native popup layers. That box is still rooted at + * the window; the origin is what the screen clamp corrects when the + * frame is pushed. + * - A **dialog** (`Dialog`, Material `AlertDialog`) belongs to its + * *window*: `Dialog.skiko.kt` places it at `containerSize.center`, and a + * window-owned dialog centred on the display would sit visibly + * off-centre — and drift further as the user moved the window. It gets + * the owner window's content size, exactly as before #569. + * + * `scrimColor` is the discriminator, and a sound one: only + * `Dialog.skiko.kt` ever writes it, from + * `DialogAppearanceController.properties` — assigned while `DialogLayout` + * composes, *before* `layer.Content { }` and so before this is read. + * `Popup.skiko.kt` never touches it. Held as snapshot state so a later + * write recomposes the content that read it. + */ + private val dialogContainerSize: IntSize + get() = + host.parentWindowInfo.containerSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + + /** + * The rectangle the panel covers, in scene coordinates: [_bounds] inflated + * by [popupDrawBounds] so shadows and the dialog appearance animation are + * not clipped at the layout edge. The panel's interactive region stays + * [_bounds], so a click in the margin falls through to the parent window + * and reaches the outside-click monitor like any other outside click. + */ + private var drawBounds: IntRect = IntRect.Zero + + /** + * The last non-empty [_bounds]: what the native surface is sized and placed + * on. `Dialog.skiko.kt`'s disappearance swaps the layer's content for an + * empty `Layout` that only replays the recorded picture, so Compose reports + * a zero-size `boundsInWindow` at the window centre for the whole fade-out. + * An in-scene layer does not care — it draws into the window canvas — but + * this surface must keep covering where the dialog was, or the fade-out + * shows as a square of margin around a point. + */ + private var contentBounds: IntRect = IntRect.Zero + /** * Panel created at parent-window-size offscreen so the inner scene * has real layout constraints, while the user doesn't see a 1×1 @@ -173,11 +232,11 @@ internal class TaoPopupSceneLayer( /** * Inner scene at screen work-area size — see "measurement chicken- - * and-egg" in the class doc. The CAMetalLayer is sized to the popup's - * actual bounds (smaller); render writes scene content (positioned - * at 0,0 by `Popup.skiko.kt`'s `RootMeasurePolicy`) into the smaller - * surface — content fits because the popup framework lays out at - * `IntSize(widthPx, heightPx)` matching `boundsInWindow.size`. + * and-egg" in the class doc. The CAMetalLayer is sized to [drawBounds] + * (smaller); the scene is laid out in window coordinates + * ([calculateLocalPosition] is the identity) and replayed into the + * surface translated by `-drawBounds.topLeft`, the same model as the + * Windows and Linux layers. * * Custom WindowInfo with `isWindowFocused = true`. Compose's * `BasicTextField` (and other focus-aware widgets) gate the visible @@ -197,7 +256,8 @@ internal class TaoPopupSceneLayer( private val popupWindowInfo: androidx.compose.ui.platform.WindowInfo = object : androidx.compose.ui.platform.WindowInfo { override val isWindowFocused: Boolean = true - override val containerSize: IntSize get() = sceneLayoutSize + override val containerSize: IntSize + get() = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize } private val sceneBundle: TaoSceneBundle = @@ -226,10 +286,41 @@ internal class TaoPopupSceneLayer( ).apply { // Report through the owner window's channel — see [TaoPopupHost.exceptionHandler]. exceptionHandler = host.exceptionHandler + // Dim this popup under the dialogs stacked above it. The scene draws + // at the panel's own top-left, so the visible surface is the origin + // plus the drawable size. + renderOverlay = { canvas -> + host.popupScrims.paintAbove( + rendererToken, + canvas, + Rect.makeXYWH( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + widthPx.toFloat(), + heightPx.toFloat(), + ), + ) + } } private val innerScene: ComposeScene get() = sceneBundle.scene + /** + * Keeps the inner scene's size on the box the layer's content lays out in + * (#569). A dialog's root `Layout` fills the scene's constraints, and + * `Dialog.skiko.kt` puts its appearance animation's `GraphicsLayer` on + * that very Layout — so the scale pivots around the *scene's* centre. In + * the window's own scene that box is the window, whose centre is the + * dialog's; a work-area-sized scene would make the dialog slide towards + * the display's centre while it scales in. Popups keep the work area so a + * tall menu can lay out at full height. Re-checked every frame: the window + * may have been resized since. + */ + private fun syncSceneSize() { + val want = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize + if (innerScene.size != want) innerScene.size = want + } + // Wheel → Scroll, trackpad gesture → Pan, same as the window host (#654). private val scrollRouter = TaoSceneScrollRouter( @@ -285,7 +376,7 @@ internal class TaoPopupSceneLayer( if (eventType == PointerEventType.Press) scrollRouter.finishPan() innerScene.sendPointerEvent( eventType = eventType, - position = Offset(x, y), + position = scenePosition(x, y), type = PointerType.Mouse, button = pointerButton, ) @@ -337,7 +428,9 @@ internal class TaoPopupSceneLayer( init { NativeMetalBridge.nativeResize(attachmentHandle, widthPx, heightPx, scale) PopupNativeBridge.nativeSetEventCallback(panelHandle, PopupEventCallback()) + PopupNativeBridge.nativeSetRegionHitTestEnabled(panelHandle, true) host.registerRenderer(rendererToken) { recordSurface() } + host.popupScrims.register(rendererToken) { scrimColorState.value } } // ── ComposeSceneLayer surface ────────────────────────────────────── @@ -360,32 +453,82 @@ internal class TaoPopupSceneLayer( get() = _bounds set(value) { _bounds = value - // `value` is in the parent scene's coordinate system - // (top-left origin). For host-window-rooted scenes the offset - // is zero; for `NativeView`'s overlay scene it is the overlay's - // own position within the host NSWindow. - val offset = host.coordinateOffset - PopupNativeBridge.nativeSetFrameInWindow( - panel = panelHandle, - xPx = value.left + offset.x, - yPx = value.top + offset.y, - widthPx = value.width.coerceAtLeast(1), - heightPx = value.height.coerceAtLeast(1), - ) - // Resize the CAMetalLayer's drawable to match the popup's - // actual size. We DON'T resize the inner scene — its size - // stays at parent window size so layout has real constraints. - // Only the visible draw area is constrained to `boundsInWindow`. - val w = value.width.coerceAtLeast(1) - val h = value.height.coerceAtLeast(1) - if (w != widthPx || h != heightPx) { - widthPx = w - heightPx = h - NativeMetalBridge.nativeResize(attachmentHandle, w, h, scale) - } + if (!value.isEmpty) contentBounds = value + updateNativeFrame() host.requestRedraw() } + /** + * Pushes the panel frame — [drawBounds], screen-clamped (#569). + * + * `boundsInWindow` is in the parent scene's coordinate system (top-left + * origin). For host-window-rooted scenes [TaoPopupHost.coordinateOffset] + * is zero; for `NativeView`'s overlay scene it is the overlay's own + * position within the host NSWindow. + * + * The clamp is decided on the content, not the inflated surface: what must + * stay on screen is the popup the user sees, and a shadow margin hanging + * past the edge is what the in-scene layer does too. Only the panel's frame + * moves — [_bounds] and [drawBounds] stay what Compose believes, which is + * what the scene draws in and what [scenePosition] maps pointers back to. + */ + private fun updateNativeFrame() { + if (contentBounds.isEmpty || disposed) return + drawBounds = popupDrawBounds(contentBounds, _density.density) + val offset = host.coordinateOffset + val contentInParent = contentBounds.translate(offset) + val frameInParent = drawBounds.translate(offset) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(contentInParent, geometry) + val w = drawBounds.width.coerceAtLeast(1) + val h = drawBounds.height.coerceAtLeast(1) + PopupNativeBridge.nativeSetFrameInWindow( + panel = panelHandle, + xPx = frameInParent.left + clamp.x, + yPx = frameInParent.top + clamp.y, + widthPx = w, + heightPx = h, + ) + // Only the content answers hit-tests; the inflated margin falls through + // to the parent window — where the outside-click monitor picks it up. + PopupNativeBridge.nativeSetInteractiveRegions( + panelHandle, + floatArrayOf( + (contentBounds.left - drawBounds.left).toFloat(), + (contentBounds.top - drawBounds.top).toFloat(), + contentBounds.width.toFloat(), + contentBounds.height.toFloat(), + ), + 1, + ) + geometry?.let { + val onScreen = it.parentContentOriginPx + clamp + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), + clampOffsetPx = clamp, + panelHandle = panelHandle, + ), + ) + } + // Resize the CAMetalLayer's drawable to match the surface. We DON'T + // resize the inner scene — its size stays at work-area size so layout + // has real constraints. Only the visible draw area follows [drawBounds]. + if (w != widthPx || h != heightPx) { + widthPx = w + heightPx = h + NativeMetalBridge.nativeResize(attachmentHandle, w, h, scale) + } + } + + /** Panel-local physical px → inner-scene (parent-window) coordinates. */ + private fun scenePosition( + x: Float, + y: Float, + ): Offset = Offset(x + drawBounds.left, y + drawBounds.top) + override var compositionLocalContext: CompositionLocalContext? get() = _compositionLocalContext set(value) { @@ -393,9 +536,13 @@ internal class TaoPopupSceneLayer( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value // TODO Phase 4: third surface + scrimColorState.value = value + syncSceneSize() + // The scrim is painted by the owner window's scene and by the layers + // below, none of which observe this state — repaint them. + host.popupScrims.notifyChanged() } override var focusable: Boolean @@ -417,7 +564,14 @@ internal class TaoPopupSceneLayer( } override fun close() { + // Idempotent: an owner torn down mid-animation closes its surviving + // layers itself (see the host's detach sweep), and Compose then + // disposes the same layer as the composition unwinds. A second pass + // would release the native panel twice. + if (disposed) return host.unregisterRenderer(rendererToken) + host.onLayerClosed(this) + host.popupScrims.unregister(rendererToken) // Mark disposed before any teardown so a surface already recorded this // frame is skipped at replay time (TaoRecordedSurface.isAlive). disposed = true @@ -461,7 +615,19 @@ internal class TaoPopupSceneLayer( // Our texture host goes *inside* the replayed locals: those carry // the window scene's host, which would otherwise shadow ours. val body: @Composable () -> Unit = { - CompositionLocalProvider(LocalTaoMetalTextureHost provides metalTextureHost) { + CompositionLocalProvider( + LocalTaoMetalTextureHost provides metalTextureHost, + // Inside the replayed parent locals, and deliberately so + // (#569): `Popup.skiko.kt` reads `LocalWindowInfo` from + // *this* composition to size the box it flips and clips the + // popup inside. The replayed snapshot carries the owner + // window's WindowInfo, which would pin every popup to the + // window — the opposite of what native popup layers exist + // for. `popupWindowInfo` reports the work area, so Compose + // flips against a screen-sized box (still rooted at the + // window; the origin is what the clamp corrects). + LocalWindowInfo provides popupWindowInfo, + ) { content() } } @@ -493,14 +659,10 @@ internal class TaoPopupSceneLayer( } } - override fun calculateLocalPosition(positionInWindow: IntOffset): IntOffset { - // boundsInWindow is in parent-window pixels with a top-left origin; - // popup-local = position - bounds.topLeft. - return IntOffset( - positionInWindow.x - _bounds.left, - positionInWindow.y - _bounds.top, - ) - } + // The scene is laid out in parent-window coordinates and translated at + // replay time (see [recordSurface]), so the popup-local position is the + // window position itself — same contract as the Windows and Linux layers. + override fun calculateLocalPosition(positionInWindow: IntOffset): IntOffset = positionInWindow // ── Per-frame record — driven by host's record pass (main thread) ────── @@ -513,12 +675,16 @@ internal class TaoPopupSceneLayer( if (disposed) return null if (widthPx <= 0 || heightPx <= 0) return null if (attachmentHandle == 0L) return null + syncSceneSize() + // The scene is recorded in window coordinates and replayed translated + // into the surface, which is rooted at [drawBounds]. return TaoRecordedSurface( attachmentHandle = attachmentHandle, directContext = directContext, - picture = recordSceneToPicture(sceneBundle, widthPx, heightPx), + picture = recordSceneToPicture(sceneBundle, widthPx, heightPx, cullRect = popupPictureCullRect(drawBounds)), clearColor = 0x00000000, isAlive = { !disposed }, + pictureOffset = IntOffset(-drawBounds.left, -drawBounds.top), ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt index 154af8378..f0e18f314 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerLinux.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -21,6 +22,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.round import dev.nucleusframework.window.tao.TaoApplication import dev.nucleusframework.window.tao.TaoMouseButton import dev.nucleusframework.window.tao.TaoWindow @@ -42,7 +44,10 @@ import dev.nucleusframework.window.tao.scene.renderGlFrame import dev.nucleusframework.window.tao.scene.withEglContextCurrent import org.jetbrains.skia.DirectContext import org.jetbrains.skia.GLAssembledInterface +import org.jetbrains.skia.Rect import org.jetbrains.skia.makeGLWithInterface +import java.util.logging.Level +import java.util.logging.Logger import kotlin.math.roundToInt /** @@ -93,7 +98,7 @@ internal class TaoPopupSceneLayerLinux( private var _layoutDirection = initialLayoutDirection private var _focusable = initialFocusable private var _bounds: IntRect = IntRect.Zero - private var _scrimColor: Color? = null + private val scrimColorState: MutableState = mutableStateOf(null) private var _compositionLocalContext: CompositionLocalContext? = null private val rendererToken: Any = Any() @@ -109,6 +114,32 @@ internal class TaoPopupSceneLayerLinux( */ private var released = false + /** + * The rectangle the popup window covers, in scene coordinates: [_bounds] + * inflated by [popupDrawBounds] so shadows and the dialog appearance + * animation are not clipped at the layout edge. A press in the margin is + * an outside press — see [sendPointer]. + */ + private var drawBounds: IntRect = IntRect.Zero + + /** + * The last non-empty [_bounds]: what the native surface is sized and placed + * on. `Dialog.skiko.kt`'s disappearance swaps the layer's content for an + * empty `Layout` that only replays the recorded picture, so Compose reports + * a zero-size `boundsInWindow` at the window centre for the whole fade-out. + * An in-scene layer does not care — it draws into the window canvas — but + * this surface must keep covering where the dialog was, or the fade-out + * shows as a square of margin around a point. + */ + private var contentBounds: IntRect = IntRect.Zero + + /** + * Whether the compositor positions this surface (`xdg_popup`) instead of us + * (`wl_subsurface`) — see [decideCompositorPlacement]. Decided at the first + * frame, since a window's map type cannot change afterwards. + */ + private var compositorPlaced: Boolean? = null + /** EGL attachment ready — flips on WINDOW_READY once the GPU side is up. */ private var attachment: Long = 0 private var directContext: DirectContext? = null @@ -142,6 +173,38 @@ internal class TaoPopupSceneLayerLinux( IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) } + /** + * Compose's box for placing this layer's content, as reported through + * `LocalWindowInfo` inside the layer's own composition (#569). + * + * Two answers, because two very different things end up in a scene layer: + * + * - A **popup** (`Popup`, `DropdownMenu`, context menu, tooltip, Jewel's + * combo-box flyout) belongs to the *display*. It gets the work area + * ([sceneLayoutSize]), so `Popup.skiko.kt` lays it out at full size and + * flips it against a screen-sized box instead of against the owner + * window — the point of native popup layers. That box is still rooted at + * the window; the origin is what the screen clamp corrects when the + * frame is pushed. + * - A **dialog** (`Dialog`, Material `AlertDialog`) belongs to its + * *window*: `Dialog.skiko.kt` places it at `containerSize.center`, and a + * window-owned dialog centred on the display would sit visibly + * off-centre — and drift further as the user moved the window. It gets + * the owner window's content size, exactly as before #569. + * + * `scrimColor` is the discriminator, and a sound one: only + * `Dialog.skiko.kt` ever writes it, from + * `DialogAppearanceController.properties` — assigned while `DialogLayout` + * composes, *before* `layer.Content { }` and so before this is read. + * `Popup.skiko.kt` never touches it. Held as snapshot state so a later + * write recomposes the content that read it. + */ + private val dialogContainerSize: IntSize + get() = + host.parentWindowInfo.containerSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + /** * Physical size of the popup's native surface and render target. Always * a multiple of [bufferScale]; the content occupies its top-left and the @@ -170,7 +233,8 @@ internal class TaoPopupSceneLayerLinux( private val popupWindowInfo: androidx.compose.ui.platform.WindowInfo = object : androidx.compose.ui.platform.WindowInfo { override val isWindowFocused: Boolean = true - override val containerSize: IntSize get() = sceneLayoutSize + override val containerSize: IntSize + get() = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize } private val sceneBundle: TaoSceneBundle = @@ -194,7 +258,7 @@ internal class TaoPopupSceneLayerLinux( override fun setPointerIcon(pointerIcon: PointerIcon) { if (released) return - NativeTaoBridge.nativeSetCursorIcon( + NativeTaoBridge.setCursorIcon( popupWindow.handle, pointerIcon.toTaoCursorIconCode(), ) @@ -204,23 +268,59 @@ internal class TaoPopupSceneLayerLinux( ).apply { // Report through the owner window's channel — see [TaoPopupHost.exceptionHandler]. exceptionHandler = host.exceptionHandler + // Dim this popup under the dialogs stacked above it. The canvas is + // translated by `-_bounds.topLeft` at this point, so the visible + // surface is `_bounds.topLeft` + the surface size in scene coordinates. + renderOverlay = { canvas -> + host.popupScrims.paintAbove( + rendererToken, + canvas, + Rect.makeXYWH( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + widthPx.toFloat(), + heightPx.toFloat(), + ), + ) + } } private val innerScene: ComposeScene get() = sceneBundle.scene + /** + * Keeps the inner scene's size on the box the layer's content lays out in + * (#569). A dialog's root `Layout` fills the scene's constraints, and + * `Dialog.skiko.kt` puts its appearance animation's `GraphicsLayer` on + * that very Layout — so the scale pivots around the *scene's* centre. In + * the window's own scene that box is the window, whose centre is the + * dialog's; a work-area-sized scene would make the dialog slide towards + * the display's centre while it scales in. Popups keep the work area so a + * tall menu can lay out at full height. Re-checked every frame: the window + * may have been resized since. + */ + private fun syncSceneSize() { + val want = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize + if (innerScene.size != want) innerScene.size = want + } + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null init { - popupWindow.onWindowReady { _, _ -> attachGpu() } + trace { "created popup window ${popupWindow.handle} focusable=$_focusable" } + popupWindow.onWindowReady { _, _ -> + trace { "window ready" } + attachGpu() + } // Compositor expose (X11) / re-map: repaint through the host pump. popupWindow.onRedrawRequested { host.requestRedraw() } registerInput() host.registerRenderer(rendererToken) { renderFrame() } + host.popupScrims.register(rendererToken) { scrimColorState.value } host.registerKeyHandler(keyHandlerToken) { dispatchKey(it) } host.registerOwnerMoveListener(moveListenerToken) { - if (_bounds != IntRect.Zero) updateNativeFrame() + if (!contentBounds.isEmpty) updateNativeFrame() } } @@ -276,6 +376,7 @@ internal class TaoPopupSceneLayerLinux( } attachment = handle directContext = ctx + trace { "gpu attached kind=$kind ${w}x$h" } glTextureHostState.value = object : TaoGlTextureHost { override val directContext: DirectContext = ctx @@ -285,7 +386,12 @@ internal class TaoPopupSceneLayerLinux( override fun withContextCurrent(block: () -> T): T? = withEglContextCurrent(attachment, block) } // Re-push any frame set before the window was ready, and paint. - if (_bounds != IntRect.Zero) updateNativeFrame() + if (!contentBounds.isEmpty) updateNativeFrame() + // Paint now, not on the owner's next frame: this first render is what + // measures the content and writes boundsInWindow, i.e. what shows the + // popup at all — waiting for the owner's redraw added a frame or two to + // every menu. The present itself still rides the owner's pump. + renderFrame() host.requestRedraw() } @@ -308,7 +414,9 @@ internal class TaoPopupSceneLayerLinux( override var boundsInWindow: IntRect get() = _bounds set(value) { + trace { "boundsInWindow=$value" } _bounds = value + if (!value.isEmpty) contentBounds = value updateNativeFrame() host.requestRedraw() } @@ -320,9 +428,13 @@ internal class TaoPopupSceneLayerLinux( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value + scrimColorState.value = value + syncSceneSize() + // The scrim is painted by the owner window's scene and by the layers + // below, none of which observe this state — repaint them. + host.popupScrims.notifyChanged() } override var focusable: Boolean @@ -339,10 +451,14 @@ internal class TaoPopupSceneLayerLinux( override fun close() { if (released) return released = true + trace { "close" } host.unregisterRenderer(rendererToken) + host.onLayerClosed(this) + host.popupScrims.unregister(rendererToken) host.unregisterKeyHandler(keyHandlerToken) host.unregisterOwnerMoveListener(moveListenerToken) host.unregisterOutsidePressListener(outsidePressToken) + host.releaseCompositorPopup(rendererToken) // Drop the TextureView handle before the context it points at dies: a // late composition must not import onto a closed context. glTextureHostState.value = null @@ -382,7 +498,19 @@ internal class TaoPopupSceneLayerLinux( // this popup window renders through its own EGL + Skia context, so // a TextureView here must import onto that one. val body: @Composable () -> Unit = { - CompositionLocalProvider(LocalTaoGlTextureHost provides glTextureHost) { + CompositionLocalProvider( + LocalTaoGlTextureHost provides glTextureHost, + // Inside the replayed parent locals, and deliberately so + // (#569): `Popup.skiko.kt` reads `LocalWindowInfo` from + // *this* composition to size the box it flips and clips the + // popup inside. The replayed snapshot carries the owner + // window's WindowInfo, which would pin every popup to the + // window — the opposite of what native popup layers exist + // for. `popupWindowInfo` reports the work area, so Compose + // flips against a screen-sized box (still rooted at the + // window; the origin is what the clamp corrects). + LocalWindowInfo provides popupWindowInfo, + ) { content() } } @@ -428,24 +556,96 @@ internal class TaoPopupSceneLayerLinux( * CSD content origin for `popupOf` windows, so we pass content-space * coords here ([TaoPopupHostLinux.parentScreenOriginPx] is zero on * Wayland). + * + * The position is clamped into the hosting display's work area + * ([popupScreenClampOffset], #569) — Compose picked it inside a + * work-area-sized virtual screen rooted at the window, so it can point off + * the real display. Only the window position moves: `_bounds` stays what + * Compose believes, and it is also the space [renderFrame] translates by + * and [scenePosition] maps pointers back through, so the surface content + * and hit-testing are unaffected. Re-clamped on every call, so the + * owner-move listener keeps an open popup on screen during an X11 drag. + * No-op on Wayland, where the host reports no screen geometry. */ private fun updateNativeFrame() { - if (_bounds == IntRect.Zero || released) return + if (contentBounds.isEmpty || released) return + drawBounds = popupDrawBounds(contentBounds, _density.density) val origin = host.parentScreenOriginPx val offset = host.coordinateOffset - val xPx = _bounds.left + offset.x + origin.x - val yPx = _bounds.top + offset.y + origin.y + // The clamp is decided on the content, not the inflated surface: what + // must stay on screen is the popup the user sees, and a shadow margin + // hanging past the edge is what the in-scene layer does too. + val contentInParent = contentBounds.translate(offset) + val frameInParent = drawBounds.translate(offset) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(contentInParent, geometry) + val xPx = frameInParent.left + clamp.x + origin.x + val yPx = frameInParent.top + clamp.y + origin.y + geometry?.let { + val onScreen = it.parentContentOriginPx + clamp + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), + clampOffsetPx = clamp, + panelHandle = popupWindow.handle, + ), + ) + } // Aligned to the surface scale: Compose bounds are arbitrary physical // pixels (odd widths come out of text measurement and half-dp padding // all the time), and a buffer that isn't a multiple of the announced // `buffer_scale` is a fatal Wayland protocol error — the compositor // drops the connection and the process dies (#502). It also keeps the // logical size below an exact integer for GTK. - val w = alignToBufferScale(_bounds.width, bufferScale) - val h = alignToBufferScale(_bounds.height, bufferScale) - popupWindow.setOuterPosition((xPx / scale).toDouble(), (yPx / scale).toDouble()) - popupWindow.setInnerSize((w / scale).toDouble(), (h / scale).toDouble()) - if (w != widthPx || h != heightPx) { + val w = alignToBufferScale(drawBounds.width, bufferScale) + val h = alignToBufferScale(drawBounds.height, bufferScale) + val compositorPlaced = + compositorPlaced ?: decideCompositorPlacement(geometry).also { + compositorPlaced = it + TaoPopupDiagnostics.lastCompositorPlaced = it + } + trace { + "push frame pos=($xPx,$yPx) size=${w}x$h shown=$shown attached=${attachment != 0L} " + + "compositorPlaced=$compositorPlaced" + } + val sizeChanged = w != widthPx || h != heightPx + if (compositorPlaced) { + // The compositor owns the position from map on, and GDK builds the + // `xdg_positioner` once, from the window's geometry as it stands at + // map — so the anchor call carries the size as well, and a plain + // move or resize afterwards would re-map the window as a + // subsurface. A size that changes after the popup is mapped (a menu + // whose items measure late) therefore cannot be applied in place: + // resizing the EGL buffer alone would leave the `xdg_surface` + // geometry at the anchored size, which is the buffer/geometry + // disagreement of #502. Re-map instead — hide, re-anchor at the new + // size, show — which is also what re-runs the compositor's flip for + // the size it now has. + if (!shown || sizeChanged) { + if (shown) { + trace { "re-anchor ${widthPx}x$heightPx -> ${w}x$h" } + popupWindow.hide() + shown = false + } + popupWindow.anchorPopupInParent( + contentXDp = contentInParent.left / scale.toDouble(), + contentYDp = contentInParent.top / scale.toDouble(), + widthDp = (w / scale).toDouble(), + heightDp = (h / scale).toDouble(), + shadowLeftDp = ((contentBounds.left - drawBounds.left) / scale).roundToInt(), + shadowTopDp = ((contentBounds.top - drawBounds.top) / scale).roundToInt(), + shadowRightDp = ((drawBounds.right - contentBounds.right) / scale).roundToInt(), + shadowBottomDp = ((drawBounds.bottom - contentBounds.bottom) / scale).roundToInt(), + ) + TaoPopupDiagnostics.compositorAnchorCount++ + } + } else { + popupWindow.setOuterPosition((xPx / scale).toDouble(), (yPx / scale).toDouble()) + popupWindow.setInnerSize((w / scale).toDouble(), (h / scale).toDouble()) + } + if (sizeChanged) { widthPx = w heightPx = h if (attachment != 0L) { @@ -454,12 +654,30 @@ internal class TaoPopupSceneLayerLinux( } if (!shown) { shown = true + trace { "show" } popupWindow.show() } } + /** + * Whether the compositor should place this surface — an `xdg_popup` it + * keeps on screen — rather than us. Only on native Wayland, the one + * backend where the client cannot see the screen and so cannot clamp (X11 + * has [popupScreenClampOffset]); only for popups, since a dialog belongs + * to its window and stays centred in it as a subsurface; and one per + * parent, because an `xdg_popup` must be its parent's topmost popup + * ([TaoPopupHostLinux.acquireCompositorPopup]). + */ + private fun decideCompositorPlacement(geometry: PopupScreenGeometry?): Boolean = + geometry == null && + popupWindow.parentIsNativeWayland() && + scrimColorState.value == null && + host.acquireCompositorPopup(rendererToken) + // ── Per-frame render — driven by the host's redraw pump ─────────────── + private var presented = false + private fun renderFrame() { if (released || attachment == 0L) return if (widthPx <= 0 || heightPx <= 0) return @@ -471,7 +689,8 @@ internal class TaoPopupSceneLayerLinux( // the popup at zero bounds forever. Same bootstrap as the Windows // layer's 1×1 initial drawBounds. The present is skipped until the // frame is real; nothing is on screen yet anyway. - val frame = _bounds + syncSceneSize() + val frame = drawBounds NativeTaoEglBridge.nativeMakeCurrent(attachment) // Private EGL context — no resetGLAll needed (unlike the Windows // shared-process-context path). @@ -480,8 +699,17 @@ internal class TaoPopupSceneLayerLinux( heightPx = heightPx, directContext = ctx, clearColorArgb = 0x00000000, + // Per-pixel-alpha popup surface (no-op on Linux today, but the + // alpha mode must be stated — see renderGlFrame). + windowTransparent = true, present = { - if (frame != IntRect.Zero) NativeTaoEglBridge.nativePresent(attachment) + if (frame != IntRect.Zero) { + if (!presented) { + presented = true + trace { "first present frame=$frame" } + } + NativeTaoEglBridge.nativePresent(attachment) + } }, ) { canvas, nanoTime -> canvas.save() @@ -537,25 +765,64 @@ internal class TaoPopupSceneLayerLinux( if (released) return@catchExceptions lastX = xPx lastY = yPx + val position = scenePosition(xPx, yPx) + // The window is inflated past the layout bounds (see [drawBounds]), and + // that margin lands on this window rather than the parent — on Windows + // and macOS the OS routes it to the parent, because those layers hand + // it the content rect. Here the layer has to do the routing: report the + // outside press (Compose's dismiss-on-click-outside) and hand the event + // to the owner window, so a click on a button beside an open menu both + // closes the menu and presses the button. + if (!_bounds.contains(position.round())) { + if (eventType == PointerEventType.Press) onOutsidePointerEvent?.invoke(eventType, button) + forwardToOwner(eventType, position, button) + return@catchExceptions + } innerScene.sendPointerEvent( eventType = eventType, - position = scenePosition(xPx, yPx), + position = position, type = PointerType.Mouse, keyboardModifiers = taoKeyboardModifiers(host.parentWindow.modifierState), button = button, ) } + /** + * Hands the owner window an event that landed on this popup's draw margin. + * + * Only while the point is over the owner's content: the margin can hang off + * the window (a menu opened at its edge), and a press over another window — + * or another application — is not the owner's to receive. Compose would + * simply hit-test nothing there, but forwarding it would still run the + * dismissal twice and report a press the user never made to that window. + */ + private fun forwardToOwner( + eventType: PointerEventType, + position: Offset, + button: PointerButton?, + ) { + val size = host.parentWindowSize + val inOwner = + position.x >= 0f && + position.y >= 0f && + position.x < size.width && + position.y < size.height + if (!inOwner) return + host.forwardMarginPointer(eventType, position, button) + } + /** Popup-window-local physical px → inner-scene (parent-window) coords. */ private fun scenePosition( x: Float, y: Float, - ): Offset = Offset(x + _bounds.left, y + _bounds.top) + ): Offset = Offset(x + drawBounds.left, y + drawBounds.top) private fun mapButton(code: Int): PointerButton = when (code) { TaoMouseButton.RIGHT -> PointerButton.Secondary TaoMouseButton.MIDDLE -> PointerButton.Tertiary + TaoMouseButton.BACK -> PointerButton.Back + TaoMouseButton.FORWARD -> PointerButton.Forward else -> PointerButton.Primary } @@ -572,7 +839,13 @@ internal class TaoPopupSceneLayerLinux( return onKeyEvent?.invoke(event) == true } + private fun trace(message: () -> String) { + if (logger.isLoggable(Level.FINE)) logger.fine("popup ${System.identityHashCode(this)}: ${message()}") + } + private companion object { + private val logger: Logger = Logger.getLogger(TaoPopupSceneLayerLinux::class.java.name) + // Wire scale — must match Rust `CURSOR_FIXED_SCALE`. private const val POSITION_SCALE: Float = 1024f diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt index 42ae46182..9fba495bd 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoPopupSceneLayerWindows.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density @@ -33,6 +34,7 @@ import dev.nucleusframework.window.tao.scene.canvasLayersSceneBundle import dev.nucleusframework.window.tao.scene.catchExceptions import dev.nucleusframework.window.tao.scene.renderGlFrame import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Rect /** * Windows popup layer backed by a transparent owned WS_POPUP HWND. @@ -68,7 +70,7 @@ internal class TaoPopupSceneLayerWindows( private val layoutDirectionState: MutableState = mutableStateOf(initialLayoutDirection) private var _focusable = initialFocusable private var _bounds: IntRect = IntRect.Zero - private var _scrimColor: Color? = null + private val scrimColorState: MutableState = mutableStateOf(null) private var _compositionLocalContext: CompositionLocalContext? = null private val rendererToken: Any = Any() @@ -93,7 +95,57 @@ internal class TaoPopupSceneLayerWindows( host.workAreaSize.let { IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) } + + /** + * Compose's box for placing this layer's content, as reported through + * `LocalWindowInfo` inside the layer's own composition (#569). + * + * Two answers, because two very different things end up in a scene layer: + * + * - A **popup** (`Popup`, `DropdownMenu`, context menu, tooltip, Jewel's + * combo-box flyout) belongs to the *display*. It gets the work area + * ([sceneLayoutSize]), so `Popup.skiko.kt` lays it out at full size and + * flips it against a screen-sized box instead of against the owner + * window — the point of native popup layers. That box is still rooted at + * the window; the origin is what the screen clamp corrects when the + * frame is pushed. + * - A **dialog** (`Dialog`, Material `AlertDialog`) belongs to its + * *window*: `Dialog.skiko.kt` places it at `containerSize.center`, and a + * window-owned dialog centred on the display would sit visibly + * off-centre — and drift further as the user moved the window. It gets + * the owner window's content size, exactly as before #569. + * + * `scrimColor` is the discriminator, and a sound one: only + * `Dialog.skiko.kt` ever writes it, from + * `DialogAppearanceController.properties` — assigned while `DialogLayout` + * composes, *before* `layer.Content { }` and so before this is read. + * `Popup.skiko.kt` never touches it. Held as snapshot state so a later + * write recomposes the content that read it. + */ + private val dialogContainerSize: IntSize + get() = + host.parentWindowInfo.containerSize.let { + IntSize(it.width.coerceAtLeast(1), it.height.coerceAtLeast(1)) + } + + /** + * The rectangle the HWND covers, in scene coordinates: [_bounds] inflated + * by [popupDrawBounds] so shadows and the dialog appearance animation are + * not clipped at the layout edge. The native side keeps [_bounds] as the + * content rect, so a click in the margin is an outside click. + */ private var drawBounds: IntRect = IntRect(0, 0, 1, 1) + + /** + * The last non-empty [_bounds]: what the native surface is sized and placed + * on. `Dialog.skiko.kt`'s disappearance swaps the layer's content for an + * empty `Layout` that only replays the recorded picture, so Compose reports + * a zero-size `boundsInWindow` at the window centre for the whole fade-out. + * An in-scene layer does not care — it draws into the window canvas — but + * this surface must keep covering where the dialog was, or the fade-out + * shows as a square of margin around a point. + */ + private var contentBounds: IntRect = IntRect.Zero private var widthPx: Int = 1 private var heightPx: Int = 1 @@ -162,7 +214,8 @@ internal class TaoPopupSceneLayerWindows( private val popupWindowInfo: androidx.compose.ui.platform.WindowInfo = object : androidx.compose.ui.platform.WindowInfo { override val isWindowFocused: Boolean = true - override val containerSize: IntSize get() = sceneLayoutSize + override val containerSize: IntSize + get() = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize } private val sceneBundle: TaoSceneBundle = @@ -188,10 +241,41 @@ internal class TaoPopupSceneLayerWindows( ).apply { // Report through the owner window's channel — see [TaoPopupHost.exceptionHandler]. exceptionHandler = host.exceptionHandler + // Dim this popup under the dialogs stacked above it. The canvas is + // translated by `-drawBounds.topLeft` at this point, so the visible + // surface is `drawBounds.topLeft` + the surface size in scene coordinates. + renderOverlay = { canvas -> + host.popupScrims.paintAbove( + rendererToken, + canvas, + Rect.makeXYWH( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + widthPx.toFloat(), + heightPx.toFloat(), + ), + ) + } } private val innerScene: ComposeScene get() = sceneBundle.scene + /** + * Keeps the inner scene's size on the box the layer's content lays out in + * (#569). A dialog's root `Layout` fills the scene's constraints, and + * `Dialog.skiko.kt` puts its appearance animation's `GraphicsLayer` on + * that very Layout — so the scale pivots around the *scene's* centre. In + * the window's own scene that box is the window, whose centre is the + * dialog's; a work-area-sized scene would make the dialog slide towards + * the display's centre while it scales in. Popups keep the work area so a + * tall menu can lay out at full height. Re-checked every frame: the window + * may have been resized since. + */ + private fun syncSceneSize() { + val want = if (scrimColorState.value != null) dialogContainerSize else sceneLayoutSize + if (innerScene.size != want) innerScene.size = want + } + private var onPreviewKeyEvent: ((KeyEvent) -> Boolean)? = null private var onKeyEvent: ((KeyEvent) -> Boolean)? = null private var onOutsidePointerEvent: ((PointerEventType, PointerButton?) -> Unit)? = null @@ -278,8 +362,9 @@ internal class TaoPopupSceneLayerWindows( // Register the per-frame renderer + owner-move listener now; both // defer / no-op until the panel exists. host.registerRenderer(rendererToken) { renderFrame() } + host.popupScrims.register(rendererToken) { scrimColorState.value } host.registerOwnerMoveListener(moveListenerToken) { - if (panelHandle != 0L && _bounds != IntRect.Zero) { + if (panelHandle != 0L && !contentBounds.isEmpty) { updateNativeFrame() } } @@ -305,6 +390,7 @@ internal class TaoPopupSceneLayerWindows( get() = _bounds set(value) { _bounds = value + if (!value.isEmpty) contentBounds = value updateDrawBoundsFromBounds() host.requestRedraw() } @@ -316,9 +402,13 @@ internal class TaoPopupSceneLayerWindows( } override var scrimColor: Color? - get() = _scrimColor + get() = scrimColorState.value set(value) { - _scrimColor = value + scrimColorState.value = value + syncSceneSize() + // The scrim is painted by the owner window's scene and by the layers + // below, none of which observe this state — repaint them. + host.popupScrims.notifyChanged() } override var focusable: Boolean @@ -334,9 +424,16 @@ internal class TaoPopupSceneLayerWindows( override var consumePointerInputOutside: Boolean = initialConsumePointerInputOutside override fun close() { + // Idempotent: an owner torn down mid-animation closes its surviving + // layers itself (see the host's detach sweep), and Compose then + // disposes the same layer as the composition unwinds. A second pass + // would release the native panel twice. + if (released) return released = true host.notifyPopupClosing() host.unregisterRenderer(rendererToken) + host.onLayerClosed(this) + host.popupScrims.unregister(rendererToken) host.unregisterOwnerMoveListener(moveListenerToken) PopupNativeBridgeWindows.nativeUninstallOutsideClickMonitor(panelHandle) PopupNativeBridgeWindows.nativeSetEventCallback(panelHandle, null) @@ -354,6 +451,17 @@ internal class TaoPopupSceneLayerWindows( CompositionLocalProvider( LocalDensity provides densityState.value, LocalLayoutDirection provides layoutDirectionState.value, + // Inside the replayed parent locals, and deliberately so + // (#569). `Popup.skiko.kt` reads `LocalWindowInfo` from + // *this* composition to size the box it flips and clips the + // popup inside; the replayed snapshot carries the owner + // window's WindowInfo, which would pin every popup to the + // window — the exact opposite of what native popup layers + // exist for. The scene's own `popupWindowInfo` reports the + // work area, so Compose lays out and flips against a + // screen-sized box (still rooted at the window — the + // origin is what [updateNativeFrame]'s clamp corrects). + LocalWindowInfo provides popupWindowInfo, ) { content() } @@ -394,6 +502,7 @@ internal class TaoPopupSceneLayerWindows( if (drawBounds == IntRect.Zero) return if (widthPx <= 0 || heightPx <= 0) return if (!ensurePanel()) return + syncSceneSize() if (!PopupNativeBridgeWindows.nativeMakeCurrent(panelHandle)) return directContext.resetGLAll() @@ -403,6 +512,8 @@ internal class TaoPopupSceneLayerWindows( heightPx = heightPx, directContext = directContext, clearColorArgb = 0x00000000, + // Per-pixel-alpha DComp surface — no LCD SurfaceProps. + windowTransparent = true, present = { PopupNativeBridgeWindows.nativeSwapBuffers(panelHandle) }, ) { canvas, nanoTime -> canvas.save() @@ -421,14 +532,8 @@ internal class TaoPopupSceneLayerWindows( ): Offset = Offset(x + drawBounds.left, y + drawBounds.top) private fun updateDrawBoundsFromBounds(): Boolean { - if (_bounds == IntRect.Zero) return false - val nextDrawBounds = - IntRect( - left = _bounds.left, - top = _bounds.top, - right = _bounds.right, - bottom = _bounds.bottom, - ) + if (contentBounds.isEmpty) return false + val nextDrawBounds = popupDrawBounds(contentBounds, _density.density) val changed = nextDrawBounds != drawBounds drawBounds = nextDrawBounds widthPx = drawBounds.width.coerceAtLeast(1) @@ -437,22 +542,57 @@ internal class TaoPopupSceneLayerWindows( return changed } + /** + * Pushes the popup frame to its HWND, screen-clamped (#569). + * + * The clamp shifts the **native frame only** — never [drawBounds] or + * [_bounds]. Those two are the popup's *scene* coordinates: [renderFrame] + * translates the inner scene by `-drawBounds` and [scenePosition] maps + * HWND-local pointers back by `+drawBounds`, so shifting them would move + * the content inside the surface by exactly as much as the surface moved + * on screen — a visual no-op — and would desynchronize hit-testing from + * what Compose believes. Only the `SetWindowPos` origin moves; the surface + * content and the coordinate space Compose sees stay untouched. + * + * Re-clamped on every call, so the owner-move listener (see [init]) keeps + * an open popup inside the work area while the window is dragged, and a + * drag onto a second display re-resolves the display too. + */ private fun updateNativeFrame() { if (panelHandle == 0L) return - if (drawBounds == IntRect.Zero || _bounds == IntRect.Zero) return + if (drawBounds == IntRect.Zero || contentBounds.isEmpty) return val offset = host.coordinateOffset - val finalX = drawBounds.left + offset.x - val finalY = drawBounds.top + offset.y + // The clamp is decided on the content, not the inflated surface: what + // must stay on screen is the popup the user sees, and a shadow margin + // hanging past the edge is what the in-scene layer does too. + val contentInParent = contentBounds.translate(offset) + val frameInParent = drawBounds.translate(offset) + val geometry = host.popupScreenGeometry + val clamp = popupScreenClampOffset(contentInParent, geometry) + val finalX = frameInParent.left + clamp.x + val finalY = frameInParent.top + clamp.y + geometry?.let { + val onScreen = it.parentContentOriginPx + clamp + TaoPopupDiagnostics.record( + PopupFrameRecord( + boundsInWindowPx = _bounds, + frameOnScreenPx = frameInParent.translate(onScreen), + contentOnScreenPx = contentInParent.translate(onScreen), + clampOffsetPx = clamp, + panelHandle = panelHandle, + ), + ) + } PopupNativeBridgeWindows.nativeSetFrameInWindow( panel = panelHandle, xPx = finalX, yPx = finalY, widthPx = drawBounds.width.coerceAtLeast(1), heightPx = drawBounds.height.coerceAtLeast(1), - contentXPx = _bounds.left - drawBounds.left, - contentYPx = _bounds.top - drawBounds.top, - contentWidthPx = _bounds.width.coerceAtLeast(1), - contentHeightPx = _bounds.height.coerceAtLeast(1), + contentXPx = contentBounds.left - drawBounds.left, + contentYPx = contentBounds.top - drawBounds.top, + contentWidthPx = contentBounds.width.coerceAtLeast(1), + contentHeightPx = contentBounds.height.coerceAtLeast(1), ) } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt index f1150d225..4dede7884 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHost.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize import dev.nucleusframework.window.tao.GlobalLayoutDirection -import dev.nucleusframework.window.tao.TaoCursorIcon import dev.nucleusframework.window.tao.TaoDnDDiagnostics import dev.nucleusframework.window.tao.TaoScreenGeometry import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher @@ -27,6 +26,7 @@ import dev.nucleusframework.window.tao.dnd.TaoSceneDnD import dev.nucleusframework.window.tao.event.ProvideTaoWindowsScrollConfig import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll import dev.nucleusframework.window.tao.event.dispatchNativeKeyEvent +import dev.nucleusframework.window.tao.event.toTaoCursorIconCode import dev.nucleusframework.window.tao.event.win32WheelToAwtScrollEvent import dev.nucleusframework.window.tao.ffi.NativeTaoGlBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDndBridge @@ -424,6 +424,8 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { heightPx = heightPx, directContext = ctx, clearColorArgb = 0x00000000, + // Per-pixel-alpha DComp surface — no LCD SurfaceProps. + windowTransparent = true, present = { PopupNativeBridgeWindows.nativeSwapBuffers(panel) }, ) { canvas, _ -> bundle.render(canvas, frameNs) @@ -605,29 +607,7 @@ internal class TaoStandalonePopupHost : StandalonePopupHost { } } - private fun mapPointerIcon(icon: PointerIcon): Int { - when { - icon === PointerIcon.Default -> return TaoCursorIcon.DEFAULT - icon === PointerIcon.Text -> return TaoCursorIcon.TEXT - icon === PointerIcon.Hand -> return TaoCursorIcon.HAND - icon === PointerIcon.Crosshair -> return TaoCursorIcon.CROSSHAIR - } - return runCatching { - val cursor = icon.javaClass.getMethod("getCursor").invoke(icon) as? java.awt.Cursor - when (cursor?.type) { - java.awt.Cursor.TEXT_CURSOR -> TaoCursorIcon.TEXT - java.awt.Cursor.HAND_CURSOR -> TaoCursorIcon.HAND - java.awt.Cursor.CROSSHAIR_CURSOR -> TaoCursorIcon.CROSSHAIR - java.awt.Cursor.WAIT_CURSOR -> TaoCursorIcon.WAIT - java.awt.Cursor.MOVE_CURSOR -> TaoCursorIcon.MOVE - java.awt.Cursor.E_RESIZE_CURSOR, java.awt.Cursor.W_RESIZE_CURSOR -> TaoCursorIcon.EW_RESIZE - java.awt.Cursor.N_RESIZE_CURSOR, java.awt.Cursor.S_RESIZE_CURSOR -> TaoCursorIcon.NS_RESIZE - java.awt.Cursor.NE_RESIZE_CURSOR, java.awt.Cursor.SW_RESIZE_CURSOR -> TaoCursorIcon.NESW_RESIZE - java.awt.Cursor.NW_RESIZE_CURSOR, java.awt.Cursor.SE_RESIZE_CURSOR -> TaoCursorIcon.NWSE_RESIZE - else -> TaoCursorIcon.DEFAULT - } - }.getOrDefault(TaoCursorIcon.DEFAULT) - } + private fun mapPointerIcon(icon: PointerIcon): Int = icon.toTaoCursorIconCode() private inner class FlushingDispatcher : kotlinx.coroutines.CoroutineDispatcher() { private val queue = ConcurrentLinkedQueue() diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt index db196a5ec..2ab41f928 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/popup/TaoStandalonePopupHostLinux.kt @@ -400,6 +400,9 @@ internal class TaoStandalonePopupHostLinux : StandalonePopupHost { heightPx = heightPx, directContext = ctx, clearColorArgb = 0x00000000, + // Per-pixel-alpha popup surface (no-op on Linux today, but the + // alpha mode must be stated — see renderGlFrame). + windowTransparent = true, present = { NativeTaoEglBridge.nativePresent(attachment) }, ) { canvas, _ -> bundle.render(canvas, frameNs) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/AbstractTaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/AbstractTaoComposeSceneHost.kt index 4e52f841d..858da89fa 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/AbstractTaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/AbstractTaoComposeSceneHost.kt @@ -137,6 +137,8 @@ internal abstract class AbstractTaoComposeSceneHost { TaoMouseButton.LEFT -> PointerButton.Primary TaoMouseButton.RIGHT -> PointerButton.Secondary TaoMouseButton.MIDDLE -> PointerButton.Tertiary + TaoMouseButton.BACK -> PointerButton.Back + TaoMouseButton.FORWARD -> PointerButton.Forward else -> PointerButton.Primary } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt index 8506f96d3..ecd75fc75 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GlSceneRenderer.kt @@ -25,6 +25,10 @@ internal inline fun renderGlFrame( directContext: DirectContext, bundle: TaoSceneBundle, clearColorArgb: Int, + // No default on purpose: `false` attaches LCD SurfaceProps on Windows, and + // silently inheriting it on a per-pixel-alpha surface ships color-fringed + // text. Every call site must state its surface's alpha mode. + windowTransparent: Boolean, crossinline present: () -> Unit, ) { renderGlFrame( @@ -32,17 +36,34 @@ internal inline fun renderGlFrame( heightPx = heightPx, directContext = directContext, clearColorArgb = clearColorArgb, + windowTransparent = windowTransparent, present = present, ) { canvas, nanoTime -> bundle.render(canvas, nanoTime) } } +internal fun makeTaoGlSurface( + context: DirectContext, + rt: BackendRenderTarget, + windowTransparent: Boolean, +): Surface? = + Surface.makeFromBackendRenderTarget( + context = context, + rt = rt, + origin = SurfaceOrigin.BOTTOM_LEFT, + colorFormat = SurfaceColorFormat.RGBA_8888, + colorSpace = ColorSpace.sRGB, + surfaceProps = lcdSurfaceProps(windowTransparent), + ) + internal inline fun renderGlFrame( widthPx: Int, heightPx: Int, directContext: DirectContext, clearColorArgb: Int, + // No default on purpose — see the overload above. + windowTransparent: Boolean, crossinline present: () -> Unit, crossinline render: (org.jetbrains.skia.Canvas, Long) -> Unit, ) { @@ -57,13 +78,7 @@ internal inline fun renderGlFrame( fbFormat = FramebufferFormat.GR_GL_RGBA8, ) val surface = - Surface.makeFromBackendRenderTarget( - context = directContext, - rt = rt, - origin = SurfaceOrigin.BOTTOM_LEFT, - colorFormat = SurfaceColorFormat.RGBA_8888, - colorSpace = ColorSpace.sRGB, - ) ?: run { + makeTaoGlSurface(directContext, rt, windowTransparent) ?: run { rt.close() return } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt new file mode 100644 index 000000000..09ac1cf12 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/GpuResourceCache.kt @@ -0,0 +1,56 @@ +package dev.nucleusframework.window.tao.scene + +/* + * Shared policy for the Skia GPU resource cache of the scene hosts' + * `DirectContext`s. + * + * Skia evicts only when a new allocation would push the cache past its budget, + * so a scene that stops drawing keeps its high-water mark for the rest of the + * process' life. `DirectContext` offers no purge of its own — skiko exposes + * `resourceCacheLimit` and nothing else: no `freeGpuResources`, no + * `purgeUnlockedResources`, not even a usage read-back — so the only primitive + * available to us is *toggling the limit*. Writing 0 runs Skia's + * `purgeAsNeeded` inline, releasing every unlocked resource; writing the budget + * back lets the next frame re-mint only what it actually needs. + * + * Two properties of that primitive shape every caller: + * + * - It frees **unlocked** resources only. Compose layers and pictures still + * referenced by live Java objects keep their Skia natives locked, and those + * are released by the skiko `Cleaner` only after a GC — which is why the + * settle paths pair the purge with a `System.gc()` nudge, and why a purge + * alone never returns a drag's or an animation's full peak. + * - It issues backend deletes, so it must run where the context is usable: + * with *that* host's GL context current on the ANGLE/EGL hosts (purging + * against a sibling's binding deletes ids in the sibling's namespace — see + * the KDoc on `TaoComposeSceneHostWindows.purgeGpuResourceCache`), and on + * the owning render thread on Metal, where the context is thread-affine. + */ + +/** + * Budget written onto a host `DirectContext` at attach. + * + * Measured, not assumed: Ganesh already hands out exactly 268435456 bytes by + * default, so at the current value this write is a deliberate no-op. It is the + * explicit anchor the limit-toggle purge restores, and the single place to + * change should we ever decide to run the hosts *below* Skia's own default + * (which is the interesting question once several surfaces each own a context). + * Do not read it as "the cache would be unbounded without this line". + */ +internal const val GPU_RESOURCE_CACHE_LIMIT_BYTES: Long = 256L * 1024 * 1024 + +/** + * Gap between in-drag purges while resize events are streaming. Every frame of + * a drag mints render-target scratch (stencil/attachments) at a size no later + * frame reuses; purging periodically releases that accumulation mid-drag so the + * peak stays bounded even on long drags, without skipping a resize frame (a + * skipped frame is composited as a geometry/content mismatch — trembling). + */ +internal const val GPU_RESIZE_PURGE_INTERVAL_NS: Long = 250_000_000L + +/* + * There is deliberately no "settle" constant here. A drag-end purge needs a + * drag-end signal, and only Windows has one (`WM_EXITSIZEMOVE`); standing a + * timer in for it on the other hosts was measured to be a bad trade — see + * `TaoComposeSceneHost.purgeResizeScratchIfDue`. + */ diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/LcdText.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/LcdText.kt new file mode 100644 index 000000000..8f1503958 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/LcdText.kt @@ -0,0 +1,55 @@ +package dev.nucleusframework.window.tao.scene + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge +import org.jetbrains.skia.PixelGeometry +import org.jetbrains.skia.SurfaceProps + +/** + * LCD / ClearType text for Tao on Windows (Compose issue #875) — the surface + * half of the feature. + * + * Skia only paints chromatic glyph edges when BOTH the GPU surface has a + * known pixel geometry AND the paragraph requests + * `FontSmoothing.SubpixelAntiAlias`. The paragraph half is handled at build + * time by the Nucleus Gradle plugin (`LcdTextDefaultTransform` patches + * Compose's `FontRasterizationSettings.PlatformDefault` on Windows), so the + * backend only attaches the pixel geometry here — and only on opaque Windows + * windows. Transparent windows, popups (per-pixel-alpha DComp surfaces), and + * every other OS keep an unknown geometry, which makes Skia fall back to + * grayscale regardless of what paragraphs request. + */ +internal fun lcdSurfaceProps( + windowTransparent: Boolean, + platform: Platform = Platform.Current, + windowsLcdGeometry: () -> PixelGeometry? = ::windowsLcdPixelGeometry, +): SurfaceProps? { + if (windowTransparent) return null + if (platform != Platform.Windows) return null + val geometry = windowsLcdGeometry() ?: return null + return SurfaceProps(isDeviceIndependentFonts = false, pixelGeometry = geometry) +} + +internal fun windowsLcdPixelGeometry(): PixelGeometry? = cachedWindowsLcdGeometry + +// The smoothing answer is effectively static for the app's lifetime, and this +// sits inside per-frame surface creation — one JNI query, not 1-3 syscalls per +// rendered frame of every host/overlay/popup. +private val cachedWindowsLcdGeometry: PixelGeometry? by lazy(::queryWindowsLcdPixelGeometry) + +private fun queryWindowsLcdPixelGeometry(): PixelGeometry? { + // Unknown smoothing state (lib missing or query failure) means grayscale, + // never an assumed RGB stripe order. + if (!NativeTaoWindowsDecoBridge.isLoaded) return null + val code = + try { + NativeTaoWindowsDecoBridge.nativeFontSmoothingPixelGeometry() + } catch (_: UnsatisfiedLinkError) { + return null + } + return when (code) { + NativeTaoWindowsDecoBridge.FONT_SMOOTHING_RGB -> PixelGeometry.RGB_H + NativeTaoWindowsDecoBridge.FONT_SMOOTHING_BGR -> PixelGeometry.BGR_H + else -> null + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt index 09c35886d..5595c59f6 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/MetalSceneRenderer.kt @@ -1,7 +1,9 @@ package dev.nucleusframework.window.tao.scene +import androidx.compose.ui.unit.IntOffset import dev.nucleusframework.window.tao.ffi.NativeMetalBridge import org.jetbrains.skia.BackendRenderTarget +import org.jetbrains.skia.Canvas import org.jetbrains.skia.ColorSpace import org.jetbrains.skia.DirectContext import org.jetbrains.skia.Picture @@ -41,11 +43,19 @@ internal fun recordSceneToPicture( widthPx: Int, heightPx: Int, nanoTime: Long = System.nanoTime(), + /** + * Where the drawable sits in the coordinate space the scene draws in. + * The window's own scene draws at the origin; a popup layer draws in + * owner-window coordinates and passes its draw bounds + * ([dev.nucleusframework.window.tao.popup.popupPictureCullRect]), because + * Skia quick-rejects a picture whose cull rect misses the replay matrix. + */ + cullRect: Rect = Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), ): Picture = PictureRecorder().use { recorder -> // The cull bounds match the drawable size (physical pixels). The scene is // rendered at this size; the clear happens at replay time, not here. - val canvas = recorder.beginRecording(Rect.makeWH(widthPx.toFloat(), heightPx.toFloat())) + val canvas = recorder.beginRecording(cullRect) bundle.render(canvas, nanoTime) // Closing the recorder here frees its native memory deterministically // (one recorder per frame — a GC-driven Cleaner would lag far behind); @@ -53,6 +63,25 @@ internal fun recordSceneToPicture( recorder.finishRecordingAsPicture() } +/** + * Draws [picture] onto this canvas with its origin moved to [pictureOffset] — + * the one step of [replayPictureToFrame] that is pure Skia, split out so it can + * be exercised against a raster surface without a Metal device. + * + * The offset and the picture's cull rect are two halves of one contract: Skia + * quick-rejects a picture whose cull rect, mapped through the current matrix, + * misses the drawable, so a caller that translates here must record with a cull + * rect expressed in the same space as the content + * ([dev.nucleusframework.window.tao.popup.popupPictureCullRect]). + */ +internal fun Canvas.replayPicture( + picture: Picture, + pictureOffset: IntOffset, +) { + translate(pictureOffset.x.toFloat(), pictureOffset.y.toFloat()) + drawPicture(picture) +} + /** * Replays a [picture] recorded by [recordSceneToPicture] into the attachment's * next Metal drawable and presents it. Must run on the render thread that owns @@ -79,6 +108,12 @@ internal fun replayPictureToFrame( directContext: DirectContext, picture: Picture, clearColor: Int, + /** + * Where the picture's origin lands on the surface. A popup layer records + * its scene in window coordinates and draws it into a surface rooted at + * the layer's draw bounds, so it passes `-drawBounds.topLeft`. + */ + pictureOffset: IntOffset = IntOffset.Zero, present: (handle: Long, drawablePtr: Long) -> Unit = { h, d -> NativeMetalBridge.nativePresent(h, d) }, @@ -100,7 +135,7 @@ internal fun replayPictureToFrame( } try { surface.canvas.clear(clearColor) - surface.canvas.drawPicture(picture) + surface.canvas.replayPicture(picture, pictureOffset) surface.flushAndSubmit(syncCpu = false) present(attachmentHandle, frame.drawablePtr) presented = true @@ -138,4 +173,6 @@ internal class TaoRecordedSurface( NativeMetalBridge.nativePresent(h, d) }, val isAlive: () -> Boolean = { true }, + /** Translation applied before the picture is drawn — see [replayPictureToFrame]. */ + val pictureOffset: IntOffset = IntOffset.Zero, ) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt index d97a54740..f617b5131 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneContext.kt @@ -7,6 +7,19 @@ import androidx.compose.ui.scene.ComposeSceneLayer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.LayoutDirection +/** + * Builds one native popup layer for a Compose `Popup` / `Dialog` opened in a + * window: the per-platform `TaoPopupSceneLayer*` constructor, with the host + * already bound. Same signature as [ComposeSceneContext.createLayer]. + */ +@OptIn(InternalComposeUiApi::class) +internal typealias TaoPopupLayerFactory = ( + density: Density, + layoutDirection: LayoutDirection, + focusable: Boolean, + consumePointerInputOutside: Boolean, +) -> ComposeSceneLayer + /** * `ComposeSceneContext` that lifts Compose `Popup` / `DropdownMenu` / * `Tooltip` content into a native popup window (an NSPanel on macOS, a Tao @@ -26,12 +39,7 @@ import androidx.compose.ui.unit.LayoutDirection @OptIn(InternalComposeUiApi::class) internal class TaoComposeSceneContext( override val platformContext: PlatformContext, - private val layerFactory: ( - density: Density, - layoutDirection: LayoutDirection, - focusable: Boolean, - consumePointerInputOutside: Boolean, - ) -> ComposeSceneLayer, + private val layerFactory: TaoPopupLayerFactory, ) : ComposeSceneContext { override fun createLayer( density: Density, diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt index c61d7c81c..1ac651ae6 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHost.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerIcon -import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerKeyboardModifiers import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.platform.PlatformContext @@ -18,45 +17,57 @@ import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeScenePointer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowExceptionHandler import dev.nucleusframework.window.WindowTransparencyMode import dev.nucleusframework.window.tao.GlobalLayoutDirection import dev.nucleusframework.window.tao.MacOSStyle -import dev.nucleusframework.window.tao.TaoCursorIcon import dev.nucleusframework.window.tao.TaoEventCode import dev.nucleusframework.window.tao.TaoFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoKeyLocation import dev.nucleusframework.window.tao.TaoModifierMask +import dev.nucleusframework.window.tao.TaoMonitors import dev.nucleusframework.window.tao.TaoNativeViewHost import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.TaoTrackpadGesture import dev.nucleusframework.window.tao.TaoTrackpadPhase import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.clearContentMeasurer import dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher import dev.nucleusframework.window.tao.event.AWT_PIXEL_TO_ROTATION +import dev.nucleusframework.window.tao.event.TaoTrackpadRotationContacts +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent +import dev.nucleusframework.window.tao.event.toTaoCursorIconCode import dev.nucleusframework.window.tao.ffi.NativeMetalBridge import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsDecoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsNativeViewBridge import dev.nucleusframework.window.tao.initialMacOsScaleFactor +import dev.nucleusframework.window.tao.installContentMeasurer +import dev.nucleusframework.window.tao.popup.PopupScreenGeometry +import dev.nucleusframework.window.tao.popup.PopupScrimRegistry import dev.nucleusframework.window.tao.popup.TaoPopupHost import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayer import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher import dev.nucleusframework.window.tao.render.TaoSelectionAccessibilityObserver import dev.nucleusframework.window.tao.shouldApplyLargeCornerRadius import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asCoroutineDispatcher import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.jetbrains.skia.Canvas import org.jetbrains.skia.DirectContext +import org.jetbrains.skia.Rect import java.util.concurrent.Callable import java.util.concurrent.ExecutorService import java.util.concurrent.locks.LockSupport @@ -148,14 +159,14 @@ internal class TaoComposeSceneHost( * App-level pre-dispatch hook. Receives every Compose [KeyEvent] before it * reaches the scene; returning `true` consumes the event and prevents * propagation. Mirrors AWT's `Window.setComponentZOrder`-pre-dispatch logic - * used by `decorated-window-jni`'s `onPreviewKeyEvent`. + * used by the legacy AWT backend's `onPreviewKeyEvent`. */ var previewKeyHandler: ((KeyEvent) -> Boolean)? = null /** * App-level post-dispatch hook. Fires only when the scene did not consume * the event. Returning `true` marks it as handled. Mirrors - * `decorated-window-jni`'s `onKeyEvent`. + * the legacy AWT backend's `onKeyEvent`. */ var keyHandler: ((KeyEvent) -> Boolean)? = null @@ -189,6 +200,12 @@ internal class TaoComposeSceneHost( private var sceneBundle: TaoSceneBundle? = null private val scene: ComposeScene? get() = sceneBundle?.scene + init { + // Reads `scene` lazily, so it is valid before the bundle exists (null) + // and across bundle swaps; cleared in dispose(). + window.installContentMeasurer { constraints -> scene?.measureContent(constraints) } + } + /** Parent locals bridged via [setSceneCompositionLocalContext]; applied to the scene once created. */ private var pendingCompositionLocalContext: androidx.compose.runtime.CompositionLocalContext? = null @@ -246,6 +263,23 @@ internal class TaoComposeSceneHost( /** Set by NativeView pointer-interop when a Press was forwarded to AppKit. */ private var nativePointerDispatchedThisEvent: Boolean = false + /** + * Handles whose [TaoNativeViewHost.detach] has already run. A layout pass + * can still report the slot of an embed in the frame that removes it, and + * [scheduleInteropAction] may drain a `setFrame` after dispose — both + * must no-op. Only *detached* handles are refused: the first `setFrame` + * routinely lands before the attach effect. + */ + private val detachedNativeViews: MutableSet = mutableSetOf() + + /** + * Captured at the first composition via [setContent]. Exposes + * `FocusManager.clearFocus(force = true)` so a press handed to an embed + * can drop a Compose `BasicTextField`'s caret — the Linux/Windows hosts + * do the same. + */ + private var capturedFocusManager: androidx.compose.ui.focus.FocusManager? = null + /** Renderer's view of whether interop is currently active — lags the * transaction's flag by one frame on the OFF transition so the * final sync flush still goes through `presentsWithTransaction`. */ @@ -360,8 +394,18 @@ internal class TaoComposeSceneHost( val devicePtr = NativeMetalBridge.nativeDevicePtr(handle) val queuePtr = NativeMetalBridge.nativeQueuePtr(handle) // The Skia Metal DirectContext is thread-affine: create it on the render - // thread that will use it for every frame's GPU encode + present. - directContext = runOnRenderThread { DirectContext.makeMetal(devicePtr, queuePtr) } + // thread that will use it for every frame's GPU encode + present. The + // resource-cache budget is anchored in the same hop — writing it purges + // to fit, so it belongs on the owning thread like every other use of + // the context. See GPU_RESOURCE_CACHE_LIMIT_BYTES for why the value + // itself changes nothing today, and [purgeGpuResourceCache] for what + // actually reclaims. + directContext = + runOnRenderThread { + DirectContext.makeMetal(devicePtr, queuePtr).also { + it.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES + } + } scale = initialMacOsScaleFactor(window) @@ -418,7 +462,7 @@ internal class TaoComposeSceneHost( isWindowTransparent = fullyTransparent, ) - val hostPopupHost = if (nativePopupLayers) popupHost() else null + val nativeLayerFactory = if (nativePopupLayers) nativePopupLayerFactory() else null // The scene's MonotonicFrameClock is owned by the FrameRecomposer inside the // bundle (Compose 1.12). It matters that the clock exists: without one the // recomposer can't tell when a frame finished and re-fires the invalidation @@ -426,7 +470,7 @@ internal class TaoComposeSceneHost( // itself in `performFrame` (one frame per FrameDispatcher tick, re-scheduling // only while animations remain), so the host no longer sends frames manually. sceneBundle = - if (hostPopupHost != null) { + if (nativeLayerFactory != null) { // Opt-in path (e.g. tray popups): every Popup becomes a native // NSPanel owned by this window, so popup content can extend // beyond — and float independently of — the window bounds. @@ -435,18 +479,7 @@ internal class TaoComposeSceneHost( density = Density(scale), layoutDirection = GlobalLayoutDirection, size = IntSize(widthPx, heightPx), - composeSceneContext = - TaoComposeSceneContext( - platformContext = taoPlatformContext, - ) { density, layoutDirection, focusable, consumeOutside -> - TaoPopupSceneLayer( - host = hostPopupHost, - initialDensity = density, - initialLayoutDirection = layoutDirection, - initialFocusable = focusable, - initialConsumePointerInputOutside = consumeOutside, - ) - }, + composeSceneContext = TaoComposeSceneContext(taoPlatformContext, nativeLayerFactory), // Schedule a frame on the render loop (coalesced); it renders // then waits for the next vsync. See startRenderLoop. requestFrame = { frameDispatcher?.scheduleFrame() }, @@ -467,10 +500,11 @@ internal class TaoComposeSceneHost( ) } scene?.compositionLocalContext = pendingCompositionLocalContext - // Frame failures (recomposition / layout / draw) are caught inside the - // bundle, the single seam all three platforms render through. - sceneBundle?.exceptionHandler = exceptionHandler + configureSceneBundle() + // One source of truth for the scene's drop target: the callback below + // resolves it through here, and so does an in-process driver. + window.inboundDragAndDropNode = { scene?.rootDragAndDropNode } registerInboundDnD() } @@ -567,7 +601,7 @@ internal class TaoComposeSceneHost( */ @OptIn(InternalComposeUiApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) private inner class InboundDnDCallback : dev.nucleusframework.window.tao.ffi.NativeTaoMacOsDndBridge.Callback { - private fun node() = scene?.rootDragAndDropNode + private fun node() = window.inboundDragAndDropNode?.invoke() override fun onDragEnter( nsView: Long, @@ -647,6 +681,8 @@ internal class TaoComposeSceneHost( fun setContent(content: @Composable () -> Unit) = exceptionHandler.catchExceptions { scene?.setContent { + val fm = androidx.compose.ui.platform.LocalFocusManager.current + androidx.compose.runtime.SideEffect { capturedFocusManager = fm } TaoTextToolbarHost(textToolbar) { val onSel = onTextSelectionForA11y // Expose the publisher so themed wrappers (nucleus-application) can @@ -685,7 +721,91 @@ internal class TaoComposeSceneHost( NativeMetalBridge.nativeResize(attachmentHandle, widthPx, heightPx, scale) scene?.size = IntSize(widthPx, heightPx) updateWindowInfoSize() - window.requestRedraw() + // Present a frame at the new size in this very run-loop turn (#576). + // AppKit has already applied the bounds; had the present waited for + // the display-link tick, Core Animation would show the new bounds + // with the *previous* drawable stretched over them + // (`kCAGravityResize`) — one stale frame per step of a + // `WindowState.size` animation or of the maximize/restore zoom, read + // as the whole content trembling and trailing the window edge. + // Same-turn presenting is what [prepareFullscreenFrame] already does + // for #327. No dispatcher pump here: we are inside the resize + // event's own dispatch, and draining Compose's queue at this point + // ran the next animation step — its `setInnerSize` — nested in this + // turn, after which AppKit delivered every `windowDidResize:` late, + // one stale size per turn, and the scene replayed the whole + // animation once it had ended. + if (renderFrameBlocking(pumpDispatcher = false)) presentedInDispatch = true else window.requestRedraw() + purgeResizeScratchIfDue() + } + + private var lastResizePurgeNs: Long = 0 + + /** + * Set by [onResized] once its same-turn present is on its way; the next + * render-loop frame then skips its own replay + present (#576). That frame + * would only put a second drawable in flight for the same vsync — and the + * next same-turn present would sit behind it in `nextDrawable`, turning a + * ~3 ms present into a ~15 ms one. The frame still records (the frame + * clock tick Compose animations run on) and paces, so the loop keeps + * waking the Tao loop. Read on the render thread, hence volatile. + */ + @Volatile + private var presentedInDispatch: Boolean = false + + /** + * Reclaims the per-size GPU scratch a live resize mints, while the sizes are + * still streaming — the macOS half of what + * [TaoComposeSceneHostWindows.onResized] does inside the OS modal + * resize/move loop. Skia's budget caps the cache, but a capped cache full of + * scratch no frame will ever ask for again is still 256 MiB resident. + * + * Deliberately only the *in-drag* half of the Windows behaviour. There is no + * settle purge and no `System.gc()` nudge here, because macOS has no + * `WM_EXITSIZEMOVE` to hang them on and a timer standing in for it proved a + * bad trade twice over: the drag's own frames are display-link paced, so + * macOS never accumulates the way Windows' unpaced modal loop does (a + * 60-step storm moved the graphics footprint 68 MB → 72 MB, and a purge + GC + * at the end of it returned essentially none of that), while the pair landed + * on an animating window as a visible stall — a window with a live + * `NativeView` embed dropped below 4 frames per 400 ms right after a storm. + * Cost with no measured benefit. The reclaim that #638 is actually after is + * at rest, not at drag end, and belongs on the idle path. + */ + private fun purgeResizeScratchIfDue() { + val now = System.nanoTime() + if (now - lastResizePurgeNs < GPU_RESIZE_PURGE_INTERVAL_NS) return + lastResizePurgeNs = now + purgeGpuResourceCache() + } + + /** + * Frees the GPU resource cache: toggling the limit to 0 runs Skia's + * `purgeAsNeeded` inline, releasing every unlocked resource, and restoring + * the budget lets the next frame re-mint only what it needs. The only purge + * primitive skiko exposes — see [GPU_RESOURCE_CACHE_LIMIT_BYTES]. + * + * Metal has no notion of a *current* context, so none of the foreign-context + * hazard the ANGLE/EGL hosts guard against (#514) applies here: the danger + * on this backend is thread affinity instead. The `DirectContext` is created + * on, and only ever touched from, [renderExecutor], so the toggle hops + * there — submitted rather than awaited, because the caller is the Tao main + * thread on the resize path and blocking it would park the drag behind the + * in-flight replay. FIFO ordering puts the purge cleanly between two frames, + * where nothing the host caches is live (each frame wraps the drawable's + * texture in a fresh `BackendRenderTarget`), and once [detach] has nulled + * the context this returns before submitting anything. + */ + private fun purgeGpuResourceCache() { + val ctx = directContext ?: return + // Rejected once detach() shut the executor down; a purge is never worth + // routing to the fatal handler. + runCatching { + renderExecutor.submit { + ctx.resourceCacheLimit = 0 + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES + } + } } /** @@ -731,6 +851,7 @@ internal class TaoComposeSceneHost( fun onFocusChanged(focused: Boolean) { windowInfo.isWindowFocused = focused + if (!focused) interruptRotation() if (!focused && isPressed) { // Whatever stole focus mid-click (a native context-menu tracking // session, a compositor drag) owns the pointer now and will eat @@ -766,13 +887,57 @@ internal class TaoComposeSceneHost( // each other when multiple popups are active. private val popupRenderers: MutableMap TaoRecordedSurface?> = LinkedHashMap() + /** + * Dialog scrims of the native popup layers, painted over the main scene at + * the end of every frame — see [PopupScrimRegistry]. + */ + private val popupScrims = + PopupScrimRegistry { + sceneBundle?.visualDirty?.set(true) + window.requestRedraw() + } + + /** + * Dialog scrims of native popup layers land on the owner window's surface, + * after its content — Compose Desktop's `onRenderOverlay`. + */ + private fun paintPopupScrims(canvas: Canvas) { + popupScrims.paintAll( + canvas, + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), + transparent = fullyTransparent, + ) + } + + /** + * Hooks every main-scene bundle gets: frame failures (recomposition / + * layout / draw) go to the window's exception handler — the single seam + * all three platforms render through — and popup scrims paint after the + * content. + */ + private fun configureSceneBundle() { + val bundle = sceneBundle ?: return + bundle.exceptionHandler = exceptionHandler + bundle.renderOverlay = ::paintPopupScrims + } + // Tao's macOS pipeline intercepts keys before AppKit's responder // chain, so an overlay NSView can't receive `keyDown:` natively. The // host's `onKeyEvent` consults these handlers first; returning `true` // consumes the event before the main scene sees it. private val popupKeyHandlers: MutableMap Boolean> = LinkedHashMap() - fun nativeViewHost(): TaoNativeViewHost? { + /** + * One host instance per scene. The composition local built from it keys + * `NativeView`'s attach effect: a fresh object on every recomposition of + * the window root would detach and re-attach every embed each time. + */ + private var nativeViewHostInstance: dev.nucleusframework.window.tao.TaoNativeViewHost? = null + + fun nativeViewHost(): dev.nucleusframework.window.tao.TaoNativeViewHost? = + nativeViewHostInstance ?: createNativeViewHost()?.also { nativeViewHostInstance = it } + + private fun createNativeViewHost(): TaoNativeViewHost? { if (nsViewHandle == 0L) return null if (!NativeTaoMacOsNativeViewBridge.isLoaded) return null val outer = this @@ -794,6 +959,7 @@ internal class TaoComposeSceneHost( WindowTransparencyMode.acquire(outer.window, outer.glassBackgroundState) } outer.interopAttachCount++ + outer.detachedNativeViews.remove(childHandle) NativeTaoMacOsNativeViewBridge.nativeAddSubview(outer.nsViewHandle, childHandle) } @@ -801,6 +967,7 @@ internal class TaoComposeSceneHost( childHandle: Long, regionToken: Any, ) { + outer.detachedNativeViews += childHandle NativeTaoMacOsNativeViewBridge.nativeRemoveSubview(childHandle) outer.interopAttachCount-- if (outer.interopAttachCount == 0) { @@ -817,7 +984,9 @@ internal class TaoComposeSceneHost( heightPx: Int, regionToken: Any, ) { + if (handle in outer.detachedNativeViews) return outer.scheduleInteropAction { + if (handle in outer.detachedNativeViews) return@scheduleInteropAction NativeTaoMacOsNativeViewBridge .nativeSetSubviewFrame(outer.nsViewHandle, handle, xPx, yPx, widthPx, heightPx) } @@ -827,7 +996,9 @@ internal class TaoComposeSceneHost( handle: Long, radiusPx: Float, ) { + if (handle in outer.detachedNativeViews) return outer.scheduleInteropAction { + if (handle in outer.detachedNativeViews) return@scheduleInteropAction NativeTaoMacOsNativeViewBridge .nativeSetSubviewCornerRadius(outer.nsViewHandle, handle, radiusPx) } @@ -842,6 +1013,16 @@ internal class TaoComposeSceneHost( pressed: Boolean, ) { if (outer.nsViewHandle == 0L || handle == 0L) return + if (handle in outer.detachedNativeViews) return + if (type == NATIVE_POINTER_PRESS) { + // The embed takes the keyboard with this press + // (`makeFirstResponder` in the bridge): a Compose text + // field must not keep showing a caret beside the embed's. + // Deferred — this runs inside the Press dispatch. + outer.flushingDispatcher.enqueue( + Runnable { outer.capturedFocusManager?.clearFocus(force = true) }, + ) + } NativeTaoMacOsNativeViewBridge.nativeDispatchPointer( outer.nsViewHandle, handle, @@ -861,6 +1042,7 @@ internal class TaoComposeSceneHost( dy: Float, ) { if (outer.nsViewHandle == 0L || handle == 0L) return + if (handle in outer.detachedNativeViews) return NativeTaoMacOsNativeViewBridge.nativeDispatchScroll( outer.nsViewHandle, handle, @@ -910,6 +1092,53 @@ internal class TaoComposeSceneHost( window.requestRedraw() } + /** + * #569: the NSView's own origin on screen — not the window frame's, a + * native title bar sits between them — paired with every screen's + * `visibleFrame`, so a popup layer can clamp against the display it lands + * on instead of the work-area-sized virtual screen Compose positions it in. + */ + private fun resolvePopupScreenGeometry(): PopupScreenGeometry? { + if (!NativeTaoMacOsDecoBridge.isLoaded) return null + val content = + NativeTaoMacOsDecoBridge + .nativeGetContentRect(nsViewHandle) + ?.takeIf { it.size >= 2 } + ?: return null + // `reported`, not `all`: `all` invents a monitor when the platform + // names none, and clamping a popup into an invented work area moves it + // somewhere no display is. No geometry means no clamp. + val areas = TaoMonitors.reported(window).map { it.workAreaPx }.ifEmpty { return null } + return PopupScreenGeometry( + parentContentOriginPx = IntOffset(content[0].toInt(), content[1].toInt()), + workAreasPx = areas, + ) + } + + /** Native popup layers handed out by [nativePopupLayerFactory] and not yet closed — swept by [detach]. */ + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + private val liveNativePopupLayers = linkedSetOf() + + /** + * Builds this window's native popup layers ([TaoPopupSceneLayer]). The + * factory behind [nativePopupLayers], and the one `NativePopupLayers { }` + * hands to a subtree that wants native surfaces while the window's own + * popups stay in-scene. `null` before the NSView is attached. + */ + + fun nativePopupLayerFactory(): TaoPopupLayerFactory? { + val popupHost = popupHost() ?: return null + return { density, layoutDirection, focusable, consumeOutside -> + TaoPopupSceneLayer( + host = popupHost, + initialDensity = density, + initialLayoutDirection = layoutDirection, + initialFocusable = focusable, + initialConsumePointerInputOutside = consumeOutside, + ).also { liveNativePopupLayers += it } + } + } + fun popupHost(): TaoPopupHost? { if (nsViewHandle == 0L) return null val outer = this @@ -918,6 +1147,7 @@ internal class TaoComposeSceneHost( override val scale: Float get() = outer.scale override val isOwnerWindowTransparent: Boolean get() = outer.fullyTransparent override val parentWindowSize: IntSize get() = IntSize(outer.widthPx, outer.heightPx) + override val parentWindowInfo: androidx.compose.ui.platform.WindowInfo get() = outer.windowInfo override val workAreaSize: IntSize get() { val packed = NativeMetalBridge.nativeOwnerWorkAreaSize(outer.nsViewHandle) if (packed == 0L) return parentWindowSize @@ -925,12 +1155,17 @@ internal class TaoComposeSceneHost( val h = (packed and 0xFFFFFFFFL).toInt() return if (w > 0 && h > 0) IntSize(w, h) else parentWindowSize } + + override val popupScreenGeometry: PopupScreenGeometry? + get() = outer.resolvePopupScreenGeometry() override val sceneCoroutineContext: CoroutineContext get() = outer.coroutineContext + outer.flushingDispatcher override val exceptionHandler: WindowExceptionHandler? get() = outer.exceptionHandler + override val popupScrims: PopupScrimRegistry get() = outer.popupScrims + override fun requestRedraw() = outer.window.requestRedraw() override fun registerRenderer( @@ -944,6 +1179,11 @@ internal class TaoComposeSceneHost( popupRenderers.remove(token) } + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + override fun onLayerClosed(layer: androidx.compose.ui.scene.ComposeSceneLayer) { + liveNativePopupLayers.remove(layer) + } + override fun runOnRenderThread(block: () -> T): T = outer.runOnRenderThread(block) override fun registerKeyHandler( @@ -958,7 +1198,7 @@ internal class TaoComposeSceneHost( } override fun setCursor(iconCode: Int) { - NativeTaoBridge.nativeSetCursorIcon(outer.window.handle, iconCode) + NativeTaoBridge.setCursorIcon(outer.window.handle, iconCode) } } } @@ -1020,6 +1260,7 @@ internal class TaoComposeSceneHost( currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers if (!pointerDeadband.shouldDispatchMove(xPx, yPx, scale)) return + interruptRotation() scene?.sendPointerEvent( eventType = PointerEventType.Move, position = Offset(pointerDeadband.x, pointerDeadband.y), @@ -1031,6 +1272,7 @@ internal class TaoComposeSceneHost( fun onPointerExited() { currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers + interruptRotation() scene?.sendPointerEvent( eventType = PointerEventType.Exit, position = Offset(pointerDeadband.x, pointerDeadband.y), @@ -1052,6 +1294,7 @@ internal class TaoComposeSceneHost( // A click ends a trackpad gesture for Compose too (a tap to stop a // fling must not race an open pan session). if (pressed) scrollRouter.finishPan() + interruptRotation() val composeButton = mapButton(buttonCode) currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers @@ -1102,6 +1345,8 @@ internal class TaoComposeSceneHost( fun onPointerScroll(event: TaoPointerScrollEvent) { currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers + // A rotation owns the fingers: a mouse-only Pan / Scroll would release its contacts. + if (rotateActive) return scrollRouter.onScroll(pointerDeadband.x, pointerDeadband.y, event, currentKeyboardModifiers) } @@ -1109,28 +1354,57 @@ internal class TaoComposeSceneHost( // // Tao 0.35 doesn't expose these events; an NSEvent local monitor in // `macos/touchpad_gestures.m` intercepts them and forwards through - // `EventCallback.onTrackpadGesture`. We synthesize two ComposeScenePointer - // Touch points around the gesture centre — distance varies with the - // accumulated magnification factor, angle with the accumulated rotation. - // detectTransformGestures reacts to the changes between consecutive Move - // events, so pinch-zoom / rotate / pan all work with no app-side change. - - private var gestureActive = false + // `EventCallback.onTrackpadGesture`. Magnify is a platform-recognized + // pinch, so it is forwarded as Compose `ScaleStart` / `ScaleChange` / + // `ScaleEnd` (#660) — MapLibre and `Modifier.transformable` consume that + // path without a second pass through touch slop. Rotation has no Compose + // equivalent, so it still synthesises two Touch pointers around the + // gesture centre and lets `detectTransformGestures` see the angle change. + // + // A real trackpad interleaves magnify and rotate, and the two models + // cannot overlap: an event lists every active pointer, so a Scale event + // without the contacts reads as their release (each rotate step then + // re-presses them — a spurious tap — and never rotates), while a Scale + // event carrying them stamps the factor on every pointer and foundation + // multiplies it once per pointer. So whichever gesture begins first owns + // the trackpad until it ends: during a pinch, rotate steps are dropped + // (foundation abandons a touch gesture on any Scale event anyway); during + // a rotation, magnify steps widen the contacts, as before #660. + // + // The same holds for every other mouse-only event: the contacts never + // coexist with one. A rotation does not start while a pan is open, drops + // trackpad scroll and smart-magnify while it owns the fingers, and a real + // cursor move / click / exit interrupts it (cancelled, so it is no tap); + // the rest of an interrupted rotation is ignored until it ends. // Centre of the gesture in physical pixels (top-left origin). private var gestureCenterX = 0f private var gestureCenterY = 0f - // Cumulative scale (1.0 at gesture start; multiplied by (1 + magnification) - // on each Magnify event) and angle in radians. - private var gestureScale = 1f + private val scaleSession = + TaoTrackpadScaleSession { type, factor -> + scene?.dispatchTrackpadScale( + x = gestureCenterX, + y = gestureCenterY, + type = type, + scaleFactor = factor, + keyboardModifiers = currentKeyboardModifiers, + ) + } + + private var rotateActive = false + private var rotateInterrupted = false private var gestureAngle = 0f + // Spacing of the rotation contacts relative to their start: magnify steps + // that arrive while the rotation owns the trackpad (1 otherwise). + private var rotateScale = 1f + /** - * Synthesises a two-finger Touch gesture for `detectTransformGestures`. - * Wire format mirrors `TaoTrackpadGesture` / `TaoTrackpadPhase` constants. - * [valueFixed] is the per-event delta × 10 000 (ratio for magnify, degrees - * for rotate, ignored for smart-magnify). + * Forwards a macOS trackpad gesture. Wire format mirrors + * `TaoTrackpadGesture` / `TaoTrackpadPhase`. [valueFixed] is the + * per-event delta × 10 000 (ratio for magnify, degrees for rotate, + * ignored for smart-magnify). */ @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) fun onTrackpadGesture( @@ -1144,119 +1418,139 @@ internal class TaoComposeSceneHost( val xPx = xFixed / TRACKPAD_POSITION_SCALE val yPx = yFixed / TRACKPAD_POSITION_SCALE val value = valueFixed / TRACKPAD_VALUE_SCALE + gestureCenterX = xPx + gestureCenterY = yPx + + when (kind) { + TaoTrackpadGesture.SMART_MAGNIFY -> if (!rotateActive && !scaleSession.active) scaleSession.smartMagnify() + TaoTrackpadGesture.MAGNIFY -> onMagnify(phase, value) + TaoTrackpadGesture.ROTATE -> onRotate(phase, value) + } + } - // Smart-magnify is one-shot: synthesise a Press → Move → Release burst - // around a fixed scale step so detectTransformGestures sees a discrete - // zoom change. - if (kind == TaoTrackpadGesture.SMART_MAGNIFY) { - startGesture(xPx, yPx) - sendGesturePointers(PointerEventType.Press) - gestureScale *= SMART_MAGNIFY_FACTOR - sendGesturePointers(PointerEventType.Move) - endGesture(cancelled = false) + private fun onMagnify( + phase: Int, + value: Float, + ) { + if (rotateActive) { + // The rotation owns this gesture: fold the step into the contacts. + if (phase == TaoTrackpadPhase.BEGAN || phase == TaoTrackpadPhase.CHANGED) { + // Bounded: past Float range the contacts become Infinity / NaN + // points and detectZoom hands the app an infinite zoom. + rotateScale = + (rotateScale * (1f + value).coerceAtLeast(TaoTrackpadScaleSession.MIN_GESTURE_SCALE)) + .coerceIn(MIN_ROTATE_SCALE, MAX_ROTATE_SCALE) + sendRotatePointers(PointerEventType.Move) + } return } + when (phase) { + TaoTrackpadPhase.BEGAN -> { + scaleSession.start() + scaleSession.magnifyBy(value) + } + TaoTrackpadPhase.CHANGED -> scaleSession.magnifyBy(value) + TaoTrackpadPhase.ENDED -> scaleSession.end() + TaoTrackpadPhase.CANCELLED -> scaleSession.end() + } + } + private fun onRotate( + phase: Int, + value: Float, + ) { + if (phase == TaoTrackpadPhase.ENDED || phase == TaoTrackpadPhase.CANCELLED) { + rotateInterrupted = false + endRotate(cancelled = phase == TaoTrackpadPhase.CANCELLED) + return + } + // A pinch or a pan owns this gesture; Compose has no rotation event to carry the step. + if (scaleSession.active || scrollRouter.panOpen) return when (phase) { TaoTrackpadPhase.BEGAN -> { - startGesture(xPx, yPx) - applyDelta(kind, value) - sendGesturePointers(PointerEventType.Press) + rotateInterrupted = false + startRotate() + applyRotateDelta(value) + sendRotatePointers(PointerEventType.Press) } TaoTrackpadPhase.CHANGED -> { - if (!gestureActive) { - startGesture(xPx, yPx) - } else { - // Track the real cursor on every tick so the synthesised - // centroid moves with `Δcursor` between events. Without - // this, `calculatePan` would always report 0 from the - // synthetic pair (centroid pinned at gesture start), and - // a pinch-while-dragging would silently lose the pan - // component. Stable PointerIds + symmetric offsets around - // the live cursor = honest pan. - gestureCenterX = xPx - gestureCenterY = yPx - } - applyDelta(kind, value) - sendGesturePointers(PointerEventType.Move) + if (rotateInterrupted) return + if (!rotateActive) startRotate() + applyRotateDelta(value) + sendRotatePointers(PointerEventType.Move) } - TaoTrackpadPhase.ENDED -> endGesture(cancelled = false) - TaoTrackpadPhase.CANCELLED -> endGesture(cancelled = true) } } - private fun startGesture( - centerX: Float, - centerY: Float, - ) { - gestureActive = true - gestureCenterX = centerX - gestureCenterY = centerY - gestureScale = 1f + /** + * A mouse-only event is about to reach the scene while the rotation + * contacts are down: it would read as their release, so end the rotation + * first — cancelled, so the contacts do not land as a tap. + */ + private fun interruptRotation() { + if (!rotateActive) return + rotateInterrupted = true + endRotate(cancelled = true) + } + + private fun startRotate() { + rotateActive = true gestureAngle = 0f + rotateScale = 1f } - private fun applyDelta( - kind: Int, - value: Float, - ) { - when (kind) { - TaoTrackpadGesture.MAGNIFY -> { - // Compose's pinch detection responds to relative distance change, - // so multiplying preserves the (1 + delta) semantics of - // NSEvent.magnification across the gesture. - gestureScale *= (1f + value).coerceAtLeast(MIN_GESTURE_SCALE) - } - TaoTrackpadGesture.ROTATE -> { - // NSEvent.rotation is positive counter-clockwise in NSView's - // bottom-left (y-up) frame. Compose lives in screen y-down, - // where positive rotation is clockwise — flip the sign so the - // synthesised pointer rotation matches the user's gesture - // direction once detectTransformGestures applies it back to - // graphicsLayer.rotationZ. - gestureAngle -= value * (Math.PI.toFloat() / DEGREES_PER_RADIAN) - } - } + private fun applyRotateDelta(value: Float) { + // NSEvent.rotation is positive counter-clockwise in NSView's + // bottom-left (y-up) frame. Compose lives in screen y-down, + // where positive rotation is clockwise — flip the sign so the + // synthesised pointer rotation matches the user's gesture + // direction once detectTransformGestures applies it back to + // graphicsLayer.rotationZ. + gestureAngle -= value * (Math.PI.toFloat() / DEGREES_PER_RADIAN) } @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) - private fun sendGesturePointers(eventType: PointerEventType) { + private fun sendRotatePointers(eventType: PointerEventType) { val sc = scene ?: return - val radius = TRACKPAD_BASE_RADIUS_PX * gestureScale - val cosA = cos(gestureAngle) - val sinA = sin(gestureAngle) - val dx = radius * cosA - val dy = radius * sinA - val pressed = eventType != PointerEventType.Release - val pointers = - listOf( - ComposeScenePointer( - id = PointerId(TRACKPAD_POINTER_ID_A), - position = Offset(gestureCenterX - dx, gestureCenterY - dy), - pressed = pressed, - type = PointerType.Touch, - ), - ComposeScenePointer( - id = PointerId(TRACKPAD_POINTER_ID_B), - position = Offset(gestureCenterX + dx, gestureCenterY + dy), - pressed = pressed, - type = PointerType.Touch, - ), - ) sc.sendPointerEvent( eventType = eventType, - pointers = pointers, + pointers = rotatePointers(pressed = eventType != PointerEventType.Release), keyboardModifiers = currentKeyboardModifiers, ) } - private fun endGesture(cancelled: Boolean) { - if (!gestureActive) return - sendGesturePointers(PointerEventType.Release) - gestureActive = false - gestureScale = 1f - gestureAngle = 0f + /** The two synthetic rotation contacts at the current angle around the gesture centre. */ + @OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + private fun rotatePointers(pressed: Boolean): List { + val radius = TRACKPAD_BASE_RADIUS_PX * rotateScale + val dx = radius * cos(gestureAngle) + val dy = radius * sin(gestureAngle) + return listOf( + ComposeScenePointer( + id = TaoTrackpadRotationContacts.A, + position = Offset(gestureCenterX - dx, gestureCenterY - dy), + pressed = pressed, + type = PointerType.Touch, + ), + ComposeScenePointer( + id = TaoTrackpadRotationContacts.B, + position = Offset(gestureCenterX + dx, gestureCenterY + dy), + pressed = pressed, + type = PointerType.Touch, + ), + ) + } + + private fun endRotate(cancelled: Boolean) { + if (!rotateActive) return + // Cancel first: a Release delivered before the cancel is an ordinary + // unconsumed touch-up, which a tap detector takes as a tap. After it, + // the Release only clears the scene's record of the contacts. if (cancelled) scene?.cancelPointerInput() + sendRotatePointers(PointerEventType.Release) + rotateActive = false + gestureAngle = 0f + rotateScale = 1f } /** @@ -1308,6 +1602,21 @@ internal class TaoComposeSceneHost( if (handler(composeEvent)) return true } } + // An embed that holds first responder owns the keyboard. Synthetic + // keys never enter AppKit's responder chain, so deliver them here + // before Compose — otherwise a focused NSTextField never sees them + // and a still-focused BasicTextField would eat the letter too. + if (nsViewHandle != 0L && + NativeTaoMacOsNativeViewBridge.isLoaded && + NativeTaoMacOsNativeViewBridge.nativeDispatchKeyToFirstResponder( + nsViewHandle, + type, + vkCode, + codePoint, + ) + ) { + return true + } if (sc.sendKeyEvent(composeEvent)) return true return keyHandler?.invoke(composeEvent) == true } @@ -1325,31 +1634,18 @@ internal class TaoComposeSceneHost( private const val TRACKPAD_POSITION_SCALE: Float = 1024f private const val TRACKPAD_VALUE_SCALE: Float = 10_000f - // Two synthesised touch pointers separated by 2 × this radius at scale 1. - // - // Sized to defeat Compose's `detectTransformGestures` touch-slop check - // for zoom-OUT: that check computes - // zoomMotion = abs(1 - cumulativeZoom) × previousCentroidSize - // and only fires the callback once it exceeds `viewConfiguration.touchSlop`. - // For zoom-out, `previousCentroidSize` shrinks together with the zoom, - // so `zoomMotion` has a hard ceiling ≈ radius × 0.25. With a 50 px - // radius the ceiling sat at ~13 px — below the default 18 px slop, so - // zoom-out gestures were silently dropped. 120 px gives a ceiling of - // ~31 px, comfortably above any reasonable slop value, while the - // initial 240 px pointer separation still fits inside common - // interactive targets (≥ 120 dp at 2× retina). + // Two synthesised touch pointers for rotation (pinch is a Scale event + // unless a rotation already owns the gesture). 120 px keeps `detectTransformGestures` rotation slop + // reachable: rotationMotion ≈ |Δθ| × π × radius / 180. private const val TRACKPAD_BASE_RADIUS_PX: Float = 120f - private const val TRACKPAD_POINTER_ID_A: Long = 0xA001L - private const val TRACKPAD_POINTER_ID_B: Long = 0xA002L - - // Smart-magnify maps to a single discrete zoom step. macOS's smart-zoom - // toggles between a "fitted" view and a 2× zoom; 1.5× is a reasonable - // default that still triggers detectTransformGestures' zoom callback. - private const val SMART_MAGNIFY_FACTOR: Float = 1.5f + // Spacing range of the rotation contacts relative to their start + // (6 px … 2 400 px apart from centre): a rotation that owns a pinch + // zooms through it, and stops there instead of reaching 0 or Infinity. + private const val MIN_ROTATE_SCALE: Float = 0.05f + private const val MAX_ROTATE_SCALE: Float = 20f private const val DEGREES_PER_RADIAN: Float = 180f - private const val MIN_GESTURE_SCALE: Float = 0.05f } // ── Background render thread (AWT/skiko `dispatcherToBlockOn` pattern) ── @@ -1469,7 +1765,9 @@ internal class TaoComposeSceneHost( // fullscreen/title-bar animation gaps don't flash. The clear itself runs // at replay time on the recorded surface. val mainClear = if (glassBackgroundState.value) 0 else clearColorArgbState.value - val mainPicture = recordSceneToPicture(bundle, widthPx, heightPx) + val frameW = widthPx + val frameH = heightPx + val mainPicture = recordSceneToPicture(bundle, frameW, frameH) val popupSurfaces = recordPopupSurfaces() // Drain Compose's async work (sendFrame continuations, recomposer steps) // synchronously so their state writes happen now and trigger invalidate → @@ -1479,34 +1777,42 @@ internal class TaoComposeSceneHost( // ── replay + present + pace (render thread) ── var mainPresented = false + val skipMain = presentedInDispatch + presentedInDispatch = false withContext(renderDispatcher) { try { - mainPresented = - replayPictureToFrame(handle, ctx, mainPicture, mainClear) { h, d -> - if (needsTransaction) { - // nativePresentWithInterop hops to the main queue - // internally for the CATransaction + AppKit mutations; - // the Runnable below therefore runs on the main thread. - NativeMetalBridge.nativePresentWithInterop( - h, - d, - Runnable { - tx.performTransaction() - if (!tx.isInteropActive) rendererIsInteropActive = false - }, - ) - } else { - NativeMetalBridge.nativePresent(h, d) + if (!skipMain) { + mainPresented = + replayPictureToFrame(handle, ctx, mainPicture, mainClear) { h, d -> + if (needsTransaction) { + // nativePresentWithInterop hops to the main queue + // internally for the CATransaction + AppKit mutations; + // the Runnable below therefore runs on the main thread. + NativeMetalBridge.nativePresentWithInterop( + h, + d, + Runnable { + tx.performTransaction() + if (!tx.isInteropActive) rendererIsInteropActive = false + }, + ) + } else { + NativeMetalBridge.nativePresent(h, d) + } } - } + } } finally { mainPicture.close() } replayPopups(popupSurfaces) - // Pace to the display: park a background thread on the vsync - // semaphore. Bounded native-side so a paused link can't deadlock. - NativeMetalBridge.nativeVSyncWait(handle) } + // Pace to the display: park a background thread on the vsync + // semaphore. Bounded native-side so a paused link can't deadlock. + // Off the render thread (#576): the same-turn present of a resize + // ([onResized]) must not queue behind this park — the frame it puts + // on screen is for the bounds AppKit is committing now. + withContext(Dispatchers.IO) { NativeMetalBridge.nativeVSyncWait(handle) } + if (mainPresented) TaoPresentDiagnostics.record(window.handle, IntSize(frameW, frameH)) // ── interop skip-drain (main) ── // If the main frame was skipped before its present lambda fired @@ -1547,6 +1853,7 @@ internal class TaoComposeSceneHost( s.directContext, s.picture, s.clearColor, + s.pictureOffset, s.present, ) } @@ -1562,26 +1869,43 @@ internal class TaoComposeSceneHost( * render thread is idle and no interop is active; the steady-state loop uses * [renderFrameSuspending]. */ - fun renderFrameBlocking() { - val bundle = sceneBundle ?: return - val ctx = directContext ?: return - if (attachmentHandle == 0L || widthPx <= 0 || heightPx <= 0) return + fun renderFrameBlocking( + /** Drain [TaoMainDispatcher] before the replay; `false` from inside an event dispatch (see [onResized]). */ + pumpDispatcher: Boolean = true, + ): Boolean { + val bundle = sceneBundle ?: return false + val ctx = directContext ?: return false + if (attachmentHandle == 0L || widthPx <= 0 || heightPx <= 0) return false val mainClear = if (glassBackgroundState.value) 0 else clearColorArgbState.value - val mainPicture = recordSceneToPicture(bundle, widthPx, heightPx) + val frameW = widthPx + val frameH = heightPx + val mainPicture = recordSceneToPicture(bundle, frameW, frameH) val popupSurfaces = recordPopupSurfaces() - TaoMainDispatcher.pump() + if (pumpDispatcher) TaoMainDispatcher.pump() val handle = attachmentHandle - runOnRenderThread { - try { - replayPictureToFrame(handle, ctx, mainPicture, mainClear) - } finally { - mainPicture.close() + val presented = + runOnRenderThread { + val ok = + try { + replayPictureToFrame(handle, ctx, mainPicture, mainClear) + } finally { + mainPicture.close() + } + replayPopups(popupSurfaces) + ok } - replayPopups(popupSurfaces) - } + if (presented) TaoPresentDiagnostics.record(window.handle, IntSize(frameW, frameH)) + return presented } fun detach() { + // Layers whose dismiss animation was still running: Compose closes a + // native popup layer only when its own disappearance finishes, so an + // owner destroyed mid-animation left the layer's popup window mapped + // for good — an invisible rectangle eating every click under it. + for (layer in liveNativePopupLayers.toList()) layer.close() + liveNativePopupLayers.clear() + window.inboundDragAndDropNode = null window.imeReplaceCommit = null window.imePreedit = null window.imeCommit = null @@ -1600,6 +1924,7 @@ internal class TaoComposeSceneHost( frameDispatcher = null renderLoopJob.cancel() textToolbar.hide() + window.clearContentMeasurer() sceneBundle?.close() sceneBundle = null // Drop the TextureView handle before the context it points at dies. @@ -1711,7 +2036,7 @@ private class TaoPlatformContext( } override fun setPointerIcon(pointerIcon: androidx.compose.ui.input.pointer.PointerIcon) { - NativeTaoBridge.nativeSetCursorIcon(windowHandle, mapPointerIcon(pointerIcon)) + NativeTaoBridge.setCursorIcon(windowHandle, mapPointerIcon(pointerIcon)) } /** @@ -1732,7 +2057,7 @@ private class TaoPlatformContext( // what lets AppKit's PressAndHold accent picker engage; a hidden // NSTextView overlay was tried and rejected because it forced an // I-beam cursor for the whole window. - NativeTaoBridge.nativeActivateInputContext(windowHandle) + val inputContextToken = NativeTaoBridge.nativeActivateInputContext(windowHandle) onInputSession(request) try { coroutineScope { @@ -1771,6 +2096,10 @@ private class TaoPlatformContext( } } finally { NativeTaoBridge.nativeSetImeDocument(windowHandle, "", 0L, -1L, -1L) + // The field is gone: its insertion point must go with it, or + // AppKit keeps drawing the input-source indicator (Caps Lock + // layout switching) over the caret it last knew about. + NativeTaoBridge.nativeDeactivateInputContext(windowHandle, inputContextToken) onInputSession(null) } } @@ -1801,29 +2130,7 @@ private class TaoPlatformContext( ) } - private fun mapPointerIcon(icon: androidx.compose.ui.input.pointer.PointerIcon): Int { - when { - icon === androidx.compose.ui.input.pointer.PointerIcon.Default -> return TaoCursorIcon.DEFAULT - icon === androidx.compose.ui.input.pointer.PointerIcon.Text -> return TaoCursorIcon.TEXT - icon === androidx.compose.ui.input.pointer.PointerIcon.Hand -> return TaoCursorIcon.HAND - icon === androidx.compose.ui.input.pointer.PointerIcon.Crosshair -> return TaoCursorIcon.CROSSHAIR - } - return runCatching { - val cursor = icon.javaClass.getMethod("getCursor").invoke(icon) as? java.awt.Cursor - when (cursor?.type) { - java.awt.Cursor.TEXT_CURSOR -> TaoCursorIcon.TEXT - java.awt.Cursor.HAND_CURSOR -> TaoCursorIcon.HAND - java.awt.Cursor.CROSSHAIR_CURSOR -> TaoCursorIcon.CROSSHAIR - java.awt.Cursor.WAIT_CURSOR -> TaoCursorIcon.WAIT - java.awt.Cursor.MOVE_CURSOR -> TaoCursorIcon.MOVE - java.awt.Cursor.E_RESIZE_CURSOR, java.awt.Cursor.W_RESIZE_CURSOR -> TaoCursorIcon.EW_RESIZE - java.awt.Cursor.N_RESIZE_CURSOR, java.awt.Cursor.S_RESIZE_CURSOR -> TaoCursorIcon.NS_RESIZE - java.awt.Cursor.NE_RESIZE_CURSOR, java.awt.Cursor.SW_RESIZE_CURSOR -> TaoCursorIcon.NESW_RESIZE - java.awt.Cursor.NW_RESIZE_CURSOR, java.awt.Cursor.SE_RESIZE_CURSOR -> TaoCursorIcon.NWSE_RESIZE - else -> TaoCursorIcon.DEFAULT - } - }.getOrDefault(TaoCursorIcon.DEFAULT) - } + private fun mapPointerIcon(icon: androidx.compose.ui.input.pointer.PointerIcon): Int = icon.toTaoCursorIconCode() } /** @@ -1833,3 +2140,6 @@ private class TaoPlatformContext( * which AppKit only ever asks near the caret. */ private const val IME_DOCUMENT_WINDOW_UTF16 = 128 + +/** `TaoNativeViewHost.dispatchPointerToNative` type code for a Press. */ +private const val NATIVE_POINTER_PRESS = 1 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt index d2269c1f0..0192cf972 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostLinux.kt @@ -7,10 +7,13 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.asSkiaBitmap import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.pointer.PointerButton import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.PointerId import androidx.compose.ui.input.pointer.PointerKeyboardModifiers @@ -20,6 +23,7 @@ import androidx.compose.ui.scene.ComposeScenePointer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowExceptionHandler @@ -29,25 +33,36 @@ import dev.nucleusframework.window.tao.TaoApplication import dev.nucleusframework.window.tao.TaoEventCode import dev.nucleusframework.window.tao.TaoGpuRenderContextConsumers import dev.nucleusframework.window.tao.TaoModifierMask +import dev.nucleusframework.window.tao.TaoMonitors import dev.nucleusframework.window.tao.TaoNonFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.TaoTouchEvent import dev.nucleusframework.window.tao.TaoTrackpadGesture import dev.nucleusframework.window.tao.TaoTrackpadPhase import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.clearContentMeasurer import dev.nucleusframework.window.tao.clipboard.ProvideTaoClipboard import dev.nucleusframework.window.tao.deco.ResizeFrameDecoration import dev.nucleusframework.window.tao.deco.TaoLinuxOverlayController import dev.nucleusframework.window.tao.deco.TaoLinuxOverlayControllerImpl +import dev.nucleusframework.window.tao.dispatch.DelayScheduler +import dev.nucleusframework.window.tao.event.TaoTrackpadRotationContacts +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession import dev.nucleusframework.window.tao.event.TaoWheelPinchZoom import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent +import dev.nucleusframework.window.tao.event.toTaoCursorIconCode import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoEglBridge import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxTouchBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxWidgetBridge import dev.nucleusframework.window.tao.hasGlTextureImports +import dev.nucleusframework.window.tao.installContentMeasurer +import dev.nucleusframework.window.tao.popup.PopupScreenGeometry +import dev.nucleusframework.window.tao.popup.PopupScrimRegistry import dev.nucleusframework.window.tao.popup.TaoPopupHostLinux import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayerLinux import dev.nucleusframework.window.tao.releaseGlTextureImports @@ -62,7 +77,6 @@ import kotlinx.coroutines.launch import org.jetbrains.skia.BackendRenderTarget import org.jetbrains.skia.BlendMode import org.jetbrains.skia.Canvas -import org.jetbrains.skia.ColorSpace import org.jetbrains.skia.DirectContext import org.jetbrains.skia.FramebufferFormat import org.jetbrains.skia.GLAssembledInterface @@ -72,10 +86,9 @@ import org.jetbrains.skia.PathFillMode import org.jetbrains.skia.RRect import org.jetbrains.skia.Rect import org.jetbrains.skia.Surface -import org.jetbrains.skia.SurfaceColorFormat -import org.jetbrains.skia.SurfaceOrigin import org.jetbrains.skia.makeGLWithInterface import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.locks.ReentrantLock import java.util.logging.Logger @@ -170,6 +183,40 @@ internal class TaoComposeSceneHostLinux( */ private val popupRenderers: MutableMap Unit> = LinkedHashMap() + /** + * Dialog scrims of the native popup layers, painted over the main scene at + * the end of every frame — see [PopupScrimRegistry]. + */ + private val popupScrims = + PopupScrimRegistry { + sceneBundle?.visualDirty?.set(true) + requestRedrawCoalesced() + } + + /** + * Dialog scrims of native popup layers land on the owner window's surface, + * after its content — Compose Desktop's `onRenderOverlay`. + */ + private fun paintPopupScrims(canvas: Canvas) { + popupScrims.paintAll( + canvas, + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), + transparent = fullyTransparent, + ) + } + + /** + * Hooks every main-scene bundle gets: frame failures (recomposition / + * layout / draw) go to the window's exception handler — the single seam + * all three platforms render through — and popup scrims paint after the + * content. + */ + private fun configureSceneBundle() { + val bundle = sceneBundle ?: return + bundle.exceptionHandler = exceptionHandler + bundle.renderOverlay = ::paintPopupScrims + } + /** * Key handlers consulted before the main scene's key dispatch. Popup * windows never own keyboard focus on Linux (override-redirect / @@ -177,6 +224,9 @@ internal class TaoComposeSceneHostLinux( */ private val popupKeyHandlers: MutableMap Boolean> = LinkedHashMap() + /** The layer holding this window's `xdg_popup` slot — see [TaoPopupHostLinux.acquireCompositorPopup]. */ + private var compositorPopupOwner: Any? = null + /** Callbacks invoked when the owner window's screen position changes (X11). */ private val ownerMoveListeners: MutableMap Unit> = LinkedHashMap() @@ -186,7 +236,7 @@ internal class TaoComposeSceneHostLinux( * parent press is by definition outside every popup. See * [TaoPopupHostLinux.registerOutsidePressListener]. */ - private val outsidePressListeners: MutableMap Unit> = + private val outsidePressListeners: MutableMap Unit> = LinkedHashMap() private val windowInfo = TaoWindowInfo() @@ -196,6 +246,12 @@ internal class TaoComposeSceneHostLinux( private var sceneBundle: TaoSceneBundle? = null private val scene: ComposeScene? get() = sceneBundle?.scene + init { + // Reads `scene` lazily, so it is valid before the bundle exists (null) + // and across bundle swaps; cleared in dispose(). + window.installContentMeasurer { constraints -> scene?.measureContent(constraints) } + } + /** * Handle `TextureView`s in this window's scene import onto — see * [TaoGlTextureHost]. A **state** rather than a plain field because a @@ -212,6 +268,10 @@ internal class TaoComposeSceneHostLinux( */ private var skipDrainBudget: Int = SKIP_DRAIN_BUDGET_PER_FRAME + /** Diagnostics for a frame the swap gate skipped — see [onRedrawRequested]. */ + private var skippedFrames: Int = 0 + private var skippedFrameStartNanos: Long = 0L + /** Parent locals bridged via [setSceneCompositionLocalContext]; applied to the scene once created. */ private var pendingCompositionLocalContext: androidx.compose.runtime.CompositionLocalContext? = null private val flushingDispatcher = FlushingMainDispatcher() @@ -266,6 +326,13 @@ internal class TaoComposeSceneHostLinux( * opaque region. Tracked by handle so duplicate attach/detach is safe. */ private val attachedNativeViews: MutableSet = linkedSetOf() + + /** + * Handles whose detach has run and that have not been attached again — + * a late `setFrame` for one of these must not touch the widget. + * Cleared on attach: a new widget can be allocated at an old address. + */ + private val detachedNativeViews: MutableSet = hashSetOf() private val nativeViewRects: MutableMap = LinkedHashMap() /** @@ -313,24 +380,14 @@ internal class TaoComposeSceneHostLinux( private var lastAppliedScale: Float = Float.NaN /** - * Wayland: size of the EGL buffer currently in use for painting. - * `wl_egl_window_resize` only takes effect on the next `eglSwapBuffers`. - * Used only when [useDrawableSizedPaint] is true (KWin): paint at this size - * and advance after present. Elsewhere (GNOME / main) paint at the window - * size so layout stays in sync with the configure. + * Whether this frame pushed a `wl_egl_window_resize` that the buffer has not + * caught up with yet — the only frames that need the drawable pinned before + * it is queried. */ - private var drawableWidthPx: Int = 0 - private var drawableHeightPx: Int = 0 + private var pushedNativeResize: Boolean = false - /** - * KWin flashes if we paint at the window size into a still-old EGL FB - * (BOTTOM_LEFT). GNOME does not need that trade-off — keep master's - * window-sized paint there (and on every non-Plasma DE). - */ - private val useDrawableSizedPaint: Boolean - get() = - attachedKind == 2 && - LinuxDesktopEnvironment.Current == LinuxDesktopEnvironment.KDE + /** A scale change asked for the Skia surface to be rebuilt once the context is current. */ + private var surfaceRebuildDue: Boolean = false // Cache the Skia RT/Surface across frames — recreated only when the size // changes. Reallocating an FBO + GL surface every frame piles up driver @@ -364,8 +421,50 @@ internal class TaoComposeSceneHostLinux( */ private var lastResizeEventNs: Long = 0L private var resizeBurstActive: Boolean = false + + /** + * Whether the content sub-surface is in `set_sync` mode — entered with the + * resize burst while an embed is attached, left when the burst ends. In + * that mode a Compose buffer only shows with GTK's toplevel commit, which + * is what makes it land atomically with the embed's new position; see + * `NativeTaoEglBridge.nativeSetSubsurfaceSync`. + */ + private var subsurfaceSynced: Boolean = false private var appliedSwapInterval: Int = 1 - private var pendingSwapInterval: Int? = null + + /** + * Whether the current resize burst renders from GTK's `draw` signal + * (#444) — set with the burst when the hook is in place, cleared with it. + * [subsurfaceSynced] follows one step later, armed from inside the first + * such draw so no in-flight swap is caught by `set_sync`. + */ + private var inFrameBurst: Boolean = false + + /** + * `System.nanoTime()` of the oldest `queue_draw` handed to GTK that + * [onToplevelDraw] has not answered yet; 0 while none is outstanding. + * GTK cannot paint while GDK's frame clock is frozen on a frame callback + * the compositor withholds — a maximized or tiled toplevel is covered + * edge to edge by its own opaque content sub-surface, which Mutter takes + * as obscured — and a burst that only rendered from GTK's draw would + * never render again. [onRedrawRequested] falls back to the event-loop + * path once an ask has gone unanswered for [IN_FRAME_DRAW_GRACE_NS]. + */ + private var toplevelDrawAskedNs: Long = 0L + + /** GTK stopped answering `queue_draw` during this burst: stay off the in-frame path until it ends. */ + private var inFrameStalled: Boolean = false + + /** + * Whether the toplevel is covered edge to edge by our opaque content + * sub-surface: maximized, tiled and fullscreen windows have no CSD shadow + * ring. Mutter culls such a parent as obscured and sends it no frame + * callback, so a paint asked of GTK there would be the last one it ever + * makes — GDK's frame clock freezes on the unanswered callback, and with + * it the flush of pointer motion (GDK holds a lone motion event until + * the clock's flush-events phase). The in-frame path is not used there. + */ + private fun parentObscured(): Boolean = window.isMaximized || window.isFullscreen || window.isTiled /** * Extra redraws after a size change so the buffer allocated by the next @@ -375,6 +474,16 @@ internal class TaoComposeSceneHostLinux( private val postResizeCatchUpFrames = AtomicInteger(0) private val sceneSizeUpdateIntervalNs = 16_666_667L // 60fps + /** + * In-drag GPU cache purge, deferred to the next render pass. [onResized] + * runs on the event-loop thread with no EGL context bound — the swap thread + * may even hold ours for its `eglSwapBuffers` — so the timing decision is + * taken here and the purge itself happens in [onRedrawRequested], the one + * place this host's context is current on this thread. + */ + private var lastResizePurgeNs: Long = 0L + private var resizePurgeDue: Boolean = false + private var lastPointerX: Float = 0f private var lastPointerY: Float = 0f @@ -401,6 +510,24 @@ internal class TaoComposeSceneHostLinux( */ private val pressedButtons = mutableSetOf() + /** + * Tao codes of the buttons whose press Compose handed to an embedded + * native widget ([TaoNativeViewHost.dispatchPointerToNative]) and whose + * release has not come back yet. + * + * Such a release routinely never comes: the embed's own context menu, or a + * drag it starts, takes a grab and the release goes there. Compose is then + * left holding a button forever, and — since a click needs a down + * *transition* — every later click on Compose is dead, and hover no longer + * updates the cursor. [healStaleNativePresses] asks GDK which buttons are + * really down on the next motion and releases the phantoms; the next press + * releases them regardless, the way the macOS host does. + */ + private val forwardedNativeButtons = mutableSetOf() + + /** Whether the press being dispatched was handed to a native view — reset at every press. */ + private var nativePointerDispatchedThisEvent = false + /** * Captured at the first composition via [setContent]. Exposes the * standard `FocusManager.clearFocus(force = true)` API which the @@ -430,6 +557,9 @@ internal class TaoComposeSceneHostLinux( /** True once attached on the X11/XWayland backend (vs native Wayland). */ val isX11: Boolean get() = attachedKind == 1 + /** True once attached on the native Wayland backend. */ + private val isWayland: Boolean get() = attachedKind == 2 + /** * True while a compositor-driven interactive resize/move drag is in * flight. The compositor's grab makes GTK report a focus-out for the @@ -477,7 +607,12 @@ internal class TaoComposeSceneHostLinux( dev.nucleusframework.window.tao.dnd.TaoDragAndDropManager( getRootNode = { scene!!.rootDragAndDropNode }, outboundLauncher = ::launchLinuxOutboundDrag, + // The cross-window gestures ride the DnD session on native + // Wayland; their token-only payload is meaningful here. + acceptsPrivateData = true, ) + liveHosts += this + window.contentSnapshot = ::snapshotContent // IME callbacks edit the focused field through `TextEditingScope`, i.e. // they run user code straight off a GTK IM callback — the Tao // counterpart of AWT's guarded `inputMethodTextChanged`. @@ -521,18 +656,7 @@ internal class TaoComposeSceneHostLinux( coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, - composeSceneContext = - TaoComposeSceneContext( - platformContext = platformContext, - ) { density, layoutDirection, focusable, consumeOutside -> - TaoPopupSceneLayerLinux( - host = popupHost(), - initialDensity = density, - initialLayoutDirection = layoutDirection, - initialFocusable = focusable, - initialConsumePointerInputOutside = consumeOutside, - ) - }, + composeSceneContext = TaoComposeSceneContext(platformContext, nativePopupLayerFactory()), requestFrame = { requestRedrawCoalesced() }, ) } else { @@ -547,9 +671,7 @@ internal class TaoComposeSceneHostLinux( ) } scene?.compositionLocalContext = pendingCompositionLocalContext - // Frame failures (recomposition / layout / draw) are caught inside the - // bundle, the single seam all three platforms render through. - sceneBundle?.exceptionHandler = exceptionHandler + configureSceneBundle() // Notify popup layers when the host window moves on screen — X11 // popups are positioned in root coordinates and don't auto-track. @@ -559,6 +681,9 @@ internal class TaoComposeSceneHostLinux( } } + // One source of truth for the scene's drop target: the callback below + // resolves it through here, and so does an in-process driver. + window.inboundDragAndDropNode = { scene?.rootDragAndDropNode } registerInboundDnD() registerTouch() } @@ -655,16 +780,36 @@ internal class TaoComposeSceneHostLinux( } val iface = GLAssembledInterface.createFromNativePointers(0L, fnPtr) val ctx = DirectContext.makeGLWithInterface(iface) + // Anchor the GPU resource cache budget while the fresh EGL context is + // still the one the native attach left current — writing the limit + // purges to fit, so like every other use of the context it belongs + // where the context is usable. The value itself changes nothing today + // (see GPU_RESOURCE_CACHE_LIMIT_BYTES); what reclaims the per-size + // scratch of a drag is [purgeResizeScratchIfDue]. + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES directContext = ctx // Publish the TextureView handle for the fresh EGL context / Skia // context pair (see glTextureHostState). + val ownAttachment = attachmentHandle glTextureHostState.value = object : TaoGlTextureHost { override val directContext: DirectContext = ctx - // Read live: 0 once the window detached, so a late disposal - // can't bind (nor dereference) a freed attachment. - override fun withContextCurrent(block: () -> T): T? = withEglContextCurrent(attachmentHandle, block) + // Bound only while this pair is the live one. A Wayland + // hide/show rebuilds the EGL context *and* the DirectContext: + // reading the outer attachment live would bind the *new* EGL + // context for a consumer still holding this object's closed + // `ctx` — a `flushAndSubmit` on it is a SIGSEGV in Skia. Once + // the outer handle moved on (or went to 0 on detach) this + // pair is gone, and the caller's null means "context gone". + override fun withContextCurrent(block: () -> T): T? = + if (attachmentHandle != ownAttachment || + directContext !== this@TaoComposeSceneHostLinux.directContext + ) { + null + } else { + withEglContextCurrent(ownAttachment, block) + } } // The native attach binds the EGL context to *this* thread (the GTK @@ -679,9 +824,6 @@ internal class TaoComposeSceneHostLinux( lastAppliedWidthPx = -1 lastAppliedHeightPx = -1 lastAppliedScale = Float.NaN - // Attach creates the wl_egl_window at the current physical size. - drawableWidthPx = widthPx.coerceAtLeast(0) - drawableHeightPx = heightPx.coerceAtLeast(0) } /** @@ -708,8 +850,6 @@ internal class TaoComposeSceneHostLinux( cachedSurface = null cachedRt?.close() cachedRt = null - drawableWidthPx = 0 - drawableHeightPx = 0 // Drop TextureView imports made on this context while it is still // current and alive; the composition survives the hide, so its leases // would otherwise hold Skia images on a destroyed context. @@ -722,6 +862,8 @@ internal class TaoComposeSceneHostLinux( NativeTaoEglBridge.nativeReleaseCurrent(attachmentHandle) NativeTaoEglBridge.nativeDetach(attachmentHandle) attachmentHandle = 0L + // The sub-surface went with the attachment; a fresh one starts desync. + subsurfaceSynced = false } /** @@ -761,11 +903,19 @@ internal class TaoComposeSceneHostLinux( // no tao event, so the `REDRAW_REQUESTED` matching a latched // `redrawPending` still sits in tao's draw channel when the drag // ends and the latch un-wedges itself on delivery. + val icon = rasterizeDragDecoration(request) dev.nucleusframework.window.tao.ffi.NativeTaoLinuxDndBridge.nativeStartDrag( handle = window.handle, files = files, text = text, + privateData = request.privateData, allowedEffects = allowedEffects, + iconArgb = icon?.argb, + iconWidth = icon?.width ?: 0, + iconHeight = icon?.height ?: 0, + iconScale = icon?.scale ?: 1f, + iconHotX = icon?.hotX ?: 0, + iconHotY = icon?.hotY ?: 0, pump = OutboundDragPump(), ) } @@ -773,6 +923,113 @@ internal class TaoComposeSceneHostLinux( return true } + /** + * Draws the scene's current composition into a raster bitmap and returns + * [rectPx] of it (content pixels), or the whole content when `null`. The + * same recompose-layout-draw pass the GL frame runs, aimed at a CPU + * surface, so it costs one extra frame and needs no context. Cleared to + * the chrome colour like a real frame, so regions without an explicit + * background come out as the window looks and not transparent. + */ + private fun snapshotContent(rectPx: IntRect?): androidx.compose.ui.graphics.ImageBitmap? { + val bundle = sceneBundle ?: return null + val width = widthPx + val height = heightPx + if (width <= 0 || height <= 0) return null + val full = + androidx.compose.ui.graphics + .ImageBitmap(width, height) + val canvas = Canvas(full.asSkiaBitmap()) + canvas.clear(clearColorArgbState.value) + bundle.render(canvas, System.nanoTime()) + val crop = rectPx?.intersect(IntRect(0, 0, width, height)) ?: return full + if (crop.width <= 0 || crop.height <= 0) return null + if (crop == IntRect(0, 0, width, height)) return full + val region = + androidx.compose.ui.graphics + .ImageBitmap(crop.width, crop.height) + androidx.compose.ui.graphics.Canvas(region).drawImageRect( + image = full, + srcOffset = crop.topLeft, + srcSize = IntSize(crop.width, crop.height), + dstSize = IntSize(crop.width, crop.height), + paint = + androidx.compose.ui.graphics + .Paint(), + ) + return region + } + + /** A rasterized drag decoration, in the shape `nativeStartDrag` takes. */ + private class DragIcon( + val argb: IntArray, + val width: Int, + val height: Int, + val scale: Float, + val hotX: Int, + val hotY: Int, + ) + + /** + * Renders the request's drag decoration to premultiplied ARGB device + * pixels for GTK's drag icon, at this window's scale so it stays crisp on + * HiDPI. `null` for an empty decoration, which leaves GTK's default icon. + * + * Compose only ever hands a decoration to the manager — the source node + * draws it into whatever the platform provides — so this is where the + * Linux host turns it into pixels; the other two hosts still show their + * platform default. + */ + private fun rasterizeDragDecoration( + request: dev.nucleusframework.window.tao.dnd.TaoDragAndDropManager.OutboundRequest, + ): DragIcon? { + val width = request.decorationSize.width.toInt() + val height = request.decorationSize.height.toInt() + if (width <= 0 || height <= 0 || width > MAX_DRAG_ICON_PX || height > MAX_DRAG_ICON_PX) return null + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val bitmap = + androidx.compose.ui.graphics + .ImageBitmap(width, height) + androidx.compose.ui.graphics.drawscope + .CanvasDrawScope() + .draw( + Density(scale), + androidx.compose.ui.unit.LayoutDirection.Ltr, + androidx.compose.ui.graphics + .Canvas(bitmap), + request.decorationSize, + ) { with(request) { drawDragDecoration() } } + val pixels = IntArray(width * height) + bitmap.readPixels(pixels) + // readPixels is straight (un-premultiplied) ARGB; cairo wants premultiplied. + for (i in pixels.indices) { + val px = pixels[i] + val a = px ushr ALPHA_SHIFT + if (a == 0) { + pixels[i] = 0 + } else if (a != CHANNEL_MAX) { + val r = ((px shr RED_SHIFT) and CHANNEL_MAX) * a / CHANNEL_MAX + val g = ((px shr GREEN_SHIFT) and CHANNEL_MAX) * a / CHANNEL_MAX + val b = (px and CHANNEL_MAX) * a / CHANNEL_MAX + pixels[i] = (a shl ALPHA_SHIFT) or (r shl RED_SHIFT) or (g shl GREEN_SHIFT) or b + } + } + return DragIcon( + argb = pixels, + width = width, + height = height, + scale = scale, + hotX = + request.decorationHotspot.x + .toInt() + .coerceIn(0, width), + hotY = + request.decorationHotspot.y + .toInt() + .coerceIn(0, height), + ) + } + /** * Drives the host while an outbound drag session owns the GTK main thread — * see [dev.nucleusframework.window.tao.ffi.NativeTaoLinuxDndBridge.DragPump]. @@ -809,6 +1066,14 @@ internal class TaoComposeSceneHostLinux( dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher .pump() onRedrawRequested() + // The other windows are frozen by the same dead draw channel, and + // they are where a cross-window drag shows its feedback — the dock + // zones lighting up in the window the pointer is over. Paint the + // ones that asked to; their latched `redrawPending` is exactly the + // request tao could not deliver. + for (host in liveHosts) { + if (host !== this@TaoComposeSceneHostLinux && host.redrawPending.get()) host.onRedrawRequested() + } } } @@ -827,7 +1092,7 @@ internal class TaoComposeSceneHostLinux( */ @OptIn(InternalComposeUiApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) private inner class InboundDnDCallback : dev.nucleusframework.window.tao.ffi.NativeTaoLinuxDndBridge.Callback { - private fun node() = scene?.rootDragAndDropNode + private fun node() = window.inboundDragAndDropNode?.invoke() // Linux keeps neither the macOS/Windows diagnostic logging nor their // `if (!hasFiles) return NONE` guard, so its overrides delegate straight @@ -891,12 +1156,11 @@ internal class TaoComposeSceneHostLinux( // GdkEventTouchpadPinch into the wire format below; we marshal them // into Compose pointer events here. // - // Trackpad gesture path: same trick as the macOS host — synthesise two - // ComposeScenePointer Touch points around the gesture focal point with - // distance varying by accumulated scale and angle by accumulated - // rotation, so `detectTransformGestures` reacts to pinch/rotate with - // strictly cross-platform application code. Smart-magnify is macOS-only - // and is never reported on Linux (no GDK equivalent). + // Trackpad gesture path: magnify is forwarded as Compose `ScaleStart` / + // `ScaleChange` / `ScaleEnd` (#660), matching the macOS host. Rotation + // has no Compose equivalent, so it still synthesises two Touch pointers + // around the focal point. Smart-magnify is macOS-only and is never + // reported on Linux (no GDK equivalent). private fun registerTouch() { if (!NativeTaoLinuxTouchBridge.isLoaded) return @@ -1013,21 +1277,61 @@ internal class TaoComposeSceneHostLinux( // rather than abstracted into a shared helper because the two hosts have // diverged in other dimensions (rendering, scale handling, lifecycle) // and a thin shared trait would obscure more than it factors. - private var gestureActive = false + // + // The same rule holds as on macOS: the rotation's Touch contacts never + // coexist with a mouse-only event (a Scale, a scroll, a cursor move, a + // click), since an event lists every active pointer and one without the + // contacts reads as their release — a touch tap per step. + // + // What differs is the source. AppKit reports magnify and rotate as two + // gestures, and the first to begin owns the trackpad. GDK reports ONE + // pinch gesture whose every event carries a scale and an angle, so + // `touch.rs` forwards a magnify and a rotate step for each, magnify + // first: first-come would hand every pinch to Scale and make rotation + // unreachable. So a pinch opens as Scale — no delay for the common case — + // and its angle is only accumulated; once the rotation clearly dominates + // (ROTATE_TAKEOVER_DEGREES turned while the scale stayed within + // ROTATE_TAKEOVER_MAX_ZOOM) the Scale gesture closes and the contacts + // take over, already turned by that angle so the takeover counts towards + // `detectTransformGestures`' rotation slop. From there magnify steps + // widen the contacts, as on macOS. private var gestureCenterX = 0f private var gestureCenterY = 0f - private var gestureScale = 1f + private val scaleSession = + TaoTrackpadScaleSession { type, factor -> + scene?.dispatchTrackpadScale( + x = gestureCenterX, + y = gestureCenterY, + type = type, + scaleFactor = factor, + keyboardModifiers = currentKeyboardModifiers, + ) + } + + // A GDK pinch is in progress (BEGIN..END), whoever owns it. + private var pinchActive = false + + // Zoom and rotation (degrees) of the pinch while Scale owns it — what the + // takeover rule reads. + private var pinchZoom = 1f + private var pinchAngleDegrees = 0f + + private var rotateActive = false + private var rotateInterrupted = false private var gestureAngle = 0f + // Spacing of the rotation contacts relative to their start: magnify steps + // that arrive while the rotation owns the pinch (1 otherwise). + private var rotateScale = 1f + // Ctrl+wheel is a discrete stream with no ENDED phase (unlike a native trackpad - // gesture), so the synthetic magnify is released by an idle timer on this scope. + // gesture), so the scale gesture is released by an idle timer on this scope. // Deliberately NOT on the #622 fatal path: gesture helpers are isolated // (SupervisorJob) — a crash there costs one gesture, logged at SEVERE. private val gestureScope = CoroutineScope(coroutineContext + flushingDispatcher + SupervisorJob() + TaoNonFatalCoroutineExceptionHandler) private var wheelZoomEndJob: Job? = null - @OptIn(ExperimentalComposeUiApi::class) private fun dispatchTrackpadGesture( kind: Int, phase: Int, @@ -1036,79 +1340,120 @@ internal class TaoComposeSceneHostLinux( valueFixed: Long, ) { if (scene == null) return - val xPx = xFixed / TOUCH_POSITION_SCALE - val yPx = yFixed / TOUCH_POSITION_SCALE + gestureCenterX = xFixed / TOUCH_POSITION_SCALE + gestureCenterY = yFixed / TOUCH_POSITION_SCALE val value = valueFixed / TRACKPAD_VALUE_SCALE + when (kind) { + TaoTrackpadGesture.MAGNIFY -> onMagnify(phase, value) + TaoTrackpadGesture.ROTATE -> onRotate(phase, value) + } + } + + private fun onMagnify( + phase: Int, + value: Float, + ) { + val factor = (1f + value).coerceAtLeast(TaoTrackpadScaleSession.MIN_GESTURE_SCALE) + if (rotateActive) { + // The rotation owns this pinch: fold the step into the contacts. + if (phase == TaoTrackpadPhase.BEGAN || phase == TaoTrackpadPhase.CHANGED) { + // Bounded: past Float range the contacts become Infinity / NaN + // points and detectZoom hands the app an infinite zoom. + rotateScale = (rotateScale * factor).coerceIn(MIN_ROTATE_SCALE, MAX_ROTATE_SCALE) + sendRotatePointers(PointerEventType.Move) + } + if (phase == TaoTrackpadPhase.ENDED || phase == TaoTrackpadPhase.CANCELLED) pinchActive = false + return + } when (phase) { TaoTrackpadPhase.BEGAN -> { - startGesture(xPx, yPx) - applyGestureDelta(kind, value) - sendGesturePointers(PointerEventType.Press) + // A Ctrl+wheel burst still closing must not end the pinch's gesture. + wheelZoomEndJob?.cancel() + wheelZoomEndJob = null + pinchActive = true + rotateInterrupted = false + pinchZoom = factor + pinchAngleDegrees = 0f + scaleSession.start() + scaleSession.magnifyBy(value) } TaoTrackpadPhase.CHANGED -> { - if (!gestureActive) { - startGesture(xPx, yPx) - } else { - // Track the focal point on every tick so a pinch-while- - // dragging keeps its pan component (the synthetic centroid - // moves with the focal point between events). - gestureCenterX = xPx - gestureCenterY = yPx - } - applyGestureDelta(kind, value) - sendGesturePointers(PointerEventType.Move) + if (rotateInterrupted) return + pinchZoom *= factor + scaleSession.magnifyBy(value) + } + TaoTrackpadPhase.ENDED, TaoTrackpadPhase.CANCELLED -> { + pinchActive = false + scaleSession.end() } - TaoTrackpadPhase.ENDED -> endGesture(cancelled = false) - TaoTrackpadPhase.CANCELLED -> endGesture(cancelled = true) } } - private fun startGesture( - centerX: Float, - centerY: Float, + private fun onRotate( + phase: Int, + value: Float, ) { - gestureActive = true - gestureCenterX = centerX - gestureCenterY = centerY - gestureScale = 1f + if (phase == TaoTrackpadPhase.ENDED || phase == TaoTrackpadPhase.CANCELLED) { + rotateInterrupted = false + endRotate(cancelled = phase == TaoTrackpadPhase.CANCELLED) + return + } + if (rotateInterrupted) return + if (rotateActive) { + applyRotateDelta(value) + sendRotatePointers(PointerEventType.Move) + return + } + // Compose has no rotation event: while Scale owns the pinch the angle + // only counts towards the takeover. + pinchAngleDegrees += value + val zoomed = pinchZoom !in (1f / ROTATE_TAKEOVER_MAX_ZOOM)..ROTATE_TAKEOVER_MAX_ZOOM + if (!pinchActive || zoomed || abs(pinchAngleDegrees) < ROTATE_TAKEOVER_DEGREES) return + scaleSession.end() + rotateActive = true + rotateScale = 1f gestureAngle = 0f + sendRotatePointers(PointerEventType.Press) + applyRotateDelta(pinchAngleDegrees) + sendRotatePointers(PointerEventType.Move) } - private fun applyGestureDelta( - kind: Int, - value: Float, - ) { - when (kind) { - TaoTrackpadGesture.MAGNIFY -> - gestureScale *= (1f + value).coerceAtLeast(MIN_GESTURE_SCALE) - TaoTrackpadGesture.ROTATE -> { - // Rust converts GDK's per-event radians into degrees so this - // matches the macOS NSEvent.rotation contract exactly. Sign - // flip for Compose's y-down screen frame. - gestureAngle -= value * (Math.PI.toFloat() / DEGREES_PER_RADIAN) - } - } + /** + * A mouse-only event is about to reach the scene while the rotation + * contacts are down: it would read as their release, so end the rotation + * first — cancelled, so the contacts do not land as a tap. The rest of + * that pinch is ignored. + */ + private fun interruptRotation() { + if (!rotateActive) return + rotateInterrupted = true + endRotate(cancelled = true) + } + + private fun applyRotateDelta(degrees: Float) { + // `touch.rs` converts GDK's per-event radians into degrees. GDK's + // angle_delta is positive clockwise on screen, which is Compose's + // y-down rotation sense too — no flip, unlike AppKit's y-up rotation. + gestureAngle += degrees * (Math.PI.toFloat() / DEGREES_PER_RADIAN) } @OptIn(ExperimentalComposeUiApi::class) - private fun sendGesturePointers(eventType: PointerEventType) { + private fun sendRotatePointers(eventType: PointerEventType) { val sc = scene ?: return - val radius = TRACKPAD_BASE_RADIUS_PX * gestureScale - val cosA = cos(gestureAngle) - val sinA = sin(gestureAngle) - val dx = radius * cosA - val dy = radius * sinA + val radius = TRACKPAD_BASE_RADIUS_PX * rotateScale + val dx = radius * cos(gestureAngle) + val dy = radius * sin(gestureAngle) val pressed = eventType != PointerEventType.Release val pointers = listOf( ComposeScenePointer( - id = PointerId(TRACKPAD_POINTER_ID_A), + id = TaoTrackpadRotationContacts.A, position = Offset(gestureCenterX - dx, gestureCenterY - dy), pressed = pressed, type = PointerType.Touch, ), ComposeScenePointer( - id = PointerId(TRACKPAD_POINTER_ID_B), + id = TaoTrackpadRotationContacts.B, position = Offset(gestureCenterX + dx, gestureCenterY + dy), pressed = pressed, type = PointerType.Touch, @@ -1121,13 +1466,16 @@ internal class TaoComposeSceneHostLinux( ) } - private fun endGesture(cancelled: Boolean) { - if (!gestureActive) return - sendGesturePointers(PointerEventType.Release) - gestureActive = false - gestureScale = 1f - gestureAngle = 0f + private fun endRotate(cancelled: Boolean) { + if (!rotateActive) return + // Cancel first: a Release delivered before the cancel is an ordinary + // unconsumed touch-up, which a tap detector takes as a tap. After it, + // the Release only clears the scene's record of the contacts. if (cancelled) scene?.cancelPointerInput() + sendRotatePointers(PointerEventType.Release) + rotateActive = false + gestureAngle = 0f + rotateScale = 1f } /** Current scale factor (logical→physical multiplier). */ @@ -1214,7 +1562,26 @@ internal class TaoComposeSceneHostLinux( } == true if (!resizeBurstActive && !framePacedContent) { resizeBurstActive = true - pendingSwapInterval = 0 + setSwapIntervalAsync(0) + } + // Sync mode for the whole burst (#444): the compositor then applies + // our buffer *with* GTK's toplevel commit — the one that carries the + // new geometry — instead of whenever it arrives. That is only + // atomic if the buffer is committed before GTK's, which is what + // rendering from the toplevel's `draw` signal guarantees (see + // [onToplevelDraw]); without that hook sync would just delay every + // frame by one GTK paint, so it is armed only once the hook is — + // and only with the interval-0 burst, since a swap that waited for + // a frame callback would wait for the GTK commit this very frame + // has yet to make. + val inFrameWanted = resizeBurstActive && !inFrameBurst && !inFrameStalled && !parentObscured() + if (inFrameWanted && attachmentHandle != 0L && ensureToplevelDrawHook()) { + inFrameBurst = true + // The interval-0 present must be in force before the first + // synced commit: a synced commit made with interval 1 registers + // a frame callback that only fires with GTK's commit, and the + // next swap would wait for it inside GTK's draw. + setSwapIntervalAsync(0) } // Two catch-up frames: (1) swap that allocates the new buffer, // (2) paint into it. Refreshed on every motion so a continuous @@ -1239,30 +1606,77 @@ internal class TaoComposeSceneHostLinux( (widthPx / opaqueScale).coerceAtLeast(1), (heightPx / opaqueScale).coerceAtLeast(1), ) + // Arm the periodic in-drag purge of the per-size GPU scratch — see + // [resizePurgeDue] for why it can't run right here. + if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) { + lastResizePurgeNs = now + resizePurgeDue = true + } requestRedrawCoalesced() } /** - * Applies a pending [pendingSwapInterval] while the EGL context is current. + * Hands the swap thread the interval the burst state calls for. * Ends the resize burst once the window has been stable for * [RESIZE_BURST_HOLD_NS]. */ private fun updateResizeBurstSwapInterval() { if (attachmentHandle == 0L || attachedKind != 2 || window.isPopup) return - if (resizeBurstActive && - lastResizeEventNs > 0L && - System.nanoTime() - lastResizeEventNs >= RESIZE_BURST_HOLD_NS - ) { + endResizeBurstIfStale() + if (swapThread == null) { + pendingSwapIntervalNoThread?.let { NativeTaoEglBridge.nativeSetSwapInterval(attachmentHandle, it) } + pendingSwapIntervalNoThread = null + } + } + + /** + * Ends the resize burst — and with it in-frame rendering — once the + * window has been still for [RESIZE_BURST_HOLD_NS]. Needs no GL context, + * so it also runs from [onRedrawRequested]: while in-frame, the render + * pass only runs from GTK's draw, and the burst's end must not wait for + * a paint GTK may never make. + */ + private fun endResizeBurstIfStale() { + if (attachmentHandle == 0L || attachedKind != 2 || window.isPopup) return + val burstOver = lastResizeEventNs > 0L && System.nanoTime() - lastResizeEventNs >= RESIZE_BURST_HOLD_NS + if (!burstOver) return + if (resizeBurstActive) { resizeBurstActive = false - pendingSwapInterval = 1 + setSwapIntervalAsync(1) } - val want = pendingSwapInterval ?: return - pendingSwapInterval = null - if (want == appliedSwapInterval) return - NativeTaoEglBridge.nativeSetSwapInterval(attachmentHandle, want) - appliedSwapInterval = want + inFrameStalled = false + leaveInFrameRendering() + } + + /** + * Back to rendering from the event loop: `set_desync` applies whatever + * the compositor still caches, so the last in-frame frame is never + * stranded. + */ + private fun leaveInFrameRendering() { + toplevelDrawAskedNs = 0L + inFrameBurst = false + if (subsurfaceSynced) { + subsurfaceSynced = false + NativeTaoEglBridge.nativeSetSubsurfaceSync(attachmentHandle, false) + } + } + + /** + * Hands the swap thread the `eglSwapInterval` to apply before its next + * present — the thread that owns the context when it matters. Applied + * directly when there is no swap thread (X11 fallback paths). + */ + private fun setSwapIntervalAsync(interval: Int) { + if (appliedSwapInterval == interval) return + appliedSwapInterval = interval + val st = swapThread + if (st != null) st.requestSwapInterval(interval) else pendingSwapIntervalNoThread = interval } + /** Interval still to apply from the render pass when there is no swap thread to hand it to. */ + private var pendingSwapIntervalNoThread: Int? = null + /** * Keeps the content subsurface aligned with GTK's content area. With the * yaru-style hidden-titlebar CSD (Wayland, non-popup), GTK draws its @@ -1278,7 +1692,16 @@ internal class TaoComposeSceneHostLinux( val packed = NativeTaoBridge.nativeLinuxContentOrigin(window.handle) val xLogical = (packed shr 32).toInt() val yLogical = packed.toInt() - NativeTaoEglBridge.nativeSetContentOffset(attachmentHandle, xLogical, yLogical) + if (NativeTaoEglBridge.nativeSetContentOffset(attachmentHandle, xLogical, yLogical)) { + // The new position is pending parent state: GTK's next commit + // applies it, and after a maximize/restore GTK is idle — ask it + // to paint. (Committing the parent ourselves is not safe; see the + // native side.) + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + if (gtkWindow != 0L && NativeTaoLinuxWidgetBridge.isLoaded) { + NativeTaoLinuxWidgetBridge.nativeQueueToplevelDraw(gtkWindow) + } + } } /** @@ -1356,15 +1779,14 @@ internal class TaoComposeSceneHostLinux( private fun applyPendingNativeResize() { if (attachmentHandle == 0L) return if (widthPx <= 0 || heightPx <= 0) return - // GNOME / main: scene tracks the window. KWin drawable path sets scene - // size from the paint size below (may lag the window by one present). - if (!useDrawableSizedPaint) { - val currentSize = IntSize(widthPx, heightPx) - if (scene?.size != currentSize) { - scene?.size = currentSize - updateWindowInfoSize() - lastSceneSizeUpdateNs = System.nanoTime() - } + // Layout always tracks the window: Compose measures for the size the + // window *is*, never for the size its buffer happens to have caught up + // to. Only the render target follows the buffer (see [resolvePaintSize]). + val currentSize = IntSize(widthPx, heightPx) + if (scene?.size != currentSize) { + scene?.size = currentSize + updateWindowInfoSize() + lastSceneSizeUpdateNs = System.nanoTime() } if (widthPx == lastAppliedWidthPx && heightPx == lastAppliedHeightPx && @@ -1373,50 +1795,54 @@ internal class TaoComposeSceneHostLinux( return } NativeTaoEglBridge.nativeResize(attachmentHandle, widthPx, heightPx, scale) - if (!useDrawableSizedPaint) { - // Master behaviour: paint size follows the window immediately. - if (widthPx != lastAppliedWidthPx || - heightPx != lastAppliedHeightPx || - scale != lastAppliedScale - ) { - cachedSurface?.close() - cachedSurface = null - cachedRt?.close() - cachedRt = null - } - drawableWidthPx = widthPx - drawableHeightPx = heightPx - } else if (scale != lastAppliedScale) { - // KWin: keep drawable lagging on size-only changes; rebuild on scale. - cachedSurface?.close() - cachedSurface = null - cachedRt?.close() - cachedRt = null - drawableWidthPx = widthPx - drawableHeightPx = heightPx - } + pushedNativeResize = true + // The Skia surface is rebuilt from the *drawable's* size, so + // [ensurePaintSurface] decides when to recreate it. A scale change + // does not resize the drawable at all but changes how the surface is + // built; that rebuild needs the context, which is not current here. + if (scale != lastAppliedScale) surfaceRebuildDue = true lastAppliedWidthPx = widthPx lastAppliedHeightPx = heightPx lastAppliedScale = scale } /** - * KWin only: after a present, the pending `wl_egl_window_resize` is in - * effect — advance the paint size and re-arm a frame if still behind. + * Reclaims the per-size GPU scratch a live resize mints, while the sizes + * are still streaming — the Linux half of what + * [TaoComposeSceneHostWindows.onResized] does inside the OS modal + * resize/move loop. Toggling the limit to 0 runs Skia's `purgeAsNeeded` + * inline, releasing every unlocked resource; restoring the budget lets the + * next frame re-mint only what it needs. The only purge primitive skiko + * exposes — see [GPU_RESOURCE_CACHE_LIMIT_BYTES]. + * + * Called from the render pass, right after [applyPendingNativeResize] has + * closed the [cachedSurface]/[cachedRt] of the previous size: their backing + * render target and stencil are unlocked at exactly this point, so this is + * where the toggle actually returns memory rather than merely walking the + * cache. It is also the only point where this host's EGL context is current + * on this thread — the purge issues `glDelete*`, and the same foreign-context + * hazard the Windows host documents on its own purge applies here, only + * worse: every Linux surface owns a *private*, unshared context (a popup + * layer, a tray panel, a sibling window), so ids collide wholesale and a + * purge against the wrong binding deletes a sibling's live textures. + * Binding from [onResized] instead would be both racy (the swap thread may + * hold our context) and pointless, since the frame that follows re-binds + * anyway. + * + * Deliberately only the *in-drag* half of the Windows behaviour: there is + * no settle purge and no `System.gc()` nudge, for the same reason macOS has + * none (see [TaoComposeSceneHost.purgeResizeScratchIfDue]). GTK gives us no + * drag-end signal to hang them on — the compositor-driven resize grab ends + * with nothing more than pointer events resuming — and a timer standing in + * for it buys a stop-the-world collection after every zoom, snap and + * programmatic resize. The reclaim #638 is really after is at rest, not at + * drag end. */ - private fun onDrawablePresented() { - if (!useDrawableSizedPaint) return - if (lastAppliedWidthPx <= 0 || lastAppliedHeightPx <= 0) return - if (drawableWidthPx == lastAppliedWidthPx && drawableHeightPx == lastAppliedHeightPx) { - return - } - drawableWidthPx = lastAppliedWidthPx - drawableHeightPx = lastAppliedHeightPx - cachedSurface?.close() - cachedSurface = null - cachedRt?.close() - cachedRt = null - requestRedrawCoalesced() + private fun purgeResizeScratchIfDue(ctx: DirectContext) { + if (!resizePurgeDue) return + resizePurgeDue = false + ctx.resourceCacheLimit = 0 + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES } fun onFocusChanged(focused: Boolean) { @@ -1427,6 +1853,7 @@ internal class TaoComposeSceneHostLinux( // is real pointer input resuming (see [onPointerMove] / [onPointerButton]), // which the compositor withholds for the whole grab. windowInfo.isWindowFocused = focused + if (!focused) interruptRotation() } private fun updateWindowInfoSize() { @@ -1439,6 +1866,178 @@ internal class TaoComposeSceneHostLinux( } fun onRedrawRequested() { + endResizeBurstIfStale() + if (inFrameRenderActive()) { + val now = System.nanoTime() + if (toplevelDrawAskedNs != 0L && now - toplevelDrawAskedNs >= IN_FRAME_DRAW_GRACE_NS) { + // GTK has not painted since we asked: its frame clock is + // frozen on a frame callback the compositor is withholding + // (the parent of a maximized or tiled window is fully covered + // by our opaque content, and Mutter sends none to an obscured + // surface). Waiting on it would be waiting forever — render + // from here for the rest of the burst, as before #444. + linuxHostLogger.fine("GTK did not answer queue_draw within the grace; leaving in-frame rendering") + inFrameStalled = true + leaveInFrameRendering() + } else { + // Tao delivers this from its event loop, *after* GTK's paint + // phase — GDK has already committed the toplevel. Rendering + // here would put the frame one GTK commit behind its geometry + // (#444). Ask GTK for a paint instead and render from its + // `draw` signal; only an invalidation we were asked for + // warrants one, or Tao's own draw handler (which also posts a + // redraw) would drive an endless repaint loop. + if (redrawPending.getAndSet(false)) askToplevelDraw(now) + return + } + } + renderFrame(inFrame = false) + } + + /** + * `queue_draw` on the toplevel, remembering the first unanswered ask and + * arming a redraw past [IN_FRAME_DRAW_GRACE_NS] so an unanswered one is + * noticed even when nothing else invalidates — a static UI after a + * maximize would otherwise sit frozen until its next invalidation. + */ + private fun askToplevelDraw(now: Long) { + queueToplevelDraw() + if (toplevelDrawAskedNs != 0L) return + toplevelDrawAskedNs = now + DelayScheduler.schedule( + { requestRedrawCoalesced() }, + IN_FRAME_DRAW_GRACE_NS / 1_000_000L + IN_FRAME_DRAW_WATCHDOG_SLACK_MS, + TimeUnit.MILLISECONDS, + ) + } + + /** + * GTK's `draw` signal on the toplevel, before GDK commits it (#444). While + * the content sub-surface is synced this is the only place a frame is + * rendered: it waits for the previous swap, renders at the window's + * current size and waits for this frame's swap, so the buffer is cached + * compositor-side when GTK's commit — geometry included — applies it. + * Both waits are bounded; a late frame merely shows on the next commit. + */ + fun onToplevelDraw() { + if (!isWayland || attachedKind == 0 || window.isPopup) return + // GTK answered; whether this draw renders is a separate matter. + toplevelDrawAskedNs = 0L + // GTK is painting — and about to commit — a configure Tao has not told + // us about yet: its `configure-event` goes through the same event + // channel as its draw. Take the size from GTK itself so this very + // paint gets content of that size. + adoptGtkClientSize() + if (!inFrameRenderActive()) return + if (parentObscured()) { + // The state flag can land after the Resized that armed the burst; + // this paint must then be GTK's last, and the invalidation it was + // asked for goes back to the event loop. + inFrameStalled = true + leaveInFrameRendering() + requestRedrawCoalesced() + return + } + val st = swapThread + if (st != null && !st.awaitIdleOrMarkOwed(IN_FRAME_SWAP_WAIT_NS)) return + if (!subsurfaceSynced) { + // Armed only while no swap is in flight: a commit already on its + // way with a frame callback attached would otherwise be cached, and + // its callback — which the next swap waits for — would need the + // GTK commit this draw has yet to return to. + subsurfaceSynced = true + NativeTaoEglBridge.nativeSetSubsurfaceSync(attachmentHandle, true) + } + renderFrame(inFrame = true) + // While synced, frames show only with a GTK commit: keep GTK painting + // until the burst has ended (the render pass leaves sync mode once the + // window has been still for the hold), so the last frame is never + // stranded in the compositor's cache. Watched like any other ask: + // this paint's commit may be the one the compositor stops answering. + if (inFrameRenderActive()) askToplevelDraw(System.nanoTime()) + } + + /** Whether frames are rendered from GTK's `draw` signal right now — see [onToplevelDraw]. */ + private fun inFrameRenderActive(): Boolean = + (subsurfaceSynced || inFrameBurst) && + isWayland && + attachedKind == 2 && + !window.isPopup && + toplevelDrawHookId != 0L + + /** + * Feeds GTK's current client size through [onResized] when it differs from + * ours — the configure GTK is laying out and painting right now (#444). + * Physical px, at GDK's integer surface scale like Tao's own report. + */ + private fun adoptGtkClientSize() { + if (window.handle == 0L || !NativeTaoLinuxWidgetBridge.isLoaded) return + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + if (gtkWindow == 0L) return + val packed = NativeTaoLinuxWidgetBridge.nativeToplevelClientSize(gtkWindow) + if (packed == 0L) return + val s = scale.roundToInt().coerceAtLeast(1) + val w = (packed ushr 32).toInt() * s + val h = (packed and 0xFFFFFFFFL).toInt() * s + if (w > 0 && h > 0 && (w != widthPx || h != heightPx)) onResized(w, h) + } + + /** Handler id of the toplevel `draw` hook, 0 until connected — see [ensureToplevelDrawHook]. */ + private var toplevelDrawHookId: Long = 0L + private var toplevelDrawHookWindow: Long = 0L + + /** Connects [onToplevelDraw] to the toplevel once; `true` when the hook is in place. */ + private fun ensureToplevelDrawHook(): Boolean { + if (!NativeTaoLinuxWidgetBridge.isLoaded || window.handle == 0L) return false + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + if (gtkWindow == 0L) return false + // One attempt per GtkWindow: a refused connection does not heal, and retrying it from + // every frame floods the log with the same JNI error. + if (toplevelDrawHookWindow == gtkWindow) return toplevelDrawHookId != 0L + toplevelDrawHookWindow = gtkWindow + toplevelDrawHookId = + NativeTaoLinuxWidgetBridge.nativeConnectToplevelDraw( + gtkWindow, + object : NativeTaoLinuxWidgetBridge.ToplevelDrawCallback { + override fun onToplevelDraw() = this@TaoComposeSceneHostLinux.onToplevelDraw() + }, + ) + return toplevelDrawHookId != 0L + } + + private fun queueToplevelDraw() { + if (window.handle == 0L || !NativeTaoLinuxWidgetBridge.isLoaded) return + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + if (gtkWindow != 0L) NativeTaoLinuxWidgetBridge.nativeQueueToplevelDraw(gtkWindow) + } + + /** + * A scale change does not resize the drawable but changes how the Skia + * surface is built; drop the cached one once the context is current. + */ + private fun rebuildSurfaceIfDue() { + if (!surfaceRebuildDue) return + surfaceRebuildDue = false + cachedSurface?.close() + cachedSurface = null + cachedRt?.close() + cachedRt = null + } + + /** + * In-frame only (#444): the swap thread's `eglSwapBuffers` (interval 0 for + * the burst, so no frame-callback wait) attaches and commits the buffer; + * in sync mode the compositor caches it until the parent commits — which + * GDK does right after the draw handler returns. Waiting here is what puts + * geometry and content in that one commit. + */ + private fun awaitInFrameSwap() { + if (swapThread?.awaitIdle(IN_FRAME_SWAP_WAIT_NS) == false) { + linuxHostLogger.fine("in-frame swap did not complete within the budget; frame lands late") + } + } + + private fun renderFrame(inFrame: Boolean) { // Open the redraw gate first thing: any invalidation triggered while // we're in this method (state writes inside scene.render, animation // continuations resuming under sendFrame, observers firing during @@ -1481,7 +2080,7 @@ internal class TaoComposeSceneHostLinux( // subsurface-backed dialog feel unresponsive while its parent kept // rendering — the parent's swap latency was paid on the input thread.) val st = swapThread - if (st != null && !st.tryBeginRenderOrMarkOwed()) { + if (st != null && !st.beginRenderOrMarkOwed(inFrame)) { // The GPU is busy presenting; the CPU is not. Drain the scene's // coroutine queue anyway — pure CPU work, with no GL context bound // (the same state as the drain in the render path below). @@ -1508,13 +2107,25 @@ internal class TaoComposeSceneHostLinux( skipDrainBudget-- flushingDispatcher.drain() } + skippedFrames++ + TaoWaylandFrameDiagnostics.noteSkipped() + if (skippedFrameStartNanos == 0L) skippedFrameStartNanos = System.nanoTime() return } + if (skippedFrameStartNanos != 0L) { + val stalledMs = (System.nanoTime() - skippedFrameStartNanos) / 1_000_000 + if (stalledMs >= FRAME_STALL_TRACE_MILLIS) { + linuxHostLogger.fine("frame stalled ${stalledMs}ms on the swap ($skippedFrames skipped)") + } + skippedFrameStartNanos = 0L + skippedFrames = 0 + } skipDrainBudget = SKIP_DRAIN_BUDGET_PER_FRAME val ctx = directContext ?: return val bundle = sceneBundle ?: return if (widthPx <= 0 || heightPx <= 0) return + if (isWayland && attachedKind == 2 && !window.isPopup) ensureToplevelDrawHook() val now = System.nanoTime() @@ -1525,22 +2136,39 @@ internal class TaoComposeSceneHostLinux( // the recompose → layout → draw the render call performs. flushingDispatcher.drain() + // Coalesced size change goes to the native window *before* the context + // is made current (#444). Mesa's `wl_egl_window` resize callback only + // adopts the new size while no back buffer is acquired — and + // `eglMakeCurrent` acquires one, at whatever size the window had. A + // resize pushed after it lands in the buffer of the *next* frame: this + // frame paints the previous size and, in a resize burst, the content + // trails the window by one configure on every commit. Pushed here, + // `eglMakeCurrent` acquires a buffer of the size this frame is for. + applyPendingNativeResize() NativeTaoEglBridge.nativeMakeCurrent(attachmentHandle) // An embedded NativeView's GPU compositor ran GL on this thread since // the last frame — drop Skia's cached GL state before any GPU work. if (foreignGlInterop) ctx.resetGLAll() - // Coalesced size/scale change is committed here, after the GL context - // is current — applyPendingNativeResize closes the stale Skia cache. - applyPendingNativeResize() + rebuildSurfaceIfDue() + purgeResizeScratchIfDue(ctx) updateResizeBurstSwapInterval() + pinDrawableIfResized(ctx) val paintSize = resolvePaintSize() - if (bundle.scene.size != paintSize) { - bundle.scene.size = paintSize + // Layout is the window's business; the render target is the buffer's. + // Sizing the scene from the drawable instead is what made the content + // lag the window through a resize — the regression that sent the + // drawable-sized paint back behind a KDE-only check. Compose measures + // for the size the window *is*, and a frame whose buffer is a step + // behind simply leaves that step uncovered for one frame. + val sceneSize = IntSize(widthPx, heightPx) + if (bundle.scene.size != sceneSize) { + bundle.scene.size = sceneSize lastSceneSizeUpdateNs = now } val surface = ensurePaintSurface(ctx, paintSize.width, paintSize.height) ?: return + probeResizeFrame(paintSize) // Clear to the resolved title-bar background (pushed by `TitleBar` via // [LocalRequestedClearColor]) so any Compose region without an explicit @@ -1565,8 +2193,10 @@ internal class TaoComposeSceneHostLinux( applyFrameDecoration(surface.canvas, paintSize.width, paintSize.height) surface.flushAndSubmit(syncCpu = false) + closeResizeProbeFrame() NativeTaoEglBridge.nativeReleaseCurrent(attachmentHandle) swapThread?.requestSwap() + if (inFrame) awaitInFrameSwap() // Re-align the content subsurface with GTK's content area AFTER the // swap was requested, so the repositioning (which the native side @@ -1579,15 +2209,98 @@ internal class TaoComposeSceneHostLinux( } /** - * KWin: paint at lagging drawable (avoids BOTTOM_LEFT flash). - * GNOME / others: paint at window size (master — no layout lag). + * Records this frame's paint size against the size of the buffer it will + * actually land in (#444). Inert unless a test armed + * [TaoWaylandFrameDiagnostics]. + */ + private fun probeResizeFrame(paintSize: IntSize) { + TaoWaylandFrameDiagnostics.record { + val queried = NativeTaoEglBridge.nativeQueryDrawableSize(attachmentHandle) + val attached = NativeTaoEglBridge.nativeAttachedSize(attachmentHandle) + TaoWaylandFrameDiagnostics.Frame( + nanos = System.nanoTime(), + windowPx = IntSize(widthPx, heightPx), + paintPx = paintSize, + attachedPx = IntSize((attached ushr 32).toInt(), (attached and 0xFFFFFFFFL).toInt()), + queriedPx = IntSize((queried ushr 32).toInt(), (queried and 0xFFFFFFFFL).toInt()), + queriedAfterPx = IntSize.Zero, + requestedPx = + IntSize( + NativeTaoEglBridge.nativeWidth(attachmentHandle), + NativeTaoEglBridge.nativeHeight(attachmentHandle), + ), + ) + } + } + + /** + * Second half of [probeResizeFrame]: samples the drawable again once the + * frame's GL work has been submitted, so a buffer reallocation that landed + * mid-frame is visible rather than inferred. + */ + private fun closeResizeProbeFrame() { + if (!TaoWaylandFrameDiagnostics.isRecording) return + val queried = NativeTaoEglBridge.nativeQueryDrawableSize(attachmentHandle) + val after = IntSize((queried ushr 32).toInt(), (queried and 0xFFFFFFFFL).toInt()) + TaoWaylandFrameDiagnostics.completeLast { it.copy(queriedAfterPx = after) } + } + + /** + * Makes the pending `wl_egl_window_resize` land in the buffer now, so the + * query behind [resolvePaintSize] describes the buffer this frame will + * actually be drawn into rather than whatever the driver has not got round + * to yet. Only on the frames that pushed a resize: it costs a Skia GL state + * reset, because the touch changes the binding behind Skia's back. + */ + private fun pinDrawableIfResized(ctx: DirectContext) { + if (!pushedNativeResize) return + pushedNativeResize = false + if (!isWayland) return + NativeTaoEglBridge.nativeTouchDrawable(attachmentHandle) + ctx.resetGLAll() + } + + /** + * The size the frame must be painted at: the size of the buffer it will + * actually land in (#444). + * + * On Wayland `wl_egl_window_resize` only records a *pending* size — the + * buffer behind the default framebuffer is reallocated inside the next + * `eglSwapBuffers`. Skia's render target wraps that framebuffer + * (`fbId = 0`), so building it from the size we just *requested* overstates + * it for one frame, and under [SurfaceOrigin.BOTTOM_LEFT] the whole frame + * lands that many rows off the top of the real drawable: a band of clear + * colour along the top edge, on roughly a third of the frames of a drag. + * + * So ask the driver instead of predicting it. Earlier attempts predicted: + * first "the buffer follows the request" (the flash), then "the buffer is + * one present behind" (KWin-only, because that guess was wrong elsewhere — + * it fixed Fedora Mutter and regressed Ubuntu GNOME). `eglQuerySurface` is + * neither guess but the answer, so there is no desktop environment in this + * decision any more. + * + * The answer is only authoritative if the driver cannot act on the pending + * resize *after* giving it. Mesa cannot — it defers the reallocation to + * `eglSwapBuffers` — but the NVIDIA proprietary driver reallocates when the + * back buffer is first used for rendering, which unaided is in the middle + * of the frame, after this render target was built. So the caller pins that + * moment first (`nativeTouchDrawable`) rather than relying on either + * driver's timing; see the call site in the render pass. + * + * The window's own size still drives *layout* — only the render target + * follows the buffer. A frame painted while the buffer is a step behind is + * therefore anchored correctly and merely leaves the last strip of a + * growing window uncovered until the catch-up frame, instead of displacing + * everything by the size of the step. */ private fun resolvePaintSize(): IntSize { - val paintW = - if (useDrawableSizedPaint && drawableWidthPx > 0) drawableWidthPx else widthPx - val paintH = - if (useDrawableSizedPaint && drawableHeightPx > 0) drawableHeightPx else heightPx - return IntSize(paintW, paintH) + if (isWayland) { + val packed = NativeTaoEglBridge.nativeQueryDrawableSize(attachmentHandle) + val drawableW = (packed ushr 32).toInt() + val drawableH = (packed and 0xFFFFFFFFL).toInt() + if (drawableW > 0 && drawableH > 0) return IntSize(drawableW, drawableH) + } + return IntSize(widthPx, heightPx) } /** @@ -1616,14 +2329,7 @@ internal class TaoComposeSceneHostLinux( fbId = 0, fbFormat = FramebufferFormat.GR_GL_RGBA8, ) - val surface = - Surface.makeFromBackendRenderTarget( - context = ctx, - rt = rt, - origin = SurfaceOrigin.BOTTOM_LEFT, - colorFormat = SurfaceColorFormat.RGBA_8888, - colorSpace = ColorSpace.sRGB, - ) + val surface = makeTaoGlSurface(ctx, rt, fullyTransparent) if (surface == null) { rt.close() NativeTaoEglBridge.nativeReleaseCurrent(attachmentHandle) @@ -1738,6 +2444,7 @@ internal class TaoComposeSceneHostLinux( val yPx = bFixed / 1024f lastPointerX = xPx lastPointerY = yPx + if (forwardedNativeButtons.isNotEmpty()) healStaleNativePresses() // Real pointer motion resuming means the compositor released any // resize/move grab — that's our grab-ended signal (the compositor // withholds motion for the whole grab), so drop the focus mask here @@ -1763,6 +2470,7 @@ internal class TaoComposeSceneHostLinux( if (resizeDecoration.onMove(direction)) return if (!pointerDeadband.shouldDispatchMove(xPx, yPx, scale)) return + interruptRotation() scene?.sendPointerEvent( eventType = PointerEventType.Move, position = Offset(pointerDeadband.x, pointerDeadband.y), @@ -1841,17 +2549,21 @@ internal class TaoComposeSceneHostLinux( // Any other real press means no compositor grab is in flight. if (pressed) { compositorDragActive = false + nativePointerDispatchedThisEvent = false + // A button an embed swallowed the release of must not still be + // "down" when this press is hit-tested — see [forwardedNativeButtons]. + for (stale in forwardedNativeButtons.toList()) { + if (stale != buttonCode && stale in pressedButtons) onPointerButton(stale, pressed = false) + } + } else { + forwardedNativeButtons.remove(buttonCode) } if (pressed) pressedButtons.add(buttonCode) else pressedButtons.remove(buttonCode) + interruptRotation() - // A press reaching the parent scene is outside every popup layer (the - // popup windows own their input region) — forward so Compose's - // dismiss-on-click-outside fires. The Linux stand-in for macOS's - // NSEvent monitor / Windows' WH_MOUSE_LL hook. - if (pressed && outsidePressListeners.isNotEmpty()) { - val button = mapButton(buttonCode) - for (cb in outsidePressListeners.values.toList()) cb(button) - } + // A press reaching the parent scene is outside every popup layer — the + // Linux stand-in for macOS's NSEvent monitor / Windows' WH_MOUSE_LL hook. + if (pressed) dismissPopupsBeforePress(mapButton(buttonCode)) currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers @@ -1862,6 +2574,62 @@ internal class TaoComposeSceneHostLinux( keyboardModifiers = currentKeyboardModifiers, button = mapButton(buttonCode), ) + if (pressed && !nativePointerDispatchedThisEvent && attachedNativeViews.isNotEmpty()) { + // Compose kept the press, so the keyboard is Compose's: an embed + // the user clicked into earlier would otherwise keep GTK focus + // and every keystroke, while Compose shows a focused text field. + // The macOS host does the same with `makeFirstResponder`. + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + if (gtkWindow != 0L && NativeTaoLinuxWidgetBridge.isLoaded) { + NativeTaoLinuxWidgetBridge.nativeClaimKeyboardForCompose(gtkWindow) + } + } + } + + /** + * Releases every [forwardedNativeButtons] entry GDK reports as up. Only + * called while there is one, so a window without embeds never pays the + * device query. + */ + private fun healStaleNativePresses() { + if (!NativeTaoLinuxWidgetBridge.isLoaded) return + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + if (gtkWindow == 0L) return + val mask = NativeTaoLinuxWidgetBridge.nativeQueryPointerButtons(gtkWindow) + if (mask < 0) return + for (button in forwardedNativeButtons.toList()) { + val bit = + when (button) { + dev.nucleusframework.window.tao.TaoMouseButton.LEFT -> GDK_BUTTON1_MASK + dev.nucleusframework.window.tao.TaoMouseButton.MIDDLE -> GDK_BUTTON2_MASK + dev.nucleusframework.window.tao.TaoMouseButton.RIGHT -> GDK_BUTTON3_MASK + else -> 0 + } + if (mask and bit == 0) { + forwardedNativeButtons.remove(button) + if (button in pressedButtons) onPointerButton(button, pressed = false) + } + } + } + + /** + * Runs the popup dismissal a press outside every layer implies, and lets + * the scene apply it before that press is dispatched. + * + * The listeners close whatever popup was open by writing Compose state, and + * the press is about to be dispatched in the same turn — so a node that is + * *disabled while the popup is open* would still be disabled when the press + * arrives, and the press would do nothing. Compose's own + * `contextMenuOpenDetector` is exactly that node, which is why a second + * right click used to close the context menu instead of moving it to the + * new spot, the way every OS menu does. One extra composition per outside + * press, and only while a popup is open. + */ + private fun dismissPopupsBeforePress(button: PointerButton?) { + if (outsidePressListeners.isEmpty()) return + for (cb in outsidePressListeners.values.toList()) cb(button) + Snapshot.sendApplyNotifications() + sceneBundle?.composeAndLayoutNow() } /** @@ -1912,8 +2680,10 @@ internal class TaoComposeSceneHostLinux( fun onPointerScroll(event: TaoPointerScrollEvent) { currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers + // A rotation owns the fingers: a mouse-only Scroll would release its contacts. + if (rotateActive) return - // Ctrl+wheel → synthetic magnify gesture, never a scroll. On Windows the native + // Ctrl+wheel → Scale gesture, never a scroll. On Windows the native // layer routes WM_MOUSEWHEEL+Ctrl to the magnify hook; GTK delivers it here as a // plain scroll, so we do the same routing in Kotlin. Keeps Ctrl+wheel = zoom (not // zoom-and-scroll) and matches the Windows backend — the AWT backend has no @@ -1933,36 +2703,30 @@ internal class TaoComposeSceneHostLinux( } /** - * Feeds one Ctrl+wheel tick into the shared magnify-gesture machinery (Touch pinch), - * so the app's pinch-zoom handler receives it exactly like a trackpad pinch. The - * gesture is opened on the first tick, moved on each tick, and released by an idle - * timer once ticks stop ([scheduleWheelZoomEnd]). + * Feeds one Ctrl+wheel tick into the shared scale-gesture session, so the + * app's pinch-zoom handler receives it exactly like a trackpad pinch. The + * gesture is opened on the first tick, moved on each tick, and released by + * an idle timer once ticks stop ([scheduleWheelZoomEnd]). */ private fun onCtrlWheelZoom(deltaAwt: Float) { if (scene == null) return // AWT sign: wheel-up (zoom in) is a negative rotation, so negate to get a - // positive magnify value that grows the gesture scale. + // positive magnify value that grows the scale factor. val step = TaoWheelPinchZoom.stepFromWheelDelta(-deltaAwt) - if (!gestureActive) { - startGesture(lastPointerX, lastPointerY) - sendGesturePointers(PointerEventType.Press) - } else { - gestureCenterX = lastPointerX - gestureCenterY = lastPointerY - } - gestureScale *= step - sendGesturePointers(PointerEventType.Move) + gestureCenterX = lastPointerX + gestureCenterY = lastPointerY + scaleSession.change(step) scheduleWheelZoomEnd() } - /** Re-arms the idle timer that releases the synthetic wheel-driven magnify. */ + /** Re-arms the idle timer that releases the wheel-driven scale gesture. */ private fun scheduleWheelZoomEnd() { wheelZoomEndJob?.cancel() wheelZoomEndJob = gestureScope.launch { delay(WHEEL_ZOOM_IDLE_END_MS) wheelZoomEndJob = null - endGesture(cancelled = false) + scaleSession.end() } } @@ -2010,9 +2774,33 @@ internal class TaoComposeSceneHostLinux( return keyHandler?.invoke(composeEvent) == true } + /** Native popup layers handed out by [nativePopupLayerFactory] and not yet closed — swept by [detach]. */ + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + private val liveNativePopupLayers = linkedSetOf() + /** - * Plumbing handed to [TaoPopupSceneLayerLinux] instances when - * [nativePopupLayers] is enabled. Mirrors the Windows + * Builds this window's native popup layers ([TaoPopupSceneLayerLinux]). + * The factory behind [nativePopupLayers], and the one `NativePopupLayers { }` + * hands to a subtree that wants native surfaces while the window's own + * popups stay in-scene. [popupHost] is resolved per layer, as it always + * was: a Wayland hide/show rebuilds the EGL pair and the host reads the + * live one. + */ + + fun nativePopupLayerFactory(): TaoPopupLayerFactory = + { density, layoutDirection, focusable, consumeOutside -> + TaoPopupSceneLayerLinux( + host = popupHost(), + initialDensity = density, + initialLayoutDirection = layoutDirection, + initialFocusable = focusable, + initialConsumePointerInputOutside = consumeOutside, + ).also { liveNativePopupLayers += it } + } + + /** + * Plumbing handed to [TaoPopupSceneLayerLinux] instances by + * [nativePopupLayerFactory]. Mirrors the Windows * [TaoComposeSceneHostWindows.popupHost] contract, adapted to the Linux * backend: layers are Tao popup windows keyed on [parentWindow], and each * owns a private EGL context so there is no shared DirectContext. @@ -2025,6 +2813,7 @@ internal class TaoComposeSceneHostLinux( override val exceptionHandler: WindowExceptionHandler? get() = outer.exceptionHandler override val parentWindowSize: IntSize get() = IntSize(outer.widthPx, outer.heightPx) + override val parentWindowInfo: androidx.compose.ui.platform.WindowInfo get() = outer.windowInfo override val workAreaSize: IntSize get() = NativeTaoBridge .nativeLinuxPrimaryMonitorWorkArea(outer.window.handle) @@ -2045,6 +2834,20 @@ internal class TaoComposeSceneHostLinux( ?: IntOffset.Zero } + // #569: clamp popups into the real display's work area instead of + // the work-area-sized virtual screen Compose positions against. + // Null on Wayland for the same reason parentScreenOriginPx is zero + // there — a subsurface has no global position to clamp. + override val popupScreenGeometry: PopupScreenGeometry? get() { + if (!outer.isX11) return null + val origin = parentScreenOriginPx + // `reported`, not `all` — see the macOS resolver: a synthesized + // monitor is a guess, and a clamp is only safe on a real one. + val areas = TaoMonitors.reported(outer.window).map { it.workAreaPx } + if (areas.isEmpty()) return null + return PopupScreenGeometry(parentContentOriginPx = origin, workAreasPx = areas) + } + /** * Nested-scene origin only. The hidden-titlebar CSD content origin * used to live here, but [TaoWindow.setOuterPosition] now applies it @@ -2057,6 +2860,8 @@ internal class TaoComposeSceneHostLinux( override val sceneCoroutineContext: CoroutineContext get() = outer.coroutineContext + outer.flushingDispatcher + override val popupScrims: PopupScrimRegistry get() = outer.popupScrims + override fun requestRedraw() = outer.requestRedrawCoalesced() override fun registerRenderer( @@ -2070,6 +2875,11 @@ internal class TaoComposeSceneHostLinux( outer.popupRenderers.remove(token) } + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + override fun onLayerClosed(layer: androidx.compose.ui.scene.ComposeSceneLayer) { + outer.liveNativePopupLayers.remove(layer) + } + override fun registerKeyHandler( token: Any, handler: (KeyEvent) -> Boolean, @@ -2102,9 +2912,47 @@ internal class TaoComposeSceneHostLinux( override fun unregisterOutsidePressListener(token: Any) { outer.outsidePressListeners.remove(token) } + + override fun forwardMarginPointer( + eventType: PointerEventType, + positionPx: Offset, + button: PointerButton?, + ) { + if (eventType == PointerEventType.Press) outer.dismissPopupsBeforePress(button) + outer.currentKeyboardModifiers = taoKeyboardModifiers(outer.window.modifierState) + outer.windowInfo.keyboardModifiers = outer.currentKeyboardModifiers + outer.scene?.sendPointerEvent( + eventType = eventType, + position = positionPx, + type = PointerType.Mouse, + keyboardModifiers = outer.currentKeyboardModifiers, + button = button, + ) + } + + override fun acquireCompositorPopup(token: Any): Boolean { + val owner = outer.compositorPopupOwner + if (owner != null && owner !== token) return false + outer.compositorPopupOwner = token + return true + } + + override fun releaseCompositorPopup(token: Any) { + if (outer.compositorPopupOwner === token) outer.compositorPopupOwner = null + } } } + /** + * One host instance per scene. The composition local built from it keys + * `NativeView`'s attach effect: a fresh object on every recomposition of + * the window root would detach and re-attach every embed each time. + */ + private var nativeViewHostInstance: dev.nucleusframework.window.tao.TaoNativeViewHost? = null + + fun nativeViewHost(): dev.nucleusframework.window.tao.TaoNativeViewHost? = + nativeViewHostInstance ?: createNativeViewHost()?.also { nativeViewHostInstance = it } + /** * Plumbing for the `GtkWidget` variant of `NucleusPlatformView`. * Resolves Tao's `GtkApplicationWindow*` once (it doesn't change @@ -2116,7 +2964,7 @@ internal class TaoComposeSceneHostLinux( * library is available (missing on non-Linux builds and on Linux * builds that didn't ship the .so). */ - fun nativeViewHost(): dev.nucleusframework.window.tao.TaoNativeViewHost? { + private fun createNativeViewHost(): dev.nucleusframework.window.tao.TaoNativeViewHost? { if (window.handle == 0L) return null if (!dev.nucleusframework.window.tao.ffi.NativeTaoLinuxWidgetBridge.isLoaded) return null val gtkWindow = @@ -2129,9 +2977,13 @@ internal class TaoComposeSceneHostLinux( childHandle: Long, regionToken: Any, ) { + // The sink must be the first focusable child of the overlay, + // ahead of the embed — see [TaoLinuxOverlayControllerImpl.ensureFocusSink]. + outer.overlayController.ensureFocusSink() dev.nucleusframework.window.tao.ffi.NativeTaoLinuxWidgetBridge .nativeAttach(gtkWindow, childHandle) outer.foreignGlInterop = true + outer.detachedNativeViews.remove(childHandle) if (childHandle != 0L && outer.attachedNativeViews.add(childHandle)) { // Force a re-push: lastOpaqueRegion may still hold the full // opaque key from before the embed existed. @@ -2146,6 +2998,7 @@ internal class TaoComposeSceneHostLinux( ) { outer.nativeViewRects.remove(childHandle) outer.overlayController.unregisterRegion(regionToken) + outer.detachedNativeViews += childHandle dev.nucleusframework.window.tao.ffi.NativeTaoLinuxWidgetBridge .nativeDetach(childHandle) if (childHandle != 0L && outer.attachedNativeViews.remove(childHandle)) { @@ -2162,6 +3015,13 @@ internal class TaoComposeSceneHostLinux( heightPx: Int, regionToken: Any, ) { + // A layout pass can still report the slot of an embed whose + // detach already ran (the node is placed once more in the + // frame that removes it); the widget may be gone by then. Only + // *detached* handles are refused: the first setFrame routinely + // lands before the attach effect, and it is what mounts the + // widget (the C side defers the mount to the first real rect). + if (handle in outer.detachedNativeViews) return // Compose feeds physical pixels; GTK 3 lays out in // logical pixels (the compositor applies the device // scale on its own). @@ -2203,10 +3063,30 @@ internal class TaoComposeSceneHostLinux( val rect = outer.nativeViewRects[handle] val xLogical = ((xPx - (rect?.get(0)?.toFloat() ?: 0f)) / s).toInt() val yLogical = ((yPx - (rect?.get(1)?.toFloat() ?: 0f)) / s).toInt() + if (type == NATIVE_POINTER_PRESS) { + // NativeView numbers buttons 1 = primary, 2 = secondary. + outer.forwardedNativeButtons += + if (button == NATIVE_SECONDARY_BUTTON) { + dev.nucleusframework.window.tao.TaoMouseButton.RIGHT + } else { + dev.nucleusframework.window.tao.TaoMouseButton.LEFT + } + // The embed takes the keyboard with this press (the bridge + // grabs GTK focus for it before forwarding): a Compose text + // field must not keep showing a caret beside the embed's. + // Deferred — this runs inside the Press dispatch. + outer.flushingDispatcher.enqueue( + Runnable { outer.capturedFocusManager?.clearFocus(force = true) }, + ) + } dev.nucleusframework.window.tao.ffi.NativeTaoLinuxWidgetBridge .nativeDispatchPointer(handle, type, xLogical, yLogical, button, pressed) } + override fun noteNativePointerDispatch() { + outer.nativePointerDispatchedThisEvent = true + } + override fun dispatchScrollToNative( handle: Long, xPx: Float, @@ -2317,6 +3197,15 @@ internal class TaoComposeSceneHostLinux( } fun detach() { + liveHosts -= this + // Layers whose dismiss animation was still running: Compose closes a + // native popup layer only when its own disappearance finishes, so an + // owner destroyed mid-animation left the layer's popup window mapped + // for good — an invisible rectangle eating every click under it. + for (layer in liveNativePopupLayers.toList()) layer.close() + liveNativePopupLayers.clear() + window.contentSnapshot = null + window.inboundDragAndDropNode = null window.imePreedit = null window.imeCommit = null imeSession.onInputSession(null) @@ -2347,6 +3236,7 @@ internal class TaoComposeSceneHostLinux( // host re-bind below must come after so the host's GPU releases land // on the right context. sceneBundle?.close() + window.clearContentMeasurer() sceneBundle = null // Re-bind THIS window's EGL context before tearing down Skia. The @@ -2379,13 +3269,50 @@ internal class TaoComposeSceneHostLinux( NativeTaoEglBridge.nativeReleaseCurrent(attachmentHandle) NativeTaoEglBridge.nativeDetach(attachmentHandle) attachmentHandle = 0L + // The sub-surface went with the attachment; a fresh one starts desync. + subsurfaceSynced = false } } private companion object { + /** A run of skipped frames is only worth a line past this. */ + private const val FRAME_STALL_TRACE_MILLIS = 100L + + /** + * Every attached Linux host, so an outbound drag session can keep + * painting the windows it is *not* running in (see [OutboundDragPump]). + * Touched on the event-loop thread only; copy-on-write so the pump can + * iterate while a drop closes a window. + */ + val liveHosts = java.util.concurrent.CopyOnWriteArrayList() + + /** A drag icon larger than this is not a decoration, it is a bug (or a fullscreen source). */ + const val MAX_DRAG_ICON_PX = 4096 + const val ALPHA_SHIFT = 24 + const val RED_SHIFT = 16 + const val GREEN_SHIFT = 8 + const val CHANNEL_MAX = 0xFF + /** Keep swap-interval 0 briefly after the last pixel of resize motion. */ private const val RESIZE_BURST_HOLD_NS = 100_000_000L // 100 ms + /** + * Longest an in-frame render waits on the swap thread, before and after + * its own swap (#444). Well past a swap with interval 0 (a few ms even + * on virgl); past it the frame simply lands one GTK commit late. + */ + private const val IN_FRAME_SWAP_WAIT_NS = 50_000_000L // 50 ms + + /** + * Longest a `queue_draw` may go unanswered before in-frame rendering + * is abandoned for the burst — three 60 Hz frames, past the two GDK's + * frame clock takes when a frame callback is already in flight. + */ + private const val IN_FRAME_DRAW_GRACE_NS = 50_000_000L // 50 ms + + /** How long after the grace the watchdog redraw lands. */ + private const val IN_FRAME_DRAW_WATCHDOG_SLACK_MS = 10L + /** * How far outside the content (logical px) a pointer still counts as * the CSD shadow ring for resize hit-testing. Theme margins run @@ -2398,13 +3325,23 @@ internal class TaoComposeSceneHostLinux( private const val TOUCH_POSITION_SCALE: Float = 1024f private const val TRACKPAD_VALUE_SCALE: Float = 10_000f - // Synth pinch radius / pointer ids — same values as the macOS host - // (see `TaoComposeSceneHost`'s companion); kept in sync manually. + // Synth rotate radius — same value as the macOS host (see + // `TaoComposeSceneHost`'s companion); kept in sync manually. private const val TRACKPAD_BASE_RADIUS_PX: Float = 120f - private const val TRACKPAD_POINTER_ID_A: Long = 0xA001L - private const val TRACKPAD_POINTER_ID_B: Long = 0xA002L private const val DEGREES_PER_RADIAN: Float = 180f - private const val MIN_GESTURE_SCALE: Float = 0.05f + + // Spacing range of the rotation contacts relative to their start, as + // on macOS: a rotation that owns a pinch zooms through it and stops + // there instead of reaching 0 or Infinity. + private const val MIN_ROTATE_SCALE: Float = 0.05f + private const val MAX_ROTATE_SCALE: Float = 20f + + // A GDK pinch is handed to the rotation once it has turned this far + // while its zoom stays within this ratio either way. A real pinch + // carries a few degrees of noise and zooms past 10 % long before it + // turns 10°; a deliberate twist does the opposite. + private const val ROTATE_TAKEOVER_DEGREES: Float = 10f + private const val ROTATE_TAKEOVER_MAX_ZOOM: Float = 1.1f private const val WHEEL_ZOOM_IDLE_END_MS: Long = 120L /** @@ -2436,6 +3373,9 @@ internal class TaoComposeSceneHostLinux( ) : Thread("TaoSwapThread-${java.lang.Long.toHexString(handle)}") { private val lock = ReentrantLock() private val workCond = lock.newCondition() + private val idleCond = lock.newCondition() + private val requestedInterval = AtomicInteger(-1) + private var presentInterval = 1 private var swapPending = false private var swapping = false private var shutdown = false @@ -2451,6 +3391,11 @@ internal class TaoComposeSceneHostLinux( isDaemon = true } + /** `eglSwapInterval` to apply, with the context current, before the next present. */ + fun requestSwapInterval(interval: Int) { + requestedInterval.set(interval) + } + /** Called on the GTK main thread after `flushAndSubmit` + release. */ fun requestSwap() { lock.withLock { @@ -2478,6 +3423,40 @@ internal class TaoComposeSceneHostLinux( } } + /** + * Blocks until no swap is pending or in flight, at most [timeoutNanos]. + * Only for the in-frame path (#444), where the caller is inside GTK's + * `draw` and the swap runs with interval 0 — it never waits on a frame + * callback that this thread's return would have to produce. + */ + fun awaitIdle(timeoutNanos: Long): Boolean = + lock.withLock { + var left = timeoutNanos + while ((swapPending || swapping) && left > 0L) left = idleCond.awaitNanos(left) + !(swapPending || swapping) + } + + /** + * The render gate: [tryBeginRenderOrMarkOwed] for a frame from the + * event loop, a bounded wait for one rendered inside GTK's `draw` + * (#444) — marking a render owed either way when the swap is still busy. + */ + fun beginRenderOrMarkOwed(inFrame: Boolean): Boolean = + if (inFrame) awaitIdleOrMarkOwed(IN_FRAME_SWAP_WAIT_NS) else tryBeginRenderOrMarkOwed() + + /** [awaitIdle], marking a render owed when the wait runs out so the swap thread re-arms it. */ + fun awaitIdleOrMarkOwed(timeoutNanos: Long): Boolean = + lock.withLock { + var left = timeoutNanos + while ((swapPending || swapping) && left > 0L) left = idleCond.awaitNanos(left) + if (swapPending || swapping) { + renderOwed = true + false + } else { + true + } + } + fun shutdownAndJoin() { lock.withLock { shutdown = true @@ -2507,6 +3486,11 @@ internal class TaoComposeSceneHostLinux( if (doSwap) { try { NativeTaoEglBridge.nativeMakeCurrent(handle) + val interval = requestedInterval.getAndSet(-1) + if (interval >= 0 && interval != presentInterval) { + NativeTaoEglBridge.nativeSetSwapInterval(handle, interval) + presentInterval = interval + } NativeTaoEglBridge.nativePresent(handle) } catch (t: Throwable) { linuxHostLogger.log(java.util.logging.Level.WARNING, "EGL present failed", t) @@ -2520,6 +3504,7 @@ internal class TaoComposeSceneHostLinux( val rearm = lock.withLock { swapping = false + idleCond.signalAll() // Decoupled pacing: hand the owed frame back // to the render thread now that the context // is free. Checked + cleared under the same @@ -2529,14 +3514,6 @@ internal class TaoComposeSceneHostLinux( renderOwed = false owed } - // KWin: drawable advances only after this present. - if (useDrawableSizedPaint) { - dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher - .dispatch( - EmptyCoroutineContext, - Runnable { onDrawablePresented() }, - ) - } // Catch-up after size change: the buffer matching the // request only exists *after* this swap — paint it // without waiting for more motion (all Wayland DEs). @@ -2658,40 +3635,19 @@ private class LinuxTaoPlatformContext( // through `gdk_window_set_device_cursor` for every master pointer of // the seat — required because GTK 3 manages cursors via XInput 2's // per-device table, which masks legacy `XDefineCursor`. - NativeTaoBridge.nativeSetCursorIcon(windowHandle, mapPointerIcon(pointerIcon)) - } - - private fun mapPointerIcon(icon: androidx.compose.ui.input.pointer.PointerIcon): Int { - when { - icon === androidx.compose.ui.input.pointer.PointerIcon.Default -> - return dev.nucleusframework.window.tao.TaoCursorIcon.DEFAULT - icon === androidx.compose.ui.input.pointer.PointerIcon.Text -> - return dev.nucleusframework.window.tao.TaoCursorIcon.TEXT - icon === androidx.compose.ui.input.pointer.PointerIcon.Hand -> - return dev.nucleusframework.window.tao.TaoCursorIcon.HAND - icon === androidx.compose.ui.input.pointer.PointerIcon.Crosshair -> - return dev.nucleusframework.window.tao.TaoCursorIcon.CROSSHAIR - } - return runCatching { - val cursor = icon.javaClass.getMethod("getCursor").invoke(icon) as? java.awt.Cursor - when (cursor?.type) { - java.awt.Cursor.TEXT_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.TEXT - java.awt.Cursor.HAND_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.HAND - java.awt.Cursor.CROSSHAIR_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.CROSSHAIR - java.awt.Cursor.WAIT_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.WAIT - java.awt.Cursor.MOVE_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.MOVE - java.awt.Cursor.E_RESIZE_CURSOR, java.awt.Cursor.W_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.EW_RESIZE - java.awt.Cursor.N_RESIZE_CURSOR, java.awt.Cursor.S_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.NS_RESIZE - java.awt.Cursor.NE_RESIZE_CURSOR, java.awt.Cursor.SW_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.NESW_RESIZE - java.awt.Cursor.NW_RESIZE_CURSOR, java.awt.Cursor.SE_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.NWSE_RESIZE - else -> dev.nucleusframework.window.tao.TaoCursorIcon.DEFAULT - } - }.getOrDefault(dev.nucleusframework.window.tao.TaoCursorIcon.DEFAULT) + NativeTaoBridge.setCursorIcon(windowHandle, mapPointerIcon(pointerIcon)) } + + private fun mapPointerIcon(icon: androidx.compose.ui.input.pointer.PointerIcon): Int = icon.toTaoCursorIconCode() } private val linuxHostLogger: Logger = Logger.getLogger("dev.nucleusframework.window.tao.scene") + +/** `TaoNativeViewHost.dispatchPointerToNative` type codes and button numbers, as `NativeView` sends them. */ +private const val NATIVE_POINTER_PRESS = 1 +private const val NATIVE_SECONDARY_BUTTON = 2 + +/** GDK button bits in a modifier mask. */ +private const val GDK_BUTTON1_MASK = 1 shl 8 +private const val GDK_BUTTON2_MASK = 1 shl 9 +private const val GDK_BUTTON3_MASK = 1 shl 10 diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt index 32a7a6680..bf5a0b1e7 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoComposeSceneHostWindows.kt @@ -22,28 +22,37 @@ import androidx.compose.ui.scene.ComposeScene import androidx.compose.ui.scene.ComposeScenePointer import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowExceptionHandler import dev.nucleusframework.window.tao.GlobalLayoutDirection import dev.nucleusframework.window.tao.TaoEventCode import dev.nucleusframework.window.tao.TaoModifierMask +import dev.nucleusframework.window.tao.TaoMonitors import dev.nucleusframework.window.tao.TaoNonFatalCoroutineExceptionHandler import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.TaoTouchEvent import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.clearContentMeasurer import dev.nucleusframework.window.tao.event.ProvideTaoWindowsScrollConfig +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession import dev.nucleusframework.window.tao.event.TaoWheelPinchZoom import dev.nucleusframework.window.tao.event.dispatchAwtShapedScroll +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyEvent import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.event.taoTypedKeyEvent +import dev.nucleusframework.window.tao.event.toTaoCursorIconCode import dev.nucleusframework.window.tao.event.win32WheelToAwtScrollEvent import dev.nucleusframework.window.tao.ffi.NativeTaoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoGlBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsOverlayBridge import dev.nucleusframework.window.tao.hasWindowsTextureImports +import dev.nucleusframework.window.tao.installContentMeasurer +import dev.nucleusframework.window.tao.popup.PopupScreenGeometry +import dev.nucleusframework.window.tao.popup.PopupScrimRegistry import dev.nucleusframework.window.tao.popup.TaoPopupHostWindows import dev.nucleusframework.window.tao.popup.TaoPopupSceneLayerWindows import dev.nucleusframework.window.tao.releaseWindowsTextureImports @@ -57,15 +66,12 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.jetbrains.skia.BackendRenderTarget -import org.jetbrains.skia.ColorSpace +import org.jetbrains.skia.Canvas import org.jetbrains.skia.DirectContext import org.jetbrains.skia.FramebufferFormat import org.jetbrains.skia.GLAssembledInterface import org.jetbrains.skia.PathBuilder import org.jetbrains.skia.Rect -import org.jetbrains.skia.Surface -import org.jetbrains.skia.SurfaceColorFormat -import org.jetbrains.skia.SurfaceOrigin import org.jetbrains.skia.makeGLWithInterface import java.util.concurrent.ConcurrentLinkedQueue import kotlin.coroutines.CoroutineContext @@ -191,6 +197,12 @@ internal class TaoComposeSceneHostWindows( private var sceneBundle: TaoSceneBundle? = null private val scene: ComposeScene? get() = sceneBundle?.scene + init { + // Reads `scene` lazily, so it is valid before the bundle exists (null) + // and across bundle swaps; cleared in dispose(). + window.installContentMeasurer { constraints -> scene?.measureContent(constraints) } + } + /** Parent locals bridged via [setSceneCompositionLocalContext]; applied to the scene once created. */ private var pendingCompositionLocalContext: androidx.compose.runtime.CompositionLocalContext? = null private val flushingDispatcher = FlushingMainDispatcher() @@ -248,6 +260,40 @@ internal class TaoComposeSceneHostWindows( */ private val popupRenderers: MutableMap Unit> = LinkedHashMap() + /** + * Dialog scrims of the native popup layers, painted over the main scene at + * the end of every frame — see [PopupScrimRegistry]. + */ + private val popupScrims = + PopupScrimRegistry { + sceneBundle?.visualDirty?.set(true) + window.requestRedraw() + } + + /** + * Dialog scrims of native popup layers land on the owner window's surface, + * after its content — Compose Desktop's `onRenderOverlay`. + */ + private fun paintPopupScrims(canvas: Canvas) { + popupScrims.paintAll( + canvas, + Rect.makeWH(widthPx.toFloat(), heightPx.toFloat()), + transparent = fullyTransparent, + ) + } + + /** + * Hooks every main-scene bundle gets: frame failures (recomposition / + * layout / draw) go to the window's exception handler — the single seam + * all three platforms render through — and popup scrims paint after the + * content. + */ + private fun configureSceneBundle() { + val bundle = sceneBundle ?: return + bundle.exceptionHandler = exceptionHandler + bundle.renderOverlay = ::paintPopupScrims + } + /** * Key handlers consulted before the main scene's key dispatch * (Phase 8). Overlay scenes register here when they hold a focusable @@ -299,6 +345,33 @@ internal class TaoComposeSceneHostWindows( */ private var nativePointerRedispatchInFlight: Boolean = false + /** Whether the press being dispatched was handed to a native view — reset at every press. */ + private var nativePointerDispatchedThisEvent: Boolean = false + + /** + * Buttons whose press was forwarded to an embedded child HWND and whose + * release Compose has not seen. A child that `SetCapture`s on the press + * (every EDIT does, so does WebView2) gets the release alone; Compose + * would keep the button down and every later click would have no down + * transition. Healed from Win32's own button state on the next move and + * released before a new press — see [healStaleNativePresses]. + */ + private val forwardedNativeButtons = mutableSetOf() + + /** Buttons the scene currently holds down, so a release Compose never saw the press of is dropped. */ + private val pressedButtons = mutableSetOf() + + /** Live `NativeView` embeds; the keyboard reclaim only runs while there is one. */ + private var attachedNativeViewCount: Int = 0 + + /** + * Captured at the first composition via [setContent]. Exposes the + * standard `FocusManager.clearFocus(force = true)` the scene-level + * focus manager doesn't, so a press that hands the keyboard to an embed + * also drops the Compose text field's caret. + */ + private var capturedFocusManager: androidx.compose.ui.focus.FocusManager? = null + // Frame pacing is delegated to VSync — `eglSwapInterval(1)` makes // eglSwapBuffers pace off the display refresh, which keeps Compose // animations (smooth scroll, etc.) aligned on the display cadence at the @@ -364,15 +437,21 @@ internal class TaoComposeSceneHostWindows( attachmentHandle = handle directContext = (ctx ?: error("Failed to create Skia DirectContext on the ANGLE ES context")).also { - // Bound the GPU resource cache. Each frame wraps the default - // framebuffer in a fresh BackendRenderTarget + Surface, and Skia - // allocates a stencil/scratch attachment sized to the current - // window for it. During a border drag every new window size mints - // new scratch resources; even with VSync pacing the present (see - // onResizeLoopChanged) an explicit budget forces purgeAsNeeded on - // each flush so the cache stays bounded, and onResizeLoopChanged - // additionally purges the scratch accumulated across the drag. - it.resourceCacheLimit = RESOURCE_CACHE_LIMIT_BYTES + // Anchor the GPU resource cache budget. Each frame wraps the + // default framebuffer in a fresh BackendRenderTarget + Surface, + // and Skia allocates a stencil/scratch attachment sized to the + // current window for it; during a border drag every new window + // size mints scratch no later frame reuses. + // + // This write is a no-op at the current value — Ganesh's own + // default is the same 256 MiB (measured) — and it does NOT, as + // this comment used to claim, "force purgeAsNeeded on each + // flush": Skia purges to fit its budget whether or not we set + // one. What actually reclaims the drag's scratch is the purge, + // in onResized and onResizeLoopChanged. Keep the write anyway: + // it is the value the limit-toggle restores and the one place + // to change if the hosts ever run below Skia's default. + it.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES } attachedHostCount.incrementAndGet() @@ -424,24 +503,14 @@ internal class TaoComposeSceneHostWindows( // Opt-in path (e.g. tray popups): every Popup becomes a // transparent WS_POPUP HWND owned by this window, so popup // content can extend beyond — and float independently of — - // the window bounds. popupHost() is non-null here: hwnd and + // the window bounds. The factory is non-null here: hwnd and // directContext were both set above. platformLayersSceneBundle( coroutineContext = coroutineContext + flushingDispatcher, density = Density(scale), layoutDirection = GlobalLayoutDirection, composeSceneContext = - TaoComposeSceneContext( - platformContext = platformContext, - ) { density, layoutDirection, focusable, consumeOutside -> - TaoPopupSceneLayerWindows( - host = requireNotNull(popupHost()), - initialDensity = density, - initialLayoutDirection = layoutDirection, - initialFocusable = focusable, - initialConsumePointerInputOutside = consumeOutside, - ) - }, + TaoComposeSceneContext(platformContext, requireNotNull(nativePopupLayerFactory())), requestFrame = { window.requestRedraw() }, ) } else { @@ -454,11 +523,12 @@ internal class TaoComposeSceneHostWindows( ) } scene?.compositionLocalContext = pendingCompositionLocalContext - // Frame failures (recomposition / layout / draw) are caught inside the - // bundle, the single seam all three platforms render through. - sceneBundle?.exceptionHandler = exceptionHandler + configureSceneBundle() publishWindowsTextureHost() + // One source of truth for the scene's drop target: the callback below + // resolves it through here, and so does an in-process driver. + window.inboundDragAndDropNode = { scene?.rootDragAndDropNode } registerInboundDnD() registerTouchInput() @@ -628,28 +698,32 @@ internal class TaoComposeSceneHostWindows( // Windows delivers a precision-touchpad pinch (and a real Ctrl+wheel) as a // WM_MOUSEWHEEL carrying the Ctrl flag; the vendored Tao patch routes those // to the magnify hook (instead of a scroll, which would drive the - // scrollable — the bug we're fixing). Each notch/tick is a discrete delta, - // but pinch detection (`detectTransformGestures`) only crosses its touch - // slop once distance has changed enough, so per-tick Press→Release bursts - // would swallow fine touchpad zooms. We instead keep ONE continuous - // two-finger Touch gesture: the first tick presses, every tick moves - // (accumulating scale), and an idle debounce releases it — the same - // continuous model the macOS path uses, so zoom is smooth and the gesture - // never reaches the scrollable. - - private var pinchActive = false - private var pinchScale = 1f + // scrollable). Each notch/tick is a discrete delta with no Began/Ended + // phase, so we keep ONE continuous Compose scale gesture: the first tick + // opens `ScaleStart`, every tick is `ScaleChange`, and an idle debounce + // sends `ScaleEnd` (#660). + private var pinchCenterX = 0f private var pinchCenterY = 0f + private val scaleSession = + TaoTrackpadScaleSession { type, factor -> + scene?.dispatchTrackpadScale( + x = pinchCenterX, + y = pinchCenterY, + type = type, + scaleFactor = factor, + keyboardModifiers = currentKeyboardModifiers, + ) + } private var pinchEndJob: Job? = null /** - * Synthesises a two-finger pinch from one Ctrl+wheel tick. [valueFixed] is - * the normalized wheel delta × [TRACKPAD_VALUE_SCALE] (positive = zoom in). - * Only magnify gestures are produced on Windows, so kind/phase/x/y from the - * shared `onTrackpadGesture` wire are ignored. + * Forwards one Ctrl+wheel / precision-touchpad pinch tick as a Compose + * scale step. [valueFixed] is the normalized wheel delta × + * [TRACKPAD_VALUE_SCALE] (positive = zoom in). Only magnify gestures are + * produced on Windows, so kind/phase/x/y from the shared + * `onTrackpadGesture` wire are ignored. */ - @OptIn(ExperimentalComposeUiApi::class) fun onTrackpadGesture( @Suppress("UNUSED_PARAMETER") kind: Int, @Suppress("UNUSED_PARAMETER") phase: Int, @@ -667,48 +741,13 @@ internal class TaoComposeSceneHostWindows( // ticks accumulate smoothly without each message behaving like a large // zoom step. val step = TaoWheelPinchZoom.stepFromWheelDelta(value) - - if (!pinchActive) { - pinchActive = true - pinchScale = 1f - // Centre on the cursor = zoom focal point (the pinch doesn't move it). - pinchCenterX = lastPointerX - pinchCenterY = lastPointerY - sendPinchPointers(PointerEventType.Press) - } - pinchScale *= step - sendPinchPointers(PointerEventType.Move) + pinchCenterX = lastPointerX + pinchCenterY = lastPointerY + scaleSession.change(step) schedulePinchEnd() } - @OptIn(ExperimentalComposeUiApi::class) - private fun sendPinchPointers(eventType: PointerEventType) { - val sc = scene ?: return - val radius = PINCH_BASE_RADIUS_PX * pinchScale - val pressed = eventType != PointerEventType.Release - val pointers = - listOf( - ComposeScenePointer( - id = PointerId(PINCH_POINTER_ID_A), - position = Offset(pinchCenterX - radius, pinchCenterY), - pressed = pressed, - type = PointerType.Touch, - ), - ComposeScenePointer( - id = PointerId(PINCH_POINTER_ID_B), - position = Offset(pinchCenterX + radius, pinchCenterY), - pressed = pressed, - type = PointerType.Touch, - ), - ) - sc.sendPointerEvent( - eventType = eventType, - pointers = pointers, - keyboardModifiers = currentKeyboardModifiers, - ) - } - - /** Re-arms the idle timer that releases the synthetic pinch once ticks stop. */ + /** Re-arms the idle timer that closes the scale gesture once ticks stop. */ private fun schedulePinchEnd() { pinchEndJob?.cancel() pinchEndJob = @@ -720,10 +759,7 @@ internal class TaoComposeSceneHostWindows( private fun endPinchGesture() { pinchEndJob = null - if (!pinchActive) return - sendPinchPointers(PointerEventType.Release) - pinchActive = false - pinchScale = 1f + scaleSession.end() } @OptIn(InternalComposeUiApi::class, ExperimentalComposeUiApi::class) @@ -775,7 +811,7 @@ internal class TaoComposeSceneHostWindows( // replaces the queued frame rather than lining up behind it, so // what the user sees during the drag stays current. val pacedByVSync = attachmentHandle != 0L - if (pacedByVSync) NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, false) + if (pacedByVSync) setVSyncEnabled(false) try { dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDndBridge.nativeStartDrag( hwnd = hwnd, @@ -785,7 +821,7 @@ internal class TaoComposeSceneHostWindows( pump = OutboundDragPump(), ) } finally { - if (pacedByVSync) NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, true) + if (pacedByVSync) setVSyncEnabled(true) // Unwedge rendering: an invalidation raised during the drag // latched `redrawPending` while DoDragDrop's pump ate the // matching REDRAW_REQUESTED, which suppresses every later @@ -862,7 +898,7 @@ internal class TaoComposeSceneHostWindows( @OptIn(InternalComposeUiApi::class, ExperimentalComposeUiApi::class) private inner class InboundDnDCallback : dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDndBridge.Callback { - private fun node() = scene?.rootDragAndDropNode + private fun node() = window.inboundDragAndDropNode?.invoke() override fun onDragEnter( hwnd: Long, @@ -934,6 +970,8 @@ internal class TaoComposeSceneHostWindows( fun setContent(content: @Composable () -> Unit) = exceptionHandler.catchExceptions { scene?.setContent { + val fm = androidx.compose.ui.platform.LocalFocusManager.current + androidx.compose.runtime.SideEffect { capturedFocusManager = fm } // Stock Compose Desktop Windows wheel behavior; only the // lines-per-notch factor is reapplied (see TaoWindowsScrollConfig). ProvideTaoWindowsScrollConfig { @@ -998,7 +1036,7 @@ internal class TaoComposeSceneHostWindows( onResized(widthPxNew, heightPxNew) return } - NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, false) + setVSyncEnabled(false) try { if (widthPxNew != widthPx || heightPxNew != heightPx) { // Resize the child + immediately present a themed clear: @@ -1015,10 +1053,24 @@ internal class TaoComposeSceneHostWindows( } onResized(widthPxNew, heightPxNew) } finally { - NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, true) + setVSyncEnabled(true) } } + /** + * Swap interval as this host last set it — `true` = 1 (pace on the + * display refresh), `false` = 0 (present immediately, replacing a queued + * frame). Starts at ANGLE's default of 1; the modal resize/move loop, the + * outbound drag session and the fullscreen transition drop it for their + * duration. + */ + private var vsyncEnabled = true + + private fun setVSyncEnabled(enabled: Boolean) { + vsyncEnabled = enabled + NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, enabled) + } + fun onResized( widthPxNew: Int, heightPxNew: Int, @@ -1036,27 +1088,33 @@ internal class TaoComposeSceneHostWindows( // the surface resize + present atomic (no black edge). pendingResizeApply = true - // Every WM_SIZE of the OS modal resize/move loop renders + presents - // inline, at swap interval 0 (see onResizeLoopChanged) — NEVER skip or - // coalesce a frame here. A skipped frame leaves the parent HWND at its - // new size while the child surface + content stay stale until the - // async redraw lands, and DWM composites that mismatch as the window - // trembling — the Windows twin of the macOS live-resize tremble - // (#476). Rendering inline is atomic instead: the modal loop is - // parked on this very call, so the geometry cannot advance while we - // paint, and each presented frame matches the window bounds exactly. - // The memory cost of the unpaced render loop (the #347 native-image - // leak) is bounded by the per-flush 256 MiB cache budget plus a - // periodic purge of the per-size GPU scratch accumulated by the drag; - // the drag-end path in onResizeLoopChanged reclaims the rest. + // Every size change renders + presents inline, in the dispatch that + // carried it — NEVER skip or coalesce a frame here. DWM registers the + // HWND resize at once and, until the next present, composites the + // previous frame over the new client area. In the OS modal + // resize/move loop (swap interval 0, see onResizeLoopChanged) a frame + // left to the async redraw shows as the window trembling — the + // Windows twin of the macOS live-resize tremble (#476); for a + // programmatic resize it is one stale step of a `WindowState.size` + // animation or of a maximize (#576). Rendering inline is atomic + // instead: the geometry cannot advance while we paint (the modal loop + // is parked on this very call; a programmatic SetWindowPos has + // returned), so each presented frame matches the window bounds + // exactly. The memory cost of the unpaced modal-loop render (the #347 + // native-image leak) is bounded by the per-flush 256 MiB cache budget + // plus a periodic purge of the per-size GPU scratch accumulated by the + // drag; the drag-end path in onResizeLoopChanged reclaims the rest. if (resizeLoopActive) { val now = System.nanoTime() - if (now - lastResizePurgeNs >= RESIZE_PURGE_INTERVAL_NS) { + if (now - lastResizePurgeNs >= GPU_RESIZE_PURGE_INTERVAL_NS) { lastResizePurgeNs = now purgeGpuResourceCache() } } - onRedrawRequested() + // Outside the modal loop the resize is programmatic and the render + // loop is running alongside — see [renderFrame] for why this frame + // must neither park on VSync nor advance the frame clock. + renderFrame(sameTurnResize = !resizeLoopActive) } /** @@ -1083,7 +1141,7 @@ internal class TaoComposeSceneHostWindows( val ctx = directContext ?: return if (attachmentHandle != 0L) NativeTaoGlBridge.nativeMakeCurrent(attachmentHandle) ctx.resourceCacheLimit = 0 - ctx.resourceCacheLimit = RESOURCE_CACHE_LIMIT_BYTES + ctx.resourceCacheLimit = GPU_RESOURCE_CACHE_LIMIT_BYTES } /** @@ -1128,10 +1186,10 @@ internal class TaoComposeSceneHostWindows( .isActive(it) } == true if (!framePacedContent) { - NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, false) + setVSyncEnabled(false) } } else { - NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, true) + setVSyncEnabled(true) // Paint the settled size once more so the first steady-state frame // is already vsync-paced and current. pendingResizeApply = true @@ -1169,7 +1227,22 @@ internal class TaoComposeSceneHostWindows( } fun onFocusChanged(focused: Boolean) { - windowInfo.isWindowFocused = focused + // Win32 focus moving to an embedded child (a `NativeView`, WebView2) + // reaches Tao as the main HWND losing it, but the window is still the + // one the user is working in. Telling the scene otherwise puts its + // focus system to sleep: carets stop, `clearFocus` stops taking, and + // anything reading `LocalWindowInfo.isWindowFocused` goes inactive + // under the user's hands. `DecoratedWindow` keeps its chrome active + // through the same question. + windowInfo.isWindowFocused = focused || isFocusInsideWindowTree() + } + + /** Whether Win32 keyboard focus is on this window or on something it contains. */ + private fun isFocusInsideWindowTree(): Boolean { + if (hwnd == 0L) return false + if (!dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge.isLoaded) return false + return dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge + .nativeIsFocusInTree(hwnd) } private fun updateWindowInfoSize() { @@ -1197,7 +1270,75 @@ internal class TaoComposeSceneHostWindows( */ private var lastPresentedClearArgb: Int? = null - fun onRedrawRequested() { + /** Timestamp the frame clock last advanced to — see [renderFrame]. */ + private var lastFrameClockNanos = 0L + + /** The frame clock's timestamp for this frame: frozen for a same-turn resize frame (see [renderFrame]). */ + private fun frameClockNanos(sameTurnResize: Boolean): Long = + if (sameTurnResize && lastFrameClockNanos != 0L) { + lastFrameClockNanos + } else { + System.nanoTime().also { lastFrameClockNanos = it } + } + + /** The present decision of [renderFrame] — see the comment block above its call site. */ + private fun mustPresent( + visualFrame: Boolean, + resizeApplied: Boolean, + clearArgb: Int, + ): Boolean = + visualFrame || + resizeApplied || + resizeLoopActive || + forcePresentOnce || + lastPresentedClearArgb != clearArgb + + /** Swaps the host surface; [unpaced] presents at interval 0 for this one swap (see [renderFrame]). */ + private fun present(unpaced: Boolean) { + if (unpaced) NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, false) + try { + NativeTaoGlBridge.nativePresent(attachmentHandle) + } finally { + if (unpaced) NativeTaoGlBridge.nativeSetVSyncEnabled(attachmentHandle, true) + } + TaoPresentDiagnostics.record(window.handle, IntSize(widthPx, heightPx)) + } + + /** A render-loop frame: WM_PAINT (`RedrawRequested`) or one of the in-loop pumps. */ + fun onRedrawRequested() = renderFrame(sameTurnResize = false) + + /** + * Records, presents and paces one frame. + * + * [sameTurnResize] marks the frame [onResized] paints inside a + * programmatic resize's own dispatch (#576, Windows half). Two things + * set it apart from a render-loop frame: + * + * - **It presents at swap interval 0.** `setInnerSize` is a tao user + * event, so the animation step the loop frame ticked lands *after* that + * frame's VSync-paced swap returned — the refresh slot is taken. A + * paced present here would queue behind it and DWM would composite the + * new bounds with the previous frame for a whole refresh: the content + * trailing the window edge, one step in two (the other step finds the + * slot free). Interval 0 puts this frame on screen at the next refresh + * regardless — flip-model DXGI replaces a queued frame rather than + * lining up behind it — and does not park the event-loop thread. + * - **It does not advance the frame clock.** With no VSync park left to + * pace it, ticking here would run the next animation step from this + * very frame: its `setInnerSize` user event is delivered before the + * pending WM_PAINT, whose `Resized` paints another same-turn frame, + * and so on — a chain of unpaced frames the render loop never gets a + * word in (#484 pacing). Re-using the last loop frame's timestamp keeps + * `withFrameNanos` animations exactly where that frame left them: the + * pending recompositions still run, the layout is at the new size, and + * time moves on in the paced loop frame that follows. + * + * The modal resize/move loop is not a same-turn resize: its WM_SIZE is + * delivered synchronously, the interval is already 0 (or deliberately 1, + * #484), and its inline frames are the only frames that run while the + * user drags, so they must keep ticking. + */ + private fun renderFrame(sameTurnResize: Boolean) { val ctx = directContext ?: return val bundle = sceneBundle ?: return val sc = bundle.scene @@ -1228,7 +1369,7 @@ internal class TaoComposeSceneHostWindows( pendingResizeApply = false } - val now = System.nanoTime() + val now = frameClockNanos(sameTurnResize) // ── Frame pump ──────────────────────────────────────────────────── // Drain queued main-thread work (scroll dispatch, a11y, etc.) before @@ -1274,14 +1415,13 @@ internal class TaoComposeSceneHostWindows( fbId = 0, fbFormat = FramebufferFormat.GR_GL_RGBA8, ) + // Mica/Acrylic backdrops arm transparentBackgroundState at runtime and + // the clear becomes a translucent tint over the DWM material — the + // surface must drop LCD SurfaceProps then too, not only for + // creation-time transparent windows. Re-evaluated every frame since + // the surface is recreated per frame. val surface = - Surface.makeFromBackendRenderTarget( - context = ctx, - rt = rt, - origin = SurfaceOrigin.BOTTOM_LEFT, - colorFormat = SurfaceColorFormat.RGBA_8888, - colorSpace = ColorSpace.sRGB, - ) ?: run { + makeTaoGlSurface(ctx, rt, fullyTransparent || transparentBackgroundState.value) ?: run { rt.close() return } @@ -1376,18 +1516,13 @@ internal class TaoComposeSceneHostWindows( // so it never raises a scene invalidation). // nativePresent defensively re-binds the host's window surface first // (a popup renderer may have left its pbuffer current) and - // eglSwapBuffers paces on the display refresh. + // eglSwapBuffers paces on the display refresh — except for the + // same-turn resize frame, presented at interval 0 (see above). val visualFrame = dirtyBeforeRender || bundle.visualDirty.get() - val mustPresent = - visualFrame || - resizeApplied || - resizeLoopActive || - forcePresentOnce || - lastPresentedClearArgb != clearArgb - if (mustPresent) { + if (mustPresent(visualFrame, resizeApplied, clearArgb)) { forcePresentOnce = false lastPresentedClearArgb = clearArgb - NativeTaoGlBridge.nativePresent(attachmentHandle) + present(unpaced = sameTurnResize && vsyncEnabled) } // Backstop for a continuation that landed after the post-record drain @@ -1407,6 +1542,9 @@ internal class TaoComposeSceneHostWindows( lastPointerY = yPx currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers + healStaleNativePresses() + // Tao saw this move, so its own idea of the position is current again. + pointerPositionSetByOverlay = false if (!pointerDeadband.shouldDispatchMove(xPx, yPx, scale)) return scene?.sendPointerEvent( eventType = PointerEventType.Move, @@ -1439,14 +1577,202 @@ internal class TaoComposeSceneHostWindows( pressed: Boolean, ) { if (nativePointerRedispatchInFlight) return + syncPointerPositionFromWin32() + if (consumeOverlayEcho(mapButton(buttonCode), pressed)) return currentKeyboardModifiers = taoKeyboardModifiers(window.modifierState) windowInfo.keyboardModifiers = currentKeyboardModifiers + sendButtonToScene(mapButton(buttonCode), pressed) + } + + /** + * The one button path of the scene, for the HWND's own messages and the + * blending overlay's alike. Around the dispatch it keeps Compose's idea + * of the buttons honest against the embeds (see [forwardedNativeButtons]) + * and gives the keyboard to whichever side the press went to. + */ + private fun sendButtonToScene( + button: PointerButton, + pressed: Boolean, + ) { + if (pressed) { + // A button an embed swallowed the release of must not still be + // "down" when this press is hit-tested: Compose would see no + // down transition and the click would be dead. + for (stale in forwardedNativeButtons.toList()) releaseStaleNativePress(stale) + nativePointerDispatchedThisEvent = false + pressedButtons.add(button) + } else { + if (forwardedNativeButtons.remove(button)) releaseChildCapture() + // A release whose press the scene never saw (it went to an + // embed, a popup layer, or the frame) means nothing to it. + if (!pressedButtons.remove(button)) return + } scene?.sendPointerEvent( eventType = if (pressed) PointerEventType.Press else PointerEventType.Release, position = Offset(pointerDeadband.x, pointerDeadband.y), type = PointerType.Mouse, keyboardModifiers = currentKeyboardModifiers, - button = mapButton(buttonCode), + button = button, + ) + if (pressed && !nativePointerDispatchedThisEvent) claimKeyboardForCompose() + } + + /** + * Takes Win32 keyboard focus back from an embed after a press Compose + * kept. Without it an embed clicked into earlier keeps the keyboard while + * Compose shows a focused text field — the macOS host does the same with + * `makeFirstResponder`. + */ + private fun claimKeyboardForCompose() { + if (attachedNativeViewCount == 0 || hwnd == 0L) return + if (!dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge.isLoaded) return + dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge + .nativeClaimKeyboardForCompose(hwnd) + } + + /** + * Whether the scene's pointer position came from the blending overlay, + * which Tao knows nothing about — see [syncPointerPositionFromWin32]. + */ + private var pointerPositionSetByOverlay: Boolean = false + + /** + * Puts the scene's pointer back where Win32 says it is, moving it there + * first when it has drifted. + * + * Tao reports a button press without a position, so the scene places it + * where the last move left the pointer — and Tao drops a `WM_MOUSEMOVE` + * whose coordinate equals the last one *it* saw. Every move over a + * `NativeView` is delivered to the blending overlay instead, which never + * reaches Tao, so its idea of the position goes stale: a pointer that + * leaves the embed and comes back to a point Tao saw before gets no move + * at all, and the click that follows is dispatched onto the embed the + * user just left. It is dead, and so is every click after it. + */ + private fun syncPointerPositionFromWin32() { + if (!pointerPositionSetByOverlay) return + pointerPositionSetByOverlay = false + if (hwnd == 0L) return + if (!dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge.isLoaded) return + val packed = + dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge + .nativeCursorPosInClient(hwnd) + if (packed == Long.MIN_VALUE) return + val xPx = (packed shr 32).toInt().toFloat() + val yPx = packed.toInt().toFloat() + if (kotlin.math.abs(xPx - pointerDeadband.x) < 1f && kotlin.math.abs(yPx - pointerDeadband.y) < 1f) return + lastPointerX = xPx + lastPointerY = yPx + pointerDeadband.shouldDispatchMove(xPx, yPx, scale) + // The scene has to *travel* there: a press on a node the pointer was + // never seen entering leaves hover and cursor state on the old one. + scene?.sendPointerEvent( + eventType = PointerEventType.Move, + position = Offset(pointerDeadband.x, pointerDeadband.y), + type = PointerType.Mouse, + keyboardModifiers = currentKeyboardModifiers, + ) + } + + /** + * The last button event the blending overlay fed to the scene, kept until + * the main HWND replays it — see [consumeOverlayEcho]. + */ + private var echoButton: PointerButton? = null + private var echoPressed: Boolean = false + private var echoXPx: Float = 0f + private var echoYPx: Float = 0f + private var echoAtNanos: Long = 0L + + private fun noteOverlayButton( + button: PointerButton, + pressed: Boolean, + xPx: Float, + yPx: Float, + ) { + echoButton = button + echoPressed = pressed + echoXPx = xPx + echoYPx = yPx + echoAtNanos = System.nanoTime() + } + + /** + * Whether this main-HWND button event is Windows replaying one the + * blending overlay already gave the scene, and must be dropped. + * + * Pixels a `NativeView` owns are the overlay's: it is the window under + * them and hit-tests them first. Forwarding the press it reports to the + * embedded child moves Win32 focus, and the queue then replays the very + * same message to the owner HWND. Dispatched a second time it leaves + * Compose holding a press with no release, and every later click on the + * window is dead. Matched on button, position and recency, and consumed + * once, so a genuine second click — a double click on the embed, or a + * programmatic dispatch straight into the window — still gets through. + */ + private fun consumeOverlayEcho( + button: PointerButton, + pressed: Boolean, + ): Boolean { + val pending = echoButton ?: return false + if (pending != button || echoPressed != pressed) return false + if (System.nanoTime() - echoAtNanos > OVERLAY_ECHO_WINDOW_NANOS) return false + if (kotlin.math.abs(lastPointerX - echoXPx) > OVERLAY_ECHO_SLACK_PX || + kotlin.math.abs(lastPointerY - echoYPx) > OVERLAY_ECHO_SLACK_PX + ) { + return false + } + echoButton = null + return true + } + + /** + * Hands the mouse capture back when an embed took it on a forwarded + * press. Without this the child HWND keeps every later mouse message and + * the Compose window — its own HWND and the blending overlay alike — + * never sees the pointer again. + */ + private fun releaseChildCapture() { + if (hwnd == 0L) return + if (!dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge.isLoaded) return + dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge + .nativeReleaseChildCapture(hwnd) + } + + /** + * Releases every [forwardedNativeButtons] entry Win32 reports as up. Only + * pays the query while there is one, so a window without embeds never + * does. + */ + private fun healStaleNativePresses() { + if (forwardedNativeButtons.isEmpty()) return + if (!dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge.isLoaded) return + val mask = + dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge + .nativeQueryPointerButtons() + for (button in forwardedNativeButtons.toList()) { + val bit = + when (button) { + PointerButton.Primary -> WIN32_LBUTTON_BIT + PointerButton.Secondary -> WIN32_RBUTTON_BIT + PointerButton.Tertiary -> WIN32_MBUTTON_BIT + else -> 0 + } + if (mask and bit == 0) releaseStaleNativePress(button) + } + } + + /** The release the embed kept, synthesized where the scene last saw the pointer. */ + private fun releaseStaleNativePress(button: PointerButton) { + forwardedNativeButtons.remove(button) + releaseChildCapture() + if (!pressedButtons.remove(button)) return + scene?.sendPointerEvent( + eventType = PointerEventType.Release, + position = Offset(pointerDeadband.x, pointerDeadband.y), + type = PointerType.Mouse, + keyboardModifiers = currentKeyboardModifiers, + button = button, ) } @@ -1562,6 +1888,57 @@ internal class TaoComposeSceneHostWindows( } } + /** + * #569: the client origin `nativeSetFrameInWindow` adds via + * `ClientToScreen`, paired with every display's work area — so a popup + * layer can clamp against the display it actually lands on instead of the + * work-area-sized virtual screen Compose positions it in. + * + * Both halves are live reads rather than a cached snapshot: the layers + * re-clamp on every owner move, so a window dragged to another monitor + * re-resolves the display too. + */ + private fun resolvePopupScreenGeometry(): PopupScreenGeometry? { + if (!NativeTaoWindowsDecoBridge.isLoaded) return null + val origin = + NativeTaoWindowsDecoBridge + .nativeClientToScreen(hwnd, 0, 0) + ?.takeIf { it.size >= 2 } + ?: return null + // `reported`, not `all`: `all` invents a monitor when the platform + // names none, and clamping a popup into an invented work area moves it + // somewhere no display is. No geometry means no clamp. + val areas = TaoMonitors.reported(window).map { it.workAreaPx }.ifEmpty { return null } + return PopupScreenGeometry( + parentContentOriginPx = IntOffset(origin[0], origin[1]), + workAreasPx = areas, + ) + } + + /** Native popup layers handed out by [nativePopupLayerFactory] and not yet closed — swept by [detach]. */ + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + private val liveNativePopupLayers = linkedSetOf() + + /** + * Builds this window's native popup layers ([TaoPopupSceneLayerWindows]). + * The factory behind [nativePopupLayers], and the one `NativePopupLayers { }` + * hands to a subtree that wants native surfaces while the window's own + * popups stay in-scene. `null` until the HWND and its Skia context exist. + */ + + fun nativePopupLayerFactory(): TaoPopupLayerFactory? { + val popupHost = popupHost() ?: return null + return { density, layoutDirection, focusable, consumeOutside -> + TaoPopupSceneLayerWindows( + host = popupHost, + initialDensity = density, + initialLayoutDirection = layoutDirection, + initialFocusable = focusable, + initialConsumePointerInputOutside = consumeOutside, + ).also { liveNativePopupLayers += it } + } + } + fun popupHost(): TaoPopupHostWindows? { if (hwnd == 0L) return null val ctx = directContext ?: return null @@ -1571,6 +1948,7 @@ internal class TaoComposeSceneHostWindows( override val scale: Float get() = outer.scale override val isOwnerWindowTransparent: Boolean get() = outer.fullyTransparent override val parentWindowSize: IntSize get() = IntSize(outer.widthPx, outer.heightPx) + override val parentWindowInfo: androidx.compose.ui.platform.WindowInfo get() = outer.windowInfo override val workAreaSize: IntSize get() { if (!NativeTaoWindowsDecoBridge.isLoaded) return parentWindowSize val area = @@ -1582,6 +1960,9 @@ internal class TaoComposeSceneHostWindows( val h = area[3].toInt().coerceAtLeast(1) return IntSize(w, h) } + + override val popupScreenGeometry: PopupScreenGeometry? + get() = outer.resolvePopupScreenGeometry() override val sceneCoroutineContext: kotlin.coroutines.CoroutineContext get() = outer.coroutineContext + outer.flushingDispatcher override val hostDirectContext: DirectContext get() = ctx @@ -1589,6 +1970,8 @@ internal class TaoComposeSceneHostWindows( override val exceptionHandler: WindowExceptionHandler? get() = outer.exceptionHandler + override val popupScrims: PopupScrimRegistry get() = outer.popupScrims + override fun requestRedraw() = outer.window.requestRedraw() override fun registerRenderer( @@ -1607,6 +1990,11 @@ internal class TaoComposeSceneHostWindows( outer.hostContextDirtied = true } + @OptIn(androidx.compose.ui.InternalComposeUiApi::class) + override fun onLayerClosed(layer: androidx.compose.ui.scene.ComposeSceneLayer) { + outer.liveNativePopupLayers.remove(layer) + } + override fun registerKeyHandler( token: Any, handler: (KeyEvent) -> Boolean, @@ -1675,7 +2063,17 @@ internal class TaoComposeSceneHostWindows( for (cb in ownerMoveListeners.values.toList()) cb() } - fun nativeViewHost(): dev.nucleusframework.window.tao.TaoNativeViewHost? { + /** + * One host instance per scene. The composition local built from it keys + * `NativeView`'s attach effect: a fresh object on every recomposition of + * the window root would detach and re-attach every embed each time. + */ + private var nativeViewHostInstance: dev.nucleusframework.window.tao.TaoNativeViewHost? = null + + fun nativeViewHost(): dev.nucleusframework.window.tao.TaoNativeViewHost? = + nativeViewHostInstance ?: createNativeViewHost()?.also { nativeViewHostInstance = it } + + private fun createNativeViewHost(): dev.nucleusframework.window.tao.TaoNativeViewHost? { if (hwnd == 0L) return null if (!dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge.isLoaded) return null val parent = hwnd @@ -1688,6 +2086,7 @@ internal class TaoComposeSceneHostWindows( dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge .nativeAttach(parent, childHandle) outer.nativeViewBlending.retain() + outer.attachedNativeViewCount++ } override fun detach( @@ -1698,6 +2097,7 @@ internal class TaoComposeSceneHostWindows( dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge .nativeDetach(childHandle) outer.nativeViewBlending.release() + outer.attachedNativeViewCount = (outer.attachedNativeViewCount - 1).coerceAtLeast(0) } override fun setFrame( @@ -1730,15 +2130,44 @@ internal class TaoComposeSceneHostWindows( pressed: Boolean, ) { if (parent == 0L) return + if (type == NATIVE_POINTER_PRESS) { + // The child SetCaptures on this press and keeps the + // release; Compose hears of it through the heal. + outer.forwardedNativeButtons += + when (button) { + NATIVE_SECONDARY_BUTTON -> PointerButton.Secondary + NATIVE_MIDDLE_BUTTON -> PointerButton.Tertiary + else -> PointerButton.Primary + } + // The embed takes the keyboard with this press (the bridge + // SetFocuses it before forwarding): a Compose text field + // must not keep showing a caret beside the embed's. + // + // Deferred, because this runs inside the Press dispatch — + // but on the main dispatcher, not the frame queue: the + // press may be the one that opens the embed's own context + // menu, whose modal loop stops the window painting, and a + // focus clear waiting for a frame would never run while + // the menu the user is looking at is up. + dev.nucleusframework.window.tao.dispatch.TaoMainDispatcher + .dispatch(kotlin.coroutines.EmptyCoroutineContext) { + outer.capturedFocusManager?.clearFocus(force = true) + } + } outer.nativePointerRedispatchInFlight = true try { dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge .nativeDispatchPointer(parent, handle, type, xPx, yPx, button, pressed) } finally { outer.nativePointerRedispatchInFlight = false + outer.window.resetRedrawLatch() } } + override fun noteNativePointerDispatch() { + outer.nativePointerDispatchedThisEvent = true + } + override fun dispatchScrollToNative( handle: Long, xPx: Float, @@ -1753,6 +2182,12 @@ internal class TaoComposeSceneHostWindows( .nativeDispatchScroll(parent, handle, xPx, yPx, dx, dy) } finally { outer.nativePointerRedispatchInFlight = false + // Handing an event to a child HWND runs its handler on this + // thread, and anything it pumps swallows the redraw this + // window had pending — the coalescing latch then suppresses + // every later request and the window silently stops + // painting. See TaoWindow.resetRedrawLatch. + outer.window.resetRedrawLatch() } } } @@ -1804,6 +2239,10 @@ internal class TaoComposeSceneHostWindows( heightPx = this@TaoComposeSceneHostWindows.heightPx, directContext = ctx, clearColorArgb = 0, + // The blending overlay is unconditionally a per-pixel-alpha + // DComp swapchain (DXGI_ALPHA_MODE_PREMULTIPLIED) regardless of + // the window's own transparency — no LCD SurfaceProps. + windowTransparent = true, present = { NativeTaoWindowsOverlayBridge.nativeSwapBuffers(overlayHandle) }, ) { canvas, nanoTime -> // Clip to the union of NativeView rects — SetWindowRgn @@ -1848,6 +2287,7 @@ internal class TaoComposeSceneHostWindows( ) { lastPointerX = x lastPointerY = y + pointerPositionSetByOverlay = true currentKeyboardModifiers = taoKeyboardModifiers(modifiers) windowInfo.keyboardModifiers = currentKeyboardModifiers val pointerButton = @@ -1863,18 +2303,21 @@ internal class TaoComposeSceneHostWindows( 2 -> PointerEventType.Release else -> PointerEventType.Move } - // Same sub-pixel deadband as the main stream (#615) — the - // overlay WndProc shares the scene's single mouse pointer. - if (eventType == PointerEventType.Move && - !pointerDeadband.shouldDispatchMove(x, y, scale) - ) { + if (eventType != PointerEventType.Move) { + noteOverlayButton(pointerButton ?: PointerButton.Primary, eventType == PointerEventType.Press, x, y) + // The overlay reports in owner-client px, like the HWND. + pointerDeadband.shouldDispatchMove(x, y, scale) + sendButtonToScene(pointerButton ?: PointerButton.Primary, eventType == PointerEventType.Press) return } + healStaleNativePresses() + // Same sub-pixel deadband as the main stream (#615) — the + // overlay WndProc shares the scene's single mouse pointer. + if (!pointerDeadband.shouldDispatchMove(x, y, scale)) return scene?.sendPointerEvent( eventType = eventType, position = Offset(pointerDeadband.x, pointerDeadband.y), type = PointerType.Mouse, - button = pointerButton, keyboardModifiers = currentKeyboardModifiers, ) } @@ -1902,10 +2345,10 @@ internal class TaoComposeSceneHostWindows( } // Hop the debounced semantics walk onto the render thread (it touches - // Compose state) and request a redraw. See AbstractTaoComposeSceneHost. + // Compose state); the enqueue asks for the frame that drains it. See + // AbstractTaoComposeSceneHost. override fun dispatchA11yWalk(block: () -> Unit) { flushingDispatcher.enqueue(Runnable { block() }) - window.requestRedraw() } /** @@ -1957,17 +2400,23 @@ internal class TaoComposeSceneHostWindows( } fun detach() { + // Layers whose dismiss animation was still running: Compose closes a + // native popup layer only when its own disappearance finishes, so an + // owner destroyed mid-animation left the layer's popup window mapped + // for good — an invisible rectangle eating every click under it. + for (layer in liveNativePopupLayers.toList()) layer.close() + liveNativePopupLayers.clear() window.showHook = null + window.inboundDragAndDropNode = null window.imePreedit = null window.imeCommit = null imeSession.onInputSession(null) nativeViewBlending.destroyOverlay() shutdownA11yScheduler() textToolbar.hide() - // Stop the pinch idle timer; the scene is going away so no Release needed. + // Stop the pinch idle timer; the scene is going away so no ScaleEnd needed. pinchEndJob?.cancel() pinchEndJob = null - pinchActive = false gestureScope.cancel() // Make THIS host's ES context current before tearing down Skia // resources. A sibling host (e.g. the main window opened while this @@ -1981,6 +2430,7 @@ internal class TaoComposeSceneHostWindows( NativeTaoGlBridge.nativeMakeCurrent(attachmentHandle) } sceneBundle?.close() + window.clearContentMeasurer() sceneBundle = null if (directContext != null) { // Belt for TextureView imports a leaked composition may still hold: @@ -2026,35 +2476,7 @@ internal class TaoComposeSceneHostWindows( */ private const val TRACKPAD_VALUE_SCALE: Float = 10_000f - /** Half-distance of the synthetic two-finger pair at scale 1.0. */ - private const val PINCH_BASE_RADIUS_PX: Float = 120f - - /** - * GPU resource cache budget for the host DirectContext. Bounds the - * per-frame scratch (wrapped-framebuffer stencil/attachments) so an - * uncapped resize flood — VSync is dropped during the OS modal - * resize/move loop — can't grow the process unbounded. Sized to cover - * a HiDPI window's render target plus Compose's layer/glyph caches - * with headroom, while still far below the >1 GB the leak reached. - */ - private const val RESOURCE_CACHE_LIMIT_BYTES: Long = 256L * 1024 * 1024 - - /** - * Gap between in-drag GPU cache purges during the OS modal - * resize/move loop. Every frame of the drag mints render-target - * scratch (stencil/attachments) at a size no later frame reuses; - * the periodic limit-toggle purge in [onResized] releases that - * accumulation mid-drag so the peak stays bounded even for long - * drags, without skipping any resize frame (a skipped frame is - * composited by DWM as a geometry/content mismatch — trembling). - */ - private const val RESIZE_PURGE_INTERVAL_NS: Long = 250_000_000L - - // Stable ids well clear of real touch ids (raw WM_POINTER finger ids). - private const val PINCH_POINTER_ID_A: Long = 0xA001L - private const val PINCH_POINTER_ID_B: Long = 0xA002L - - /** Idle gap after the last tick before the synthetic pinch releases. */ + /** Idle gap after the last tick before the scale gesture closes. */ private const val PINCH_IDLE_END_MS: Long = 120L /** @@ -2090,8 +2512,15 @@ internal class TaoComposeSceneHostWindows( window.requestRedraw() } + /** + * Queues [block] for the next drain. The drains all sit in the frame + * path, so this asks for a frame too — a window with nothing else to + * redraw would otherwise hold the block forever (a focus clear that + * never runs leaves two carets on screen). + */ fun enqueue(block: Runnable) { queue.add(block) + window.requestRedraw() } fun drain() { @@ -2172,41 +2601,27 @@ private class WindowsTaoPlatformContext( } override fun setPointerIcon(pointerIcon: androidx.compose.ui.input.pointer.PointerIcon) { - NativeTaoBridge.nativeSetCursorIcon( + NativeTaoBridge.setCursorIcon( windowHandle, mapPointerIcon(pointerIcon), ) } - private fun mapPointerIcon(icon: androidx.compose.ui.input.pointer.PointerIcon): Int { - when { - icon === androidx.compose.ui.input.pointer.PointerIcon.Default -> - return dev.nucleusframework.window.tao.TaoCursorIcon.DEFAULT - icon === androidx.compose.ui.input.pointer.PointerIcon.Text -> - return dev.nucleusframework.window.tao.TaoCursorIcon.TEXT - icon === androidx.compose.ui.input.pointer.PointerIcon.Hand -> - return dev.nucleusframework.window.tao.TaoCursorIcon.HAND - icon === androidx.compose.ui.input.pointer.PointerIcon.Crosshair -> - return dev.nucleusframework.window.tao.TaoCursorIcon.CROSSHAIR - } - return runCatching { - val cursor = icon.javaClass.getMethod("getCursor").invoke(icon) as? java.awt.Cursor - when (cursor?.type) { - java.awt.Cursor.TEXT_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.TEXT - java.awt.Cursor.HAND_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.HAND - java.awt.Cursor.CROSSHAIR_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.CROSSHAIR - java.awt.Cursor.WAIT_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.WAIT - java.awt.Cursor.MOVE_CURSOR -> dev.nucleusframework.window.tao.TaoCursorIcon.MOVE - java.awt.Cursor.E_RESIZE_CURSOR, java.awt.Cursor.W_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.EW_RESIZE - java.awt.Cursor.N_RESIZE_CURSOR, java.awt.Cursor.S_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.NS_RESIZE - java.awt.Cursor.NE_RESIZE_CURSOR, java.awt.Cursor.SW_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.NESW_RESIZE - java.awt.Cursor.NW_RESIZE_CURSOR, java.awt.Cursor.SE_RESIZE_CURSOR -> - dev.nucleusframework.window.tao.TaoCursorIcon.NWSE_RESIZE - else -> dev.nucleusframework.window.tao.TaoCursorIcon.DEFAULT - } - }.getOrDefault(dev.nucleusframework.window.tao.TaoCursorIcon.DEFAULT) - } + private fun mapPointerIcon(icon: androidx.compose.ui.input.pointer.PointerIcon): Int = icon.toTaoCursorIconCode() } + +/** `NativeView` pointer type / button codes (see `TaoNativeViewHost.dispatchPointerToNative`). */ +private const val NATIVE_POINTER_PRESS = 1 +private const val NATIVE_SECONDARY_BUTTON = 2 +private const val NATIVE_MIDDLE_BUTTON = 3 + +/** Bits of `NativeTaoWindowsNativeViewBridge.nativeQueryPointerButtons`. */ +private const val WIN32_LBUTTON_BIT = 1 +private const val WIN32_RBUTTON_BIT = 2 +private const val WIN32_MBUTTON_BIT = 4 + +/** How long after an overlay button event its main-HWND replay may arrive. */ +private const val OVERLAY_ECHO_WINDOW_NANOS = 500_000_000L + +/** How far the replayed position may sit from the overlay's, in px. */ +private const val OVERLAY_ECHO_SLACK_PX = 2f diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoPresentDiagnostics.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoPresentDiagnostics.kt new file mode 100644 index 000000000..e124dff96 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoPresentDiagnostics.kt @@ -0,0 +1,31 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.ui.unit.IntSize +import java.util.concurrent.ConcurrentHashMap + +/** + * Size of the last frame each window's host presented, keyed by + * `TaoWindow.handle` — the seam the headful suite asserts the #576 contract + * through: a resize event must not end its run-loop turn before a frame at + * the new size has been presented, or the compositor (Core Animation on + * macOS, DWM on Windows) shows the previous frame stretched to the new + * bounds and the whole content trembles. + * + * The macOS (Metal) and Windows (ANGLE) hosts record; the Linux host leaves + * its entries `null`. + * Same shape as [dev.nucleusframework.window.tao.popup.TaoPopupDiagnostics]: + * plain writes on the frame path, not snapshot state. + */ +internal object TaoPresentDiagnostics { + private val last = ConcurrentHashMap() + + fun record( + windowHandle: Long, + sizePx: IntSize, + ) { + last[windowHandle] = sizePx + } + + /** Physical size of the last frame presented for [windowHandle], `null` before the first. */ + fun lastPresentedPx(windowHandle: Long): IntSize? = last[windowHandle] +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt index 855cd8c16..14a478560 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneBundle.kt @@ -119,6 +119,15 @@ internal class TaoSceneBundle( else -> false } + /** + * Painted over the scene at the end of every [render], on the same canvas + * and in the same coordinates the scene drew in. This is where the dialog + * scrims of native popup layers land — the owner window paints every + * layer's scrim, each layer paints the scrims of the layers above it + * (Compose Desktop's `onRenderOverlay`). `null` paints nothing. + */ + var renderOverlay: ((Canvas) -> Unit)? = null + /** * Recomposes, lays out, and draws one frame into [canvas] — the drop-in * replacement for the pre-1.12 `scene.render(canvas.asComposeCanvas(), nanoTime)`. @@ -134,6 +143,7 @@ internal class TaoSceneBundle( with(renderingScope) { scene.render(frameRecomposer, canvas.asComposeCanvas(), nanoTime) } + renderOverlay?.invoke(canvas) edtGuard.afterFrame() swallowed = false } @@ -146,6 +156,26 @@ internal class TaoSceneBundle( if (swallowed && isRecomposerAlive) requestFrame() } + /** + * Recomposes and re-lays-out the scene now, without drawing. + * + * For the one case where a Compose state write has to reach the node tree + * *between* two things that happen in the same turn, rather than on the + * next frame: a press that dismisses a popup, which the scene then receives + * (see `TaoComposeSceneHostLinux.onPointerButton`). Two frame rolls, because + * the first one composes the nodes and only the second runs the effects they + * launched — a `pointerInput` handler awaits from a coroutine, so a node + * composed but not yet started would let the press through untouched. + */ + fun composeAndLayoutNow() { + exceptionHandler.catchExceptions { + val nanoTime = System.nanoTime() + frameRecomposer.performFrame(nanoTime) + frameRecomposer.performFrame(nanoTime) + scene.measureAndLayout() + } + } + @Suppress("TooGenericExceptionCaught") override fun close() { closed.set(true) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt index ecdcceb81..a84acc111 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneScrollRouter.kt @@ -142,6 +142,9 @@ internal class TaoSceneScrollRouter( } } + /** Whether a trackpad pan is open, its deferred PanEnd included. */ + val panOpen: Boolean get() = pan.isOpen + /** Closes an open pan now — a pointer press ends the gesture for Compose too. */ fun finishPan() { if (cancelled) return diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt index f08845dd8..b02836603 100644 --- a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoTrackpadPanRouter.kt @@ -45,6 +45,9 @@ internal class TaoTrackpadPanRouter( ) { private var active = false + /** Whether a pan is open: from its PanStart until its PanEnd has been sent. */ + val isOpen: Boolean get() = active + // The end of the open pan is a deadline, not a timer per step: steps arrive // at frame rate and re-arming a coroutine for each would cost a launch, a // main-loop wake and a cancel every few milliseconds. One timer is in diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoWaylandFrameDiagnostics.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoWaylandFrameDiagnostics.kt new file mode 100644 index 000000000..cfe38b1a5 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/scene/TaoWaylandFrameDiagnostics.kt @@ -0,0 +1,116 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.ui.unit.IntSize +import java.util.concurrent.CopyOnWriteArrayList + +/** + * Per-frame record of the three sizes a Wayland resize keeps disagreeing about + * (#444), the seam the headful suite asserts the contract through. + * + * On Wayland the buffer behind the default framebuffer is not the one we last + * asked for: `wl_egl_window_resize` only records a *pending* size, and the + * reallocation happens inside the next `eglSwapBuffers`. Skia's render target + * wraps that framebuffer (`fbId = 0`) with a size of our choosing, so if the + * two disagree under [org.jetbrains.skia.SurfaceOrigin.BOTTOM_LEFT] the frame + * lands off the top of the real drawable by the difference — a band of clear + * colour along one edge, which is what the issue sees flicker during a drag. + * + * [attachedPx] is the authoritative answer (`wl_egl_window_get_attached_size`, + * libwayland-egl's own record of the buffer the compositor holds); [paintPx] + * is what Skia was told. Any frame where they disagree is the defect, whether + * or not the eye caught it. + * + * Off by default: [recording] is flipped on by a test around the gesture it + * measures. Plain writes on the frame path, not snapshot state — same shape as + * [TaoPresentDiagnostics]. + */ +internal object TaoWaylandFrameDiagnostics { + /** One render pass: what the window measured, what Skia painted, what the buffer was. */ + internal data class Frame( + val nanos: Long, + val windowPx: IntSize, + val paintPx: IntSize, + /** `wl_egl_window_get_attached_size`, or [IntSize.Zero] on X11 / before the first swap. */ + val attachedPx: IntSize, + /** + * `eglQuerySurface(EGL_WIDTH/EGL_HEIGHT)` sampled **before** the frame is + * drawn: the size of the back buffer the GL commands are about to land + * in. This is the size the render target must agree with. + */ + val queriedPx: IntSize, + /** + * The same query sampled **after** the frame was flushed, which says + * whether the pending `wl_egl_window_resize` was applied mid-frame — + * if it were, agreeing with [queriedPx] up front would not be enough. + */ + val queriedAfterPx: IntSize, + /** The size last handed to `wl_egl_window_resize`. */ + val requestedPx: IntSize, + ) { + /** Rows by which the painted frame overshoots the buffer it lands in. */ + val heightDelta: Int get() = paintPx.height - queriedPx.height + + /** Columns by which the painted frame overshoots the buffer it lands in. */ + val widthDelta: Int get() = paintPx.width - queriedPx.width + + /** Whether the buffer was reallocated between the start and the end of this frame. */ + val reallocatedMidFrame: Boolean get() = queriedAfterPx != queriedPx + } + + @Volatile + private var recording = false + + private val frames = CopyOnWriteArrayList() + + /** Starts a fresh recording; any frames from an earlier one are dropped. */ + fun start() { + frames.clear() + renderPasses = 0 + skipped = 0 + recording = true + } + + /** Stops recording and returns everything captured since [start]. */ + fun stop(): List { + recording = false + return frames.toList() + } + + /** Render passes that reached the probe since [start]. */ + @Volatile + var renderPasses: Int = 0 + private set + + /** + * Render passes dropped since [start] because a swap was still in flight. + * + * A Wayland surface the compositor treats as occluded stops receiving frame + * callbacks, so `eglSwapBuffers` never returns and every pass lands here: + * the window renders nothing at all. Without this number a case that + * measured nothing is indistinguishable from a window that never resized, + * and both look like a pass. + */ + @Volatile + var skipped: Int = 0 + private set + + fun noteSkipped() { + skipped++ + } + + fun record(frame: () -> Frame) { + renderPasses++ + if (!recording) return + frames += frame() + } + + /** Whether a recording is armed — lets the frame path skip the closing sample too. */ + val isRecording: Boolean get() = recording + + /** Replaces the last recorded frame, once its closing sample is known. */ + fun completeLast(update: (Frame) -> Frame) { + if (!recording) return + val index = frames.lastIndex + if (index >= 0) frames[index] = update(frames[index]) + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/DialogState.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/DialogState.kt new file mode 100644 index 000000000..8103867a8 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/DialogState.kt @@ -0,0 +1,254 @@ +@file:OptIn(ExperimentalComposeUiApi::class) +@file:Suppress("TooManyFunctions") + +package dev.nucleusframework.window.tao.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.size +import kotlinx.coroutines.channels.Channel + +/** + * Creates a [DialogState] remembered across compositions and saved across + * configuration changes. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.rememberDialogState` — + * see [rememberWindowState] for the migration story. + * + * @param initialScreenProvider Provides the screen the dialog is first placed on. + * @param initialBoundsProvider Provides the initial bounds of the dialog. + */ +@Composable +public fun rememberDialogState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, +): DialogState = + rememberSaveable(saver = DialogState.Saver) { + DialogState( + initialScreenProvider = initialScreenProvider, + initialBoundsProvider = initialBoundsProvider, + ) + } + +/** + * Creates a [DialogState] remembered across compositions, from a plain position + * and size. + * + * @param initialPosition The initial position; centred on the screen if `null`. + * @param initialSize The initial size; 800×600 if `null`. + */ +@Composable +public fun rememberDialogStateWithBounds( + initialPosition: DpOffset? = null, + initialSize: DpSize? = null, +): DialogState = + rememberSaveable(saver = DialogState.Saver) { + DialogStateWithBounds(initialPosition = initialPosition, initialSize = initialSize) + } + +/** + * Creates a [DialogState] with the given initial values. + * + * @param initialScreenProvider Provides the screen the dialog is first placed on. + * @param initialBoundsProvider Provides the initial bounds of the dialog. + */ +@Suppress("FunctionNaming") +public fun DialogState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, +): DialogState = + DialogState.createUninitialized().apply { + requestScreen(initialScreenProvider) + requestBounds(initialBoundsProvider) + } + +/** + * Creates a [DialogState] with the given initial position and size. + * + * @param initialPosition The initial position; centred on the screen if `null`. + * @param initialSize The initial size; 800×600 if `null`. + */ +@Suppress("FunctionNaming") +public fun DialogStateWithBounds( + initialPosition: DpOffset? = null, + initialSize: DpSize? = null, +): DialogState = + DialogState( + initialBoundsProvider = + WindowBoundsProvider( + sizeProvider = initialSize?.let { WindowSizeProvider.Fixed(it) } ?: WindowSizeProvider.Default, + positionProvider = + initialPosition?.let { WindowPositionProvider.Absolute(it) } + ?: WindowPositionProvider.CenteredOnScreen, + ), + ) + +/** + * A state object that can be hoisted to control and observe dialog attributes + * (screen, size, position). + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.DialogState`. + */ +@Stable +public class DialogState private constructor( + isInitialized: Boolean, + screenId: String?, + bounds: DpRect?, +) { + internal constructor(screenId: String, bounds: DpRect) : this( + isInitialized = true, + screenId = screenId, + bounds = bounds, + ) + + init { + bounds?.requireReal() + } + + /** Whether the dialog has become visible at least once. */ + public var isInitialized: Boolean by mutableStateOf(isInitialized) + internal set + + internal var screenIdOrNull: String? by mutableStateOf(screenId) + + /** + * The id of the screen the dialog is currently on; throws + * [IllegalStateException] before [isInitialized]. + */ + public val screenId: String + get() = screenIdOrNull ?: notInitializedDialog("screenId") + + internal val screenRequests = Channel(Channel.CONFLATED) + + /** Requests to move the dialog to the screen the provider picks. */ + public fun requestScreen(screenProvider: WindowScreenProvider) { + screenRequests.trySend(screenProvider) + } + + internal var boundsOrNull: DpRect? by mutableStateOf(bounds) + + /** + * The current bounds of the dialog, decorations included; throws + * [IllegalStateException] before [isInitialized]. + */ + public val bounds: DpRect + get() = boundsOrNull ?: notInitializedDialog("bounds") + + /** The current position of the dialog; throws before [isInitialized]. */ + public val position: DpOffset + get() = boundsOrNull?.topLeft ?: notInitializedDialog("position") + + /** The current size of the dialog; throws before [isInitialized]. */ + public val size: DpSize + get() = boundsOrNull?.size ?: notInitializedDialog("size") + + internal val boundsRequests = Channel(Channel.UNLIMITED) + + /** Requests to set the bounds of the dialog via a [WindowBoundsProvider]. */ + public fun requestBounds(boundsProvider: WindowBoundsProvider) { + boundsRequests.trySend(boundsProvider) + } + + /** Requests to set the bounds of the dialog from a scoped function. */ + public fun requestBounds(boundsProvider: WindowGeometryProviderScope.() -> DpRect) { + boundsRequests.trySend(WindowBoundsProvider(boundsProvider)) + } + + /** Requests to set the bounds of the dialog. Same as [WindowBoundsProvider.Absolute]. */ + public fun requestBounds(bounds: DpRect) { + boundsRequests.trySend(WindowBoundsProvider.Absolute(bounds)) + } + + /** Requests to set the position of the dialog via a [WindowPositionProvider]. */ + public fun requestPosition(positionProvider: WindowPositionProvider) { + boundsRequests.trySend(WindowBoundsProvider(positionProvider = positionProvider)) + } + + /** Requests to move the dialog to [position]. */ + public fun requestPosition(position: DpOffset) { + requestPosition(WindowPositionProvider.Absolute(position)) + } + + /** Requests to move the dialog to ([x], [y]). */ + public fun requestPosition( + x: Dp, + y: Dp, + ) { + requestPosition(WindowPositionProvider.Absolute(x, y)) + } + + /** Requests to set the size of the dialog via a [WindowSizeProvider]. */ + public fun requestSize(sizeProvider: WindowSizeProvider) { + boundsRequests.trySend(WindowBoundsProvider(sizeProvider = sizeProvider)) + } + + /** Requests to resize the dialog to [size]. */ + public fun requestSize(size: DpSize) { + requestSize(WindowSizeProvider.Fixed(size)) + } + + /** Requests to resize the dialog to [width] × [height]. */ + public fun requestSize( + width: Dp, + height: Dp, + ) { + requestSize(WindowSizeProvider.Fixed(width, height)) + } + + /** Factories and the [Saver]. */ + public companion object { + internal fun createUninitialized(): DialogState = + DialogState(isInitialized = false, screenId = null, bounds = null) + + /** A [Saver] implementation for [DialogState]. */ + public val Saver: Saver = + listSaver( + save = { + if (!it.isInitialized) { + emptyList() + } else { + val bounds = it.bounds + listOf( + it.screenId, + bounds.top.value, + bounds.left.value, + bounds.right.value, + bounds.bottom.value, + ) + } + }, + restore = { state -> + if (state.isEmpty()) { + null + } else { + DialogState( + screenId = state[0] as String, + bounds = + DpRect( + top = Dp(state[1] as Float), + left = Dp(state[2] as Float), + right = Dp(state[3] as Float), + bottom = Dp(state[4] as Float), + ), + ) + } + }, + ) + } +} + +private fun notInitializedDialog(propertyName: String): Nothing = + throw IllegalStateException( + "Can't read $propertyName before the dialog has been made visible; use isInitialized to check.", + ) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/Screen.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/Screen.kt new file mode 100644 index 000000000..66827cd8d --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/Screen.kt @@ -0,0 +1,155 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao.v2 + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.DpInsets +import androidx.compose.ui.unit.DpRect +import dev.nucleusframework.window.tao.TaoMonitor +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.TaoWindow + +/** + * Represents a screen (a graphical device on which windows can be rendered). + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.Screen`: same members, + * backed by [TaoMonitor] instead of `java.awt.GraphicsDevice`. Migrating is one + * import — see the package overview on [WindowState]. + * + * Unlike the Compose original, a [Screen] holds no native handle, so keeping a + * reference is harmless. It is still a *snapshot*: a screen that has been + * unplugged keeps reporting its last known geometry, and [id] no longer + * resolves through [TaoMonitors.byId]. + */ +@Immutable +public class Screen internal constructor( + internal val monitor: TaoMonitor, + /** + * Scale factor the [DpRect] members are expressed in. Every rectangle the + * window API produces has to share one scale, so this is the scale of the + * window being positioned rather than the monitor's own — they differ on a + * mixed-DPI setup. See [TaoMonitor.boundsDp]. + */ + internal val referenceScale: Float, +) { + /** The identifier of the screen. See [TaoMonitor.id] for its per-platform shape. */ + public val id: String get() = monitor.id + + /** + * Human-readable display name, for a screen picker UI. + * + * Not part of the Compose API — `Screen.id` is the only identity there, and + * on Windows it is a device path (`\\.\DISPLAY1`) nobody wants to read. + */ + public val name: String get() = monitor.name + + /** + * The bounds of the screen in the coordinate system of all screens. + * + * Coordinates may be negative: a screen can sit to the left of or above the + * primary one. + */ + public val bounds: DpRect get() = monitor.boundsDp(referenceScale) + + /** The insets of the screen — taskbar, menu bar, dock, panels. */ + public val insets: DpInsets + get() { + val full = bounds + val available = availableBounds + return DpInsets( + top = available.top - full.top, + left = available.left - full.left, + bottom = full.bottom - available.bottom, + right = full.right - available.right, + ) + } + + /** The bounds of the screen excluding the insets. */ + public val availableBounds: DpRect get() = monitor.workAreaDp(referenceScale) + + /** Whether this is the primary screen. */ + public val isPrimary: Boolean get() = monitor.isPrimary + + override fun equals(other: Any?): Boolean = this === other || (other is Screen && other.id == id) + + override fun hashCode(): Int = id.hashCode() + + override fun toString(): String = "Screen $id" +} + +/** + * The scope in which a [WindowScreenProvider] is evaluated. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowScreenProviderScope`. + */ +public class WindowScreenProviderScope internal constructor( + /** The list of screens on which the window can be placed. Never empty. */ + public val screens: List, + /** The default screen, on which the window should typically be placed. */ + public val defaultScreen: Screen, +) { + /** The primary screen, or [defaultScreen] when no screen claims the flag. */ + public val primaryScreen: Screen + get() = screens.firstOrNull { it.isPrimary } ?: defaultScreen +} + +/** + * Provides the screen on which the window will be placed. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowScreenProvider` — + * and, unlike it, actually applied by the Tao backend. + */ +public fun interface WindowScreenProvider { + /** + * Returns the screen on which the window will be placed. + * + * Use the [WindowScreenProviderScope] receiver to examine the available + * screens and pick the appropriate one. + */ + public fun WindowScreenProviderScope.getScreen(): Screen + + /** Built-in providers. */ + public companion object { + /** Keeps the window on the screen it would land on by default. */ + public val Default: WindowScreenProvider = WindowScreenProvider { defaultScreen } + + /** Places the window on the primary screen. */ + public val Primary: WindowScreenProvider = WindowScreenProvider { primaryScreen } + + /** + * Places the window on the screen with the given [id], falling back to + * [Default] while that screen is not attached. + * + * Pairs with the [WindowState.screenId] a previous session persisted. + */ + public fun ById(id: String): WindowScreenProvider = + WindowScreenProvider { + screens.firstOrNull { it.id == id } ?: defaultScreen + } + } +} + +/** + * Evaluates [provider] in this scope. + * + * The scoped `getScreen` is an internal member extension — mirroring Compose, + * where the same member keeps provider evaluation out of the public API — so + * this is how the window bridge reaches it. + */ +internal fun WindowScreenProviderScope.evaluateScreen(provider: WindowScreenProvider): Screen = + with(provider) { getScreen() } + +/** + * Screen scope for the given window: every attached monitor, with [window]'s + * own monitor as the default. A `null` window (the window does not exist yet) + * defaults to the primary monitor. + */ +internal fun screenScope(window: TaoWindow?): WindowScreenProviderScope { + val scale = TaoMonitors.referenceScale(window) + val monitors = TaoMonitors.all(window) + val screens = monitors.map { Screen(it, scale) } + val defaultMonitor = TaoMonitors.forWindow(window) + val default = screens.firstOrNull { it.id == defaultMonitor.id } ?: Screen(defaultMonitor, scale) + return WindowScreenProviderScope(screens = screens, defaultScreen = default) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowGeometry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowGeometry.kt new file mode 100644 index 000000000..69009b846 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowGeometry.kt @@ -0,0 +1,162 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao.v2 + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpInsets +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.plus +import androidx.compose.ui.unit.size +import kotlin.math.roundToInt + +/** + * The properties of a window that are useful inside a + * [WindowGeometryProviderScope]. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowMetrics`. The + * Compose original reads a live `java.awt.Window`; this one is a snapshot taken + * when the provider is evaluated, which is the only moment a provider can + * observe it anyway. + */ +@Immutable +public class WindowMetrics internal constructor( + /** The screen on which the window is placed. */ + public val screen: Screen, + /** The bounds of the entire window — decorations included — on the screen. */ + public val bounds: DpRect, + /** + * The window's insets: the areas where content isn't placed, such as the + * title bar and resize borders. + * + * [DpInsets] of zero for the undecorated, client-side-decorated windows + * `DecoratedWindow` draws by default, and while the native window has not + * been measured yet. + */ + public val insets: DpInsets, +) { + /** The content area — [bounds] minus [insets]. */ + internal val contentSize: DpSize + get() = + DpSize( + width = (bounds.size.width - insets.left - insets.right).coerceAtLeastZero(), + height = (bounds.size.height - insets.top - insets.bottom).coerceAtLeastZero(), + ) +} + +/** + * The scope in which window geometry providers ([WindowBoundsProvider], + * [WindowSizeProvider], [WindowPositionProvider]) are evaluated. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowGeometryProviderScope` + * — the class whose `java.awt.Window` constructor parameter makes every Compose + * v2 geometry provider inert on the Tao backend. + */ +public class WindowGeometryProviderScope internal constructor( + /** The window's metrics. */ + public val windowMetrics: WindowMetrics, + /** The metrics of the parent window, if any. */ + public val parentWindowMetrics: WindowMetrics?, + /** Scale the window's pixels are expressed in; converts measured px to dp. */ + private val scale: Float = 1f, + /** The live scene's `measureContent`, or `null` before the window has one. */ + private val measureContent: ((Constraints) -> IntSize?)? = null, +) { + /** + * Returns the size a window should have, given the size of its content. + * + * The content size is expanded by the window's insets and then constrained + * to [Screen.availableBounds]. + */ + public fun contentToWindowSize(contentSize: DpSize): DpSize = + DpSize( + width = + (contentSize.width + windowMetrics.insets.left + windowMetrics.insets.right) + .coerceAtMostReal(windowMetrics.screen.availableBounds.size.width), + height = + (contentSize.height + windowMetrics.insets.top + windowMetrics.insets.bottom) + .coerceAtMostReal(windowMetrics.screen.availableBounds.size.height), + ) + + /** + * Measures the window content in the given constraints and returns the + * resulting size. + * + * A real measure pass against the live scene (`ComposeScene.measureContent`) + * once the window has one. Before that — evaluating an *initial* provider, + * or a host that never exposes its window — there is no content to measure, + * so the current content size clamped to the constraints stands in. + * + * [maxWidth] and [maxHeight] can be [Dp.Infinity] to mean unconstrained. + */ + public fun measureWindowContent( + minWidth: Dp = 0.dp, + maxWidth: Dp = Dp.Infinity, + minHeight: Dp = 0.dp, + maxHeight: Dp = Dp.Infinity, + ): DpSize { + val measured = + measureContent?.invoke( + Constraints( + minWidth = minWidth.toPxOrInfinity(), + maxWidth = maxWidth.toPxOrInfinity(), + minHeight = minHeight.toPxOrInfinity(), + maxHeight = maxHeight.toPxOrInfinity(), + ), + ) + if (measured != null) { + return DpSize((measured.width / scale).dp, (measured.height / scale).dp) + } + val content = windowMetrics.contentSize + return DpSize( + width = content.width.clampTo(minWidth, maxWidth), + height = content.height.clampTo(minHeight, maxHeight), + ) + } + + private fun Dp.toPxOrInfinity(): Int = if (isReal) (value * scale).roundToInt() else Constraints.Infinity +} + +/** + * Evaluates [provider] in this scope. + * + * The scoped `getBounds` is a member extension — mirroring Compose, which keeps + * provider evaluation out of its public API — so this is how the window bridge + * reaches it. + */ +internal fun WindowGeometryProviderScope.evaluateBounds(provider: WindowBoundsProvider): DpRect = + with(provider) { getBounds() } + +/** Evaluates [provider] in this scope. See [evaluateBounds]. */ +internal fun WindowGeometryProviderScope.evaluateSize(provider: WindowSizeProvider): DpSize = + with(provider) { getSize() } + +/** Evaluates [provider] in this scope. See [evaluateBounds]. */ +internal fun WindowGeometryProviderScope.evaluatePosition( + provider: WindowPositionProvider, + size: DpSize, +): DpOffset = with(provider) { getPosition(size) } + +/** + * Clamps to `[min, max]`, tolerating the unspecified and infinite bounds the + * geometry providers use to mean "no constraint". + */ +private fun Dp.clampTo( + min: Dp, + max: Dp, +): Dp { + var result = this + if (min.isReal && result < min) result = min + if (max.isReal && result > max) result = max + return result +} + +private fun Dp.coerceAtMostReal(other: Dp): Dp = if (other.isReal && this > other) other else this + +private fun Dp.coerceAtLeastZero(): Dp = if (value < 0f) 0.dp else this diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowProviders.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowProviders.kt new file mode 100644 index 000000000..e2329e495 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowProviders.kt @@ -0,0 +1,312 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao.v2 + +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.isSpecified +import androidx.compose.ui.unit.minus +import androidx.compose.ui.unit.size +import kotlin.math.roundToInt + +internal val DEFAULT_WINDOW_SIZE: DpSize = DpSize(800.dp, 600.dp) + +/** + * Provides the bounds of the window. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowBoundsProvider`. + */ +public interface WindowBoundsProvider { + /** + * Returns the bounds of the window. + * + * Use the [WindowGeometryProviderScope] receiver to examine the geometry of + * the screen and the window. + */ + public fun WindowGeometryProviderScope.getBounds(): DpRect + + /** Built-in providers. */ + public companion object { + /** The default position and size for a new window. */ + public val Default: WindowBoundsProvider = + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Default, + positionProvider = WindowPositionProvider.Default, + ) + + /** + * Positions the window at the given [bounds]. + * + * All coordinates must be specified and finite. + */ + public fun Absolute(bounds: DpRect): WindowBoundsProvider { + bounds.requireReal() + return WindowBoundsProvider { bounds } + } + } +} + +/** Creates a [WindowBoundsProvider] from the given [bounds] function. */ +public fun WindowBoundsProvider(bounds: WindowGeometryProviderScope.() -> DpRect): WindowBoundsProvider = + object : WindowBoundsProvider { + override fun WindowGeometryProviderScope.getBounds(): DpRect = bounds() + } + +/** Combines a [WindowSizeProvider] and a [WindowPositionProvider]. */ +public fun WindowBoundsProvider( + sizeProvider: WindowSizeProvider = WindowSizeProvider.Current, + positionProvider: WindowPositionProvider = WindowPositionProvider.Current, +): WindowBoundsProvider = CombinedBoundsProvider(sizeProvider, positionProvider) + +/** + * Size and position kept apart instead of folded into a [DpRect]. + * + * A rectangle cannot carry the two sentinels this API relies on: an unspecified + * position ([WindowPositionProvider.Default] — let the window manager choose) or + * a wrap-content axis ([WindowSizeProvider.Unconstrained]) turns `right - left` + * into `NaN`, taking the *other* value down with it. The bridge recognises this + * type and evaluates the two providers separately; [getBounds] stays correct for + * anything else that composes it. + */ +internal class CombinedBoundsProvider( + val sizeProvider: WindowSizeProvider, + val positionProvider: WindowPositionProvider, +) : WindowBoundsProvider { + override fun WindowGeometryProviderScope.getBounds(): DpRect { + val size = evaluateSize(sizeProvider) + val position = evaluatePosition(positionProvider, size) + val topLeft = if (position.isSpecified) position else windowMetrics.bounds.topLeft + val resolved = if (size.isSpecified) size else windowMetrics.bounds.size + return DpRect(topLeft, resolved) + } +} + +/** + * Provides the position of the window. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowPositionProvider`. + */ +public fun interface WindowPositionProvider { + /** + * Returns the position of the window, given the [size] it will have. + * + * Use the [WindowGeometryProviderScope] receiver to examine the geometry of + * the screen and the parent window. + */ + public fun WindowGeometryProviderScope.getPosition(size: DpSize): DpOffset + + /** Built-in providers. */ + public companion object { + /** + * Leaves the position to the window manager. + * + * Compose's original cascades new windows itself, through AWT's + * `WindowLocationTracker`. On Tao the platform already does that — and + * does it better on Wayland, where a client cannot position itself at + * all — so this maps to + * [androidx.compose.ui.window.WindowPosition.PlatformDefault], signalled + * by an unspecified [DpOffset]. + */ + public val Default: WindowPositionProvider = WindowPositionProvider { DpOffset.Unspecified } + + /** Keeps the current position of the window. */ + public val Current: WindowPositionProvider = WindowPositionProvider { windowMetrics.bounds.topLeft } + + /** Centers the window within its screen. */ + public val CenteredOnScreen: WindowPositionProvider = AlignedToScreen(alignment = Alignment.Center) + + /** Centers the window within its parent window. */ + public val CenteredInParentWindow: WindowPositionProvider = + AlignedToParentWindow(alignment = Alignment.Center, anchor = Alignment.Center) + + /** Positions the window at the given [position]. */ + public fun Absolute(position: DpOffset): WindowPositionProvider { + position.requireReal() + return WindowPositionProvider { position } + } + + /** Positions the window at the given coordinates. */ + public fun Absolute( + x: Dp, + y: Dp, + ): WindowPositionProvider = Absolute(DpOffset(x, y)) + + /** + * Aligns the window within its screen's available bounds according to + * [alignment], then applies [offset]. + */ + public fun AlignedToScreen( + alignment: Alignment, + offset: DpOffset = DpOffset.Zero, + ): WindowPositionProvider = + WindowPositionProvider { size -> + val availableBounds = windowMetrics.screen.availableBounds + val position = + alignment.align( + size = size.roundToIntSize(), + space = availableBounds.size.roundToIntSize(), + layoutDirection = LayoutDirection.Ltr, + ) + DpOffset( + x = availableBounds.left + position.x.dp + offset.x, + y = availableBounds.top + position.y.dp + offset.y, + ) + } + + /** + * Aligns the window relative to its parent window. + * + * [anchor] is the point in the parent bounds the alignment is applied + * around; [alignment] then places the window inside an area centred on + * that point and twice the window's size, so + * [Alignment.TopStart] puts the window's bottom-right corner on the + * anchor. [excludeParentInsets] anchors against the parent's content + * area instead of its whole frame. + */ + public fun AlignedToParentWindow( + anchor: Alignment, + alignment: Alignment, + offset: DpOffset = DpOffset.Zero, + excludeParentInsets: Boolean = false, + ): WindowPositionProvider = + WindowPositionProvider { size -> + val parentMetrics = + parentWindowMetrics + ?: error("No parent window metrics available; this window has no parent") + val parentBounds = + if (excludeParentInsets) parentMetrics.bounds - parentMetrics.insets else parentMetrics.bounds + + val anchorInParent = + anchor.align( + size = IntSize.Zero, + space = parentBounds.size.roundToIntSize(), + layoutDirection = LayoutDirection.Ltr, + ) + val anchorPoint = + IntOffset( + anchorInParent.x + parentBounds.left.value.roundToInt(), + anchorInParent.y + parentBounds.top.value.roundToInt(), + ) + val intSize = size.roundToIntSize() + val targetArea = + IntRect( + left = anchorPoint.x - intSize.width, + top = anchorPoint.y - intSize.height, + right = anchorPoint.x + intSize.width, + bottom = anchorPoint.y + intSize.height, + ) + val positionInTargetArea = alignment.align(intSize, targetArea.size, LayoutDirection.Ltr) + DpOffset( + x = (targetArea.left + positionInTargetArea.x).dp, + y = (targetArea.top + positionInTargetArea.y).dp, + ) + offset + } + } +} + +/** + * Provides the size of the window. + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowSizeProvider`. + * + * The wrap-content providers ([Unconstrained], [PreferredWidth], + * [PreferredHeight]) return [Dp.Unspecified] on the axes the window should size + * to its content. That is not a sentinel invented here: it is how + * `DecoratedWindow` already expresses wrap-content, and it re-measures + * continuously instead of freezing a one-shot measurement. + */ +public fun interface WindowSizeProvider { + /** + * Returns the size of the window. + * + * Use the [WindowGeometryProviderScope] receiver to examine the geometry of + * the screen and the window's content. + */ + public fun WindowGeometryProviderScope.getSize(): DpSize + + /** Built-in providers. */ + public companion object { + /** The default size of a new window, 800×600. */ + public val Default: WindowSizeProvider = Fixed(DEFAULT_WINDOW_SIZE) + + /** Keeps the current size of the window. */ + public val Current: WindowSizeProvider = WindowSizeProvider { windowMetrics.bounds.size } + + /** Sets the size of the window to the given [size]. */ + public fun Fixed(size: DpSize): WindowSizeProvider { + size.requireReal() + return WindowSizeProvider { size } + } + + /** Sets the size of the window to the given [width] and [height]. */ + public fun Fixed( + width: Dp, + height: Dp, + ): WindowSizeProvider = Fixed(DpSize(width, height)) + + /** + * Sizes the window to its content on both axes, bounded by the screen's + * available size. + */ + public val Unconstrained: WindowSizeProvider = WindowSizeProvider { DpSize.Unspecified } + + /** Sizes the window to its content's preferred width at the given [height]. */ + public fun PreferredWidth(height: Dp): WindowSizeProvider { + height.requireReal("height") + return WindowSizeProvider { DpSize(Dp.Unspecified, height) } + } + + /** Sizes the window to its content's preferred height at the given [width]. */ + public fun PreferredHeight(width: Dp): WindowSizeProvider { + width.requireReal("width") + return WindowSizeProvider { DpSize(width, Dp.Unspecified) } + } + } +} + +// ── Internal geometry helpers ──────────────────────────────────────────────── +// Compose keeps its equivalents internal to compose-ui, so they are re-declared +// here rather than reached into. + +internal val DpRect.topLeft: DpOffset get() = DpOffset(left, top) + +internal fun DpSize.roundToIntSize(): IntSize = + IntSize(width = width.value.roundToInt(), height = height.value.roundToInt()) + +internal val Dp.isReal: Boolean get() = isSpecified && value.isFinite() + +internal fun Dp.requireReal(name: String): Dp { + require(isReal) { "$name must be specified and finite" } + return this +} + +internal fun DpSize.requireReal(): DpSize { + require(isSpecified) { "size must be specified" } + width.requireReal("width") + height.requireReal("height") + return this +} + +internal fun DpOffset.requireReal(): DpOffset { + require(isSpecified) { "offset must be specified" } + x.requireReal("x") + y.requireReal("y") + return this +} + +internal fun DpRect.requireReal(): DpRect { + left.requireReal("left") + top.requireReal("top") + right.requireReal("right") + bottom.requireReal("bottom") + return this +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowState.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowState.kt new file mode 100644 index 000000000..57dc07faa --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/v2/WindowState.kt @@ -0,0 +1,374 @@ +@file:OptIn(ExperimentalComposeUiApi::class) +@file:Suppress("TooManyFunctions") + +package dev.nucleusframework.window.tao.v2 + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.size +import androidx.compose.ui.window.WindowPlacement +import kotlinx.coroutines.channels.Channel + +/** + * Creates a [WindowState] remembered across compositions and saved across + * configuration changes. + * + * ## Migrating from the Compose window API v2 + * + * This package mirrors `androidx.compose.ui.window.v2` member for member, with + * one difference: it works on the Tao backend. Change the import and nothing + * else: + * + * ```kotlin + * // import androidx.compose.ui.window.v2.rememberWindowState + * import dev.nucleusframework.window.tao.v2.rememberWindowState + * + * val state = rememberWindowState( + * initialScreenProvider = WindowScreenProvider.Primary, + * initialBoundsProvider = WindowBoundsProvider( + * sizeProvider = WindowSizeProvider.Fixed(1200.dp, 800.dp), + * positionProvider = WindowPositionProvider.CenteredOnScreen, + * ), + * ) + * DecoratedWindow(onCloseRequest = ::exitApplication, state = state) { } + * + * state.requestScreen { screens.last() } // actually moves the window + * ``` + * + * ### Why a clone exists + * + * Compose's v2 geometry API is hard-wired 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 — it is a native, no-AWT, GraalVM-native-image-first window shell — + * so every provider that touches the scope is inert there, and `requestScreen` + * has no screen list to choose from. Reflection is not an option in a + * native-image-compatible runtime, and faking a `GraphicsDevice` would still + * boot the AWT toolkit through `Screen.insets`. + * + * The clone swaps those two AWT anchors for [dev.nucleusframework.window.tao.TaoMonitors] + * and [dev.nucleusframework.window.tao.TaoWindow], and keeps every name and + * signature identical. When Compose decouples its own types from AWT, deleting + * this package restores the upstream import with no other source change. + * + * @param initialScreenProvider Provides the screen the window is first placed on. + * @param initialPlacement The initial placement of the window. + * @param initialBoundsProvider Provides the initial bounds of the window. + * @param initiallyMinimized Whether the window starts minimized. + */ +@Composable +public fun rememberWindowState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialPlacement: WindowPlacement = WindowPlacement.Floating, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, + initiallyMinimized: Boolean = false, +): WindowState = + rememberSaveable(saver = WindowState.Saver) { + WindowState( + initialScreenProvider = initialScreenProvider, + initialPlacement = initialPlacement, + initialBoundsProvider = initialBoundsProvider, + initiallyMinimized = initiallyMinimized, + ) + } + +/** + * Creates a [WindowState] remembered across compositions, from a plain position + * and size. + * + * @param initialPosition The initial position; platform default if `null`. + * @param initialSize The initial size; 800×600 if `null`. + * @param initiallyMinimized Whether the window starts minimized. + */ +@Composable +public fun rememberWindowStateWithBounds( + initialPosition: DpOffset? = null, + initialSize: DpSize? = null, + initiallyMinimized: Boolean = false, +): WindowState = + rememberSaveable(saver = WindowState.Saver) { + WindowStateWithBounds( + initialPosition = initialPosition, + initialSize = initialSize, + initiallyMinimized = initiallyMinimized, + ) + } + +/** + * Creates a [WindowState] with the given initial values. + * + * @param initialScreenProvider Provides the screen the window is first placed on. + * @param initialPlacement The initial placement of the window. + * @param initialBoundsProvider Provides the initial bounds of the window. + * @param initiallyMinimized Whether the window starts minimized. + */ +@Suppress("FunctionNaming") +public fun WindowState( + initialScreenProvider: WindowScreenProvider = WindowScreenProvider.Default, + initialPlacement: WindowPlacement = WindowPlacement.Floating, + initialBoundsProvider: WindowBoundsProvider = WindowBoundsProvider.Default, + initiallyMinimized: Boolean = false, +): WindowState = + WindowState.createUninitialized().apply { + requestScreen(initialScreenProvider) + requestPlacement(initialPlacement) + requestBounds(initialBoundsProvider) + requestMinimized(initiallyMinimized) + } + +/** + * Creates a [WindowState] with the given initial position and size. + * + * @param initialPosition The initial position; platform default if `null`. + * @param initialSize The initial size; 800×600 if `null`. + * @param initiallyMinimized Whether the window starts minimized. + */ +@Suppress("FunctionNaming") +public fun WindowStateWithBounds( + initialPosition: DpOffset? = null, + initialSize: DpSize? = null, + initiallyMinimized: Boolean = false, +): WindowState = + WindowState( + initialBoundsProvider = + WindowBoundsProvider( + sizeProvider = initialSize?.let { WindowSizeProvider.Fixed(it) } ?: WindowSizeProvider.Default, + positionProvider = + initialPosition?.let { WindowPositionProvider.Absolute(it) } + ?: WindowPositionProvider.Default, + ), + initiallyMinimized = initiallyMinimized, + ) + +/** + * A state object that can be hoisted to control and observe window attributes + * (screen, size, position, placement). + * + * AWT-free drop-in for `androidx.compose.ui.window.v2.WindowState` — see + * [rememberWindowState] for what that means and how to migrate. + * + * Requests are applied asynchronously by the window that consumes this state; + * observed values ([bounds], [screenId], [placement], [isMinimized]) only + * become readable once the window has been shown at least once, which + * [isInitialized] reports. + */ +@Stable +public class WindowState private constructor( + isInitialized: Boolean, + screenId: String?, + placement: WindowPlacement?, + isMinimized: Boolean?, + bounds: DpRect?, +) { + internal constructor( + screenId: String, + placement: WindowPlacement, + isMinimized: Boolean, + bounds: DpRect, + ) : this( + isInitialized = true, + screenId = screenId, + placement = placement, + isMinimized = isMinimized, + bounds = bounds, + ) + + init { + bounds?.requireReal() + } + + /** Whether the window has become visible at least once. */ + public var isInitialized: Boolean by mutableStateOf(isInitialized) + internal set + + internal var screenIdOrNull: String? by mutableStateOf(screenId) + + /** + * The id of the screen the window is currently on; throws + * [IllegalStateException] before [isInitialized]. + */ + public val screenId: String + get() = screenIdOrNull ?: notInitialized("screenId") + + internal val screenRequests = Channel(Channel.CONFLATED) + + /** Requests to move the window to the screen the provider picks. */ + public fun requestScreen(screenProvider: WindowScreenProvider) { + screenRequests.trySend(screenProvider) + } + + internal var placementOrNull: WindowPlacement? by mutableStateOf(placement) + + /** + * The placement of the window; throws [IllegalStateException] before + * [isInitialized]. + */ + public val placement: WindowPlacement + get() = placementOrNull ?: notInitialized("placement") + + internal val placementRequests = Channel(Channel.CONFLATED) + + /** Requests to set the placement of the window. */ + public fun requestPlacement(placement: WindowPlacement) { + placementRequests.trySend(placement) + } + + internal var minimizedOrNull: Boolean? by mutableStateOf(isMinimized) + + /** + * Whether the window is minimized; throws [IllegalStateException] before + * [isInitialized]. + */ + public val isMinimized: Boolean + get() = minimizedOrNull ?: notInitialized("isMinimized") + + internal val minimizedRequests = Channel(Channel.CONFLATED) + + /** Requests to minimize or restore the window. */ + public fun requestMinimized(value: Boolean) { + minimizedRequests.trySend(value) + } + + internal var boundsOrNull: DpRect? by mutableStateOf(bounds) + + /** + * The current bounds of the window, decorations included; throws + * [IllegalStateException] before [isInitialized]. + */ + public val bounds: DpRect + get() = boundsOrNull ?: notInitialized("bounds") + + /** The current position of the window; throws before [isInitialized]. */ + public val position: DpOffset + get() = boundsOrNull?.topLeft ?: notInitialized("position") + + /** The current size of the window; throws before [isInitialized]. */ + public val size: DpSize + get() = boundsOrNull?.size ?: notInitialized("size") + + internal val boundsRequests = Channel(Channel.UNLIMITED) + + /** + * Requests to set the bounds of the window via a [WindowBoundsProvider]. + * + * Applying bounds to a window that is not [WindowPlacement.Floating] also + * makes it floating. + */ + public fun requestBounds(boundsProvider: WindowBoundsProvider) { + boundsRequests.trySend(boundsProvider) + } + + /** Requests to set the bounds of the window from a scoped function. */ + public fun requestBounds(boundsProvider: WindowGeometryProviderScope.() -> DpRect) { + boundsRequests.trySend(WindowBoundsProvider(boundsProvider)) + } + + /** Requests to set the bounds of the window. Same as [WindowBoundsProvider.Absolute]. */ + public fun requestBounds(bounds: DpRect) { + boundsRequests.trySend(WindowBoundsProvider.Absolute(bounds)) + } + + /** Requests to set the position of the window via a [WindowPositionProvider]. */ + public fun requestPosition(positionProvider: WindowPositionProvider) { + boundsRequests.trySend(WindowBoundsProvider(positionProvider = positionProvider)) + } + + /** Requests to move the window to [position]. */ + public fun requestPosition(position: DpOffset) { + requestPosition(WindowPositionProvider.Absolute(position)) + } + + /** Requests to move the window to ([x], [y]). */ + public fun requestPosition( + x: Dp, + y: Dp, + ) { + requestPosition(WindowPositionProvider.Absolute(x, y)) + } + + /** Requests to set the size of the window via a [WindowSizeProvider]. */ + public fun requestSize(sizeProvider: WindowSizeProvider) { + boundsRequests.trySend(WindowBoundsProvider(sizeProvider = sizeProvider)) + } + + /** Requests to resize the window to [size]. */ + public fun requestSize(size: DpSize) { + requestSize(WindowSizeProvider.Fixed(size)) + } + + /** Requests to resize the window to [width] × [height]. */ + public fun requestSize( + width: Dp, + height: Dp, + ) { + requestSize(WindowSizeProvider.Fixed(width, height)) + } + + /** Factories and the [Saver]. */ + public companion object { + internal fun createUninitialized(): WindowState = + WindowState( + isInitialized = false, + screenId = null, + placement = null, + isMinimized = null, + bounds = null, + ) + + /** A [Saver] implementation for [WindowState]. */ + public val Saver: Saver = + listSaver( + save = { + if (!it.isInitialized) { + emptyList() + } else { + val bounds = it.bounds + listOf( + it.screenId, + it.placement.ordinal, + it.isMinimized, + bounds.top.value, + bounds.left.value, + bounds.right.value, + bounds.bottom.value, + ) + } + }, + restore = { state -> + if (state.isEmpty()) { + null + } else { + WindowState( + screenId = state[0] as String, + placement = WindowPlacement.entries[state[1] as Int], + isMinimized = state[2] as Boolean, + bounds = + DpRect( + top = Dp(state[3] as Float), + left = Dp(state[4] as Float), + right = Dp(state[5] as Float), + bottom = Dp(state[6] as Float), + ), + ) + } + }, + ) + } +} + +internal fun notInitialized(propertyName: String): Nothing = + throw IllegalStateException( + "Can't read $propertyName before the window has been made visible; use isInitialized to check.", + ) diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/CrossWindowDrag.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/CrossWindowDrag.kt new file mode 100644 index 000000000..5a93d9021 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/CrossWindowDrag.kt @@ -0,0 +1,179 @@ +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.drag +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.isFinite +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.platform.LocalWindowInfo +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.TaoPointerIcons +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.math.roundToInt + +/** + * The drag a group is publishing feedback for, if any. One at a time: a new + * [begin] releases the previous session, so a gesture that was interrupted + * rather than finished — its pointer input cancelled by a resize, its window + * dropped from composition — can neither keep stale feedback on screen nor + * act on a later release. + * + * Sessions check [isLive] before acting and [release] themselves when they + * end or are cancelled; [clearFeedback] then resets whatever the owner + * publishes (the dragged item, the drop preview, the ghost). + */ +internal class DragController( + private val clearFeedback: () -> Unit, +) { + /** + * The live session, or `null`. Snapshot state: a composable branching on + * the workspace's `dragKind` has to see a drag begin and end. + */ + var active: S? by mutableStateOf(null) + private set + + /** Makes [session] the live one, ending whichever was. */ + fun begin(session: S) { + active?.let(::release) + active = session + } + + fun isLive(session: S): Boolean = active === session + + /** Ends [session] if it is the live one; `null` ends whichever is live. Idempotent. */ + fun release(session: S?) { + if (session != null && active !== session) return + active = null + clearFeedback() + } +} + +/** + * The pointer position, or `null` when it is not a usable screen coordinate. + * + * Compose hands out `Offset.Unspecified` (NaN) for a layout that has been + * detached, and a synthetic or replayed event can carry an infinity. Feeding + * either into window geometry produces a window at an undefined position, so + * a drag drops the sample instead. + */ +internal fun Offset.sanitizedOrNull(): Offset? = takeIf { it.isFinite } + +/** Physical pixels → an `Int` window coordinate, clamped to a range no screen exceeds. */ +internal fun Float.toWindowCoordinate(): Int = roundToInt().coerceIn(-WINDOW_COORDINATE_LIMIT, WINDOW_COORDINATE_LIMIT) + +/** Well past any real multi-monitor desktop, well inside `Int` arithmetic. */ +private const val WINDOW_COORDINATE_LIMIT = 1_000_000 + +/** What a [screenDragHandle] gesture drives. Positions are physical screen pixels. */ +internal interface ScreenDrag { + /** The pointer moved. */ + fun update(pointerScreenPx: Offset) + + /** The pointer was released here. */ + fun end(pointerScreenPx: Offset) + + /** The gesture was abandoned: nothing may change. */ + fun cancel() +} + +/** + * Makes this element the grip of a drag resolved in physical *screen* pixels — + * the coordinate space windows are placed in, and the only one every window + * the pointer may cross agrees on. + * + * A press without movement does nothing, so buttons can sit inside the grip. + * Once the touch slop is passed, [begin] is asked for the drag with the + * pointer's screen position; it is then fed every move and the release, or + * cancelled when the gesture is abandoned — including when this modifier is + * detached or re-keyed mid-drag (a window resize does that), which no branch + * of the gesture itself would observe. + * + * The press is claimed in the Main pass, which keeps an enclosing title bar + * from starting the native window move instead (see `Modifier.noWindowDrag`). + * The pointer shows [idleIcon] over the grip and [draggingIcon] while + * [isDragging] holds. + * + * Pointer events keep arriving while the button is held, with coordinates + * outside the window if need be: the OS captures the pointer for the pressed + * window, which is what lets a drag leave one window and land on another. + * + * No-op outside a Tao window. On a window without client-side screen + * placement ([canPlaceOnScreen] — native Wayland) the gesture is a + * [TransferDrag] instead, asked of [beginTransfer]: the platform's DnD session + * carries it and the window the pointer is over resolves the drop, since no + * window can be moved or hit-tested from here. See [transferDragHandle]. + */ +internal fun Modifier.screenDragHandle( + key: Any?, + isDragging: () -> Boolean, + idleIcon: PointerIcon = TaoPointerIcons.Grab, + draggingIcon: PointerIcon = TaoPointerIcons.Grabbing, + beginTransfer: (window: TaoWindow) -> TransferDrag?, + begin: (window: TaoWindow, pointerScreenPx: Offset) -> ScreenDrag?, +): Modifier = + composed { + val window = LocalTaoWindow.current ?: return@composed Modifier + if (!window.canPlaceOnScreen) { + val currentBeginTransfer by rememberUpdatedState(beginTransfer) + return@composed Modifier + .pointerHoverIcon(if (isDragging()) draggingIcon else idleIcon) + .transferDragHandle(key, window, begin = { currentBeginTransfer(window) }) + } + val containerSize = LocalWindowInfo.current.containerSize + var coordinates by remember { mutableStateOf(null) } + val currentBegin by rememberUpdatedState(begin) + Modifier + .pointerHoverIcon(if (isDragging()) draggingIcon else idleIcon) + .onPlaced { coordinates = it } + .pointerInput(key, window, containerSize) { + /** Pointer position in this element → physical screen pixels. */ + fun screenPx(local: Offset): Offset? { + val inWindow = coordinates?.localToWindow(local) ?: return null + val outer = window.outerBoundsPx() ?: return null + return clientOriginPx(outer, containerSize) + inWindow + } + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + // Claimed in the Main pass: the title bar's native drag arms + // on an unconsumed press in the Final pass. + down.consume() + val start = + awaitTouchSlopOrCancellation(down.id) { change, _ -> change.consume() } + ?: return@awaitEachGesture + var pointer = screenPx(start.position) ?: return@awaitEachGesture + val session = currentBegin(window, pointer) ?: return@awaitEachGesture + try { + session.update(pointer) + val released = + drag(start.id) { change -> + change.consume() + screenPx(change.position)?.let { + pointer = it + session.update(it) + } + } + if (released) session.end(pointer) else session.cancel() + } finally { + // The pointer-input coroutine is cancelled whenever this + // modifier is re-keyed or detached — a window resize + // mid-drag does it — and neither branch above would run. + // Without this the feedback would stay on screen for + // good. No-op once the session is done. + session.cancel() + } + } + } + } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/DragGhostWindow.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/DragGhostWindow.kt new file mode 100644 index 000000000..3233d42a5 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/DragGhostWindow.kt @@ -0,0 +1,97 @@ +// #636: a window opener — `@ComposableOpenTarget(-1)` with a `@UiComposable` +// content lambda, callable from any applier. ktlint's `annotation` and +// `function-type-modifier-spacing` rules contradict each other on the +// resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation") + +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.UiComposable +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.DecoratedWindow +import dev.nucleusframework.window.tao.TaoDecoratedWindowScope +import dev.nucleusframework.window.tao.TaoWindow + +/** + * A borderless, click-through, always-on-top window covering [screenRectPx] + * and following it as the caller republishes the rect: the preview of + * something being dragged out of a window. + * + * A real window rather than an overlay drawn inside the host, because the + * whole point is that it leaves the host's bounds. It never takes focus and + * never takes the pointer, so the drag gesture keeps running in the window + * underneath. + * + * @param screenRectPx outer frame of the ghost, physical pixels — on screen, + * or relative to [popupFor] when it is given, which is the space a popup + * overlay is positioned in on a compositor-placed surface. + * @param scaleFactor physical pixels per dp of the window the rect came from. + * The application scope this is composed in belongs to no window, so its + * density is always 1 and cannot be used to convert. + * @param title the window title (invisible, but what a screen reader announces). + * @param compositionLocalContext parent locals bridged into the ghost's scene. + * @param popupFor the window this ghost overlays, on Linux: a popup of it + * rather than a toplevel of its own — a `wl_subsurface` on native Wayland, + * the only window kind a client may position there, so the ghost can follow + * the pointer at all. `null` is a plain window, placed on screen. + * @param layoutDirection what [content] is laid out in: the direction of the + * strip or panel the ghost stands for, else the call site's. A scene of its + * own re-provides the global direction over the bridged locals, so the ghost + * cannot simply inherit one. + * @param content what the ghost shows; fills the window, composed with the + * ghost window's scope like any window content. + */ +@Suppress("FunctionNaming") +@Composable +@ComposableOpenTarget(-1) +internal fun ApplicationScope.DragGhostWindow( + screenRectPx: Rect, + scaleFactor: Float, + title: String, + compositionLocalContext: CompositionLocalContext?, + popupFor: TaoWindow? = null, + layoutDirection: LayoutDirection = LocalLayoutDirection.current, + content: @Composable @UiComposable TaoDecoratedWindowScope.() -> Unit, +) { + val scale = scaleFactor.takeIf { it > 0f } ?: 1f + val state = + rememberWindowState( + position = WindowPosition.Absolute((screenRectPx.left / scale).dp, (screenRectPx.top / scale).dp), + size = DpSize((screenRectPx.width / scale).dp, (screenRectPx.height / scale).dp), + ) + // Reactive follow: the caller republishes the rect on every pointer move, + // and DecoratedWindow pushes state changes to the native window. + SideEffect { + state.position = WindowPosition.Absolute((screenRectPx.left / scale).dp, (screenRectPx.top / scale).dp) + state.size = DpSize((screenRectPx.width / scale).dp, (screenRectPx.height / scale).dp) + } + DecoratedWindow( + onCloseRequest = {}, + state = state, + title = title, + undecorated = true, + transparent = true, + resizable = false, + focusable = false, + clickThrough = true, + alwaysOnTop = true, + popupFor = popupFor, + compositionLocalContext = compositionLocalContext, + ) { + val scope: TaoDecoratedWindowScope = this + SideEffect { scope.window.closesOnQuit = false } + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { scope.content() } + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt new file mode 100644 index 000000000..afb5be3b2 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometry.kt @@ -0,0 +1,229 @@ +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.edgeStripPx +import dev.nucleusframework.window.tao.onPositionChanged + +/** + * What a drop target inside a window publishes about itself: the window, the + * target's bounds in that window and the content size those bounds were + * measured against — enough to place the target on screen (physical px) and + * hit-test a pointer against it, whichever window that pointer is over. + * + * Geometry is read through lambdas so tests can stand in for the native window. + */ +internal class HostGeometry( + val host: TaoWindow, + val outerBoundsPx: () -> LongArray? = host::outerBoundsPx, + val scaleFactor: () -> Float = { host.scaleFactor }, + /** Whether the host is minimized: its frame is still on record, but nothing of it is on screen. */ + val minimized: () -> Boolean = { host.isMinimized }, +) { + /** The target's bounds in the host window (physical px). */ + var layoutBoundsInWindowPx: Rect = Rect.Zero + + /** + * The layout direction the host's strip or dock is composed in — what a + * ghost torn out of it is laid out in, so the card reads the way the tab + * or panel was drawn. + */ + var layoutDirection: LayoutDirection = LayoutDirection.Ltr + + /** The host's content size when [layoutBoundsInWindowPx] was captured. */ + var containerSizePx: IntSize = IntSize.Zero + + /** + * The drop zones the target offers right now, in the host window + * (physical px) — exactly the rectangles it draws while a drag is in + * flight, so what a drag is hit-tested against is what the user sees. + * Empty while nothing is being dragged, or for a target that publishes + * none; the hit test then falls back to the edges of + * [layoutBoundsInWindowPx]. + */ + var zoneBoundsInWindowPx: Map = emptyMap() + + /** Physical pixels per dp on the host, `1` while the window has none yet. */ + fun scaleOrOne(): Float = scaleFactor().takeIf { it > 0f } ?: 1f + + /** + * Screen position of the host's content origin, `null` before the first + * layout, while unmapped, or on a host whose screen position is not + * knowable ([canPlaceOnScreen] — native Wayland), where the origin + * GDK reports would place every window at the top-left of the screen. + */ + fun clientOriginPx(): Offset? { + if (containerSizePx == IntSize.Zero || !host.canPlaceOnScreen) return null + val outer = outerBoundsPx() ?: return null + return clientOriginPx(outer, containerSizePx) + } + + /** The target's rect on screen (physical px), `null` while [clientOriginPx] is. */ + fun layoutScreenRectPx(): Rect? = clientOriginPx()?.let { layoutBoundsInWindowPx.translate(it) } + + /** + * The drop zones on screen (physical px): the published + * [zoneBoundsInWindowPx], else a strip of [zoneWidthPx] inside each edge + * of the layout — the same four zones the pointer hit test uses. `null` + * while [clientOriginPx] is. + */ + fun zoneScreenRectsPx(zoneWidthPx: Float): Map? { + val origin = clientOriginPx() ?: return null + if (zoneBoundsInWindowPx.isNotEmpty()) { + return zoneBoundsInWindowPx.mapValues { (_, zone) -> zone.translate(origin) } + } + val rect = layoutBoundsInWindowPx.translate(origin) + return DockSide.entries.associateWith { side -> DockDropZone(edgeStripPx(rect, side, zoneWidthPx)) } + } +} + +/** + * What one side of a drop target offers a drag, in whichever px space the + * holder says: the [strip] a satellite enters the side by, and — when panels + * are already docked there — one [slots] rect per rank the dropped panel can + * take among them, in rank order, covering the stack and the strip between + * them. Empty [slots] mean the side has no panel to order against. + */ +internal data class DockDropZone( + val strip: Rect, + val slots: List = emptyList(), +) { + fun translate(offset: Offset): DockDropZone = + DockDropZone(strip.translate(offset), slots.map { it.translate(offset) }) + + /** Whether [point] is on the strip or on one of the slots. */ + fun contains(point: Offset): Boolean = strip.contains(point) || slots.any { it.contains(point) } + + /** + * The rank [point] aims at: the slot it is in, else the nearest one, so a + * pointer past either end of the stack means its first or last rank. + * `null` without slots: nothing to order against. An empty slot is a rank + * that is not on offer (it would displace a pinned panel) and is skipped. + */ + fun slotAt(point: Offset): Int? = + slots.indices + .filter { !slots[it].isEmpty } + .minByOrNull { distanceSquaredPx(slots[it], point) } + + private fun distanceSquaredPx( + rect: Rect, + point: Offset, + ): Float { + val dx = maxOf(rect.left - point.x, 0f, point.x - rect.right) + val dy = maxOf(rect.top - point.y, 0f, point.y - rect.bottom) + return dx * dx + dy * dy + } +} + +/** + * The published geometry of every host in a group: one per window, the latest + * publisher winning, an unregister only taking effect for the geometry that is + * still registered (two layouts swapping in one window must not unregister + * each other). + */ +internal class HostGeometryRegistry { + private val geometries = LinkedHashMap() + + fun register(geometry: HostGeometry) { + geometries[geometry.host] = geometry + } + + fun unregister(geometry: HostGeometry) { + if (geometries[geometry.host] === geometry) geometries.remove(geometry.host) + } + + operator fun get(host: TaoWindow?): HostGeometry? = host?.let(geometries::get) + + /** + * Every geometry, in the order [hosts] lists their windows (hosts without + * one skipped), then the ones [hosts] does not name in registration order. + * The caller decides what "first" means — the owner, focus recency, z-order. + */ + fun ordered(hosts: List): List { + val ordered = ArrayList(geometries.size) + for (host in hosts) geometries[host]?.let(ordered::add) + for (geometry in geometries.values) if (geometry !in ordered) ordered += geometry + return ordered + } +} + +/** The host's side borders are assumed symmetric: half the outer/inner width difference each. */ +private const val SIDE_BORDER_SPLIT = 2f + +/** + * Screen position (physical px) of a window's content origin, derived from its + * outer frame `[x, y, w, h]` and its content size. + * + * Side borders are split evenly, the bottom border is assumed to match them, + * and whatever vertical difference is left sits above the content — a title + * bar, the top margin of a client-side-decorated shadow. + * + * Attributing the bottom border rather than putting the whole vertical + * difference on top is what makes this right on Win32, whose `GetWindowRect` + * includes the invisible resize border below the content as well as beside it: + * a pointer aimed through a frame modelled as "all chrome on top" lands one + * border too low, which is enough to miss the bottom of a tab. It is a no-op + * for a frame that adds nothing horizontally (a Tao window on X11), and stays + * exact for a symmetric shadow and for macOS's title bar. + */ +@Suppress("MagicNumber") +internal fun clientOriginPx( + outer: LongArray, + containerSizePx: IntSize, +): Offset { + val sideBorder = (outer[2] - containerSizePx.width) / SIDE_BORDER_SPLIT + return Offset( + outer[0] + sideBorder, + outer[1] + (outer[3] - containerSizePx.height) - sideBorder, + ) +} + +/** + * A [HostGeometry] for [host], registered with [registry] for as long as the + * caller is composed. `null` without a host (a preview, a test composition). + */ +@Composable +internal fun rememberHostGeometry( + registry: HostGeometryRegistry, + host: TaoWindow?, +): HostGeometry? { + val geometry = remember(registry, host) { host?.let { HostGeometry(it) } } + if (geometry != null) { + DisposableEffect(registry, geometry) { + registry.register(geometry) + onDispose { registry.unregister(geometry) } + } + } + return geometry +} + +/** + * Publishes this element's bounds into [geometry] whenever they move, together + * with the window content size ([containerSizePx]) they were measured in. + * A no-op without a geometry. + */ +internal fun Modifier.publishHostGeometry( + geometry: HostGeometry?, + containerSizePx: IntSize, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, +): Modifier = + if (geometry == null) { + this + } else { + geometry.layoutDirection = layoutDirection + // Not with the bounds: a window resize that leaves this element's rect + // alone moves no layout callback, and the caller recomposes on it. + geometry.containerSizePx = containerSizePx + onPositionChanged { coordinates -> + geometry.layoutBoundsInWindowPx = coordinates.boundsInWindow() + } + } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/RelocatableContent.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/RelocatableContent.kt new file mode 100644 index 000000000..058039a07 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/RelocatableContent.kt @@ -0,0 +1,199 @@ +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.currentCompositeKeyHashCode +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.LocalSaveableStateRegistry +import androidx.compose.runtime.saveable.SaveableStateRegistry + +/** + * `rememberSaveable` values saved by one host, with the composite key hash of + * the [RelocatedContentHost] they were composed under ([anchor]). + */ +internal class RelocatedSavedState( + val anchor: Long, + val values: Map>, +) + +/** + * The saveable state of one piece of content that moves between hosts — a + * panel between its floating window and a dock, a tab between windows: what + * the last host saved, and the registry of the host composing it right now. + * + * Owned by whatever identifies the content across the move (a workspace + * entry), never by a host. + */ +internal class RelocatableSlot { + /** What the previous host saved on its way out. */ + var savedState: RelocatedSavedState? = null + + /** The registry of the host currently composing the content, if any. */ + var activeRegistry: RelocatingSaveableStateRegistry? = null + + /** Everything known right now: the live registry's values, else what the last host saved. */ + fun snapshot(): RelocatedSavedState? = activeRegistry?.snapshot() ?: savedState +} + +/** + * Composes [content] under a saveable-state registry owned by [slot], so + * `rememberSaveable` values follow the content from one host to the next. + * + * Two things make this more than a shared `SaveableStateHolder`: + * + * - The hosts live in different compositions (two windows' scenes) whose + * dispose / compose order in the switching frame is not defined. The new + * host therefore pulls the live values straight out of the registry that + * is still mounted, falling back to the values the previous host saved on + * dispose — correct in both orders. + * - `rememberSaveable` keys are the composite key hash of the call site, + * which encodes the whole path from the root of the composition — and the + * path differs between hosts. [RelocatingSaveableStateRegistry] maps the + * keys across using the hash recorded here, see there. + * + * The relocation only holds if every group between this composable and the + * content's own `rememberSaveable` call sites is identical in both hosts, + * which is why [content] must be invoked from here and only from here — + * never through a per-host wrapper lambda, whose group key would differ. + * + * @param scope the receiver [content] is composed with; the same instance in + * every host. + * @param content the relocatable content, or `null` while it is not declared. + */ +@Composable +internal fun RelocatedContentHost( + slot: RelocatableSlot, + scope: S, + content: (@Composable S.() -> Unit)?, +) { + val anchor: Long = currentCompositeKeyHashCode + val registry = + remember(slot) { + RelocatingSaveableStateRegistry(slot.snapshot(), anchor).also { slot.activeRegistry = it } + } + DisposableEffect(registry) { + onDispose { + slot.savedState = registry.snapshot() + if (slot.activeRegistry === registry) slot.activeRegistry = null + } + } + if (content == null) return + CompositionLocalProvider(LocalSaveableStateRegistry provides registry) { + content(scope) + } +} + +/** + * A [SaveableStateRegistry] that restores values saved under a *different* + * composition path. + * + * Compose derives a `rememberSaveable` key from the composite key hash, built + * top-down as `hash = (hash rol shift) xor segment` for every group entered, + * and rendered in radix 36. For the same content composed below two anchors + * `A` and `B`, a call site at the same relative position therefore hashes to + * `kA` and `kB` with `kA xor kB == (A xor B) rol n` for some `n` (the shifts + * accumulated on the way down). The hash is 64-bit on the JVM, so there are + * at most 64 candidates for that rotation — [consumeRestored] matches a + * requested key against the saved ones by testing exactly that, after trying + * an exact match (same host, or explicit string keys) first. + * + * Only the linearity of the hash is relied on, not the shift constants or the + * group structure, so the mapping is exact as long as the content composes the + * same `rememberSaveable` call sites in both hosts, which + * [RelocatedContentHost] guarantees by construction. + */ +internal class RelocatingSaveableStateRegistry( + saved: RelocatedSavedState?, + private val anchor: Long, +) : SaveableStateRegistry { + /** + * One registered provider. Several call sites can share a key — Compose + * then stores a *list* per key and hands the values back in composition + * order — so a slot keeps its position in that list for the lifetime of + * the host, whether its provider is still registered or not. + */ + private class Slot( + var provider: (() -> Any?)?, + ) { + /** Value read out of [provider] when it unregistered. */ + var captured: Any? = null + } + + private val slots = LinkedHashMap>() + private val pending: MutableMap> = + saved?.values.orEmpty().mapValuesTo(LinkedHashMap()) { (_, values) -> values.toMutableList() } + private val rotations: Set = + saved?.let { previous -> + val delta = previous.anchor xor anchor + (0 until Long.SIZE_BITS).mapTo(HashSet()) { delta.rotateLeft(it) } + } ?: emptySet() + + override fun consumeRestored(key: String): Any? { + val match = if (key in pending) key else relocatedKey(key) ?: return null + val values = pending.getValue(match) + val value = values.removeAt(0) + if (values.isEmpty()) pending.remove(match) + return value + } + + private fun relocatedKey(key: String): String? { + if (rotations.isEmpty()) return null + val requested = key.toLongOrNull(KEY_RADIX) ?: return null + return pending.keys.firstOrNull { candidate -> + val saved = candidate.toLongOrNull(KEY_RADIX) ?: return@firstOrNull false + (saved xor requested) in rotations + } + } + + override fun registerProvider( + key: String, + valueProvider: () -> Any?, + ): SaveableStateRegistry.Entry { + val keySlots = slots.getOrPut(key) { mutableListOf() } + // Reuse a vacated slot before growing the list: a recomposing + // `rememberSaveable` unregisters and registers again under the same + // key, and must not shift the values of its neighbours. + val slot = + keySlots.firstOrNull { it.provider == null }?.apply { provider = valueProvider } + ?: Slot(valueProvider).also { keySlots += it } + return object : SaveableStateRegistry.Entry { + override fun unregister() { + slot.captured = slot.provider?.invoke() + slot.provider = null + } + } + } + + override fun canBeSaved(value: Any): Boolean = true + + /** + * Every value this host knows, per key, in registration order. + * + * Order is the whole contract when several call sites share a key, and it + * cannot be read off the providers still registered: when a host is + * disposed Compose unregisters them in reverse composition order, and it + * does so *before* the host's own disposable effect runs. Hence the slots, + * which hold their position and keep the value their provider had on the + * way out. + * + * Keys restored but never consumed are carried over, so content that + * moves hosts twice before it composes keeps its state. + */ + override fun performSave(): Map> { + val map = LinkedHashMap>() + for ((key, values) in pending) map[key] = values.toList() + for ((key, keySlots) in slots) { + map[key] = keySlots.map { slot -> slot.provider?.invoke() ?: slot.captured } + } + return map + } + + /** Everything this host knows, tagged with its anchor. */ + fun snapshot(): RelocatedSavedState = RelocatedSavedState(anchor, performSave()) + + private companion object { + /** `rememberSaveable` renders the composite key hash in this radix. */ + const val KEY_RADIX = 36 + } +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/ScreenPlacement.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/ScreenPlacement.kt new file mode 100644 index 000000000..4d30cdd4c --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/ScreenPlacement.kt @@ -0,0 +1,30 @@ +package dev.nucleusframework.window.tao.workspace + +import dev.nucleusframework.window.tao.TaoWindow +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Logger + +private val warnedFeatures = ConcurrentHashMap.newKeySet() + +/** Same JUL logger `TaoWindow` reports its other Wayland gaps on. */ +private val waylandLogger: Logger = Logger.getLogger("dev.nucleusframework.window.tao.wayland") + +/** + * Logs once per process and per [feature] that the feature is unavailable on + * this window because it has no client-side screen placement. A no-op where + * [canPlaceOnScreen] holds. + * + * Per process rather than per window: the windows these features live in — + * floating satellites, torn-off tab windows — are created and destroyed with + * every dock, undock and merge, and one line is enough to explain the missing + * gesture. + */ +internal fun TaoWindow.warnScreenPlacementUnsupported(feature: String) { + if (canPlaceOnScreen || !warnedFeatures.add(feature)) return + waylandLogger.warning( + "$feature needs client-side screen placement, which native Wayland (xdg-shell) does not offer: " + + "a client can neither read its windows' screen position nor move them. The built-in grips " + + "carry the gesture over the platform drag-and-drop session instead; " + + "run with NUCLEUS_TAO_LINUX_RENDERER=x11 (XWayland) for the screen-space API.", + ) +} diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt new file mode 100644 index 000000000..eabf05979 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/TransferDrag.kt @@ -0,0 +1,440 @@ +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitTouchSlopOrCancellation +import androidx.compose.foundation.gestures.drag +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropSourceModifierNode +import androidx.compose.ui.draganddrop.DragAndDropTransferAction +import androidx.compose.ui.draganddrop.DragAndDropTransferData +import androidx.compose.ui.draganddrop.DragAndDropTransferable +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.RoundRect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipPath +import androidx.compose.ui.input.pointer.SuspendingPointerInputModifierNode +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onPlaced +import androidx.compose.ui.node.DelegatingNode +import androidx.compose.ui.node.ModifierNodeElement +import androidx.compose.ui.platform.InspectorInfo +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.dnd.TaoPrivateTransfer +import java.awt.dnd.DropTargetDragEvent +import java.awt.dnd.DropTargetDropEvent +import kotlin.math.roundToInt + +/** + * A cross-window drag carried by the platform's drag-and-drop session — the + * path taken where the client cannot read or set window positions (native + * Wayland, see [canPlaceOnScreen]). + * + * The roles are inverted with respect to [ScreenDrag]: the *source* learns + * nothing about where the pointer is, and the *target* window — the one the + * pointer is over, which the compositor tells about it in its own coordinates + * — resolves the drop and records the outcome on the session. What the source + * gets is [end], once the session is over, to act on that record: dock, move, + * tear off, or nothing. + * + * The compositor draws the drag icon; [title] on a card the size of + * [ghostSizePx] is what it shows. + */ +internal interface TransferDrag { + /** What the drag icon reads when it falls back to a title card. */ + val title: String + + /** The title card's size in physical pixels — the grabbed strip, not the grip. */ + val ghostSizePx: Size + + /** + * What the drag icon pictures: the source window's whole content, one + * region of it (a docked panel, a tab), or nothing but the title card. + */ + val ghostSource: TransferGhostSource + + /** The session is over; act on what a target recorded, if any. */ + fun end() + + /** The session never started; publish nothing and change nothing. */ + fun cancel() +} + +/** The part of the source window a transfer drag's icon is a picture of. */ +internal sealed interface TransferGhostSource { + /** The whole content area: a floating palette. */ + data object WholeWindow : TransferGhostSource + + /** One region, in the source window's content pixels: a docked panel, a tab. */ + data class Region( + val rectPx: IntRect, + ) : TransferGhostSource + + /** No picture; the title card stands in. */ + data object None : TransferGhostSource +} + +/** + * Makes this element the grip of a [TransferDrag]: a press that passes the + * touch slop asks [begin] for the session and hands it to the platform's DnD + * machinery, which owns the pointer until the release. + * + * Claims the press in the Main pass, exactly like [screenDragHandle], so the + * title bar's compositor move does not start on the first sub-slop movement. + * Compose's own `dragAndDropSource` leaves the press unclaimed, which is why + * this builds on the same public [DragAndDropSourceModifierNode] rather than + * on the finished modifier. + */ +@Composable +internal fun Modifier.transferDragHandle( + key: Any?, + window: TaoWindow, + begin: () -> TransferDrag?, + gesture: TransferDragGesture = TransferDragGesture.Immediate, +): Modifier { + val accent = LocalTitleBarStyle.current.colors.content + val measurer = rememberTextMeasurer() + val grab = remember { GrabCoordinates() } + return this + .onPlaced { grab.coordinates = it } + .then(TransferDragElement(key, window, grab, begin, accent, measurer, gesture)) +} + +/** + * What a grip does with the gesture before the platform's drag-and-drop + * session takes it — the hook a tab strip uses to reorder locally first. + * + * [Immediate] hands over as soon as the touch slop is passed, which is what a + * palette wants. Anything else keeps the pointer for as long as [onDrag] + * answers `false`: every sample is the caller's, and the session starts on the + * first `true`. + * + * Starting it late is legal and is the only way a client that cannot place its + * windows can show a drop where it is aimed: until the platform session + * exists, no other window of the app hears anything about the pointer. + */ +internal interface TransferDragGesture { + /** The gesture has passed the slop, pressed at [pressPosition] in the grip. */ + fun onStart(pressPosition: Offset) = Unit + + /** A sample at [position] in the grip; `true` hands the gesture to the platform session. */ + fun onDrag(position: Offset): Boolean = true + + /** The gesture ended in the caller's hands; [released] tells a release from an abandon. */ + fun onEnd(released: Boolean) = Unit + + /** Hands over at once: every grip with nothing of its own to do. */ + object Immediate : TransferDragGesture +} + +/** + * Where the grip is, for turning the press into a window position. + * + * Read off a plain holder written by [Modifier.onPlaced] rather + * than by making the drag node itself layout-aware: a `DelegatingNode` that + * implements [androidx.compose.ui.node.LayoutAwareModifierNode] takes those + * callbacks *instead of* its delegates, and Compose's own drag-and-drop source + * node needs its `onPlaced` to learn its size — without it the node measures + * as empty and silently refuses every transfer request. + */ +private class GrabCoordinates { + var coordinates: LayoutCoordinates? = null +} + +private data class TransferDragElement( + val key: Any?, + val window: TaoWindow, + val grab: GrabCoordinates, + val begin: () -> TransferDrag?, + val accent: Color, + val measurer: TextMeasurer, + val gesture: TransferDragGesture, +) : ModifierNodeElement() { + override fun create(): TransferDragNode = TransferDragNode(window, grab, begin, accent, measurer, gesture) + + override fun update(node: TransferDragNode) { + node.window = window + node.grab = grab + node.begin = begin + node.accent = accent + node.measurer = measurer + node.gesture = gesture + } + + override fun InspectorInfo.inspectableProperties() { + name = "transferDragHandle" + properties["key"] = key + } +} + +@OptIn(ExperimentalComposeUiApi::class) +private class TransferDragNode( + var window: TaoWindow, + var grab: GrabCoordinates, + var begin: () -> TransferDrag?, + var accent: Color, + var measurer: TextMeasurer, + var gesture: TransferDragGesture, +) : DelegatingNode() { + private val source = + delegate( + DragAndDropSourceModifierNode { offset -> + val drag = begin() ?: return@DragAndDropSourceModifierNode + val picture = snapshotFor(drag) + val ghost = transferGhost(drag, picture) + val grabInWindow = grab.coordinates?.takeIf { it.isAttached }?.localToWindow(offset) + val started = + startDragAndDropTransfer( + transferData = transferDragData(drag, ghost.sizePx, ghost.hotspotPx(grabInWindow)), + decorationSize = ghost.sizePx, + drawDragDecoration = { drawTransferGhost(drag.title, picture, accent, measurer) }, + ) + if (!started) drag.cancel() + }, + ) + + private fun snapshotFor(drag: TransferDrag): ImageBitmap? = + when (val src = drag.ghostSource) { + TransferGhostSource.WholeWindow -> window.snapshotContent(null) + is TransferGhostSource.Region -> window.snapshotContent(src.rectPx) + TransferGhostSource.None -> null + } + + /** + * Hands the gesture to the platform, if [gesture] says so: from the + * *press* position, since Compose only starts a transfer for a point + * inside the source node — and by then the pointer is long gone from it. + */ + private fun handOver( + pressPosition: Offset, + currentPosition: Offset, + ): Boolean { + if (!gesture.onDrag(currentPosition)) return false + if (!source.isRequestDragAndDropTransferRequired) return false + source.requestDragAndDropTransfer(pressPosition) + return true + } + + init { + delegate( + SuspendingPointerInputModifierNode { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + val start = + awaitTouchSlopOrCancellation(down.id) { change, _ -> change.consume() } + ?: return@awaitEachGesture + gesture.onStart(down.position) + var handedOver = handOver(down.position, start.position) + if (handedOver) return@awaitEachGesture + // The caller's gesture until it says otherwise: it keeps + // every sample, and the platform session starts on the + // first one it hands over. + val released = + drag(start.id) { change -> + change.consume() + if (!handedOver) handedOver = handOver(down.position, change.position) + } + if (!handedOver) gesture.onEnd(released) + } + }, + ) + } +} + +/** The token every transfer drag carries; the session's meaning lives in the workspace, not in the payload. */ +internal const val TRANSFER_DRAG_TOKEN = "workspace-drag" + +/** + * The drag icon's geometry: its size, and how a point of the source window + * maps into it. A picture is shown reduced — a palette-sized icon would hide + * the very zones the drag is aimed at — and never larger than + * [TRANSFER_GHOST_MAX_EDGE_PX] on its longer edge; the title card is shown as + * it is. + */ +internal class TransferGhost( + val sizePx: Size, + /** Where the pictured region starts in the source window, content pixels. */ + val sourceTopLeftPx: Offset, + /** Icon pixels per source pixel. */ + val scale: Float, +) { + /** + * The pointer's position inside the icon for a grab at [grabInWindowPx] + * (source window content pixels), clamped to the icon. Without a grab + * position the icon hangs from its top edge, centred on the pointer. + */ + fun hotspotPx(grabInWindowPx: Offset?): Offset { + val raw = + if (grabInWindowPx == null) { + Offset(sizePx.width / 2f, TRANSFER_GHOST_TOP_HOTSPOT_PX) + } else { + (grabInWindowPx - sourceTopLeftPx) * scale + } + return Offset(raw.x.coerceIn(0f, sizePx.width), raw.y.coerceIn(0f, sizePx.height)) + } +} + +/** The icon [drag] gets: a reduced [picture] when there is one, else the title card. */ +internal fun transferGhost( + drag: TransferDrag, + picture: ImageBitmap?, +): TransferGhost { + val sourceTopLeft = + (drag.ghostSource as? TransferGhostSource.Region) + ?.rectPx + ?.topLeft + ?.let { Offset(it.x.toFloat(), it.y.toFloat()) } ?: Offset.Zero + if (picture == null || picture.width <= 0 || picture.height <= 0) { + return TransferGhost(drag.ghostSizePx, sourceTopLeft, scale = 1f) + } + val longest = maxOf(picture.width, picture.height).toFloat() + val scale = minOf(TRANSFER_GHOST_SCALE, TRANSFER_GHOST_MAX_EDGE_PX / longest) + return TransferGhost(Size(picture.width * scale, picture.height * scale), sourceTopLeft, scale) +} + +/** + * The transfer Compose hands to the platform for [drag]: an icon of + * [ghostSizePx] with the pointer at [hotspotPx] inside it. + * + * Named rather than inlined at the call site because of + * [DragAndDropTransferData.onTransferCompleted]: it is the *only* signal that + * the platform session is over, and therefore the only thing that ends the + * workspace's drag. Losing it strands the gesture — the drop record is never + * acted on and the drop-zone highlights never clear — without any error, so it + * is asserted on directly (`TransferDragTest`). + */ +@OptIn(ExperimentalComposeUiApi::class) +internal fun transferDragData( + drag: TransferDrag, + ghostSizePx: Size, + hotspotPx: Offset, +): DragAndDropTransferData = + DragAndDropTransferData( + transferable = DragAndDropTransferable(TaoPrivateTransfer.transferable(TRANSFER_DRAG_TOKEN)), + supportedActions = listOf(DragAndDropTransferAction.Move), + // Compose places the icon's origin at the pointer plus this offset, so + // the grab point stays under the pointer when it is minus the hotspot. + dragDecorationOffset = + -Offset( + hotspotPx.x.coerceIn(0f, ghostSizePx.width), + hotspotPx.y.coerceIn(0f, ghostSizePx.height), + ), + onTransferCompleted = { drag.end() }, + ) + +/** + * What the compositor shows under the pointer: a reduced picture of the + * dragged palette or panel when one could be taken, framed and slightly + * translucent so the zones under it stay readable; else a card with the + * title on a tinted, rounded surface. The drag-icon counterpart of the ghost + * windows the screen-placing platforms fly. + */ +private fun DrawScope.drawTransferGhost( + title: String, + picture: ImageBitmap?, + accent: Color, + measurer: TextMeasurer, +) { + val corner = CornerRadius(GHOST_CORNER_DP.dp.toPx()) + if (picture != null && picture.width > 0 && picture.height > 0) { + val frame = Path().apply { addRoundRect(RoundRect(Rect(Offset.Zero, size), corner)) } + clipPath(frame) { + drawImage( + image = picture, + srcOffset = IntOffset.Zero, + srcSize = IntSize(picture.width, picture.height), + dstOffset = IntOffset.Zero, + dstSize = IntSize(size.width.roundToInt(), size.height.roundToInt()), + alpha = GHOST_PICTURE_ALPHA, + ) + } + val stroke = GHOST_BORDER_DP.dp.toPx() + drawRoundRect( + color = accent.copy(alpha = GHOST_BORDER_ALPHA), + topLeft = Offset(stroke / 2f, stroke / 2f), + size = Size(size.width - stroke, size.height - stroke), + cornerRadius = corner, + style = Stroke(stroke), + ) + return + } + drawRoundRect(color = accent.copy(alpha = GHOST_FILL_ALPHA), cornerRadius = corner) + val stroke = GHOST_BORDER_DP.dp.toPx() + drawRoundRect( + color = accent.copy(alpha = GHOST_BORDER_ALPHA), + topLeft = Offset(stroke / 2f, stroke / 2f), + size = Size(size.width - stroke, size.height - stroke), + cornerRadius = corner, + style = Stroke(stroke), + ) + val padding = GHOST_PADDING_DP.dp.toPx() + val maxWidth = (size.width - padding * 2).roundToInt() + if (maxWidth <= 0) return + val layout = + measurer.measure( + text = AnnotatedString(title), + style = TextStyle(color = accent, fontSize = GHOST_TITLE_SP.sp, fontWeight = FontWeight.Medium), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + constraints = Constraints(maxWidth = maxWidth), + ) + drawText(layout, topLeft = Offset(padding, (size.height - layout.size.height) / 2f)) +} + +/** Icon pixels per source pixel for a pictured drag: readable, yet out of the way of the zones. */ +private const val TRANSFER_GHOST_SCALE = 0.6f + +/** Longest edge a pictured icon may have, whatever the source's size. */ +private const val TRANSFER_GHOST_MAX_EDGE_PX = 480f + +/** Where the pointer sits in an icon grabbed at an unknown position: just under the top edge. */ +private const val TRANSFER_GHOST_TOP_HOTSPOT_PX = 12f + +private const val GHOST_PICTURE_ALPHA = 0.92f +private const val GHOST_FILL_ALPHA = 0.22f +private const val GHOST_BORDER_ALPHA = 0.55f +private const val GHOST_BORDER_DP = 1 +private const val GHOST_CORNER_DP = 8 +private const val GHOST_PADDING_DP = 8 +private const val GHOST_TITLE_SP = 13 + +/** + * Where an inbound drag-and-drop event is, in the receiving window's content + * coordinates (physical px) — the space the Tao hosts build their synthetic + * AWT events in (see `TaoSceneDnD`). Compose keeps its own `positionInRoot` + * internal, so the position is read back off the native event; `Unspecified` + * for an event that is not one of the hosts', which no zone then contains. + */ +@OptIn(ExperimentalComposeUiApi::class) +internal fun DragAndDropEvent.positionInWindowPx(): Offset = + when (val native = nativeEvent) { + is DropTargetDragEvent -> Offset(native.location.x.toFloat(), native.location.y.toFloat()) + is DropTargetDropEvent -> Offset(native.location.x.toFloat(), native.location.y.toFloat()) + else -> Offset.Unspecified + } diff --git a/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/WindowGroup.kt b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/WindowGroup.kt new file mode 100644 index 000000000..7319512e2 --- /dev/null +++ b/decorated-window-tao/src/main/kotlin/dev/nucleusframework/window/tao/workspace/WindowGroup.kt @@ -0,0 +1,122 @@ +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import dev.nucleusframework.window.tao.TaoWindow + +/** + * A set of windows that act as one: the members of a satellite workspace, the + * hosts a panel can be docked into, the windows a torn-off tab can be dropped + * on. + * + * Tracks membership, focus recency and an optional pin, and derives the + * [owner] from them: the pinned member, else the most recently focused one + * (with [followFocus]), else the first to have joined. A member leaves on its + * own when its native window is destroyed. + * + * Everything here runs on the Tao event-loop thread, which is also the Compose + * dispatcher, so the state writes need no synchronisation. + * + * @param followFocus whether the owner follows keyboard focus between members. + * @param onJoined called once [join] has added a window. + * @param onLeft called once [leave] has removed a window, with the owner that + * remains — `null` when the group is empty — so the caller can re-home what + * the departed window hosted. + */ +internal class WindowGroup( + val followFocus: Boolean, + private val onJoined: (TaoWindow) -> Unit = {}, + private val onLeft: (left: TaoWindow, remainingOwner: TaoWindow?) -> Unit = { _, _ -> }, +) { + private class Hooks( + val focus: (Boolean) -> Unit, + val destroyed: () -> Unit, + ) + + private val memberList = mutableStateListOf() + private val hooks = HashMap() + + /** Members by focus recency, most recent first; only those focused while in the group. */ + private val recency = mutableStateListOf() + + /** The member [pinTo] selected, or `null` when the owner follows focus. Kept even for a non-member. */ + var pinned: TaoWindow? by mutableStateOf(null) + private set + + /** + * Windows that have joined, in join order. + * + * A snapshot of the live list, so reading it in composition subscribes to + * it and comparing it with `==` means what it says — the observable list + * Compose keeps underneath compares by identity, and would also change + * shape under a caller iterating it while a window opens or closes. + */ + val members: List get() = memberList.toList() + + /** + * The pinned member if it is one, else the most recently focused member + * when [followFocus] is on, else the first member; `null` while empty. + */ + val owner: TaoWindow? + get() = + pinned?.takeIf { it in memberList } + ?: recency.firstOrNull()?.takeIf { followFocus } + ?: memberList.firstOrNull() + + /** + * Every member: the [owner] first, then the rest by focus recency, then + * the members never focused, in join order. The order to hit-test + * overlapping windows in — the window the user worked in most recently is + * the one most likely to be on top. + */ + val membersByRecency: List + get() { + val first = owner ?: return emptyList() + val ordered = ArrayList(memberList.size) + ordered += first + for (window in recency) if (window !== first) ordered += window + for (window in memberList) if (window !in ordered) ordered += window + return ordered + } + + /** Adds [window]. Idempotent. */ + fun join(window: TaoWindow) { + if (window in memberList) return + val windowHooks = + Hooks( + focus = { focused -> if (focused) noteFocus(window) }, + destroyed = { leave(window) }, + ) + window.onFocusChanged(windowHooks.focus) + window.onDestroyed(windowHooks.destroyed) + hooks[window] = windowHooks + memberList += window + if (window.isFocused) noteFocus(window) + onJoined(window) + } + + /** Removes [window]; a no-op for a non-member. */ + fun leave(window: TaoWindow) { + val windowHooks = hooks.remove(window) ?: return + window.removeFocusListener(windowHooks.focus) + window.removeDestroyedListener(windowHooks.destroyed) + memberList -= window + recency -= window + if (pinned === window) pinned = null + onLeft(window, owner) + } + + /** Records [window] as the most recently focused member; ignored for a non-member. */ + fun noteFocus(window: TaoWindow) { + if (window !in memberList) return + recency -= window + recency.add(0, window) + } + + /** Makes [window] the [owner] regardless of focus; `null` returns to the focus-driven choice. */ + fun pinTo(window: TaoWindow?) { + pinned = window + } +} diff --git a/decorated-window-tao/src/main/native/Cargo.toml b/decorated-window-tao/src/main/native/Cargo.toml index 14e63f6f4..b18ac79fd 100644 --- a/decorated-window-tao/src/main/native/Cargo.toml +++ b/decorated-window-tao/src/main/native/Cargo.toml @@ -59,6 +59,7 @@ windows = { version = "0.62", features = [ "Win32_Graphics_Gdi", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse", + "Win32_System_Threading", ] } [build-dependencies] diff --git a/decorated-window-tao/src/main/native/linux/nucleus_tao_egl.c b/decorated-window-tao/src/main/native/linux/nucleus_tao_egl.c index dc851cdb7..f93c52548 100644 --- a/decorated-window-tao/src/main/native/linux/nucleus_tao_egl.c +++ b/decorated-window-tao/src/main/native/linux/nucleus_tao_egl.c @@ -225,7 +225,10 @@ typedef void *(*PFN_eglGetProcAddress)(const char *); typedef const char *(*PFN_eglQueryString)(EGLDisplay, EGLint); typedef EGLContext (*PFN_eglGetCurrentContext)(void); typedef EGLDisplay (*PFN_eglGetCurrentDisplay)(void); +typedef EGLBoolean (*PFN_eglQuerySurface)(EGLDisplay, EGLSurface, EGLint, EGLint *); +#define EGL_SURF_HEIGHT 0x3056 +#define EGL_SURF_WIDTH 0x3057 #define EGL_VENDOR 0x3053 #define EGL_VERSION 0x3054 @@ -241,6 +244,7 @@ typedef struct wl_event_queue_ wl_event_queue; typedef wl_egl_window *(*PFN_wl_egl_window_create)(wl_surface *, int, int); typedef void (*PFN_wl_egl_window_destroy)(wl_egl_window *); typedef void (*PFN_wl_egl_window_resize)(wl_egl_window *, int, int, int, int); +typedef void (*PFN_wl_egl_window_get_attached_size)(wl_egl_window *, int *, int *); /* `wl_message` and `wl_interface` are the static introspection tables for * each Wayland interface. We don't define our own — we read pointers via @@ -291,6 +295,7 @@ typedef int (*PFN_wl_display_flush)(wl_display *); #define WL_SUBCOMPOSITOR_GET_SUBSURFACE 1 #define WL_SUBSURFACE_DESTROY 0 #define WL_SUBSURFACE_SET_POSITION 1 +#define WL_SUBSURFACE_SET_SYNC 4 #define WL_SUBSURFACE_SET_DESYNC 5 #define WL_SURFACE_DESTROY 0 #define WL_SURFACE_ATTACH 1 @@ -345,6 +350,7 @@ static PFN_eglGetProcAddress p_eglGetProcAddress = NULL; static PFN_eglQueryString p_eglQueryString = NULL; static PFN_eglGetCurrentContext p_eglGetCurrentContext = NULL; static PFN_eglGetCurrentDisplay p_eglGetCurrentDisplay = NULL; +static PFN_eglQuerySurface p_eglQuerySurface = NULL; static PFN_XGetWindowAttributes p_XGetWindowAttributes = NULL; static PFN_XVisualIDFromVisual p_XVisualIDFromVisual = NULL; @@ -368,6 +374,7 @@ static int g_libs_loaded = 0; static PFN_wl_egl_window_create p_wl_egl_window_create = NULL; static PFN_wl_egl_window_destroy p_wl_egl_window_destroy = NULL; static PFN_wl_egl_window_resize p_wl_egl_window_resize = NULL; +static PFN_wl_egl_window_get_attached_size p_wl_egl_window_get_attached_size = NULL; /* libwayland-client function pointers + interface globals (the latter * are exported `const struct wl_interface` symbols in the .so). */ @@ -453,6 +460,7 @@ static int load_libs(void) { * display/context the external-texture import must run on. */ LOAD(g_libegl, eglGetCurrentContext); LOAD(g_libegl, eglGetCurrentDisplay); + LOAD(g_libegl, eglQuerySurface); LOAD(g_libx11, XGetWindowAttributes); LOAD(g_libx11, XVisualIDFromVisual); @@ -477,6 +485,12 @@ static int load_libs(void) { (PFN_wl_egl_window_destroy) dlsym(g_libwlegl, "wl_egl_window_destroy"); p_wl_egl_window_resize = (PFN_wl_egl_window_resize) dlsym(g_libwlegl, "wl_egl_window_resize"); + /* The authoritative "what size is the buffer the compositor + * currently holds" — as opposed to the size we last asked for. + * Part of the stable libwayland-egl ABI since 1.0. */ + p_wl_egl_window_get_attached_size = + (PFN_wl_egl_window_get_attached_size) + dlsym(g_libwlegl, "wl_egl_window_get_attached_size"); } if (g_libwlclient) { p_wl_proxy_marshal_flags = @@ -1583,14 +1597,14 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeResize( * draws. Cheap no-op when the offset is unchanged; no-op on X11 (the CSD is * never latched there). */ -JNIEXPORT void JNICALL +JNIEXPORT jboolean JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeSetContentOffset( JNIEnv *env, jclass clazz, jlong handle, jint xLogical, jint yLogical) { (void) env; (void) clazz; EglAttachment *att = (EglAttachment *) (uintptr_t) handle; - if (!att || !att->wl_subsurface || !p_wl_proxy_marshal_flags) return; - if (att->content_off_x == xLogical && att->content_off_y == yLogical) return; + if (!att || !att->wl_subsurface || !p_wl_proxy_marshal_flags) return JNI_FALSE; + if (att->content_off_x == xLogical && att->content_off_y == yLogical) return JNI_FALSE; att->content_off_x = xLogical; att->content_off_y = yLogical; p_wl_proxy_marshal_flags(att->wl_subsurface, WL_SUBSURFACE_SET_POSITION, @@ -1600,14 +1614,44 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeSetContentOffs * GTK's next commit, and after a maximize/restore GTK has already * committed its reallocation by the time this runs and then goes idle, * which would leave the old offset applied forever (content shifted - * bottom-right by the former shadow margins). Issue an empty commit on - * GTK's toplevel surface ourselves: it applies pending state only, and - * this call always runs on the GTK main thread (the render loop), so - * GTK is never mid-way through its own attach/damage/commit sequence. */ - if (att->wl_parent_surface) { - p_wl_proxy_marshal_flags(att->wl_parent_surface, WL_SURFACE_COMMIT, - NULL, p_wl_proxy_get_version(att->wl_parent_surface), 0); - } + * bottom-right by the former shadow margins). This used to issue an + * empty commit on GTK's toplevel surface here. That is not safe: GDK + * attaches its SHM buffer in `end_paint` and commits it in + * `after_paint`, and a commit of ours between the two hands the + * compositor a buffer GDK still counts as staged — the release then + * fails GDK's `buffer_release_callback` check and cairo aborts the + * process (seen after a minimize/restore storm). The caller asks GTK to + * repaint the toplevel instead, and GTK's own commit applies the + * position. Returns whether the offset changed, so the caller knows to. */ + if (p_wl_display_flush && att->wl_display_conn) p_wl_display_flush(att->wl_display_conn); + return JNI_TRUE; +} + +/** + * Switches the content sub-surface between `set_sync` and `set_desync`. + * + * Normally desync: Compose's buffers land on their own, independently of + * GTK's cairo paint cycle (see the file header). Through an interactive + * resize that independence is the problem: an embedded native view + * (`NativeView`, e.g. WebKit's accelerated sub-surface) is positioned by GTK + * on its allocation, and a sub-surface position is parent state that only + * takes effect on GTK's toplevel commit — one GTK paint after Compose laid + * the new hole out and swapped. The embed peels off the hole by a frame on + * every configure. In sync mode our buffer is cached by the compositor and + * applied atomically with that same GTK commit, hole and embed together. + * Per the protocol, `set_desync` applies any cached state at once, so + * leaving sync mode never strands a frame. + */ +JNIEXPORT void JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeSetSubsurfaceSync( + JNIEnv *env, jclass clazz, jlong handle, jboolean sync) +{ + (void) env; (void) clazz; + EglAttachment *att = (EglAttachment *) (uintptr_t) handle; + if (!att || !att->wl_subsurface || !p_wl_proxy_marshal_flags) return; + p_wl_proxy_marshal_flags(att->wl_subsurface, + sync ? WL_SUBSURFACE_SET_SYNC : WL_SUBSURFACE_SET_DESYNC, + NULL, p_wl_proxy_get_version(att->wl_subsurface), 0); if (p_wl_display_flush && att->wl_display_conn) p_wl_display_flush(att->wl_display_conn); } @@ -1630,6 +1674,16 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeSetContentOffs * `applyFrameDecoration` paints them transparent so the shadow shows through — * claiming them opaque would leave square corners with the shadow clipped away. * + * The bottom row is always left out. A toplevel covered edge to edge by an + * opaque subsurface — maximized, tiled or fullscreen, where GTK collapses the + * shadow margins — is culled by Mutter as obscured, and an obscured surface + * gets no frame callback. GDK's frame clock freezes on the callback of the + * last commit GTK made in that state (the one `applyContentOffset` asks for, + * to land the subsurface at (0, 0)), and with it the flush-events phase that + * delivers pointer motion: the app then renders at full rate but hover and + * drags only move when another event arrives. One row the compositor still + * has to blend keeps the toplevel painted and its callbacks flowing. + * * Pass `logicalW <= 0` to clear the region (window genuinely translucent). * Coordinates are surface-local (logical) units. Queued state: it lands with the * next `eglSwapBuffers` commit, so there is no extra commit and no race with the @@ -1645,7 +1699,9 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeSetOpaqueRegio if (!att || !att->wl_child_surface || !p_wl_proxy_marshal_flags) return; if (!att->wl_compositor || !g_wl_region_interface) return; - if (logicalW <= 0 || logicalH <= 0) { + /* Bottom row excluded — see above. */ + int opaqueH = logicalH - 1; + if (logicalW <= 0 || opaqueH <= 0) { p_wl_proxy_marshal_flags( att->wl_child_surface, WL_SURFACE_SET_OPAQUE_REGION, NULL, p_wl_proxy_get_version(att->wl_child_surface), 0, NULL); @@ -1661,18 +1717,18 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeSetOpaqueRegio int r = cornerRadius; if (r < 0) r = 0; - if (2 * r >= logicalW || 2 * r >= logicalH) r = 0; + if (2 * r >= logicalW || 2 * r >= opaqueH) r = 0; if (r == 0) { p_wl_proxy_marshal_flags(region, WL_REGION_ADD, NULL, - p_wl_proxy_get_version(region), 0, 0, 0, logicalW, logicalH); + p_wl_proxy_get_version(region), 0, 0, 0, logicalW, opaqueH); } else { - /* Everything except the four r x r corner squares. */ + /* Everything except the four r x r corner squares (and the bottom row). */ p_wl_proxy_marshal_flags(region, WL_REGION_ADD, NULL, - p_wl_proxy_get_version(region), 0, 0, r, logicalW, logicalH - 2 * r); + p_wl_proxy_get_version(region), 0, 0, r, logicalW, opaqueH - 2 * r); p_wl_proxy_marshal_flags(region, WL_REGION_ADD, NULL, p_wl_proxy_get_version(region), 0, r, 0, logicalW - 2 * r, r); p_wl_proxy_marshal_flags(region, WL_REGION_ADD, NULL, - p_wl_proxy_get_version(region), 0, r, logicalH - r, logicalW - 2 * r, r); + p_wl_proxy_get_version(region), 0, r, opaqueH - r, logicalW - 2 * r, r); } p_wl_proxy_marshal_flags( att->wl_child_surface, WL_SURFACE_SET_OPAQUE_REGION, NULL, @@ -1704,6 +1760,90 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeSetSwapInterva p_eglSwapInterval(att->display, (EGLint) interval); } +/** + * Diagnostic probe (#444): the size of the buffer actually behind the + * default framebuffer, as opposed to the size last *requested* through + * `wl_egl_window_resize` — which is what `nativeWidth`/`nativeHeight` + * report. On Wayland the two disagree until the next `eglSwapBuffers` + * reallocates. Packed as (width << 32) | height; 0 when unavailable. + */ +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeQueryDrawableSize( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + EglAttachment *att = (EglAttachment *) (uintptr_t) handle; + if (!att || !p_eglQuerySurface) return 0; + EGLint w = 0, h = 0; + if (!p_eglQuerySurface(att->display, att->surface, EGL_SURF_WIDTH, &w)) return 0; + if (!p_eglQuerySurface(att->display, att->surface, EGL_SURF_HEIGHT, &h)) return 0; + return ((jlong) (uint32_t) w << 32) | (jlong) (uint32_t) h; +} + +/** + * Size of the buffer currently *attached* to the content surface, as + * libwayland-egl itself tracks it: what the compositor holds, not what we + * last requested through `wl_egl_window_resize`. Packed as + * (width << 32) | height; 0 on X11 or when the symbol is unavailable. + */ +/* GL entry points used by `nativeTouchDrawable`, resolved lazily through the + * same proc loader Skia is handed. Values from . */ +#define NUCLEUS_GL_FRAMEBUFFER 0x8D40 +#define NUCLEUS_GL_COLOR_BUFFER_BIT 0x00004000 +typedef void (*PFN_glBindFramebuffer)(unsigned int, unsigned int); +typedef void (*PFN_glClear)(unsigned int); +static PFN_glBindFramebuffer p_glBindFramebuffer = NULL; +static PFN_glClear p_glClear = NULL; + +/** + * Forces the driver to acquire (and, if a `wl_egl_window_resize` is pending, + * reallocate) the buffer behind the default framebuffer, right now. + * + * The size of that buffer is what Skia's render target must agree with, and + * drivers disagree on *when* they act on a pending resize: Mesa defers it to + * `eglSwapBuffers`, the NVIDIA proprietary driver does it when the back buffer + * is first used for rendering — which, left to itself, is in the middle of our + * frame, after the render target was already built from a size that is by then + * stale. Rather than predict the driver, this pins the moment: issue the first + * use ourselves, before asking `eglQuerySurface`, so the answer describes the + * buffer the whole frame will land in on either driver. + * + * The clear is not wasted work — the frame clears the surface anyway. The + * caller must reset Skia's cached GL state afterwards, since this touches the + * binding behind its back. + */ +JNIEXPORT void JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeTouchDrawable( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + EglAttachment *att = (EglAttachment *) (uintptr_t) handle; + if (!att) return; + if (!p_glBindFramebuffer) { + p_glBindFramebuffer = + (PFN_glBindFramebuffer) nucleus_tao_egl_get_proc(NULL, "glBindFramebuffer"); + } + if (!p_glClear) { + p_glClear = (PFN_glClear) nucleus_tao_egl_get_proc(NULL, "glClear"); + } + if (!p_glBindFramebuffer || !p_glClear) return; + p_glBindFramebuffer(NUCLEUS_GL_FRAMEBUFFER, 0); + p_glClear(NUCLEUS_GL_COLOR_BUFFER_BIT); +} + +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeAttachedSize( + JNIEnv *env, jclass clazz, jlong handle) +{ + (void) env; (void) clazz; + EglAttachment *att = (EglAttachment *) (uintptr_t) handle; + if (!att || !att->wl_window || !p_wl_egl_window_get_attached_size) return 0; + int w = 0, h = 0; + p_wl_egl_window_get_attached_size(att->wl_window, &w, &h); + if (w <= 0 || h <= 0) return 0; + return ((jlong) (uint32_t) w << 32) | (jlong) (uint32_t) h; +} + JNIEXPORT jint JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoEglBridge_nativeWidth( JNIEnv *env, jclass clazz, jlong handle) diff --git a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_clipboard.c b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_clipboard.c index cfb0f90be..62f4f9596 100644 --- a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_clipboard.c +++ b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_clipboard.c @@ -46,6 +46,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -256,7 +257,7 @@ static jmethodID on_bytes_method(JNIEnv *env, jobject callback) { if (clazz == NULL) return NULL; jmethodID method = (*env)->GetMethodID(env, clazz, "onBytes", "([B)V"); (*env)->DeleteLocalRef(env, clazz); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return method; } @@ -321,7 +322,7 @@ static void deliver(jobject callback, const void *data, size_t len) { if (method != NULL) { jbyteArray arr = bytes_to_array(env, data, len); (*env)->CallVoidMethod(env, callback, method, arr); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); if (arr != NULL) (*env)->DeleteLocalRef(env, arr); } (*env)->DeleteGlobalRef(env, callback); diff --git a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup.c b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup.c index a94ba39da..78159efb0 100644 --- a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup.c +++ b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup.c @@ -79,6 +79,7 @@ */ #include "nucleus_tao_linux_popup.h" +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -279,7 +280,7 @@ static void cache_event_callback_ids(JNIEnv *env, jobject callback) { g_on_scroll = (*env)->GetMethodID(env, cls, "onScroll", "(FFFF)V"); g_on_key_event = (*env)->GetMethodID(env, cls, "onKeyEvent", "(IIII)V"); (*env)->DeleteLocalRef(env, cls); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static void cache_outside_listener_id(JNIEnv *env, jobject listener) { @@ -288,7 +289,7 @@ static void cache_outside_listener_id(JNIEnv *env, jobject listener) { if (cls == NULL) return; g_on_outside_click = (*env)->GetMethodID(env, cls, "onOutsideClick", "(II)V"); (*env)->DeleteLocalRef(env, cls); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* ── Keysym helpers ─────────────────────────────────────────────────────── */ @@ -390,7 +391,7 @@ static void forward_pointer(JNIEnv *env, Panel *p, int type, float x, float y, if (cb == NULL || g_on_pointer_event == NULL) return; (*env)->CallVoidMethod(env, cb, g_on_pointer_event, (jint) type, (jfloat) x, (jfloat) y, (jint) button, (jint) mods); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static void forward_scroll(JNIEnv *env, Panel *p, float x, float y, @@ -401,7 +402,7 @@ static void forward_scroll(JNIEnv *env, Panel *p, float x, float y, if (cb == NULL || g_on_scroll == NULL) return; (*env)->CallVoidMethod(env, cb, g_on_scroll, (jfloat) x, (jfloat) y, (jfloat) dx, (jfloat) dy); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static void forward_key(JNIEnv *env, Panel *p, int type, int vk, int codepoint, @@ -412,7 +413,7 @@ static void forward_key(JNIEnv *env, Panel *p, int type, int vk, int codepoint, if (cb == NULL || g_on_key_event == NULL) return; (*env)->CallVoidMethod(env, cb, g_on_key_event, (jint) type, (jint) vk, (jint) codepoint, (jint) mods); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static void forward_outside_click(JNIEnv *env, Panel *p, int button) { @@ -421,7 +422,7 @@ static void forward_outside_click(JNIEnv *env, Panel *p, int button) { pthread_mutex_unlock(&p->lock); if (cb == NULL || g_on_outside_click == NULL) return; (*env)->CallVoidMethod(env, cb, g_on_outside_click, (jint) 1, (jint) button); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* Raw XI2 ButtonPress: hit-test the pointer against the panel rect and diff --git a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup_xdnd.c b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup_xdnd.c index cced229c2..c79ddfe01 100644 --- a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup_xdnd.c +++ b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_popup_xdnd.c @@ -12,6 +12,7 @@ */ #include "nucleus_tao_linux_popup.h" +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -33,7 +34,7 @@ static void cache_dnd_callback_ids(JNIEnv *env, jobject callback) { g_on_drag_drop = (*env)->GetMethodID(env, cls, "onDrop", "(JIII[Ljava/lang/String;)I"); (*env)->DeleteLocalRef(env, cls); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } void popup_xdnd_intern_atoms(Display *dpy, Panel *p) { @@ -176,8 +177,7 @@ static jint call_dnd_motion(JNIEnv *env, Panel *p, jmethodID method, int x, int jint effect = (*env)->CallIntMethod(env, cb, method, (jlong) (uintptr_t) p, (jint) x, (jint) y, (jint) 0, has_files ? JNI_TRUE : JNI_FALSE); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { return DROP_EFFECT_NONE; } return effect; @@ -189,7 +189,7 @@ static void call_dnd_leave(JNIEnv *env, Panel *p) { pthread_mutex_unlock(&p->lock); if (cb == NULL || g_on_drag_leave == NULL) return; (*env)->CallVoidMethod(env, cb, g_on_drag_leave, (jlong) (uintptr_t) p); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static jint call_dnd_drop(JNIEnv *env, Panel *p, int x, int y, @@ -201,19 +201,19 @@ static jint call_dnd_drop(JNIEnv *env, Panel *p, int x, int y, jclass str_cls = (*env)->FindClass(env, "java/lang/String"); if (str_cls == NULL) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return DROP_EFFECT_NONE; } jobjectArray arr = (*env)->NewObjectArray(env, npaths, str_cls, NULL); (*env)->DeleteLocalRef(env, str_cls); if (arr == NULL) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return DROP_EFFECT_NONE; } for (int i = 0; i < npaths; i++) { jstring s = (*env)->NewStringUTF(env, paths[i]); if (s == NULL) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); continue; } (*env)->SetObjectArrayElement(env, arr, i, s); @@ -223,8 +223,7 @@ static jint call_dnd_drop(JNIEnv *env, Panel *p, int x, int y, (jlong) (uintptr_t) p, (jint) x, (jint) y, (jint) 0, arr); (*env)->DeleteLocalRef(env, arr); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { return DROP_EFFECT_NONE; } return effect; diff --git a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_widget.c b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_widget.c index f5e720f59..d61de6c15 100644 --- a/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_widget.c +++ b/decorated-window-tao/src/main/native/linux/nucleus_tao_linux_widget.c @@ -37,6 +37,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -123,6 +124,15 @@ typedef void (*PFN_gdk_event_free)(void *event); typedef void *(*PFN_g_object_ref)(void *obj); typedef void (*PFN_g_object_unref)(void *obj); typedef void (*PFN_g_list_free)(GList *list); +typedef GtkWidget *(*PFN_gtk_window_get_focus)(GtkWindow *window); +typedef void (*PFN_gtk_container_check_resize)(GtkContainer *container); +typedef void (*PFN_gtk_widget_queue_draw)(GtkWidget *widget); +typedef void (*PFN_gtk_window_get_size)(GtkWindow *window, int *width, int *height); +typedef void *(*PFN_gdk_window_get_display)(void *window); +typedef void *(*PFN_gdk_display_get_default_seat)(void *display); +typedef void *(*PFN_gdk_seat_get_pointer)(void *seat); +typedef void *(*PFN_gdk_window_get_device_position)( + void *window, void *device, int *x, int *y, unsigned int *mask); /* GtkAlign enum — `GTK_ALIGN_FILL` = 0 (GTK 3), `GTK_ALIGN_START` = 1. * We use START on the dummy main child so it doesn't request expansion. */ @@ -168,6 +178,15 @@ static struct { PFN_g_object_ref g_object_ref; PFN_g_object_unref g_object_unref; PFN_g_list_free g_list_free; + /* Optional: keyboard-owner bookkeeping and the live button state. */ + PFN_gtk_window_get_focus gtk_window_get_focus; + PFN_gtk_container_check_resize gtk_container_check_resize; + PFN_gtk_widget_queue_draw gtk_widget_queue_draw; + PFN_gtk_window_get_size gtk_window_get_size; + PFN_gdk_window_get_display gdk_window_get_display; + PFN_gdk_display_get_default_seat gdk_display_get_default_seat; + PFN_gdk_seat_get_pointer gdk_seat_get_pointer; + PFN_gdk_window_get_device_position gdk_window_get_device_position; } g; static void *load_first(const char *const *names) { @@ -236,7 +255,15 @@ static int ensure_gtk_loaded(void) { if (libgdk != NULL) { g.gdk_event_copy = (PFN_gdk_event_copy) dlsym(libgdk, "gdk_event_copy"); g.gdk_event_free = (PFN_gdk_event_free) dlsym(libgdk, "gdk_event_free"); + g.gdk_window_get_display = (PFN_gdk_window_get_display) dlsym(libgdk, "gdk_window_get_display"); + g.gdk_display_get_default_seat = (PFN_gdk_display_get_default_seat) dlsym(libgdk, "gdk_display_get_default_seat"); + g.gdk_seat_get_pointer = (PFN_gdk_seat_get_pointer) dlsym(libgdk, "gdk_seat_get_pointer"); + g.gdk_window_get_device_position = (PFN_gdk_window_get_device_position) dlsym(libgdk, "gdk_window_get_device_position"); } + g.gtk_window_get_focus = (PFN_gtk_window_get_focus) dlsym(libgtk, "gtk_window_get_focus"); + g.gtk_container_check_resize = (PFN_gtk_container_check_resize) dlsym(libgtk, "gtk_container_check_resize"); + g.gtk_widget_queue_draw = (PFN_gtk_widget_queue_draw) dlsym(libgtk, "gtk_widget_queue_draw"); + g.gtk_window_get_size = (PFN_gtk_window_get_size) dlsym(libgtk, "gtk_window_get_size"); g.g_object_ref = (PFN_g_object_ref) dlsym(libgobj, "g_object_ref"); g.g_object_unref = (PFN_g_object_unref) dlsym(libgobj, "g_object_unref"); if (libglib != NULL) { @@ -284,7 +311,7 @@ static void ensure_callback_cache(JNIEnv *env, jobject sample) { if (sCallbackClass == NULL) return; sOnEventMethod = (*env)->GetMethodID(env, sCallbackClass, "onEvent", "(IIIII)V"); sOnScrollMethod = (*env)->GetMethodID(env, sCallbackClass, "onScroll", "(IIFF)V"); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static JNIEnv *attach_jvm_thread(void) { @@ -324,7 +351,7 @@ static void invoke_callback(GtkWidget *box, int type, int x, int y, int button) if (env == NULL) return; (*env)->CallVoidMethod(env, cb, sOnEventMethod, (jint) type, (jint) x, (jint) y, (jint) button, (jint) (type == EVT_OVERLAY_PRESS ? 1 : 0)); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static void invoke_scroll_callback(GtkWidget *box, int x, int y, float dx, float dy) { @@ -335,7 +362,7 @@ static void invoke_scroll_callback(GtkWidget *box, int x, int y, float dx, float if (env == NULL) return; (*env)->CallVoidMethod(env, cb, sOnScrollMethod, (jint) x, (jint) y, (jfloat) dx, (jfloat) dy); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* ── Per-widget rect storage + overlay positioning ─────────────────── */ @@ -349,6 +376,24 @@ typedef struct { gint valid; } widget_rect_t; +/* Whether [widget] is one of the EventBoxes this file creates — the input + * boxes and the focus sink. GTK focus on one of them means Compose owns the + * keyboard (Tao's toplevel handler feeds it); focus on anything else means + * an embed does. Tao reads the same marker (`nucleus_tao_input_box`) to + * decide whether a key press is Compose's or the embed's. */ +static int is_nucleus_input_box(GtkWidget *widget) { + return widget != NULL && g.g_object_get_data(widget, NUCLEUS_INPUT_BOX_KEY) != NULL; +} + +/* The GTK focus widget of the toplevel [widget] lives in, or NULL. */ +static GtkWidget *focus_widget_of(GtkWidget *widget) { + if (g.gtk_window_get_focus == NULL) return NULL; + GtkWidget *toplevel = g.gtk_widget_get_toplevel(widget); + if (toplevel == NULL) return NULL; + return g.gtk_window_get_focus((GtkWindow *) toplevel); +} + + /* `get-child-position` signal handler. Reads the cached rect from the * child's GObject data and writes it into [allocation]. ALWAYS returns * TRUE with w,h >= 1: returning FALSE makes GtkOverlay fall back to @@ -529,6 +574,25 @@ static void egl_restore(const egl_snapshot_t *s) { * widget never realises at the offscreen 1×1 parking allocation * (WebKit's GPU compositor sizes its glyph atlas at first paint and * never recovers from a 1×1 start: page text stays blank). */ +/* Re-runs `get-child-position` for the overlay's children with the rects + * just cached — synchronously. `gtk_widget_queue_resize` alone parks the + * allocation until GTK's next frame-clock layout phase, one compositor + * frame after Compose laid the slot out: through a resize the embed then + * trails the window by a frame at best, and by several when the frame + * clock is paced slower than the Compose layouts feeding it. Processing + * the queued resize right here lands the embed in the same frame as the + * Compose content around it. The overlay reports min = 0, so the pass + * never reaches the GtkApplicationWindow. The embed's own size_allocate + * may touch its GL (WebKit's accelerated surface): guard the thread's EGL + * context like every other GTK call that can. */ +static void relayout_overlay_now(GtkWidget *overlay) { + g.gtk_widget_queue_resize(overlay); + if (g.gtk_container_check_resize == NULL) return; + egl_snapshot_t saved = egl_save(); + g.gtk_container_check_resize((GtkContainer *) overlay); + egl_restore(&saved); +} + static void mount_on_overlay(GtkWidget *overlay, GtkWidget *widget) { GtkWidget *parent = g.gtk_widget_get_parent(widget); if (parent == overlay) return; @@ -669,12 +733,10 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeSetFra return; } - /* Trigger a re-layout pass on the overlay so - * `get-child-position` runs with the new rect. The overlay - * itself reports min = 0 (we pinned it via set_size_request), - * so this does NOT propagate up to the GtkApplicationWindow — - * shrinking the window stays cheap. */ - g.gtk_widget_queue_resize(parent); + /* Re-layout the overlay so `get-child-position` runs with the new + * rect — now, not at the next frame-clock tick (see + * relayout_overlay_now). */ + relayout_overlay_now(parent); } /* Clears the GTK window's focused widget. The Compose overlay slot @@ -702,6 +764,156 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeReques g.gtk_window_set_focus(win, NULL); } +/* Gives the keyboard back to Compose after a press Compose kept: when an + * embed holds GTK focus (the user clicked into it earlier), a click on + * Compose ground outside every input box would otherwise leave the keys + * with the embed while Compose shows a focused text field. Clearing the + * focus widget routes keys to the toplevel handler again — Tao picks them + * up for Compose — and the next focus-in lands on the focus sink, never + * on the embed. Returns whether anything changed. */ +EXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeClaimKeyboardForCompose( + JNIEnv *env, jclass clazz, jlong gtk_window_ptr) +{ + (void) env; (void) clazz; + if (!ensure_gtk_loaded() || g.gtk_window_get_focus == NULL) return JNI_FALSE; + if (gtk_window_ptr == 0) return JNI_FALSE; + GtkWindow *win = (GtkWindow *) (uintptr_t) gtk_window_ptr; + GtkWidget *focus = g.gtk_window_get_focus(win); + if (focus == NULL || is_nucleus_input_box(focus)) return JNI_FALSE; + g.gtk_window_set_focus(win, NULL); + return JNI_TRUE; +} + +/* The pointer's button state as GDK sees it right now (GDK_BUTTON1_MASK = + * 1 << 8, BUTTON2 = 1 << 9, BUTTON3 = 1 << 10), or -1 when it cannot be + * read. A press forwarded to an embed can lose its release to a grab the + * embed takes — its context menu, a drag it starts — so the host asks GDK + * which buttons are really down instead of trusting the last event. */ +EXPORT jint JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeQueryPointerButtons( + JNIEnv *env, jclass clazz, jlong gtk_window_ptr) +{ + (void) env; (void) clazz; + if (!ensure_gtk_loaded() || gtk_window_ptr == 0) return -1; + if (g.gtk_widget_get_window == NULL || g.gdk_window_get_display == NULL || + g.gdk_display_get_default_seat == NULL || g.gdk_seat_get_pointer == NULL || + g.gdk_window_get_device_position == NULL) { + return -1; + } + void *gdk_window = g.gtk_widget_get_window((GtkWidget *) (uintptr_t) gtk_window_ptr); + if (gdk_window == NULL) return -1; + void *display = g.gdk_window_get_display(gdk_window); + void *seat = display != NULL ? g.gdk_display_get_default_seat(display) : NULL; + void *pointer = seat != NULL ? g.gdk_seat_get_pointer(seat) : NULL; + if (pointer == NULL) return -1; + unsigned int mask = 0; + g.gdk_window_get_device_position(gdk_window, pointer, NULL, NULL, &mask); + return (jint) mask; +} + +/* Asks GTK to paint — and so commit — its toplevel on its next frame. While + * the content sub-surface is in sync mode (resize burst with an embed), a + * Compose buffer is only shown by GTK's commit; GTK commits on every + * configure while the pointer moves, and this covers the frames in between + * and the last one after the pointer stops. */ +EXPORT void JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeQueueToplevelDraw( + JNIEnv *env, jclass clazz, jlong gtk_window_ptr) +{ + (void) env; (void) clazz; + if (!ensure_gtk_loaded() || g.gtk_widget_queue_draw == NULL || gtk_window_ptr == 0) return; + g.gtk_widget_queue_draw((GtkWidget *) (uintptr_t) gtk_window_ptr); +} + +/** + * The toplevel's client size in logical units (`gtk_window_get_size`, CSD + * shadows excluded), packed `(width << 32) | height`; 0 when unavailable. + * Read from the `draw` hook: during a resize GTK lays out and paints a + * configure before Tao's `configure-event` has been delivered to the host, so + * this is the size the paint being committed is for (#444). + */ +EXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeToplevelClientSize( + JNIEnv *env, jclass clazz, jlong gtk_window_ptr) +{ + (void) env; (void) clazz; + if (!ensure_gtk_loaded() || g.gtk_window_get_size == NULL || gtk_window_ptr == 0) return 0; + int w = 0, h = 0; + g.gtk_window_get_size((GtkWindow *) (uintptr_t) gtk_window_ptr, &w, &h); + if (w <= 0 || h <= 0) return 0; + return ((jlong) (uint32_t) w << 32) | (jlong) (uint32_t) h; +} + +/* ── toplevel draw hook (#444) ───────────────────────────────────────── + * + * Tao's own `draw` handler only posts the window id to its event channel; the + * `RedrawRequested` the host renders on is delivered by the event loop *after* + * GTK's paint phase — and after GDK's after-paint has already committed the + * toplevel, on Wayland with the geometry of the configure just acked. Content + * rendered from there always lands one toplevel commit late, which on a + * left/top-edge resize is the window origin moving one step ahead of the + * content. This hook hands the host the `draw` signal itself (connected after + * GTK's class handler, still inside the paint phase): a frame rendered and + * committed from here rides GTK's commit of the same frame, atomically with + * the geometry, once the content sub-surface is in sync mode. */ +static jmethodID sOnToplevelDrawMethod = NULL; /* ()V */ + +static gboolean on_toplevel_draw(GtkWidget *widget, void *cr, void *data) { + (void) widget; (void) cr; + jobject cb = (jobject) data; + if (cb == NULL || sOnToplevelDrawMethod == NULL) return 0; + JNIEnv *env = attach_jvm_thread(); + if (env == NULL) return 0; + (*env)->CallVoidMethod(env, cb, sOnToplevelDrawMethod); + nucleus_jni_clear_exception(env); + return 0; /* FALSE: never swallow GTK's own drawing */ +} + +static void toplevel_draw_cb_destroy_notify(void *data, void *closure) { + (void) closure; + jobject ref = (jobject) data; + if (ref == NULL) return; + JNIEnv *env = attach_jvm_thread(); + if (env != NULL) (*env)->DeleteGlobalRef(env, ref); +} + +/** + * Connects [callback]'s `onToplevelDraw()` to the GtkWindow's `draw` signal + * (`G_CONNECT_AFTER`). Returns the handler id, 0 when unavailable. The + * handler lives as long as the GtkWindow: GObject drops it — and the global + * ref through the destroy notify — when the window is finalized. + */ +EXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeConnectToplevelDraw( + JNIEnv *env, jclass clazz, jlong gtk_window_ptr, jobject callback) +{ + (void) clazz; + if (!ensure_gtk_loaded() || gtk_window_ptr == 0 || callback == NULL) return 0; + if (g.g_signal_connect_data == NULL) return 0; + if (sJVM == NULL) (*env)->GetJavaVM(env, &sJVM); + if (sOnToplevelDrawMethod == NULL) { + /* Resolved on the interface, not on GetObjectClass(callback): the + * callback is an anonymous class, and a native image only knows the + * JNI method the reachability metadata registers — the interface's. + * CallVoidMethod still dispatches to the implementation. */ + jclass local = (*env)->FindClass(env, + "dev/nucleusframework/window/tao/ffi/NativeTaoLinuxWidgetBridge$ToplevelDrawCallback"); + if (local != NULL) { + sOnToplevelDrawMethod = (*env)->GetMethodID(env, local, "onToplevelDraw", "()V"); + (*env)->DeleteLocalRef(env, local); + } + nucleus_jni_clear_exception(env); + if (sOnToplevelDrawMethod == NULL) return 0; + } + jobject ref = (*env)->NewGlobalRef(env, callback); + /* G_CONNECT_AFTER = 1 << 0 */ + gulong id = g.g_signal_connect_data((void *) (uintptr_t) gtk_window_ptr, "draw", + (void (*)(void)) on_toplevel_draw, ref, + (void (*)(void *, void *)) toplevel_draw_cb_destroy_notify, 1); + return (jlong) id; +} + /* ── Input-box overlay: hit capture for NativeView blending ── * * The Linux equivalent of Compose-first hit-testing over an embed. We @@ -776,16 +988,18 @@ typedef struct { double delta_y; } gdk_event_scroll_t; -/* Map GTK's native button code (1 = LEFT, 2 = MIDDLE, 3 = RIGHT) to - * Tao's AWT-style encoding (`TaoMouseButton.LEFT = 0`, `RIGHT = 1`, - * `MIDDLE = 2`). Anything else stays a passthrough — Compose's - * `mapButton` falls back to `Primary` for unknown codes. */ +/* Map GTK's native button code (1 = LEFT, 2 = MIDDLE, 3 = RIGHT, + * 8 = BACK, 9 = FORWARD) to Tao's AWT-style encoding + * (`TaoMouseButton.LEFT = 0` … `FORWARD = 4`, `OTHER = 5`) — the same + * codes `events.rs` `mouse_button_code` sends for the main surface. */ static int gtk_button_to_tao(unsigned int gtk_button) { switch (gtk_button) { case 1: return 0; /* LEFT */ case 2: return 2; /* MIDDLE */ case 3: return 1; /* RIGHT */ - default: return (int) gtk_button; + case 8: return 3; /* BACK */ + case 9: return 4; /* FORWARD */ + default: return 5; /* OTHER */ } } @@ -886,8 +1100,13 @@ static gboolean on_input_box_button_press(GtkWidget *widget, void *event_ptr, s_live_event = NULL; /* Focus the box only when Compose kept the press; if it was * forwarded to the embed, the embed grabbed focus and stealing it - * back would send the next keystrokes to Compose instead. */ - if (!s_live_event_forwarded && g.gtk_widget_grab_focus != NULL) { + * back would send the next keystrokes to Compose instead. And only + * when the keyboard is not already Compose's: moving focus from one + * of our boxes to another fires a focus-out that the Kotlin side + * turns into "clear the Compose focus" — a click on a Compose button + * over an embed would deselect the text field beside it. */ + if (!s_live_event_forwarded && g.gtk_widget_grab_focus != NULL && + !is_nucleus_input_box(focus_widget_of(widget))) { g.gtk_widget_grab_focus(widget); } /* TRUE = consume the event. We have already dispatched it to @@ -1100,7 +1319,7 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeMoveIn rect->valid = 1; GtkWidget *overlay = g.gtk_widget_get_parent(box); - if (overlay != NULL) g.gtk_widget_queue_resize(overlay); + if (overlay != NULL) relayout_overlay_now(overlay); } EXPORT void JNICALL @@ -1195,3 +1414,157 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeDispat if (!g.g_type_check_instance_is_a(widget, g.gtk_widget_get_type())) return; forward_live_event(widget, (double) x_logical, (double) y_logical); } + +/* ── Diagnostics for the headful suite ────────────────────────────────── + * + * The test module has no way to fabricate a `GtkWidget*` of its own, and + * a NativeView case needs a real, focusable one: a widget that takes GTK + * keyboard focus on click and shows an I-beam is what the focus and + * cursor races between Compose and an embed happen against. These entry + * points hand out a plain `GtkEntry`, say which widget the GTK window + * currently focuses, and read the entry back — nothing here is used by + * `NativeView` itself. Resolved lazily and optionally, so a GTK without + * one of these symbols still mounts embeds. */ + +typedef GtkWidget *(*PFN_gtk_entry_new)(void); +typedef const char *(*PFN_gtk_entry_get_text)(GtkWidget *entry); +typedef GtkWidget *(*PFN_gtk_window_get_focus)(GtkWindow *window); +typedef gboolean (*PFN_gtk_widget_has_focus)(GtkWidget *widget); +typedef void *(*PFN_g_object_ref_sink)(void *object); +typedef void (*PFN_gtk_widget_get_allocation)(GtkWidget *widget, GdkRectangle *allocation); +typedef gboolean (*PFN_gtk_widget_get_mapped)(GtkWidget *widget); + +static struct { + int resolved; + PFN_gtk_entry_new gtk_entry_new; + PFN_gtk_entry_get_text gtk_entry_get_text; + PFN_gtk_window_get_focus gtk_window_get_focus; + PFN_gtk_widget_has_focus gtk_widget_has_focus; + PFN_g_object_ref_sink g_object_ref_sink; + PFN_gtk_widget_get_allocation gtk_widget_get_allocation; + PFN_gtk_widget_get_mapped gtk_widget_get_mapped; +} diag; + +static int ensure_diag_loaded(void) { + if (!ensure_gtk_loaded()) return 0; + if (diag.resolved) return diag.gtk_entry_new != NULL; + diag.resolved = 1; + const char *gtk_libs[] = { "libgtk-3.so.0", "libgtk-3.so", NULL }; + void *libgtk = load_first(gtk_libs); + if (libgtk == NULL) return 0; + diag.gtk_entry_new = (PFN_gtk_entry_new) dlsym(libgtk, "gtk_entry_new"); + diag.gtk_entry_get_text = (PFN_gtk_entry_get_text) dlsym(libgtk, "gtk_entry_get_text"); + diag.gtk_window_get_focus = (PFN_gtk_window_get_focus) dlsym(libgtk, "gtk_window_get_focus"); + diag.gtk_widget_has_focus = (PFN_gtk_widget_has_focus) dlsym(libgtk, "gtk_widget_has_focus"); + diag.gtk_widget_get_allocation = (PFN_gtk_widget_get_allocation) dlsym(libgtk, "gtk_widget_get_allocation"); + diag.gtk_widget_get_mapped = (PFN_gtk_widget_get_mapped) dlsym(libgtk, "gtk_widget_get_mapped"); + const char *gobj_libs[] = { "libgobject-2.0.so.0", "libgobject-2.0.so", NULL }; + void *libgobj = load_first(gobj_libs); + if (libgobj != NULL) diag.g_object_ref_sink = (PFN_g_object_ref_sink) dlsym(libgobj, "g_object_ref_sink"); + return diag.gtk_entry_new != NULL && diag.g_object_ref_sink != NULL; +} + +/* A fresh, unparented `GtkEntry` — the caller owns it until + * nativeDiagDestroyWidget. Owned the way a well-behaved embedder owns a + * widget it hands to NativeView: `g_object_ref_sink` here, so the + * container's unparent on detach does not finalise it under the app, and + * `g_object_unref` after the destroy. Shown here so the deferred mount in + * nativeSetFrame maps it as soon as it is realised. */ +EXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeDiagCreateEntry( + JNIEnv *env, jclass clazz) +{ + (void) env; (void) clazz; + if (!ensure_diag_loaded()) return 0; + GtkWidget *entry = diag.gtk_entry_new(); + if (entry == NULL) return 0; + diag.g_object_ref_sink(entry); + g.gtk_widget_set_can_focus(entry, GTK_TRUE); + g.gtk_widget_show(entry); + return (jlong) (uintptr_t) entry; +} + +/* Destroys a widget made by nativeDiagCreateEntry. Detaches it first so + * GtkOverlay's child window bookkeeping runs before GTK finalises it. */ +EXPORT void JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeDiagDestroyWidget( + JNIEnv *env, jclass clazz, jlong widget_ptr) +{ + (void) env; (void) clazz; + if (!ensure_gtk_loaded() || widget_ptr == 0) return; + GtkWidget *widget = (GtkWidget *) (uintptr_t) widget_ptr; + if (!g.g_type_check_instance_is_a(widget, g.gtk_widget_get_type())) return; + GtkWidget *parent = g.gtk_widget_get_parent(widget); + if (parent != NULL) g.gtk_container_remove((GtkContainer *) parent, widget); + g.gtk_widget_destroy(widget); + g.g_object_unref(widget); +} + +/* The widget the GTK window routes key events to, as a pointer, or 0 + * when nothing in the window has focus. A NativeView case compares it + * against its entry and against nothing — after a click on Compose the + * focus must sit on an input box (or nowhere), never on the embed. */ +EXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeDiagFocusWidget( + JNIEnv *env, jclass clazz, jlong gtk_window_ptr) +{ + (void) env; (void) clazz; + if (!ensure_diag_loaded() || diag.gtk_window_get_focus == NULL) return 0; + if (gtk_window_ptr == 0) return 0; + GtkWidget *focus = diag.gtk_window_get_focus((GtkWindow *) (uintptr_t) gtk_window_ptr); + return (jlong) (uintptr_t) focus; +} + +/* Whether [widget_ptr] itself has GTK focus (its toplevel need not be active). */ +EXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeDiagWidgetHasFocus( + JNIEnv *env, jclass clazz, jlong widget_ptr) +{ + (void) env; (void) clazz; + if (!ensure_diag_loaded() || diag.gtk_widget_has_focus == NULL) return JNI_FALSE; + if (widget_ptr == 0) return JNI_FALSE; + return diag.gtk_widget_has_focus((GtkWidget *) (uintptr_t) widget_ptr) ? JNI_TRUE : JNI_FALSE; +} + +/* The text typed into an entry made by nativeDiagCreateEntry — proves + * that keystrokes reached the embed (or did not) after a focus change. */ +EXPORT jstring JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeDiagEntryText( + JNIEnv *env, jclass clazz, jlong widget_ptr) +{ + (void) clazz; + if (!ensure_diag_loaded() || diag.gtk_entry_get_text == NULL) return NULL; + if (widget_ptr == 0) return NULL; + const char *text = diag.gtk_entry_get_text((GtkWidget *) (uintptr_t) widget_ptr); + return text != NULL ? (*env)->NewStringUTF(env, text) : NULL; +} + +/* Where the probe actually sits: its allocation translated into the + * coordinates of Tao's content box (the widget Compose's origin maps to), + * in logical pixels, as `[x, y, w, h]` — or null while it is not mapped. + * A resize case compares this against the Compose slot to measure how far + * the embed trails the layout. */ +EXPORT jintArray JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxWidgetBridge_nativeDiagWidgetFrame( + JNIEnv *env, jclass clazz, jlong gtk_window_ptr, jlong widget_ptr) +{ + (void) clazz; + if (!ensure_diag_loaded() || diag.gtk_widget_get_allocation == NULL || diag.gtk_widget_get_mapped == NULL) { + return NULL; + } + if (gtk_window_ptr == 0 || widget_ptr == 0) return NULL; + GtkWidget *widget = (GtkWidget *) (uintptr_t) widget_ptr; + if (!g.g_type_check_instance_is_a(widget, g.gtk_widget_get_type())) return NULL; + if (!diag.gtk_widget_get_mapped(widget)) return NULL; + GtkWidget *content = g.gtk_bin_get_child((GtkWidget *) (uintptr_t) gtk_window_ptr); + if (content == NULL) return NULL; + GdkRectangle allocation; + diag.gtk_widget_get_allocation(widget, &allocation); + int x = 0, y = 0; + if (!g.gtk_widget_translate_coordinates(widget, content, 0, 0, &x, &y)) return NULL; + jint out[4] = { x, y, allocation.width, allocation.height }; + jintArray result = (*env)->NewIntArray(env, 4); + if (result == NULL) return NULL; + (*env)->SetIntArrayRegion(env, result, 0, 4, out); + return result; +} diff --git a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m index 1bb0bdfb2..b3ed40c15 100644 --- a/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m +++ b/decorated-window-tao/src/main/native/macos/NucleusTaoMetal.m @@ -19,12 +19,14 @@ #import #import #import +#import #import #import #include #include #include #import +#include "../../../../../native-common/nucleus_jni.h" // Diagnostic logging for the title-bar / fullscreen / menu-bar paths. Off by // default (no-op) so production apps stay silent; opt in by launching with @@ -213,9 +215,7 @@ static void notifyMenuBarOffsetChanged(jlong nsViewPtr, float offset) { (*env)->CallStaticVoidMethod(env, sMetalBridgeClass, sMetalOnOffsetChanged, nsViewPtr, (jfloat)offset); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } // Calls NativeMetalBridge.onFullscreenPrepare(nsViewPtr, widthPx, heightPx) @@ -242,10 +242,7 @@ static void notifyFullscreenPrepare(jlong nsViewPtr, jint widthPx, jint heightPx (*env)->CallStaticVoidMethod(env, sMetalBridgeClass, sMetalOnFullscreenPrepare, nsViewPtr, widthPx, heightPx); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } static void reinstallToolbarIfNeeded(NSWindow *window) { @@ -2280,29 +2277,37 @@ - (NSView *)hitTest:(NSPoint)point { att->layer.contentsScale = scale; att->layer.drawableSize = CGSizeMake(widthPx, heightPx); att->layer.frame = att->view.bounds; - // During an interactive live-resize the present always lags the - // bounds by one frame (the Resized event is queued and processed on - // a later runloop turn than the AppKit layout commit). With the - // default `kCAGravityResize`, Core Animation stretches the stale - // last drawable to the new — oscillating — bounds, which reads as - // the whole window trembling when the pointer circles a corner. - // Instead anchor the stale drawable to the window's *fixed* corner - // for the duration of the drag so it stops rubber-banding around - // the layer centre; the render thread still presents crisp frames - // at the new size, and `kCAGravityResize` is restored on drag end. - // The fixed corner is inferred from the NSWindow frame origin delta - // (macOS reports the origin at the bottom-left corner): - // origin.x unchanged -> left edge fixed (else right edge fixed) - // origin.y unchanged -> bottom edge fixed (else top edge fixed) - NSString *gravity = kCAGravityResize; + // Outside a programmatic resize (presented in the same turn by the + // scene host, #576) the present lags the bounds by one frame: the + // Resized event is processed on a later runloop turn than the + // AppKit layout commit. With the default `kCAGravityResize`, Core + // Animation stretches the stale last drawable to the new bounds — + // oscillating under a pointer circling a corner, growing step by + // step under the zoom animation a title-bar double-click starts — + // which reads as the whole content trembling. So never stretch: + // anchor the stale drawable to a corner and let the crisp frame at + // the new size land a frame later, the exposed band showing the + // window's own background colour meanwhile. + // - interactive live-resize: the window's *fixed* corner, so the + // content holds still on screen instead of rubber-banding + // around the layer centre. Inferred from the NSWindow frame + // origin delta (macOS reports the origin at the bottom-left): + // origin.x unchanged -> left edge fixed (else right edge) + // origin.y unchanged -> bottom edge fixed (else top edge) + // - anything else (zoom / animator frame changes, #576): the + // top-left, where the next frame lays its content out anyway. + // At rest contents and bounds agree, so the anchor is invisible. + NSString *gravity = kCAGravityTopLeft; NSWindow *win = att->view.window; // Never anchor during an AppKit fullscreen transition: the #327 // snapshot ramp depends on Resize gravity for the whole animation, // and AppKit may report inLiveResize while it animates the frame. - BOOL liveResize = att->view.inLiveResize && - atomic_load(&att->in_transition) == 0; - if (liveResize && win != nil && - !isnan(att->prev_origin_x) && !isnan(att->prev_origin_y)) { + BOOL inTransition = atomic_load(&att->in_transition) != 0; + BOOL liveResize = att->view.inLiveResize && !inTransition; + if (inTransition) { + gravity = kCAGravityResize; + } else if (liveResize && win != nil && + !isnan(att->prev_origin_x) && !isnan(att->prev_origin_y)) { NSRect fr = win.frame; BOOL leftFixed = fabs(fr.origin.x - att->prev_origin_x) < 0.5; BOOL bottomFixed = fabs(fr.origin.y - att->prev_origin_y) < 0.5; @@ -2311,11 +2316,6 @@ - (NSView *)hitTest:(NSPoint)point { } else { gravity = bottomFixed ? kCAGravityBottomRight : kCAGravityTopRight; } - } else if (liveResize) { - // First tick of the drag: no prior origin to diff against. Pin - // top-left — the common bottom/right case — and let the next - // tick self-correct to the proper fixed corner. - gravity = kCAGravityTopLeft; } att->layer.contentsGravity = gravity; if (win != nil) { @@ -2602,10 +2602,7 @@ static void ensureInteropModeSource(void) { } if (sRunMethod != NULL) { (*menv)->CallVoidMethod(menv, interopGlobal, sRunMethod); - if ((*menv)->ExceptionCheck(menv)) { - (*menv)->ExceptionDescribe(menv); - (*menv)->ExceptionClear(menv); - } + nucleus_jni_clear_exception(menv); } (*menv)->DeleteGlobalRef(menv, interopGlobal); } @@ -2873,6 +2870,19 @@ static void ensureInteropModeSource(void) { return packed; } +/* Gate of the nativeDiagInject* entries: they DRIVE the app, so they are + * inert unless the process was started with NUCLEUS_TAO_INPUT_INJECTION=1 + * (the taoHeadfulTest Gradle task sets it). Main thread only, so the lazy + * flag needs no atomics. */ +static BOOL taoInputInjectionEnabled(void) { + static int sEnabled = -1; + if (sEnabled < 0) { + const char *flag = getenv("NUCLEUS_TAO_INPUT_INJECTION"); + sEnabled = (flag != NULL && strcmp(flag, "1") == 0) ? 1 : 0; + } + return sEnabled == 1; +} + /* macOS only, headful e2e (#652 / #653 / #654): hands a synthetic * `scrollWheel:` NSEvent to the tao NSView passed in — the entry point a real * trackpad or wheel event takes once the WindowServer has routed it. Skipping @@ -2911,13 +2921,7 @@ static void ensureInteropModeSource(void) { jint phase, jint momentumPhase) { (void)env; (void)clazz; if (![NSThread isMainThread] || nsViewPtr == 0) return JNI_FALSE; - // Main thread only from here on, so the lazy flag needs no atomics. - static int sEnabled = -1; - if (sEnabled < 0) { - const char *flag = getenv("NUCLEUS_TAO_INPUT_INJECTION"); - sEnabled = (flag != NULL && strcmp(flag, "1") == 0) ? 1 : 0; - } - if (!sEnabled) return JNI_FALSE; + if (!taoInputInjectionEnabled()) return JNI_FALSE; NSView *view = (__bridge NSView *)(void *)(uintptr_t)nsViewPtr; NSWindow *window = view.window; NSScreen *primary = NSScreen.screens.firstObject; @@ -2943,6 +2947,79 @@ static void ensureInteropModeSource(void) { return JNI_TRUE; } +/* macOS only, headful e2e (#660): queues a synthetic magnify / rotate / + * smart-magnify NSEvent with `-[NSApplication postEvent:atStart:]`, so the + * local monitor in touchpad_gestures.m sees it exactly as it sees a real + * trackpad gesture — no WindowServer, Accessibility grant or cursor position + * needed. Posted, never sent: the caller runs inside tao's event callback, + * and a synchronous `sendEvent:` re-enters that callback from the monitor + * (the loop's callback lock is held — deadlock). + * + * The event is a CGEvent of the WindowServer's gesture type (29) that + * `+[NSEvent eventWithCGEvent:]` decodes (verified on macOS 26): + * field 110 gesture HID type: 8 zoom → NSEventTypeMagnify, + * 5 rotation → NSEventTypeRotate, 22 → NSEventTypeSmartMagnify + * field 113 zoom value → `magnification` + * field 114 rotation value (degrees) → `rotation` + * field 132 phase, IOHID encoding: 1 began, 2 changed, 4 ended, 8 cancelled + * field 51 window number → `window` + * A CGEvent-built NSEvent has no window unless field 51 is set, and with a + * window its `locationInWindow` comes from the event's window location (top- + * left origin, window frame), which only the private + * `CGEventSetWindowLocation` writes — resolved with dlsym so a missing symbol + * fails the injection instead of the load. + * + * kind: 0 magnify, 1 rotate, 2 smart-magnify (the touchpad_gestures.m wire). + * (x, y) are view-local points with a top-left origin. `value` is the + * magnification delta or the rotation in degrees (ignored for smart-magnify). + * + * Same gate as nativeDiagInjectScrollWheel. Returns JNI true once the event + * is queued; events posted in order are delivered in order. */ +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeMetalBridge_nativeDiagInjectTrackpadGesture( + JNIEnv *env, jclass clazz, jlong nsViewPtr, + jint kind, jint phase, jfloat x, jfloat y, jdouble value) { + (void)env; (void)clazz; + if (![NSThread isMainThread] || nsViewPtr == 0) return JNI_FALSE; + if (!taoInputInjectionEnabled()) return JNI_FALSE; + typedef void (*SetWindowLocationFn)(CGEventRef, CGPoint); + static SetWindowLocationFn sSetWindowLocation = NULL; + static BOOL sResolved = NO; + if (!sResolved) { + sResolved = YES; + sSetWindowLocation = (SetWindowLocationFn) dlsym(RTLD_DEFAULT, "CGEventSetWindowLocation"); + } + if (sSetWindowLocation == NULL) return JNI_FALSE; + int64_t hidType; + CGEventField valueField = 0; + switch (kind) { + case 0: hidType = 8; valueField = (CGEventField) 113; break; + case 1: hidType = 5; valueField = (CGEventField) 114; break; + case 2: hidType = 22; break; + default: return JNI_FALSE; + } + NSView *view = (__bridge NSView *)(void *)(uintptr_t)nsViewPtr; + NSWindow *window = view.window; + if (window == nil) return JNI_FALSE; + // View-local top-left → window base (bottom-left) → window top-left. + NSPoint local = NSMakePoint(x, view.isFlipped ? y : view.bounds.size.height - y); + NSPoint inWindow = [view convertPoint:local toView:nil]; + CGPoint windowTopLeft = CGPointMake(inWindow.x, window.frame.size.height - inWindow.y); + CGEventRef cg = CGEventCreate(NULL); + if (cg == NULL) return JNI_FALSE; + CGEventSetType(cg, (CGEventType) 29); + CGEventSetIntegerValueField(cg, (CGEventField) 110, hidType); + if (valueField != 0) CGEventSetDoubleValueField(cg, valueField, value); + if (phase != 0) CGEventSetIntegerValueField(cg, (CGEventField) 132, phase); + CGEventSetIntegerValueField(cg, (CGEventField) 51, window.windowNumber); + sSetWindowLocation(cg, windowTopLeft); + NSEvent *event = [NSEvent eventWithCGEvent:cg]; + CFRelease(cg); + if (event == nil || event.window != window) return JNI_FALSE; + [NSApp postEvent:event atStart:NO]; + return JNI_TRUE; +} + /* CFGetRetainCount of view.window. Only deltas are meaningful (AppKit holds * its own references); the set_focusable leak regression compares the count * before/after a burst of calls. Returns -1 when view/window is gone. */ diff --git a/decorated-window-tao/src/main/native/macos/decoration.m b/decorated-window-tao/src/main/native/macos/decoration.m index feedafd6f..65fbe4a25 100644 --- a/decorated-window-tao/src/main/native/macos/decoration.m +++ b/decorated-window-tao/src/main/native/macos/decoration.m @@ -14,11 +14,16 @@ // - nativeGetWindowRect: returns the NSWindow's outer frame in physical // pixels using a top-left origin (matching Win32 `GetWindowRect`), so the // Kotlin centring math is the same on every platform. +// - nativeGetContentRect: same convention, but for the view's own rect on +// screen — the origin window-rooted Compose coordinates are relative to, +// which the #569 popup screen clamp converts through. // - nativeGetPrimaryMonitorWorkArea: returns NSScreen.visibleFrame for the // primary screen, in physical pixels with top-left origin (matches the // Windows `SystemParametersInfo(SPI_GETWORKAREA)` shape). // - nativeGetPrimaryMonitorScaleMilli: backingScaleFactor of the primary // screen as `(scale * 1000)`. +// - nativeGetMonitors: one tab-separated descriptor per NSScreen (id, name, +// frame, visibleFrame, scale, primary flag) for the multi-monitor API. // - nativeSetHiddenFromDock: hides/shows the app's Dock icon by switching the // shared NSApplication's activation policy (app-wide, macOS only). // @@ -133,6 +138,10 @@ static jlongArray make_rect_array(JNIEnv *env, NSRect r, CGFloat scale) { } [owner addChildWindow:child ordered:NSWindowAbove]; + // Re-stack explicitly: a zoom / fullscreen transition re-orders the owner + // and can leave an already-attached child behind it; `addChildWindow:` + // on its own does not always move a visible child back above. + [child orderWindow:NSWindowAbove relativeTo:owner.windowNumber]; } JNIEXPORT jlongArray JNICALL @@ -149,6 +158,32 @@ static jlongArray make_rect_array(JNIEnv *env, NSRect r, CGFloat scale) { return make_rect_array(env, topLeft, window.backingScaleFactor); } +/* Returns the *content* rect of the view's window — the rect window-rooted + * Compose coordinates are relative to — as `[x, y, width, height]` in physical + * pixels with a top-left origin, i.e. the same space nativeGetWindowRect and + * nativeGetMonitors report in. + * + * Distinct from nativeGetWindowRect: a window with a native title bar has its + * content origin below the frame origin, and the #569 popup screen clamp is + * only as accurate as this offset. `convertRectToScreen:` is asked for the + * view's own bounds rather than the window's contentLayoutRect so a nested + * overlay view answers for itself. */ +JNIEXPORT jlongArray JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsDecoBridge_nativeGetContentRect( + JNIEnv *env, jclass clazz, jlong nsViewLong) +{ + (void)clazz; + if (!nsViewLong) return NULL; + NSView *view = (__bridge NSView *)(void *)(uintptr_t)nsViewLong; + NSWindow *window = view.window; + if (!window) return NULL; + + NSRect inWindow = [view convertRect:view.bounds toView:nil]; + NSRect onScreen = [window convertRectToScreen:inWindow]; + NSRect topLeft = to_top_left_rect(onScreen); + return make_rect_array(env, topLeft, window.backingScaleFactor); +} + JNIEXPORT jlongArray JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsDecoBridge_nativeGetPrimaryMonitorWorkArea( JNIEnv *env, jclass clazz) @@ -161,6 +196,67 @@ static jlongArray make_rect_array(JNIEnv *env, NSRect r, CGFloat scale) { return make_rect_array(env, topLeft, screen.backingScaleFactor); } +/* Returns one tab-separated descriptor per screen, in `[NSScreen screens]` + * order (index 0 is the primary): + * id \t name \t x \t y \t w \t h \t workX \t workY \t workW \t workH + * \t scaleMilli \t primary + * Geometry is physical pixels with a top-left origin — same convention as + * nativeGetPrimaryMonitorWorkArea, so the JVM side needs no per-platform math. + * `id` is derived from the CGDirectDisplayID, which is stable for as long as + * the display stays attached. */ +JNIEXPORT jobjectArray JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsDecoBridge_nativeGetMonitors( + JNIEnv *env, jclass clazz) +{ + (void)clazz; + NSArray *screens = [NSScreen screens]; + if (screens.count == 0) return NULL; + + jclass stringClass = (*env)->FindClass(env, "java/lang/String"); + if (!stringClass) return NULL; + jobjectArray arr = + (*env)->NewObjectArray(env, (jsize)screens.count, stringClass, NULL); + if (!arr) return NULL; + + for (NSUInteger i = 0; i < screens.count; i++) { + NSScreen *screen = screens[i]; + CGFloat scale = screen.backingScaleFactor; + if (scale <= 0) scale = 1.0; + + NSRect bounds = to_top_left_rect(screen.frame); + NSRect work = to_top_left_rect(screen.visibleFrame); + + NSNumber *displayId = screen.deviceDescription[@"NSScreenNumber"]; + NSString *identifier = displayId + ? [NSString stringWithFormat:@"display-%u", displayId.unsignedIntValue] + : [NSString stringWithFormat:@"screen-%lu", (unsigned long)i]; + NSString *name = screen.localizedName.length > 0 + ? screen.localizedName + : identifier; + + NSString *row = [NSString stringWithFormat: + @"%@\t%@\t%ld\t%ld\t%ld\t%ld\t%ld\t%ld\t%ld\t%ld\t%ld\t%d", + [identifier stringByReplacingOccurrencesOfString:@"\t" withString:@" "], + [name stringByReplacingOccurrencesOfString:@"\t" withString:@" "], + (long)llround(bounds.origin.x * scale), + (long)llround(bounds.origin.y * scale), + (long)llround(bounds.size.width * scale), + (long)llround(bounds.size.height * scale), + (long)llround(work.origin.x * scale), + (long)llround(work.origin.y * scale), + (long)llround(work.size.width * scale), + (long)llround(work.size.height * scale), + (long)llround(scale * 1000.0), + (i == 0) ? 1 : 0]; + + jstring jrow = (*env)->NewStringUTF(env, row.UTF8String); + if (!jrow) return NULL; + (*env)->SetObjectArrayElement(env, arr, (jsize)i, jrow); + (*env)->DeleteLocalRef(env, jrow); + } + return arr; +} + JNIEXPORT jint JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsDecoBridge_nativeGetPrimaryMonitorScaleMilli( JNIEnv *env, jclass clazz) diff --git a/decorated-window-tao/src/main/native/macos/dnd.m b/decorated-window-tao/src/main/native/macos/dnd.m index 51ca6ffda..503776afc 100644 --- a/decorated-window-tao/src/main/native/macos/dnd.m +++ b/decorated-window-tao/src/main/native/macos/dnd.m @@ -25,6 +25,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -208,9 +209,7 @@ static NSDragOperation nucleus_draggingEntered(id self, SEL _cmd, idCallIntMethod(env, st.callbackRef, g_method_on_enter, (jlong)(intptr_t)view, x, y, (jint)0, JNI_TRUE); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { effect = DROP_EFFECT_NONE; } } @@ -235,9 +234,7 @@ static NSDragOperation nucleus_draggingUpdated(id self, SEL _cmd, idCallIntMethod(env, st.callbackRef, g_method_on_over, (jlong)(intptr_t)view, x, y, (jint)0, JNI_TRUE); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { effect = DROP_EFFECT_NONE; } } @@ -257,10 +254,7 @@ static void nucleus_draggingExited(id self, SEL _cmd, id sender) if (env && g_method_on_leave) { (*env)->CallVoidMethod(env, st.callbackRef, g_method_on_leave, (jlong)(intptr_t)view); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } detach_if_needed(attached); } @@ -290,9 +284,7 @@ static BOOL nucleus_performDragOperation(id self, SEL _cmd, id s if (g_method_on_drop) { effect = (*env)->CallIntMethod(env, st.callbackRef, g_method_on_drop, (jlong)(intptr_t)view, x, y, (jint)0, files); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { effect = DROP_EFFECT_NONE; } } @@ -454,7 +446,7 @@ static void drag_pump_resolve(JNIEnv *env, jobject pump, NucleusDragPump *out) { if (out->method) out->ref = pump; /* Optional: a failure here only costs the host its frames during the drag, * so keep the session going. */ - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* CFRunLoopTimerCallBack. Fires on the main thread for as long as the timer is @@ -465,12 +457,10 @@ static void drag_pump_tick(CFRunLoopTimerRef timer, void *info) { if (!p || !p->ref || !p->method) return; JNIEnv *env = p->env; (*env)->CallVoidMethod(env, p->ref, p->method); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { /* Whatever broke (Metal layer, Skia recording) will break again on the * next tick, and we tick ~120×/s — latch the pump off so one failure - * reports once instead of flooding stderr. The drag degrades to the old + * reports once instead of flooding logs. The drag degrades to the old * frozen-but-quiet behaviour and still completes normally. */ p->method = NULL; } diff --git a/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m b/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m index 6276a2e75..7ba80f680 100644 --- a/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m +++ b/decorated-window-tao/src/main/native/macos/main_thread_dispatch.m @@ -44,7 +44,7 @@ int nucleus_tao_is_main_thread(void) { return [NSThread isMainThread] ? 1 : 0; } -extern void nucleus_tao_post_exit(void); +extern bool nucleus_tao_post_quit_requested(void); static id sCmdQMonitor = nil; @@ -55,7 +55,7 @@ void nucleus_tao_install_cmd_q_handler(void) { NSEventModifierFlags mods = event.modifierFlags & NSEventModifierFlagDeviceIndependentFlagsMask; if ((mods & NSEventModifierFlagCommand) && [event.charactersIgnoringModifiers isEqualToString:@"q"]) { - nucleus_tao_post_exit(); + nucleus_tao_post_quit_requested(); return nil; } return event; @@ -77,8 +77,14 @@ void nucleus_tao_install_cmd_q_handler(void) { // ── IME caret rect plumbing (used by `firstRectForCharacterRange:` swizzle) ── // // Stored in screen coords (Cocoa bottom-up Y) so the swizzled getter can hand -// it back unchanged. Updated from the JVM side via `nativeSetImeRect`. - +// it back unchanged. Updated from the JVM side via `nativeSetImeRect`, and +// scoped to the view that pushed it: the rect is an *insertion point*, so it +// only exists while that view hosts a live text-input session. A rect kept +// past the session anchors AppKit's input-source indicator — the badge a +// Caps Lock bound to "switch input source" raises — over the caret of a field +// that no longer exists. With no rect, AppKit leaves the badge off. + +static _Atomic int64_t g_ime_rect_view = 0; static _Atomic CGFloat g_ime_screen_x = 0; static _Atomic CGFloat g_ime_screen_y = 0; static _Atomic CGFloat g_ime_w = 1; @@ -87,10 +93,13 @@ void nucleus_tao_install_cmd_q_handler(void) { static NSRect tao_view_first_rect_for_character_range( id self, SEL _cmd, NSRange range, NSRangePointer actual_range ) { - (void)self; (void)_cmd; (void)range; + (void)_cmd; (void)range; if (actual_range) { *actual_range = range; } + if (atomic_load(&g_ime_rect_view) != (int64_t)(intptr_t)(__bridge void *)self) { + return NSZeroRect; + } return NSMakeRect(g_ime_screen_x, g_ime_screen_y, g_ime_w, g_ime_h); } @@ -355,13 +364,60 @@ static void nucleus_tao_swizzle_view_methods_once(void) { }); } -void nucleus_tao_activate_input_context(long ns_view_handle) { +/// Installs the `NSTextInputClient` overrides on TaoView. Called once per +/// window creation (the class only exists once a window has been built), not +/// only when a text-input session starts: tao's own +/// `firstRectForCharacterRange:` answers the window corner with a *top-down* +/// y read back as a Cocoa coordinate, which parks the input-source indicator +/// in the bottom-left corner of an app that has never shown a text field. +/// Ours answers `NSZeroRect` until a session publishes a caret, and that is +/// the one shape AppKit reads as "no insertion point". +void nucleus_tao_install_ime_client_overrides(void) { + nucleus_tao_swizzle_view_methods_once(); +} + +// Session tokens for the input-context activation. `g_ime_token_seq` never +// repeats a value, so a token identifies one text-input session for the whole +// process lifetime; `g_ime_active_token` is the live one (0 = none). +static _Atomic int64_t g_ime_token_seq = 0; +static _Atomic int64_t g_ime_active_token = 0; + +int64_t nucleus_tao_activate_input_context(long ns_view_handle) { nucleus_tao_swizzle_view_methods_once(); NSView *view = (__bridge NSView *)(void *)ns_view_handle; NSTextInputContext *ctx = view.inputContext; if (ctx) { [ctx activate]; } + int64_t token = atomic_fetch_add(&g_ime_token_seq, 1) + 1; + atomic_store(&g_ime_active_token, token); + return token; +} + +/// Ends the session [token] identifies: deactivates TaoView's input context +/// and drops the cached caret rect. Deactivating is what takes the focused +/// field's insertion point off AppKit's books — a still-active context keeps +/// the input-source indicator (Caps Lock layout switching) anchored to it. +/// +/// [ns_view_handle] is 0 when the window is already gone; the cached state is +/// still dropped, only the AppKit call is skipped. +void nucleus_tao_deactivate_input_context(long ns_view_handle, int64_t token) { + // Focus moving between fields (or windows) starts the incoming session + // *before* the outgoing one is torn down, so only the newest activation + // may be undone — same ordering trap as the document cache above. + if (token == 0 || token != atomic_load(&g_ime_active_token)) { + return; + } + atomic_store(&g_ime_active_token, 0); + atomic_store(&g_ime_rect_view, 0); + if (ns_view_handle == 0) { + return; + } + NSView *view = (__bridge NSView *)(void *)ns_view_handle; + NSTextInputContext *ctx = view.inputContext; + if (ctx) { + [ctx deactivate]; + } } static NSCursor *nucleus_tao_cursor_from_selector(NSString *selectorName) { @@ -394,6 +450,8 @@ void nucleus_tao_activate_input_context(long ns_view_handle) { return cursor ?: [NSCursor arrowCursor]; } case 9: return [NSCursor resizeLeftRightCursor]; + case 13: return [NSCursor openHandCursor]; + case 14: return [NSCursor closedHandCursor]; case 10: return [NSCursor resizeUpDownCursor]; case 11: { NSCursor *cursor = nucleus_tao_cursor_from_selector( @@ -440,4 +498,5 @@ void nucleus_tao_set_ime_local_rect(long ns_view_handle, atomic_store(&g_ime_screen_y, rectOnScreen.origin.y); atomic_store(&g_ime_w, rectOnScreen.size.width > 0 ? rectOnScreen.size.width : 1); atomic_store(&g_ime_h, rectOnScreen.size.height > 0 ? rectOnScreen.size.height : 18); + atomic_store(&g_ime_rect_view, (int64_t)ns_view_handle); } diff --git a/decorated-window-tao/src/main/native/macos/native_view.m b/decorated-window-tao/src/main/native/macos/native_view.m index 02fdc0a01..3666d439f 100644 --- a/decorated-window-tao/src/main/native/macos/native_view.m +++ b/decorated-window-tao/src/main/native/macos/native_view.m @@ -32,6 +32,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -167,7 +168,7 @@ - (void)dispatchPointer:(NSEvent *)event type:(jint)type button:(jint)button { jfloat x, y; [self pixelsForEvent:event outX:&x outY:&y]; (*env)->CallVoidMethod(env, cb, sOnPointerMethod, type, x, y, button, [self modifierMaskFor:event]); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* On click, become first responder of the host NSWindow so subsequent @@ -190,7 +191,7 @@ - (BOOL)resignFirstResponder { JNIEnv *env = attachThread(); if (env != NULL) { (*env)->CallVoidMethod(env, cb, sOnResignMethod); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } } } @@ -211,7 +212,7 @@ - (void)scrollWheel:(NSEvent *)event { [self pixelsForEvent:event outX:&x outY:&y]; (*env)->CallVoidMethod(env, cb, sOnScrollMethod, x, y, (jfloat)event.scrollingDeltaX, (jfloat)event.scrollingDeltaY); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* Deliberately NOT overriding `keyDown:` / `keyUp:`. AppKit's @@ -412,6 +413,18 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat pressure:1.0]; } +/* NSControl.mouseDown: and NSTextView.mouseDown: run + * trackMouse:untilMouseUp: (or a selection loop) and do not return + * until an AppKit mouse-up is dequeued. Compose pointer dispatch is + * on the Tao main thread; the matching up is the *next* event we have + * not delivered yet. Calling those selectors from here stalls the loop + * forever on a synthetic press, and on a live one steals the up + * Compose still needs to see. A right-click handler may also pop an + * NSMenu, which is the same kind of nested modal loop. */ +static BOOL view_runs_mouse_tracking(NSView *hit) { + return [hit isKindOfClass:[NSControl class]] || [hit isKindOfClass:[NSTextView class]]; +} + /* [type] 1 = down, 2 = up, 3 = move. [button] 0 none, 1 primary, 2 secondary. * [pressed] is the Compose pointer-down state (move + pressed → dragged). */ JNIEXPORT void JNICALL @@ -426,7 +439,12 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat if (content == nil || child == nil) return; NSPoint windowPoint = window_point_from_compose_px(content, xPx, yPx); NSView *hit = hit_native_child(child, windowPoint); - if (hit == nil) return; + if (hit == nil) { + // Press on the slot before the child has a hit-testable frame: + // first-responder is still enough for typing. + if (type == 1) [child.window makeFirstResponder:child]; + return; + } NSEventType nsType; if (type == 1) { @@ -438,20 +456,24 @@ static NSPoint window_point_from_compose_px(NSView *content, jfloat xPx, jfloat } else { nsType = NSEventTypeMouseMoved; } + if (type == 1) { + [hit.window makeFirstResponder:hit]; + if (view_runs_mouse_tracking(hit) || nsType == NSEventTypeRightMouseDown) return; + } else if (view_runs_mouse_tracking(hit) || + nsType == NSEventTypeRightMouseUp || + nsType == NSEventTypeRightMouseDragged) { + return; + } NSEvent *current = NSApp.currentEvent; NSEvent *event = (current != nil && current.type == nsType) ? current : mouse_event_at(hit, nsType, windowPoint, type == 1 ? 1 : 0); if (type == 1) { - [hit.window makeFirstResponder:hit]; - if (nsType == NSEventTypeRightMouseDown) [hit rightMouseDown:event]; - else [hit mouseDown:event]; + [hit mouseDown:event]; } else if (type == 2) { - if (nsType == NSEventTypeRightMouseUp) [hit rightMouseUp:event]; - else [hit mouseUp:event]; + [hit mouseUp:event]; } else if (pressed == JNI_TRUE) { - if (nsType == NSEventTypeRightMouseDragged) [hit rightMouseDragged:event]; - else [hit mouseDragged:event]; + [hit mouseDragged:event]; } else { [hit mouseMoved:event]; } @@ -613,6 +635,103 @@ static void nvNoteDelivered(jint phase, BOOL terminal) { [content.window makeFirstResponder:content]; } +/* Whether [first] is some view other than the Tao content view — an + * embed, or the field editor working on one. Keys then belong to AppKit, + * not Compose. */ +static BOOL first_responder_is_embed(NSView *content) { + if (content == nil || content.window == nil) return NO; + NSResponder *first = content.window.firstResponder; + if (first == nil || first == content) return NO; + if ([first isKindOfClass:[NSTextView class]]) { + NSTextView *editor = (NSTextView *)first; + if (editor.isFieldEditor) return YES; + } + return [first isKindOfClass:[NSView class]] && ((NSView *)first).window == content.window; +} + +/* Carbon HIToolbox virtual key codes (Events.h). Used only to synthesise + * a caret-key NSEvent onto the first responder; we do not link Carbon. */ +#define NUCLEUS_VK_LEFT_ARROW 0x7B +#define NUCLEUS_VK_RIGHT_ARROW 0x7C +#define NUCLEUS_VK_DOWN_ARROW 0x7D +#define NUCLEUS_VK_UP_ARROW 0x7E +#define NUCLEUS_VK_DELETE 0x33 +#define NUCLEUS_VK_RETURN 0x24 +#define NUCLEUS_VK_FORWARD_DEL 0x75 +#define NUCLEUS_VK_TAB 0x30 +#define NUCLEUS_VK_ESCAPE 0x35 + +/* AWT VK_* → Carbon kVK_* for the caret / editing keys the host forwards + * when Compose did not consume them and an embed holds first responder. */ +static unsigned short carbon_key_code_for_awt(jint vkCode) { + switch (vkCode) { + case 37: return NUCLEUS_VK_LEFT_ARROW; /* VK_LEFT */ + case 39: return NUCLEUS_VK_RIGHT_ARROW; /* VK_RIGHT */ + case 40: return NUCLEUS_VK_DOWN_ARROW; /* VK_DOWN */ + case 38: return NUCLEUS_VK_UP_ARROW; /* VK_UP */ + case 8: return NUCLEUS_VK_DELETE; /* VK_BACK_SPACE */ + case 10: return NUCLEUS_VK_RETURN; /* VK_ENTER */ + case 127: return NUCLEUS_VK_FORWARD_DEL; /* VK_DELETE */ + case 9: return NUCLEUS_VK_TAB; /* VK_TAB */ + case 27: return NUCLEUS_VK_ESCAPE; /* VK_ESCAPE */ + default: return 0xFFFF; + } +} + +/* Tao KEY_DOWN / KEY_UP / KEY_TYPED onto the current first responder when + * that responder is an embed. Synthetic Compose keys never enter AppKit's + * responder chain (they are posted into the Tao window), so without this + * an NSTextField that holds first responder never sees a letter typed + * through the in-process driver. Returns JNI_TRUE when the embed took it. */ +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDispatchKeyToFirstResponder( + JNIEnv *env, jclass clazz, + jlong contentPtr, jint type, jint vkCode, jint codePoint) +{ + (void)env; (void)clazz; + NSView *content = view_from_long(contentPtr); + if (!first_responder_is_embed(content)) return JNI_FALSE; + NSResponder *first = content.window.firstResponder; + const jint kTaoKeyDown = 14; + const jint kTaoKeyUp = 15; + const jint kTaoKeyTyped = 19; + if (type == kTaoKeyTyped) { + if (codePoint <= 0) return JNI_FALSE; + unichar ch = (unichar)codePoint; + NSString *text = [NSString stringWithCharacters:&ch length:1]; + if ([first conformsToProtocol:@protocol(NSTextInputClient)]) { + [(id)first insertText:text + replacementRange:NSMakeRange(NSNotFound, 0)]; + return JNI_TRUE; + } + if ([first isKindOfClass:[NSTextField class]]) { + NSTextField *field = (NSTextField *)first; + NSString *current = field.stringValue ?: @""; + field.stringValue = [current stringByAppendingString:text]; + return JNI_TRUE; + } + return JNI_FALSE; + } + if (type != kTaoKeyDown && type != kTaoKeyUp) return JNI_FALSE; + unsigned short keyCode = carbon_key_code_for_awt(vkCode); + if (keyCode == 0xFFFF) return JNI_FALSE; + NSEventType nsType = (type == kTaoKeyDown) ? NSEventTypeKeyDown : NSEventTypeKeyUp; + NSEvent *event = [NSEvent keyEventWithType:nsType + location:NSZeroPoint + modifierFlags:0 + timestamp:[NSProcessInfo processInfo].systemUptime + windowNumber:content.window.windowNumber + context:nil + characters:@"" + charactersIgnoringModifiers:@"" + isARepeat:NO + keyCode:keyCode]; + if (event == nil) return JNI_FALSE; + if (type == kTaoKeyDown) [first keyDown:event]; + else [first keyUp:event]; + return JNI_TRUE; +} + /* ================================================================== */ /* JNI exports — sibling overlay NSView */ /* Class: NativeTaoMacOsNativeViewBridge */ @@ -776,3 +895,101 @@ static void nvNoteDelivered(jint phase, BOOL terminal) { } [overlay removeFromSuperview]; } + +// ── Diagnostics for the headful suite ────────────────────────────────── +// +// A NativeView case needs a real, focusable AppKit view — one that takes +// first responder on click and shows an I-beam — to race against Compose. +// The test module cannot allocate one itself, so these hand out a plain +// NSTextField and read the responder chain and the text back. Nothing here +// is used by NativeView proper. + +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDiagCreateTextField( + JNIEnv *env, jclass clazz) +{ + (void)env; (void)clazz; + NSTextField *field = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 64, 24)]; + field.editable = YES; + field.selectable = YES; + field.bezeled = YES; + field.wantsLayer = YES; + return (jlong)(uintptr_t)(__bridge_retained void *)field; +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDiagReleaseView( + JNIEnv *env, jclass clazz, jlong viewPtr) +{ + (void)env; (void)clazz; + if (viewPtr == 0) return; + NSView *view = (__bridge_transfer NSView *)(void *)(uintptr_t)viewPtr; + [view removeFromSuperview]; +} + +/* An NSTextField never is the first responder itself while edited: the + * window's shared field editor (an NSTextView whose delegate is the + * field) is. Both shapes mean "keystrokes go to the embed". */ +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDiagViewIsEditing( + JNIEnv *env, jclass clazz, jlong viewPtr) +{ + (void)env; (void)clazz; + NSView *view = view_from_long(viewPtr); + if (view == nil || view.window == nil) return JNI_FALSE; + NSResponder *first = view.window.firstResponder; + if (first == view) return JNI_TRUE; + if ([first isKindOfClass:[NSTextView class]]) { + NSTextView *editor = (NSTextView *)first; + if (editor.isFieldEditor && editor.delegate == (id)view) return JNI_TRUE; + } + return JNI_FALSE; +} + +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDiagViewIsFirstResponder( + JNIEnv *env, jclass clazz, jlong viewPtr) +{ + (void)env; (void)clazz; + NSView *view = view_from_long(viewPtr); + if (view == nil || view.window == nil) return JNI_FALSE; + return view.window.firstResponder == view ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT jstring JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDiagTextFieldString( + JNIEnv *env, jclass clazz, jlong viewPtr) +{ + (void)clazz; + NSView *view = view_from_long(viewPtr); + if (![view isKindOfClass:[NSTextField class]]) return NULL; + NSString *value = ((NSTextField *)view).stringValue ?: @""; + return (*env)->NewStringUTF(env, value.UTF8String); +} + +/* The view's frame in its superview, converted to Compose's convention: + * physical pixels, top-left origin, as `[x, y, w, h]`. Null without a + * superview or a window. */ +JNIEXPORT jintArray JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsNativeViewBridge_nativeDiagViewFrame( + JNIEnv *env, jclass clazz, jlong viewPtr) +{ + (void)clazz; + NSView *view = view_from_long(viewPtr); + if (view == nil || view.superview == nil || view.window == nil) return NULL; + CGFloat scale = view.window.backingScaleFactor; + if (scale <= 0) scale = 1.0; + NSRect frame = view.frame; + CGFloat parentHeight = view.superview.bounds.size.height; + CGFloat topLeftY = view.superview.isFlipped ? frame.origin.y : parentHeight - frame.origin.y - frame.size.height; + jint out[4] = { + (jint)lround(frame.origin.x * scale), + (jint)lround(topLeftY * scale), + (jint)lround(frame.size.width * scale), + (jint)lround(frame.size.height * scale), + }; + jintArray result = (*env)->NewIntArray(env, 4); + if (result == NULL) return NULL; + (*env)->SetIntArrayRegion(env, result, 0, 4, out); + return result; +} diff --git a/decorated-window-tao/src/main/native/macos/popup_panel.m b/decorated-window-tao/src/main/native/macos/popup_panel.m index 7b1d11cd8..a4a173367 100644 --- a/decorated-window-tao/src/main/native/macos/popup_panel.m +++ b/decorated-window-tao/src/main/native/macos/popup_panel.m @@ -37,6 +37,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" #include // ── JVM caching for the per-panel event callback ──────────────────────── @@ -249,7 +250,7 @@ - (void)dispatchPointer:(NSEvent *)event type:(jint)type button:(jint)button { jfloat x, y; [self pixelsForEvent:event outX:&x outY:&y]; (*env)->CallVoidMethod(env, cb, sOnPointerMethod, type, x, y, button, [self modifierMaskFor:event]); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* On mouseDown inside a focusable panel, escalate the panel to key @@ -319,7 +320,7 @@ - (void)scrollWheel:(NSEvent *)event { x, y, (jfloat)event.scrollingDeltaX, (jfloat)event.scrollingDeltaY, event.hasPreciseScrollingDeltas ? JNI_TRUE : JNI_FALSE, scrollGesturePhase(event)); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } - (void)dispatchKey:(NSEvent *)event type:(jint)type { @@ -332,7 +333,7 @@ - (void)dispatchKey:(NSEvent *)event type:(jint)type { if (chars.length == 0) chars = event.charactersIgnoringModifiers; jint cp = (chars.length > 0) ? (jint)[chars characterAtIndex:0] : 0; (*env)->CallVoidMethod(env, cb, sOnKeyMethod, type, vk, cp, [self modifierMaskFor:event]); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } - (void)keyDown:(NSEvent *)event { [self dispatchKey:event type:EVT_KEY_DOWN]; } @@ -351,6 +352,12 @@ @interface NucleusTaoPopupPanel : NSPanel @property (nonatomic, strong) id outsideMonitor; // local NSEvent monitor token @property (nonatomic, strong) id outsideGlobalMonitor; // global NSEvent monitor token (standalone only) @property (nonatomic, strong) NSValue *outsideListenerVal; // jobject global ref boxed +// Buttons whose press this panel handed to its parent and whose release has +// not followed. AppKit keeps the whole gesture on the window that took the +// mouseDown — this panel — so the parent cannot see the end of a gesture we +// started for it unless we pass it on, and cannot see it at all once the panel +// is ordered out. See `nucleusCloseForwardedGestures`. +@property (nonatomic) NSUInteger forwardedButtons; @end @implementation NucleusTaoPopupPanel @@ -400,13 +407,103 @@ - (void)nucleusForwardMouseEventToParent:(NSEvent *)event { [parent sendEvent:forwarded]; } +/// Bit of [event]'s button, or 0 for an event that is not part of a button +/// gesture. +- (NSUInteger)nucleusGestureBitFor:(NSEvent *)event { + switch (event.type) { + case NSEventTypeLeftMouseDown: + case NSEventTypeLeftMouseUp: + case NSEventTypeLeftMouseDragged: + return 1u << 0; + case NSEventTypeRightMouseDown: + case NSEventTypeRightMouseUp: + case NSEventTypeRightMouseDragged: + return 1u << 1; + case NSEventTypeOtherMouseDown: + case NSEventTypeOtherMouseUp: + case NSEventTypeOtherMouseDragged: + return 1u << 2; + default: + return 0; + } +} + +/// Once the press went to the parent, the rest of that gesture goes there too. +/// +/// Deciding each event on its own — is this point in the content region? — +/// loses the drags and the release the moment the answer changes mid-gesture, +/// and it changes often: the press is what dismisses a hover card, which +/// re-lays out the content under the pointer. +- (BOOL)nucleusGestureBelongsToParent:(NSEvent *)event { + if (self.parentHostWindow == nil) return NO; + NSUInteger bit = [self nucleusGestureBitFor:event]; + return bit != 0 && (self.forwardedButtons & bit) != 0; +} + +- (NSEventType)nucleusUpEventTypeForBit:(NSUInteger)bit { + if (bit == (1u << 1)) return NSEventTypeRightMouseUp; + if (bit == (1u << 2)) return NSEventTypeOtherMouseUp; + return NSEventTypeLeftMouseUp; +} + +/// Ends every gesture this panel forwarded and never finished, by handing the +/// parent the release AppKit will not deliver. +/// +/// A popup is very often taken down *by* the press it forwarded — the card +/// this panel shows is dismissed the moment the pointer presses the tab it +/// belongs to — and an ordered-out window receives no events, so the real +/// mouseUp reaches no one at all. Without this the parent's scene is left +/// holding a press that never ends: the click never completes, and every +/// gesture after it is read as a continuation of that one. +- (void)nucleusCloseForwardedGestures { + NSUInteger pending = self.forwardedButtons; + if (pending == 0) return; + self.forwardedButtons = 0; + NSWindow *parent = self.parentHostWindow; + if (parent == nil) return; + NSPoint parentPoint = [parent convertPointFromScreen:[NSEvent mouseLocation]]; + for (NSUInteger bit = 1u; bit <= (1u << 2); bit <<= 1) { + if ((pending & bit) == 0) continue; + NSEvent *up = [NSEvent mouseEventWithType:[self nucleusUpEventTypeForBit:bit] + location:parentPoint + modifierFlags:0 + timestamp:NSProcessInfo.processInfo.systemUptime + windowNumber:parent.windowNumber + context:nil + eventNumber:0 + clickCount:1 + pressure:0]; + if (up != nil) [parent sendEvent:up]; + } +} + - (void)sendEvent:(NSEvent *)event { - if ([self nucleusShouldForwardToParent:event]) { + if ([self nucleusGestureBelongsToParent:event] || [self nucleusShouldForwardToParent:event]) { + NSUInteger bit = [self nucleusGestureBitFor:event]; + switch (event.type) { + case NSEventTypeLeftMouseDown: + case NSEventTypeRightMouseDown: + case NSEventTypeOtherMouseDown: + self.forwardedButtons |= bit; + break; + case NSEventTypeLeftMouseUp: + case NSEventTypeRightMouseUp: + case NSEventTypeOtherMouseUp: + self.forwardedButtons &= ~bit; + break; + default: + break; + } [self nucleusForwardMouseEventToParent:event]; return; } [super sendEvent:event]; } + +- (void)orderOut:(id)sender { + [self nucleusCloseForwardedGestures]; + [super orderOut:sender]; +} @end static NSWindow *window_from_view(jlong viewPtr) { @@ -856,7 +953,7 @@ static BOOL nucleus_isStatusItemOrMenuWindow(NSWindow *window) { if (e.type == NSEventTypeRightMouseDown) btn = 2; else if (e.type == NSEventTypeOtherMouseDown) btn = 3; (*jenv)->CallVoidMethod(jenv, cb, sOutsideOnClickMethod, type, btn); - if ((*jenv)->ExceptionCheck(jenv)) (*jenv)->ExceptionClear(jenv); + nucleus_jni_clear_exception(jenv); return e; }]; @@ -882,7 +979,7 @@ static BOOL nucleus_isStatusItemOrMenuWindow(NSWindow *window) { if (e.type == NSEventTypeRightMouseDown) btn = 2; else if (e.type == NSEventTypeOtherMouseDown) btn = 3; (*jenv)->CallVoidMethod(jenv, cb, sOutsideOnClickMethod, type, btn); - if ((*jenv)->ExceptionCheck(jenv)) (*jenv)->ExceptionClear(jenv); + nucleus_jni_clear_exception(jenv); }]; } } diff --git a/decorated-window-tao/src/main/native/macos/text_input_client_probe.m b/decorated-window-tao/src/main/native/macos/text_input_client_probe.m index a74726a89..7852e6f64 100644 --- a/decorated-window-tao/src/main/native/macos/text_input_client_probe.m +++ b/decorated-window-tao/src/main/native/macos/text_input_client_probe.m @@ -50,6 +50,24 @@ int nucleus_tao_query_text_input_client( return 1; } +/// Headful e2e: the rect the swizzled `firstRectForCharacterRange:` hands +/// AppKit — the anchor of the IME candidate window *and* of the input-source +/// indicator. [out_rect] is 4×double (x, y, w, h) in Cocoa screen coordinates; +/// an all-zero rect is the client saying "no insertion point here". +int nucleus_tao_query_ime_rect(int64_t ns_view_ptr, double *out_rect) { + if (ns_view_ptr == 0 || out_rect == NULL) { + return 0; + } + NSView *view = (__bridge NSView *)(void *)(intptr_t)ns_view_ptr; + NSRect rect = [(id)view firstRectForCharacterRange:NSMakeRange(0, 0) + actualRange:NULL]; + out_rect[0] = rect.origin.x; + out_rect[1] = rect.origin.y; + out_rect[2] = rect.size.width; + out_rect[3] = rect.size.height; + return 1; +} + int nucleus_tao_inject_marked_text( int64_t ns_view_ptr, const char *utf8, diff --git a/decorated-window-tao/src/main/native/macos/texture.m b/decorated-window-tao/src/main/native/macos/texture.m index 60aabfb9f..7788dfcab 100644 --- a/decorated-window-tao/src/main/native/macos/texture.m +++ b/decorated-window-tao/src/main/native/macos/texture.m @@ -256,6 +256,30 @@ static jlong nucleusWrapImport( free(t); } +/* Extra retain on an IOSurface held by a TextureViewSource, so a producer + * close (its own CFRelease) cannot free the surface while the source is + * still reachable. Remounting TextureView after CloseProducerUnderView + * would otherwise call IOSurfaceGetPixelFormat on a dangling pointer. + * The matching release is the source's Cleaner. */ +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsTextureBridge_nativeRetainIOSurface( + JNIEnv *env, jclass clazz, jlong ioSurfacePtr) { + (void)env; (void)clazz; + if (ioSurfacePtr == 0) return JNI_FALSE; + CFTypeRef ref = (CFTypeRef)(uintptr_t)ioSurfacePtr; + if (CFGetTypeID(ref) != IOSurfaceGetTypeID()) return JNI_FALSE; + CFRetain(ref); + return JNI_TRUE; +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoMacOsTextureBridge_nativeReleaseIOSurface( + JNIEnv *env, jclass clazz, jlong ioSurfacePtr) { + (void)env; (void)clazz; + if (ioSurfacePtr == 0) return; + CFRelease((CFTypeRef)(uintptr_t)ioSurfacePtr); +} + /* ================================================================== */ /* Metal test producer (demos / smoke tests) */ /* ================================================================== */ diff --git a/decorated-window-tao/src/main/native/macos/touchpad_gestures.m b/decorated-window-tao/src/main/native/macos/touchpad_gestures.m index 906e47452..582a0084c 100644 --- a/decorated-window-tao/src/main/native/macos/touchpad_gestures.m +++ b/decorated-window-tao/src/main/native/macos/touchpad_gestures.m @@ -6,9 +6,8 @@ // (`WindowEvent` only exposes `TouchpadPressure`), so we intercept them // before AppKit dispatches them down the responder chain. // -// The Rust side then synthesizes two `ComposeScenePointer` Touch points on the -// JVM side so that `detectTransformGestures` reacts to pinch-zoom and rotate -// uniformly across platforms — see TOUCH_PLAN.md, Phase 3. +// The JVM side forwards magnify as Compose Scale events (#660) and still +// synthesises two Touch points for rotate (Compose has no rotation event). // // Threading: the monitor block runs on the AppKit main thread (where Tao's // event loop already lives), so the callback fires on the same thread that diff --git a/decorated-window-tao/src/main/native/src/cursor.rs b/decorated-window-tao/src/main/native/src/cursor.rs index c8a4b2659..4adfa20b0 100644 --- a/decorated-window-tao/src/main/native/src/cursor.rs +++ b/decorated-window-tao/src/main/native/src/cursor.rs @@ -17,8 +17,9 @@ use tao::window::CursorIcon; use crate::state::WINDOWS; /// Mirrors `TaoCursorIcon` on the JVM side. Numeric codes only, so the JNI -/// signature stays `(JI)V`. Subset chosen to cover what Compose Desktop's -/// `PointerIcon` constants surface — additional shapes can be added later. +/// signature stays `(JI)V`. Covers what Compose Desktop's `PointerIcon` +/// constants surface, plus the shapes Nucleus exposes itself through +/// `TaoPointerIcons` (grab / grabbing for drag handles, move, …). /// On macOS, code 0 is an explicit arrow cursor rather than Tao's null /// `Default`, matching Compose AWT's concrete `Cursor.DEFAULT_CURSOR`. fn cursor_from_code(code: jint) -> CursorIcon { @@ -37,6 +38,8 @@ fn cursor_from_code(code: jint) -> CursorIcon { 10 => CursorIcon::NsResize, 11 => CursorIcon::NeswResize, 12 => CursorIcon::NwseResize, + 13 => CursorIcon::Grab, + 14 => CursorIcon::Grabbing, #[cfg(target_os = "macos")] _ => CursorIcon::Arrow, #[cfg(not(target_os = "macos"))] diff --git a/decorated-window-tao/src/main/native/src/event_loop.rs b/decorated-window-tao/src/main/native/src/event_loop.rs index 03c081570..f0d7344cd 100644 --- a/decorated-window-tao/src/main/native/src/event_loop.rs +++ b/decorated-window-tao/src/main/native/src/event_loop.rs @@ -17,12 +17,13 @@ use crate::events::{ CURSOR_FIXED_SCALE, EVENT_CLOSE_REQUESTED, EVENT_CURSOR_LEFT, EVENT_CURSOR_MOVED, EVENT_DESTROYED, EVENT_FOCUSED, EVENT_KEY_DOWN, EVENT_KEY_TYPED, EVENT_KEY_UP, EVENT_LAUNCHED, EVENT_MAIN_EVENTS_CLEARED, EVENT_MODIFIERS_CHANGED, EVENT_MOUSE_DOWN, EVENT_MOUSE_UP, - EVENT_MOVED, EVENT_REDRAW_REQUESTED, EVENT_RESIZED, EVENT_SCALE_FACTOR_CHANGED, - EVENT_SCROLL_LINE, EVENT_SCROLL_PIXEL, EVENT_UNFOCUSED, EVENT_WINDOW_READY, SCROLL_FIXED_SCALE, - SCROLL_GESTURE_BEGAN, SCROLL_GESTURE_CANCELLED, SCROLL_GESTURE_CHANGED, SCROLL_GESTURE_ENDED, - SCROLL_GESTURE_MAY_BEGIN, SCROLL_GESTURE_MOMENTUM_BEGAN, SCROLL_GESTURE_MOMENTUM_CHANGED, - SCROLL_GESTURE_MOMENTUM_ENDED, TOUCH_EVENT_CANCEL, TOUCH_EVENT_MOVE, TOUCH_EVENT_PRESS, - TOUCH_EVENT_RELEASE, TOUCH_FORCE_FIXED_SCALE, TOUCH_FORCE_UNKNOWN, + EVENT_MOVED, EVENT_QUIT_REQUESTED, EVENT_REDRAW_REQUESTED, EVENT_RESIZED, + EVENT_SCALE_FACTOR_CHANGED, EVENT_SCROLL_LINE, EVENT_SCROLL_PIXEL, EVENT_UNFOCUSED, + EVENT_WINDOW_READY, SCROLL_FIXED_SCALE, SCROLL_GESTURE_BEGAN, SCROLL_GESTURE_CANCELLED, + SCROLL_GESTURE_CHANGED, SCROLL_GESTURE_ENDED, SCROLL_GESTURE_MAY_BEGIN, + SCROLL_GESTURE_MOMENTUM_BEGAN, SCROLL_GESTURE_MOMENTUM_CHANGED, SCROLL_GESTURE_MOMENTUM_ENDED, + TOUCH_EVENT_CANCEL, TOUCH_EVENT_MOVE, TOUCH_EVENT_PRESS, TOUCH_EVENT_RELEASE, + TOUCH_FORCE_FIXED_SCALE, TOUCH_FORCE_UNKNOWN, }; #[cfg(target_os = "windows")] use crate::events::{ @@ -148,6 +149,39 @@ fn x11_display() -> Option { }) } +/// Serves the redraws asked for during this batch (Windows only — see +/// `UserEvent::RequestRedraw`), after the dispatcher drain that precedes every +/// call site so a frame sees the work that produced it. A window destroyed +/// meanwhile is skipped; one that asks again while being painted lands in the +/// next batch, which the request itself wakes the loop for. +/// Whether the thread's message queue currently holds mouse, keyboard or +/// other hardware input — the high word of `GetQueueStatus` reports the +/// kinds of messages present. See `UserEvent::Wake`. +#[cfg(target_os = "windows")] +fn input_pending() -> bool { + use windows::Win32::UI::WindowsAndMessaging::{GetQueueStatus, QS_INPUT}; + // SAFETY: plain query of the calling thread's queue, no pointers. + let status = unsafe { GetQueueStatus(QS_INPUT) }; + (status >> 16) & QS_INPUT.0 != 0 +} + +#[cfg(target_os = "windows")] +fn serve_pending_redraws(pending: &mut Vec) { + if pending.is_empty() { + return; + } + let serving: Vec = pending.drain(..).collect(); + for handle in serving { + let alive = { + let guard = WINDOWS.lock().unwrap(); + guard.as_ref().is_some_and(|map| map.contains_key(&handle)) + }; + if alive { + dispatch(handle, EVENT_REDRAW_REQUESTED, 0, 0); + } + } +} + pub(crate) fn run_event_loop_blocking() { // GTK backend selection. Default: let GDK auto-pick (= native Wayland on // a Wayland session, X11 elsewhere). The Wayland-native path goes through @@ -230,6 +264,10 @@ pub(crate) fn run_event_loop_blocking() { // guards against duplicate callbacks. #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] let mut last_minimized: HashMap = HashMap::new(); + // Windows: handles that asked for a redraw during the batch being + // processed, served at `MainEventsCleared`. See UserEvent::RequestRedraw. + #[cfg(target_os = "windows")] + let mut pending_redraws: Vec = Vec::new(); event_loop.run_return(move |event, target, control_flow| { *control_flow = ControlFlow::Wait; @@ -239,10 +277,41 @@ pub(crate) fn run_event_loop_blocking() { } Event::UserEvent(user) => match user { UserEvent::Wake => { - // No-op: the side-effect we want is the loop returning from - // its `Wait` to dispatch this event, which guarantees a - // following `MainEventsCleared` tick that drains + // The side-effect we want is the loop returning from its + // `Wait` to dispatch this event, which normally guarantees + // a following `MainEventsCleared` tick that drains // `TaoMainDispatcher`. + // + // Windows: not inside a nested modal message loop. Tao + // derives `MainEventsCleared` from an internal WM_PAINT on + // its thread-message window, and a modal loop running on + // this thread — an embedded EDIT's context menu, a + // `DoDragDrop` — never generates it, while it does deliver + // the posted wake. Drain the dispatcher here, and serve the + // frames that work asks for, so the app keeps running *and* + // painting for as long as the menu is up. Outside a modal + // loop the tick that follows finds both queues empty. + // + // But never over pending input. A wake is a *posted* + // message, and Win32 hands posted messages out before + // hardware input; a frame served here resumes the + // continuations that post the next wake, so a window that + // animates (a `withFrameNanos` producer, an infinite + // transition) keeps the posted queue non-empty and every + // WM_MOUSEMOVE / WM_LBUTTONDOWN starves behind it — the + // window paints at full rate and takes no clicks. The + // WM_PAINT-derived `MainEventsCleared` never had that + // problem: paint ranks below input. So a wake only serves + // frames when the queue holds no input; otherwise the + // requests stay pending and the tick that follows the + // input serves them, exactly as before. + #[cfg(target_os = "windows")] + { + dispatch(0, EVENT_MAIN_EVENTS_CLEARED, 0, 0); + if !input_pending() { + serve_pending_redraws(&mut pending_redraws); + } + } } UserEvent::CreateWindow { handle, @@ -338,6 +407,15 @@ pub(crate) fn run_event_loop_blocking() { } let window = builder.build(target); if let Ok(window) = window { + // TaoView exists from here on, so its NSTextInputClient + // answers can be ours before any text field is focused. + // Tao's own `firstRectForCharacterRange:` would + // otherwise anchor the input-source indicator (the + // Caps Lock layout badge) to the bottom-left corner. + #[cfg(target_os = "macos")] + unsafe { + crate::platform::macos::ffi::nucleus_tao_install_ime_client_overrides(); + } #[cfg(target_os = "linux")] if force_x11 { move_window_to_x11(&window); @@ -347,6 +425,19 @@ pub(crate) fn run_event_loop_blocking() { let logical_w = width as jint; let logical_h = height as jint; + // GTK takes a transient window down with its owner + // (`gtk_window_set_destroy_with_parent`), behind tao's + // back: nothing else records that the toplevel is gone. + // See `state::GTK_DESTROYED`. + #[cfg(target_os = "linux")] + { + use gtk::prelude::WidgetExt; + use tao::platform::unix::WindowExtUnix; + window.gtk_window().connect_destroy(move |_| { + crate::state::mark_gtk_destroyed(handle); + }); + } + { let mut guard = WINDOWS.lock().unwrap(); if let Some(map) = guard.as_mut() { @@ -383,7 +474,14 @@ pub(crate) fn run_event_loop_blocking() { { use gtk::prelude::WidgetExt; use tao::platform::unix::WindowExtUnix; - w.gtk_window().show_all(); + // Never on a toplevel GTK already + // destroyed with its owner: showing it + // re-realizes a disposed + // GtkApplicationWindow and crashes + // inside GTK. See `state::GTK_DESTROYED`. + if !crate::state::is_gtk_destroyed(handle) { + w.gtk_window().show_all(); + } } // Force a fresh frame into the now-composited surface. // The first frame is rendered (SwapBuffers) while the @@ -412,10 +510,35 @@ pub(crate) fn run_event_loop_blocking() { } } UserEvent::RequestRedraw { handle } => { - let guard = WINDOWS.lock().unwrap(); - if let Some(map) = guard.as_ref() { - if let Some(w) = map.get(&handle) { - w.request_redraw(); + // Windows: queue the request for the end of this batch + // instead of asking the OS for a paint. `request_redraw` is + // `RedrawWindow(RDW_INTERNALPAINT)`, and Win32 only + // synthesises WM_PAINT once the thread's message queue is + // otherwise empty — so a window animating flat out (each + // frame posting the next request as a queued user event) + // starves the paints of every *other* window in the app. + // They stop being scheduled for good: their next frame + // waits on a WM_PAINT that only arrives when the animation + // stops. Answering it here, on the other hand, re-enters + // rendering from inside the event batch and `MainEventsCleared` + // — the tick that drains `TaoMainDispatcher` — is never + // reached at all. So the requests are collected and served + // below, once per batch, after that drain: every window is + // painted at the same priority, in request order. + // OS-driven repaints still arrive as Event::RedrawRequested. + #[cfg(target_os = "windows")] + { + if !pending_redraws.contains(&handle) { + pending_redraws.push(handle); + } + } + #[cfg(not(target_os = "windows"))] + { + let guard = WINDOWS.lock().unwrap(); + if let Some(map) = guard.as_ref() { + if let Some(w) = map.get(&handle) { + w.request_redraw(); + } } } } @@ -451,6 +574,8 @@ pub(crate) fn run_event_loop_blocking() { if let Some(map) = guard.as_mut() { map.remove(&handle); } + #[cfg(target_os = "linux")] + crate::state::forget_gtk_destroyed(handle); } } UserEvent::SetMaximized { handle, maximized } => { @@ -469,6 +594,25 @@ pub(crate) fn run_event_loop_blocking() { } } } + UserEvent::SetMinimizable { handle, minimizable } => { + let guard = WINDOWS.lock().unwrap(); + if let Some(map) = guard.as_ref() { + if let Some(w) = map.get(&handle) { + // tao: styleMask on macOS, WS_MINIMIZEBOX on Windows, no-op on Linux. + w.set_minimizable(minimizable); + } + } + } + UserEvent::SetMaximizable { handle, maximizable } => { + let guard = WINDOWS.lock().unwrap(); + if let Some(map) = guard.as_ref() { + if let Some(w) = map.get(&handle) { + // tao: zoom button + Window > Zoom on macOS, WS_MAXIMIZEBOX + // (caption button, Win+Up, Aero Snap) on Windows, no-op on Linux. + w.set_maximizable(maximizable); + } + } + } UserEvent::SetMinimized { handle, minimized } => { { let guard = WINDOWS.lock().unwrap(); @@ -636,6 +780,29 @@ pub(crate) fn run_event_loop_blocking() { } } } + UserEvent::SetMaxInnerSize { + handle, + width, + height, + } => { + let guard = WINDOWS.lock().unwrap(); + if let Some(map) = guard.as_ref() { + if let Some(w) = map.get(&handle) { + if width < 0.0 || height < 0.0 { + w.set_max_inner_size::>(None); + } else { + w.set_max_inner_size(Some(LogicalSize::new(width, height))); + let scale = w.scale_factor(); + let current = w.inner_size().to_logical::(scale); + let new_w = current.width.min(width); + let new_h = current.height.min(height); + if new_w < current.width || new_h < current.height { + w.set_inner_size(LogicalSize::new(new_w, new_h)); + } + } + } + } + } UserEvent::SetWindowIcon { handle, width, @@ -697,6 +864,44 @@ pub(crate) fn run_event_loop_blocking() { } } } + UserEvent::PopupAnchor { + handle, + x, + y, + width, + height, + shadow_left, + shadow_top, + shadow_right, + shadow_bottom, + } => { + #[cfg(target_os = "linux")] + { + use tao::platform::unix::WindowExtUnix; + let guard = WINDOWS.lock().unwrap(); + if let Some(w) = guard.as_ref().and_then(|map| map.get(&handle)) { + w.popup_anchor( + x, + y, + width, + height, + (shadow_left, shadow_right, shadow_top, shadow_bottom), + ); + } + } + #[cfg(not(target_os = "linux"))] + let _ = ( + handle, + x, + y, + width, + height, + shadow_left, + shadow_top, + shadow_right, + shadow_bottom, + ); + } UserEvent::SetFullscreen { handle, fullscreen } => { let guard = WINDOWS.lock().unwrap(); if let Some(map) = guard.as_ref() { @@ -709,6 +914,9 @@ pub(crate) fn run_event_loop_blocking() { } } } + UserEvent::QuitRequested => { + dispatch(0, EVENT_QUIT_REQUESTED, 0, 0); + } UserEvent::Exit => { *control_flow = ControlFlow::Exit; } @@ -1011,6 +1219,14 @@ pub(crate) fn run_event_loop_blocking() { } Event::MainEventsCleared => { dispatch(0, EVENT_MAIN_EVENTS_CLEARED, 0, 0); + // The redraws asked for during this batch (Windows only — see + // UserEvent::RequestRedraw), served after the dispatcher drain + // above so a frame sees the work that produced it. A window + // destroyed meanwhile is skipped; one that asks again while + // being painted lands in the next batch, which the request + // itself wakes the loop for. + #[cfg(target_os = "windows")] + serve_pending_redraws(&mut pending_redraws); } // macOS deep links: AppKit installs its own `kAEGetURL` handler // during `finishLaunching` (routing to `application:openURLs:`). @@ -1026,6 +1242,25 @@ pub(crate) fn run_event_loop_blocking() { _ => {} } }); + #[cfg(target_os = "linux")] + flush_displays_after_loop(); +} + +/// Sends what the last loop turn left in the display connections' output +/// buffers. Dropping a window (`UserEvent::RequestClose`) only *queues* its +/// `XDestroyWindow` / `wl_surface.destroy`, and GDK flushes from its main loop, +/// which never runs again once `run_return` has returned: with +/// `exitProcessOnExit = false` the closed window stayed mapped and frozen on +/// screen for as long as the process lived. A flush, not a GTK iteration, so +/// no callback can reach the JVM after the loop has ended. +#[cfg(target_os = "linux")] +fn flush_displays_after_loop() { + if let Some(display) = gtk::gdk::Display::default() { + display.flush(); + } + if let Some(display) = x11_display() { + display.flush(); + } } /// Ensure the WINDOWS map exists. Called from the JNI entry point before the diff --git a/decorated-window-tao/src/main/native/src/events.rs b/decorated-window-tao/src/main/native/src/events.rs index d2bdc4b95..ad49c28bb 100644 --- a/decorated-window-tao/src/main/native/src/events.rs +++ b/decorated-window-tao/src/main/native/src/events.rs @@ -184,6 +184,7 @@ pub(crate) const EVENT_SHOWN: jint = 24; // loop — see `on_tao_size_move`. #[cfg(target_os = "windows")] pub(crate) const EVENT_SIZE_MOVE: jint = 25; +pub(crate) const EVENT_QUIT_REQUESTED: jint = 26; // Sub-pixel precision through the JNI int payload. pub(crate) const SCROLL_FIXED_SCALE: f64 = 100.0; @@ -244,7 +245,25 @@ pub(crate) const TOUCH_FORCE_UNKNOWN: jint = -1; pub(crate) const MOUSE_BUTTON_LEFT: jint = 0; pub(crate) const MOUSE_BUTTON_RIGHT: jint = 1; pub(crate) const MOUSE_BUTTON_MIDDLE: jint = 2; -pub(crate) const MOUSE_BUTTON_OTHER: jint = 3; +pub(crate) const MOUSE_BUTTON_BACK: jint = 3; +pub(crate) const MOUSE_BUTTON_FORWARD: jint = 4; +pub(crate) const MOUSE_BUTTON_OTHER: jint = 5; + +// Raw `MouseButton::Other(n)` numbers tao reports for the back / forward side +// buttons: `XBUTTON1` / `XBUTTON2` on Windows, X11/GDK buttons 8 / 9 on Linux, +// `NSEvent.buttonNumber` 3 / 4 on macOS. +#[cfg(target_os = "windows")] +const OTHER_BACK: u16 = 1; +#[cfg(target_os = "windows")] +const OTHER_FORWARD: u16 = 2; +#[cfg(target_os = "macos")] +const OTHER_BACK: u16 = 3; +#[cfg(target_os = "macos")] +const OTHER_FORWARD: u16 = 4; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +const OTHER_BACK: u16 = 8; +#[cfg(not(any(target_os = "windows", target_os = "macos")))] +const OTHER_FORWARD: u16 = 9; // ── User events posted from JNI calls into the event loop ───────────────── @@ -329,6 +348,14 @@ pub(crate) enum UserEvent { handle: u64, resizable: bool, }, + SetMinimizable { + handle: u64, + minimizable: bool, + }, + SetMaximizable { + handle: u64, + maximizable: bool, + }, SetMinimized { handle: u64, minimized: bool, @@ -372,6 +399,12 @@ pub(crate) enum UserEvent { width: f64, height: f64, }, + SetMaxInnerSize { + handle: u64, + // Negative width/height means "clear the maximum". + width: f64, + height: f64, + }, SetWindowIcon { handle: u64, // Premultiplied RGBA pixel buffer, row-major. Empty `pixels` clears. @@ -389,10 +422,26 @@ pub(crate) enum UserEvent { x: f64, y: f64, }, + /// Linux: anchor a popup overlay at a logical point of its parent so GDK + /// maps it as a compositor-positioned `xdg_popup` (see `popup_anchor`). + PopupAnchor { + handle: u64, + x: i32, + y: i32, + width: i32, + height: i32, + shadow_left: i32, + shadow_top: i32, + shadow_right: i32, + shadow_bottom: i32, + }, SetFullscreen { handle: u64, fullscreen: bool, }, + // Posted by the macOS quit paths only (Cmd-Q, `-[TaoApp terminate:]`). + #[cfg_attr(not(target_os = "macos"), allow(dead_code))] + QuitRequested, Exit, } @@ -651,6 +700,8 @@ pub(crate) fn mouse_button_code(b: MouseButton) -> jint { MouseButton::Left => MOUSE_BUTTON_LEFT, MouseButton::Right => MOUSE_BUTTON_RIGHT, MouseButton::Middle => MOUSE_BUTTON_MIDDLE, + MouseButton::Other(OTHER_BACK) => MOUSE_BUTTON_BACK, + MouseButton::Other(OTHER_FORWARD) => MOUSE_BUTTON_FORWARD, _ => MOUSE_BUTTON_OTHER, } } diff --git a/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs b/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs index be7caa341..c4bc42a9e 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/decoration.rs @@ -45,6 +45,7 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ _class: JClass, child_handle: jlong, owner_handle: jlong, + destroy_with_owner: jni::sys::jboolean, ) { use gtk::prelude::GtkWindowExt; @@ -55,6 +56,7 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ if owner_handle == 0 { GtkWindowExt::set_transient_for(child_gtk, None::<>k::Window>); + GtkWindowExt::set_destroy_with_parent(child_gtk, false); return; } @@ -66,9 +68,12 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ // dialog: keep the dialog out of the taskbar — the owner already // represents the app there. GtkWindowExt::set_skip_taskbar_hint(child_gtk, true); - // If the owner closes (or is destroyed), bring the dialog down with it - // so the user can never end up with an orphan transient. - GtkWindowExt::set_destroy_with_parent(child_gtk, true); + // A dialog comes down with its owner so the user is never left with an + // orphan transient. A satellite must NOT: it outlives the window it is + // anchored to, and GTK destroying its toplevel behind tao's back leaves a + // live `TaoWindow` with no GtkWindow — no geometry, and re-realized on the + // next show, which faults inside `gtk_application_window_real_realize`. + GtkWindowExt::set_destroy_with_parent(child_gtk, destroy_with_owner != 0); } /// Returns `[x, y, width, height]` of the window's outer (decoration-inclusive) diff --git a/decorated-window-tao/src/main/native/src/platform/linux/dnd.rs b/decorated-window-tao/src/main/native/src/platform/linux/dnd.rs index 0f392a672..331c14903 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/dnd.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/dnd.rs @@ -54,8 +54,8 @@ use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::rc::Rc; -use jni::objects::{GlobalRef, JClass, JObject, JObjectArray, JString, JValue}; -use jni::sys::{jint, jlong, JNI_FALSE, JNI_TRUE}; +use jni::objects::{GlobalRef, JClass, JIntArray, JObject, JObjectArray, JString, JValue}; +use jni::sys::{jfloat, jint, jlong, JNI_FALSE, JNI_TRUE}; use jni::JNIEnv; use gtk::gdk::DragAction; @@ -73,6 +73,15 @@ const DROP_EFFECT_COPY: jint = 1; const DROP_EFFECT_MOVE: jint = 2; const DROP_EFFECT_LINK: jint = 4; +/// Target for data that never leaves the process: the JVM's cross-window +/// gestures (satellite docking, tab tear-off) ride the platform DnD session on +/// native Wayland, where it is the only pointer grab that crosses windows with +/// coordinates. Advertised and accepted `SAME_APP` only, so a foreign drop +/// target never sees it and a foreign source can never spoof it. Must match +/// Kotlin `TaoPrivateTransfer.MIME`. +const PRIVATE_TARGET: &str = "application/x-nucleus-private"; +const PRIVATE_TARGET_INFO: u32 = 6; + /// Anti-rebound delay for `drag-leave` → `onExited`/`onEnded` dispatch. The /// specialist report cites 250 ms as a safe upper bound on GTK 3's spurious /// leave/motion pair latency. Any incoming `drag-motion` cancels the timer. @@ -84,6 +93,27 @@ const LEAVE_DEBOUNCE_MS: u32 = 250; /// coalesced by the host's owed-render gate. const DRAG_PUMP_INTERVAL_MS: u64 = 8; +/// How many queued GTK events to drain after `drag-end` so the toolkit can +/// finish releasing the drag's pointer grab. A handful of iterations: the +/// teardown is a few events, and the loop stops as soon as none are pending. +const DRAG_TEARDOWN_ITERATIONS: usize = 64; + +/// How long the session may run on with no pointer button held before it is +/// declared dead and cancelled. +/// +/// A legitimate session ends within a frame or two of the release — `drag-end` +/// follows the compositor's `dnd_finished` / `cancelled` immediately. Waiting a +/// full second past the release costs a correct drag nothing and only ever +/// fires for a session the compositor never took (see [`buttons_held`]). +const DRAG_DEAD_SESSION_GRACE_MS: u128 = 1_000; + +/// Watchdog wake interval while a session is in flight. +/// +/// `main_iteration_do(true)` blocks until GTK has something to dispatch, so the +/// deadline check needs a source that wakes the loop on its own — the pump +/// cannot be relied on for it, since a session may run with `pump = None`. +const DRAG_WATCHDOG_INTERVAL_MS: u64 = 50; + // ── Per-window registration ──────────────────────────────────────────────── #[allow(dead_code)] @@ -111,17 +141,31 @@ thread_local! { struct OutboundSession { files: Vec, text: Option, + private_data: Option, result: Rc>, done: Rc>, } +/// The drag icon a session shows under the pointer: premultiplied ARGB32 in +/// native endianness (cairo's own layout), `width × height` device pixels +/// rendered at `scale` px per logical pixel, with the pointer at +/// (`hot_x`, `hot_y`) device pixels. +pub(crate) struct DragIcon { + pub argb: Vec, + pub width: i32, + pub height: i32, + pub scale: f64, + pub hot_x: i32, + pub hot_y: i32, +} + thread_local! { static OUTBOUND: RefCell> = RefCell::new(HashMap::new()); } // ── Helpers ──────────────────────────────────────────────────────────────── -fn target_entries() -> [TargetEntry; 5] { +fn target_entries() -> [TargetEntry; 6] { // Info codes are forwarded to drag-data-get verbatim; we use them to pick // the right serialiser. text/uri-list is the primary inbound target for // file drops on Linux (Nautilus, Files, Konqueror, Firefox bookmarks…). @@ -131,9 +175,34 @@ fn target_entries() -> [TargetEntry; 5] { TargetEntry::new("UTF8_STRING", TargetFlags::OTHER_APP, 4), TargetEntry::new("STRING", TargetFlags::OTHER_APP, 5), TargetEntry::new("text/plain", TargetFlags::OTHER_APP, 3), + TargetEntry::new(PRIVATE_TARGET, TargetFlags::SAME_APP, PRIVATE_TARGET_INFO), ] } +/// Builds the GTK drag icon from [`DragIcon`]: a cairo surface at the source's +/// device scale, so it stays crisp on HiDPI, with the hotspot expressed as the +/// surface's device offset (the way `gtk_drag_set_icon_surface` reads it). +fn drag_icon_surface(icon: DragIcon) -> Option { + use gtk::cairo::{Format, ImageSurface}; + if icon.width <= 0 || icon.height <= 0 { + return None; + } + let stride = Format::ARgb32.stride_for_width(icon.width as u32).ok()?; + if stride != icon.width * 4 || icon.argb.len() != (icon.width * icon.height) as usize { + return None; + } + let mut bytes = Vec::with_capacity(icon.argb.len() * 4); + for px in icon.argb { + bytes.extend_from_slice(&px.to_ne_bytes()); + } + let surface = + ImageSurface::create_for_data(bytes, Format::ARgb32, icon.width, icon.height, stride).ok()?; + let scale = if icon.scale > 0.0 { icon.scale } else { 1.0 }; + surface.set_device_scale(scale, scale); + surface.set_device_offset(-(icon.hot_x as f64), -(icon.hot_y as f64)); + Some(surface) +} + fn map_action_to_effect(action: DragAction) -> jint { if action.contains(DragAction::COPY) { DROP_EFFECT_COPY @@ -186,6 +255,24 @@ fn translate_to_content_phys(window: >k::Window, x: i32, y: i32) -> (i32, i32) (lx * scale, ly * scale) } +/// Whether the seat still reports a pointer button held down over `widget`. +/// +/// Read straight off GDK's device state rather than tracked from the events we +/// forward: it is precisely a *stale* view of the button that this answers, +/// and only GDK's own state is in step with the serial `gtk_drag_begin` is +/// about to spend. `None` when the state cannot be read (window not realised, +/// no seat), which callers treat as "cannot vouch for it" and let through. +fn buttons_held(widget: >k::Window) -> Option { + let gdk_window = WidgetExt::window(widget)?; + let pointer = gdk_window.display().default_seat()?.pointer()?; + let (_, _, _, mask) = gdk_window.device_position(&pointer); + Some(mask.intersects( + gtk::gdk::ModifierType::BUTTON1_MASK + | gtk::gdk::ModifierType::BUTTON2_MASK + | gtk::gdk::ModifierType::BUTTON3_MASK, + )) +} + fn with_window R>(handle: u64, f: F) -> Option { let guard = WINDOWS.lock().ok()?; let map = guard.as_ref()?; @@ -549,16 +636,39 @@ fn start_outbound( handle: u64, files: Vec, text: Option, + private_data: Option, allowed: jint, + icon: Option, pump: Option, ) -> jint { - if files.is_empty() && text.as_deref().map(str::is_empty).unwrap_or(true) { + if files.is_empty() + && text.as_deref().map(str::is_empty).unwrap_or(true) + && private_data.is_none() + { return DROP_EFFECT_NONE; } let Some(widget) = with_window(handle, |w| w.gtk_window().clone()) else { return DROP_EFFECT_NONE; }; + // Refuse a session the compositor is guaranteed to drop on the floor. + // + // `wl_data_device.start_drag` is validated against the serial of the last + // input event *and* a still-pressed button (Mutter: + // `meta_wayland_pointer_get_grab_info(require_pressed = TRUE)`). With the + // button already up it is silently ignored — no protocol error, no grab, + // and therefore no `cancelled` / `dnd_finished` on the source, so GTK + // never emits `drag-end` and the cooperative pump below would spin for the + // rest of the process's life with the pointer frozen mid-gesture. + // + // We get there whenever the client falls behind the compositor: Compose + // crosses the touch slop on a *queued* motion that tao dispatches after + // the real release has already been delivered. A maximized window with + // satellites is the easy way to see it, since it is the slowest to render. + if buttons_held(&widget) == Some(false) { + return DROP_EFFECT_NONE; + } + let target_list = TargetList::new(&[]); if !files.is_empty() { target_list.add(>k::gdk::Atom::intern("text/uri-list"), 0, 1); @@ -567,6 +677,13 @@ fn start_outbound( target_list.add(>k::gdk::Atom::intern("text/plain;charset=utf-8"), 0, 2); target_list.add(>k::gdk::Atom::intern("UTF8_STRING"), 0, 4); } + if private_data.is_some() { + target_list.add( + >k::gdk::Atom::intern(PRIVATE_TARGET), + TargetFlags::SAME_APP.bits(), + PRIVATE_TARGET_INFO, + ); + } let result = Rc::new(Cell::new(DROP_EFFECT_NONE)); let done = Rc::new(Cell::new(false)); @@ -574,6 +691,7 @@ fn start_outbound( let session = OutboundSession { files: files.clone(), text: text.clone(), + private_data: private_data.clone(), result: Rc::clone(&result), done: Rc::clone(&done), }; @@ -609,6 +727,11 @@ fn start_outbound( let _ = data.set_text(&joined); } } + PRIVATE_TARGET_INFO => { + if let Some(p) = s.private_data.as_deref() { + data.set(>k::gdk::Atom::intern(PRIVATE_TARGET), 8, p.as_bytes()); + } + } _ => {} } }); @@ -639,7 +762,10 @@ fn start_outbound( return DROP_EFFECT_NONE; } if let Some(ref c) = ctx { - c.drag_set_icon_default(); + match icon.and_then(drag_icon_surface) { + Some(surface) => c.drag_set_icon_surface(&surface), + None => c.drag_set_icon_default(), + } } // Keep the host alive for the session, the Linux counterpart of the Windows @@ -674,11 +800,71 @@ fn start_outbound( ) }); + // Wakes the blocking loop below so its deadline check runs even while the + // compositor sends nothing at all — which is the state a dead session is + // in. Does no work of its own; the check itself stays in the loop body, + // where cancelling is safe (a `gtk_drag_cancel` from inside a glib + // callback would re-enter GTK's drag teardown under our own pump). + let watchdog = glib::timeout_add_local( + std::time::Duration::from_millis(DRAG_WATCHDOG_INTERVAL_MS), + || glib::ControlFlow::Continue, + ); + // Cooperatively pump the GTK main loop until drag-end fires. The session // runs through the same loop we're already on; drag_begin returned // immediately. Mirrors Win32 `DoDragDrop`'s nested message pump. + // + // Bounded past the release, never during the drag: the user may hold a + // legitimate drag for as long as they like, so the deadline only starts + // once no button is held any more. A session still alive then is one the + // compositor never took, and spinning on it is the freeze this guards. + let mut released_at: Option = None; while !done.get() { gtk::main_iteration_do(true); + if done.get() { + break; + } + if buttons_held(&widget) == Some(false) { + let since = released_at.get_or_insert_with(std::time::Instant::now); + if since.elapsed().as_millis() >= DRAG_DEAD_SESSION_GRACE_MS { + // Emits drag-failed + drag-end synchronously, which sets + // `done` through our own handlers and lets the ordinary + // teardown below run. A no-op if GTK already dropped the + // session's source info. + if let Some(ref c) = ctx { + unsafe { + gtk::ffi::gtk_drag_cancel( + glib::translate::ToGlibPtr::to_glib_none(c).0, + ); + } + } + break; + } + } else { + // A button came back down (a second gesture, or a state we simply + // could not read): the grace period is not running. + released_at = None; + } + } + watchdog.remove(); + + // `drag-end` is emitted *before* GTK has finished tearing the drag down — + // in particular before it releases the implicit pointer grab + // `gtk_drag_begin` took on the seat. Returning the instant our own handler + // sets the flag (and then disconnecting GTK's handlers underneath it) + // leaves that grab in place, and every window of the application goes + // deaf to the pointer for good. So drain what GTK still has queued, then + // make sure the seat is ungrabbed either way. + for _ in 0..DRAG_TEARDOWN_ITERATIONS { + if !gtk::events_pending() { + break; + } + gtk::main_iteration_do(false); + } + if let Some(gdk_window) = WidgetExt::window(&widget) { + if let Some(seat) = gdk_window.display().default_seat() { + seat.ungrab(); + } } if let Some(src) = pump_source { @@ -736,7 +922,14 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxDn handle: jlong, files: JObjectArray, text: JString, + private_data: JString, allowed_effects: jint, + icon_argb: JIntArray, + icon_width: jint, + icon_height: jint, + icon_scale: jfloat, + icon_hot_x: jint, + icon_hot_y: jint, pump: JObject, ) -> jint { if handle == 0 { @@ -773,6 +966,31 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxDn // GlobalRef, unlike the macOS timer's raw jobject: the timeout closure is // `'static`, so it cannot borrow this frame's local ref. It fires on this // same already-attached thread either way. + let private_opt: Option = if !private_data.is_null() { + env.get_string(&private_data) + .ok() + .map(|s| s.to_str().unwrap_or("").to_string()) + } else { + None + }; + let icon: Option = if icon_argb.is_null() || icon_width <= 0 || icon_height <= 0 { + None + } else { + let len = env.get_array_length(&icon_argb).unwrap_or(0) as usize; + let mut buf: Vec = vec![0; len]; + if env.get_int_array_region(&icon_argb, 0, &mut buf).is_ok() { + Some(DragIcon { + argb: buf.into_iter().map(|v| v as u32).collect(), + width: icon_width, + height: icon_height, + scale: icon_scale as f64, + hot_x: icon_hot_x, + hot_y: icon_hot_y, + }) + } else { + None + } + }; let pump_ref: Option = if pump.is_null() { None } else { @@ -782,7 +1000,9 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxDn handle as u64, files_vec, text_opt, + private_opt, allowed_effects, + icon, pump_ref, ) } diff --git a/decorated-window-tao/src/main/native/src/platform/linux/handles.rs b/decorated-window-tao/src/main/native/src/platform/linux/handles.rs index d1dfdfc8b..451609d10 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/handles.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/handles.rs @@ -52,22 +52,29 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ fn fill_linux_handles(window: &Window, out: &mut [jlong; 3]) { let Ok(wh) = window.window_handle() else { return }; - let Ok(dh) = window.display_handle() else { return }; - match (wh.as_raw(), dh.as_raw()) { - (RawWindowHandle::Xlib(w), RawDisplayHandle::Xlib(_)) => { - // Tao's `raw_display_handle_rwh_06` calls `XOpenDisplay(NULL)` - // and returns a *fresh* X11 connection. GLX requires the context, - // drawable and display to all share the same connection — using - // tao's display with a GDK-owned XID makes `glXMakeCurrent` fail - // silently. Pull GDK's actual `Display*` via `gdk_x11_*`. + match wh.as_raw() { + RawWindowHandle::Xlib(w) => { + // Never ask tao for the Xlib display handle: its + // `raw_display_handle_rwh_06` calls `XOpenDisplay(NULL)` and + // returns a *fresh* X11 connection on every call, which it never + // closes — a caller polling this export (the JVM reads the surface + // kind from slot 0) would exhaust the X server's client limit + // ("Maximum number of clients reached"). GLX could not use that + // connection anyway: context, drawable and display must share one, + // and the XID is GDK's. Pull GDK's actual `Display*` via `gdk_x11_*`. out[0] = 1; out[1] = gdk_x11_display_for_window(window).unwrap_or(0); out[2] = w.window as jlong; } - (RawWindowHandle::Wayland(w), RawDisplayHandle::Wayland(d)) => { - out[0] = 2; - out[1] = d.display.as_ptr() as jlong; - out[2] = w.surface.as_ptr() as jlong; + RawWindowHandle::Wayland(w) => { + // The Wayland display handle is GDK's own `wl_display*`; nothing + // is opened or leaked by asking for it. + let Ok(dh) = window.display_handle() else { return }; + if let RawDisplayHandle::Wayland(d) = dh.as_raw() { + out[0] = 2; + out[1] = d.display.as_ptr() as jlong; + out[2] = w.surface.as_ptr() as jlong; + } } _ => {} } diff --git a/decorated-window-tao/src/main/native/src/platform/linux/monitor.rs b/decorated-window-tao/src/main/native/src/platform/linux/monitor.rs index 5323f83ef..cbb962683 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/monitor.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/monitor.rs @@ -14,8 +14,8 @@ // Compose dispatcher which is pinned to the Tao / GTK main thread, so the // GDK API contract (main thread only) is satisfied. -use jni::objects::JClass; -use jni::sys::{jint, jlong, jlongArray}; +use jni::objects::{JClass, JObject}; +use jni::sys::{jint, jlong, jlongArray, jobjectArray}; use jni::JNIEnv; use tao::platform::unix::WindowExtUnix; @@ -30,10 +30,13 @@ fn with_window(handle: jlong, f: impl FnOnce(&Window) -> Option) -> Option f(window) } -fn primary_monitor(window: &Window) -> Option { +fn display_of(window: &Window) -> gtk::gdk::Display { use gtk::prelude::WidgetExt; - let gtk_window = window.gtk_window(); - let display = WidgetExt::display(gtk_window); + WidgetExt::display(window.gtk_window()) +} + +fn primary_monitor(window: &Window) -> Option { + let display = display_of(window); display.primary_monitor().or_else(|| display.monitor(0)) } @@ -81,6 +84,145 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ arr.into_raw() } +/// Returns one tab-separated descriptor per monitor, in GDK enumeration order: +/// `id \t name \t x \t y \t width \t height \t workX \t workY \t workWidth \t +/// workHeight \t scaleMilli \t primary`. Geometry is physical pixels with a +/// top-left origin, matching the Win32 / NSScreen conventions of the sibling +/// bridges; `primary` is `1` or `0`. +/// +/// [handle] may be `0`: monitors are a display-wide property, so the default +/// GDK display is used when no window is available (a tray-only app). Returns +/// `null` when GDK has no display at all. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeLinuxMonitors( + mut env: JNIEnv, + _class: JClass, + handle: jlong, +) -> jobjectArray { + let Some(rows) = collect_monitors(handle) else { + return std::ptr::null_mut(); + }; + match build_string_array(&mut env, &rows) { + Some(arr) => arr.into_raw(), + None => std::ptr::null_mut(), + } +} + +fn collect_monitors(handle: jlong) -> Option> { + // `Display`'s monitor accessors are inherent in gdk3-rs (no DisplayExt); + // `Monitor`'s are on MonitorExt, like the primary-monitor helpers above. + use gtk::prelude::MonitorExt; + + let display = match with_window(handle, |w| Some(display_of(w))) { + Some(display) => display, + // `Display::default()` is `assert_initialized_main_thread!()`, and a + // failed Rust assertion across FFI aborts the process — it took the + // whole test JVM down with SIGABRT on a headless CI box. Anything that + // reaches here without a realized window (a tray-only app, a unit + // test) has to be told "no monitors", not killed. + None if gtk::is_initialized_main_thread() => gtk::gdk::Display::default()?, + None => return None, + }; + let count = display.n_monitors(); + let mut rows = Vec::with_capacity(count.max(0) as usize); + for index in 0..count { + let Some(monitor) = display.monitor(index) else { + continue; + }; + let scale = monitor.scale_factor().max(1) as i64; + // Read the numbers out before picking: `Rectangle` is a boxed inline + // type, so selecting between the two rectangles by value would move + // `geometry` out from under the bounds array below. + let geometry = monitor.geometry(); + let bounds = ( + geometry.x(), + geometry.y(), + geometry.width(), + geometry.height(), + ); + let area = monitor.workarea(); + let area = (area.x(), area.y(), area.width(), area.height()); + // Some Wayland compositors report no work area. + let work = if area.2 > 0 && area.3 > 0 { area } else { bounds }; + // GDK reports logical pixels on HiDPI; scale up to physical. + let model = monitor.model().map(|s| s.to_string()).unwrap_or_default(); + let manufacturer = monitor + .manufacturer() + .map(|s| s.to_string()) + .unwrap_or_default(); + let id = if model.is_empty() { + format!("monitor-{index}") + } else { + model.clone() + }; + let name = match (manufacturer.as_str(), model.as_str()) { + ("", "") => id.clone(), + ("", m) => m.to_string(), + (mf, "") => mf.to_string(), + (mf, m) => format!("{mf} {m}"), + }; + let is_primary = monitor.is_primary(); + rows.push(encode_monitor( + &id, + &name, + [ + bounds.0 as i64 * scale, + bounds.1 as i64 * scale, + bounds.2 as i64 * scale, + bounds.3 as i64 * scale, + ], + [ + work.0 as i64 * scale, + work.1 as i64 * scale, + work.2 as i64 * scale, + work.3 as i64 * scale, + ], + scale * 1000, + is_primary, + )); + } + Some(rows) +} + +fn encode_monitor( + id: &str, + name: &str, + bounds: [i64; 4], + work: [i64; 4], + scale_milli: i64, + primary: bool, +) -> String { + // Tabs are the separator, so they must not survive inside a display name. + let sanitize = |s: &str| s.replace(['\t', '\n'], " "); + format!( + "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}", + sanitize(id), + sanitize(name), + bounds[0], + bounds[1], + bounds[2], + bounds[3], + work[0], + work[1], + work[2], + work[3], + scale_milli, + if primary { 1 } else { 0 }, + ) +} + +fn build_string_array<'a>(env: &mut JNIEnv<'a>, items: &[String]) -> Option> { + let cls = env.find_class("java/lang/String").ok()?; + let arr = env + .new_object_array(items.len() as i32, cls, JObject::null()) + .ok()?; + for (index, item) in items.iter().enumerate() { + let js = env.new_string(item).ok()?; + env.set_object_array_element(&arr, index as i32, js).ok()?; + } + Some(arr.into()) +} + /// Returns the primary monitor's scale factor encoded as `(scale * 1000)`. /// Used as a scale source for the centring math when the window's own /// scale factor is not yet resolvable. diff --git a/decorated-window-tao/src/main/native/src/platform/linux/touch.rs b/decorated-window-tao/src/main/native/src/platform/linux/touch.rs index 39be1f350..6859bdd4b 100644 --- a/decorated-window-tao/src/main/native/src/platform/linux/touch.rs +++ b/decorated-window-tao/src/main/native/src/platform/linux/touch.rs @@ -541,3 +541,58 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoLinuxTo revoke(handle as u64); 0 } + +// ── Headful e2e injection ───────────────────────────────────────────────── + +/// Linux only, headful e2e: deliver a synthetic `GdkEventTouchpadPinch` to +/// the GtkWindow behind [handle] through the `event` signal — the handler a +/// real touchpad pinch reaches, so [handle_touchpad_pinch]'s absolute-scale +/// and radian conversions run as they do for a real gesture. +/// +/// [phase] is a `GdkTouchpadGesturePhase` (`0=BEGIN … 3=CANCEL`), [scale_micro] +/// GDK's *absolute* scale × 1 000 000 (1 000 000 at BEGIN), [angle_delta_micro] +/// the per-event angle in micro-radians. Coordinates are widget-local logical +/// px. Returns JNI `true` when the signal was emitted on a realized window. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeLinuxInjectGdkTouchpadPinch( + _env: JNIEnv, + _class: JClass, + handle: jlong, + phase: jint, + x: jint, + y: jint, + scale_micro: jint, + angle_delta_micro: jint, +) -> jni::sys::jboolean { + use glib::translate::{ToGlibPtr, ToGlibPtrMut}; + + if !(GDK_TOUCHPAD_PHASE_BEGIN..=GDK_TOUCHPAD_PHASE_CANCEL).contains(&phase) { + return 0; + } + let Some(gtk_window) = with_window(handle as u64, |w| w.gtk_window().clone()) else { + return 0; + }; + let Some(gdk_window) = gtk_window.window() else { + return 0; + }; + let mut event = gdk::Event::new(EventType::TouchpadPinch); + unsafe { + let raw: *mut gdk::ffi::GdkEvent = event.to_glib_none_mut().0; + let ptr = raw as *mut gdk::ffi::GdkEventTouchpadPinch; + (*ptr).window = gdk_window.to_glib_full(); + (*ptr).send_event = 1; + (*ptr).phase = phase as i8; + (*ptr).n_fingers = 2; + (*ptr).x = x as f64; + (*ptr).y = y as f64; + (*ptr).x_root = x as f64; + (*ptr).y_root = y as f64; + (*ptr).scale = scale_micro as f64 / 1_000_000.0; + (*ptr).angle_delta = angle_delta_micro as f64 / 1_000_000.0; + } + if let Some(pointer) = gdk_window.display().default_seat().and_then(|s| s.pointer()) { + event.set_device(Some(&pointer)); + } + let _handled: bool = glib::prelude::ObjectExt::emit_by_name(>k_window, "event", &[&event]); + 1 +} diff --git a/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs b/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs index 31344960f..ad3ecc1e6 100644 --- a/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs +++ b/decorated-window-tao/src/main/native/src/platform/macos/ffi.rs @@ -12,7 +12,18 @@ extern "C" { ); pub(crate) fn nucleus_tao_is_main_thread() -> i32; pub(crate) fn nucleus_tao_install_cmd_q_handler(); - pub(crate) fn nucleus_tao_activate_input_context(ns_view_handle: i64); + /// Installs the `NSTextInputClient` overrides on TaoView (idempotent). + /// Called per window creation so the caret-rect answer is ours from the + /// first frame, not only once a text field has been focused. + pub(crate) fn nucleus_tao_install_ime_client_overrides(); + /// Activates TaoView's `NSTextInputContext` and returns the token that + /// identifies the text-input session it opens. + pub(crate) fn nucleus_tao_activate_input_context(ns_view_handle: i64) -> i64; + /// Ends the session `token` identifies (deactivates the input context, + /// drops the cached caret rect). A stale token is ignored; a 0 + /// `ns_view_handle` means the window is gone and only the cached state is + /// dropped. + pub(crate) fn nucleus_tao_deactivate_input_context(ns_view_handle: i64, token: i64); /// Pushes the focused field's committed text (a bounded UTF-16 window), /// selection and composition so the swizzled `NSTextInputClient` getters /// can answer AppKit like a document-backed client (Chromium's @@ -84,6 +95,9 @@ extern "C" { substring_buf: *mut std::os::raw::c_char, substring_buf_len: i32, ) -> i32; + /// Headful e2e: the rect `firstRectForCharacterRange:` publishes, as + /// 4×f64 (x, y, w, h) in Cocoa screen coordinates. + pub(crate) fn nucleus_tao_query_ime_rect(ns_view_ptr: i64, out_rect: *mut f64) -> i32; /// Headful e2e: `[view setMarkedText:selectedRange:replacementRange:]`. pub(crate) fn nucleus_tao_inject_marked_text( ns_view_ptr: i64, diff --git a/decorated-window-tao/src/main/native/src/platform/macos/ime.rs b/decorated-window-tao/src/main/native/src/platform/macos/ime.rs index 4dfd6f14e..b0d7127f1 100644 --- a/decorated-window-tao/src/main/native/src/platform/macos/ime.rs +++ b/decorated-window-tao/src/main/native/src/platform/macos/ime.rs @@ -3,16 +3,17 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; -use jni::objects::{JClass, JLongArray, JString}; -use jni::sys::{jboolean, jint, jlong, jlongArray, JNI_FALSE, JNI_TRUE}; +use jni::objects::{JClass, JDoubleArray, JLongArray, JString}; +use jni::sys::{jboolean, jdoubleArray, jint, jlong, jlongArray, JNI_FALSE, JNI_TRUE}; use jni::JNIEnv; use tao::platform::macos::WindowExtMacOS; use crate::platform::macos::ffi::{ nucleus_tao_activate_input_context, nucleus_tao_current_input_source_id, - nucleus_tao_inject_insert_text, nucleus_tao_inject_marked_text, nucleus_tao_kotoeri_available, - nucleus_tao_kotoeri_restore, nucleus_tao_kotoeri_select, nucleus_tao_post_key_to_view, + nucleus_tao_deactivate_input_context, nucleus_tao_inject_insert_text, + nucleus_tao_inject_marked_text, nucleus_tao_kotoeri_available, nucleus_tao_kotoeri_restore, + nucleus_tao_kotoeri_select, nucleus_tao_post_key_to_view, nucleus_tao_query_ime_rect, nucleus_tao_query_text_input_client, nucleus_tao_set_ime_document, nucleus_tao_set_ime_local_rect, }; @@ -25,21 +26,34 @@ fn ns_view_for_handle(handle: jlong) -> Option { Some(window.ns_view() as i64) } +/// Opens a text-input session on [handle]'s view and returns its token, to be +/// handed back to `nativeDeactivateInputContext` when the session ends. Returns +/// 0 when the window is gone. #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeActivateInputContext( _env: JNIEnv, _class: JClass, handle: jlong, -) { - let guard = match WINDOWS.lock() { - Ok(g) => g, - Err(_) => return, +) -> jlong { + let Some(ns_view) = ns_view_for_handle(handle) else { + return 0; }; - let Some(map) = guard.as_ref() else { return }; - if let Some(window) = map.get(&(handle as u64)) { - let ns_view = window.ns_view() as i64; - unsafe { nucleus_tao_activate_input_context(ns_view) }; - } + unsafe { nucleus_tao_activate_input_context(ns_view) } +} + +/// Ends the session [token] opened. The window is often already gone by then +/// (a closing window tears its focused field down with it), which is not a +/// reason to leave the caret rect cached — native is handed 0 and drops the +/// cached state without touching the dead view. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeDeactivateInputContext( + _env: JNIEnv, + _class: JClass, + handle: jlong, + token: jlong, +) { + let ns_view = ns_view_for_handle(handle).unwrap_or(0); + unsafe { nucleus_tao_deactivate_input_context(ns_view, token) }; } /// Pushes the caret rectangle in *window-local physical pixels* (top-left origin) @@ -268,6 +282,36 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ .unwrap_or(std::ptr::null_mut()) } +/// Headful e2e: the caret rect TaoView publishes to AppKit, as 4×double +/// (x, y, w, h) in Cocoa screen coordinates. An all-zero rect means the view +/// has no insertion point to anchor the IME candidate window — or the +/// input-source indicator — to. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeMacOsQueryImeRect( + env: JNIEnv, + _class: JClass, + handle: jlong, + rect_out: jdoubleArray, +) -> jboolean { + let mut rect = [0f64; 4]; + let Some(ns_view) = ns_view_for_handle(handle) else { + return JNI_FALSE; + }; + let ok = unsafe { nucleus_tao_query_ime_rect(ns_view, rect.as_mut_ptr()) }; + let arr = unsafe { JDoubleArray::from_raw(rect_out) }; + if env.get_array_length(&arr).unwrap_or(0) < 4 { + return JNI_FALSE; + } + if env.set_double_array_region(&arr, 0, &rect).is_err() { + return JNI_FALSE; + } + if ok != 0 { + JNI_TRUE + } else { + JNI_FALSE + } +} + /// Headful e2e: `setMarkedText:selectedRange:replacementRange:` on TaoView. #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeMacOsInjectMarkedText( diff --git a/decorated-window-tao/src/main/native/src/platform/macos/main_thread.rs b/decorated-window-tao/src/main/native/src/platform/macos/main_thread.rs index cf466ec92..cee571573 100644 --- a/decorated-window-tao/src/main/native/src/platform/macos/main_thread.rs +++ b/decorated-window-tao/src/main/native/src/platform/macos/main_thread.rs @@ -36,9 +36,9 @@ pub(crate) fn dispatch_run_event_loop_on_main() { } } -/// Called from `main_thread_dispatch.m` when the user hits Cmd-Q. -/// Posts a `UserEvent::Exit` on the running Tao event-loop proxy. +/// Cmd-Q (`main_thread_dispatch.m`) and `-[TaoApp terminate:]` (vendored tao). +/// `false` once the event loop is gone, so the caller can fall back to a real quit. #[no_mangle] -pub extern "C" fn nucleus_tao_post_exit() { - send_user_event(crate::events::UserEvent::Exit); +pub extern "C" fn nucleus_tao_post_quit_requested() -> bool { + send_user_event(crate::events::UserEvent::QuitRequested) } diff --git a/decorated-window-tao/src/main/native/src/platform/windows/mod.rs b/decorated-window-tao/src/main/native/src/platform/windows/mod.rs index 2bf566d0a..41b6aed1c 100644 --- a/decorated-window-tao/src/main/native/src/platform/windows/mod.rs +++ b/decorated-window-tao/src/main/native/src/platform/windows/mod.rs @@ -1,3 +1,4 @@ pub(crate) mod a11y; pub(crate) mod handles; pub(crate) mod ime; +pub(crate) mod watchdog; diff --git a/decorated-window-tao/src/main/native/src/platform/windows/watchdog.rs b/decorated-window-tao/src/main/native/src/platform/windows/watchdog.rs new file mode 100644 index 000000000..d2d7bb1c1 --- /dev/null +++ b/decorated-window-tao/src/main/native/src/platform/windows/watchdog.rs @@ -0,0 +1,60 @@ +// Event-loop liveness probe (#643). +// +// `IsHungAppWindow` is a pure query of state the OS already maintains — it is +// what the shell itself reads to decide whether to ghost a window. It sends +// nothing to the owning thread, so probing costs the event loop exactly +// nothing and, unlike a `SendMessageTimeout(WM_NULL)` probe, cannot deliver an +// inline sent message into a `PeekMessageW` the loop makes (the re-entrancy +// that deadlocked #640). +// +// Called from the watchdog thread, never from the event loop: it takes the +// HWND as a value and touches no crate state, so no lock the stalled loop +// might hold is on its path. + +use std::ffi::c_void; + +use jni::objects::JClass; +use jni::sys::{jboolean, jlong, JNI_FALSE, JNI_TRUE}; +use jni::JNIEnv; + +use windows::Win32::Foundation::HWND; +use windows::Win32::System::Threading::GetCurrentProcessId; +use windows::Win32::UI::WindowsAndMessaging::{ + GetWindowThreadProcessId, IsHungAppWindow, IsWindow, +}; + +/// `true` when Windows considers [hwnd]'s thread to have stopped pumping +/// messages (~5 s without a `GetMessage` / `PeekMessage`, the OS's own +/// threshold). `false` for a healthy window and for a handle that is no longer +/// one of ours. +/// +/// Ownership is re-checked on every call, not just window-ness: Windows +/// recycles HWNDs, so a cached handle whose window went away without the +/// JVM hearing about it can come back as *another process's* window — and +/// that one being hung says nothing about us. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeIsWindowHung( + _env: JNIEnv, + _class: JClass, + hwnd: jlong, +) -> jboolean { + if hwnd == 0 { + return JNI_FALSE; + } + let hwnd = HWND(hwnd as *mut c_void); + unsafe { + if !IsWindow(Some(hwnd)).as_bool() { + return JNI_FALSE; + } + let mut pid = 0u32; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid != GetCurrentProcessId() { + return JNI_FALSE; + } + if IsHungAppWindow(hwnd).as_bool() { + JNI_TRUE + } else { + JNI_FALSE + } + } +} diff --git a/decorated-window-tao/src/main/native/src/state.rs b/decorated-window-tao/src/main/native/src/state.rs index fef48a0e9..09184823b 100644 --- a/decorated-window-tao/src/main/native/src/state.rs +++ b/decorated-window-tao/src/main/native/src/state.rs @@ -25,6 +25,45 @@ pub(crate) static EVENT_LOOP_PROXY: Mutex>> = M pub(crate) static WINDOWS: Mutex>> = Mutex::new(None); +/// Handles whose GTK toplevel was destroyed by GTK itself rather than through +/// `RequestClose` — a transient window taken down with its owner +/// (`gtk_window_set_destroy_with_parent`). The tao `Window` and its entry in +/// [WINDOWS] both survive that, so nothing else records it; showing such a +/// window re-realizes a disposed `GtkApplicationWindow`, and +/// `gtk_application_window_real_realize` then dereferences the menu sections +/// dispose has already cleared (SIGSEGV inside `g_menu_model_get_n_items`, +/// with no GTK warning first). +#[cfg(target_os = "linux")] +pub(crate) static GTK_DESTROYED: Mutex>> = Mutex::new(None); + +/// Records that GTK destroyed [handle]'s toplevel behind tao's back. +#[cfg(target_os = "linux")] +pub(crate) fn mark_gtk_destroyed(handle: u64) { + if let Ok(mut guard) = GTK_DESTROYED.lock() { + guard.get_or_insert_with(std::collections::HashSet::new).insert(handle); + } +} + +/// Whether GTK has destroyed [handle]'s toplevel — see [GTK_DESTROYED]. +#[cfg(target_os = "linux")] +pub(crate) fn is_gtk_destroyed(handle: u64) -> bool { + GTK_DESTROYED + .lock() + .ok() + .and_then(|guard| guard.as_ref().map(|set| set.contains(&handle))) + .unwrap_or(false) +} + +/// Forgets [handle] once tao itself drops the window. +#[cfg(target_os = "linux")] +pub(crate) fn forget_gtk_destroyed(handle: u64) { + if let Ok(mut guard) = GTK_DESTROYED.lock() { + if let Some(set) = guard.as_mut() { + set.remove(&handle); + } + } +} + // Tracked across `WindowEvent::ModifiersChanged`. AWT-style modifier state // (which Compose `KeyEvent` consumes) carries Shift/Ctrl/Alt/Meta booleans on // every event, so we need to remember the latest snapshot. Stored as already- @@ -43,10 +82,11 @@ pub(crate) fn clear_event_loop_proxy() { } } -pub(crate) fn send_user_event(event: UserEvent) { +/// `false` when no event loop is running to receive [event]. +pub(crate) fn send_user_event(event: UserEvent) -> bool { let Ok(guard) = EVENT_LOOP_PROXY.lock() else { - return; + return false; }; - let Some(proxy) = guard.as_ref() else { return }; - let _ = proxy.send_event(event); + let Some(proxy) = guard.as_ref() else { return false }; + proxy.send_event(event).is_ok() } diff --git a/decorated-window-tao/src/main/native/src/window_jni.rs b/decorated-window-tao/src/main/native/src/window_jni.rs index f7d47d2b5..128c96b40 100644 --- a/decorated-window-tao/src/main/native/src/window_jni.rs +++ b/decorated-window-tao/src/main/native/src/window_jni.rs @@ -137,6 +137,32 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ }); } +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeSetMinimizable( + _env: JNIEnv, + _class: JClass, + handle: jlong, + minimizable: jboolean, +) { + send_user_event(UserEvent::SetMinimizable { + handle: handle as u64, + minimizable: minimizable != JNI_FALSE, + }); +} + +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeSetMaximizable( + _env: JNIEnv, + _class: JClass, + handle: jlong, + maximizable: jboolean, +) { + send_user_event(UserEvent::SetMaximizable { + handle: handle as u64, + maximizable: maximizable != JNI_FALSE, + }); +} + #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeRequestRedraw( _env: JNIEnv, @@ -441,6 +467,21 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ }); } +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeSetMaxInnerSize( + _env: JNIEnv, + _class: JClass, + handle: jlong, + width: jdouble, + height: jdouble, +) { + send_user_event(UserEvent::SetMaxInnerSize { + handle: handle as u64, + width, + height, + }); +} + #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeSetWindowIcon( env: JNIEnv, @@ -496,6 +537,34 @@ pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_ }); } +/// Linux only: see `UserEvent::PopupAnchor`. Logical parent-window pixels. +#[no_mangle] +pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeLinuxPopupAnchor( + _env: JNIEnv, + _class: JClass, + handle: jlong, + x: jint, + y: jint, + width: jint, + height: jint, + shadow_left: jint, + shadow_top: jint, + shadow_right: jint, + shadow_bottom: jint, +) { + send_user_event(UserEvent::PopupAnchor { + handle: handle as u64, + x, + y, + width, + height, + shadow_left, + shadow_top, + shadow_right, + shadow_bottom, + }); +} + #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_window_tao_ffi_NativeTaoBridge_nativeIsFullscreen( _env: JNIEnv, diff --git a/decorated-window-tao/src/main/native/vendor/accesskit-patches/README.md b/decorated-window-tao/src/main/native/vendor/accesskit-patches/README.md new file mode 100644 index 000000000..b1913c4c1 --- /dev/null +++ b/decorated-window-tao/src/main/native/vendor/accesskit-patches/README.md @@ -0,0 +1,34 @@ +# accesskit patches + +Local changes applied directly to the vendored AccessKit crates +(`../accesskit_atspi_common/`, `../accesskit_unix/`, `../accesskit_windows/`). + +Unlike tao, these are edited in place rather than kept as a `.patch` series — +they are small and few. Every one carries a `PATCH(nucleus)` comment at the +site, so `grep -rn 'PATCH(nucleus)' ../accesskit_*` lists the whole set before +a version bump. + +## Pinned upstream versions + +- **accesskit_atspi_common**, **accesskit_unix**, **accesskit_windows**: as + vendored; see `../../Cargo.toml` for the versions the tree was copied from. + +## Patch list + +| Crate | File | Summary | +| ----- | ---- | ------- | +| `accesskit_atspi_common` | `src/context.rs` | `AppContext::push_adapter` inserts at the position `adapter_index` searched for instead of pushing to the end. The list is looked up with `binary_search_by`, which is only defined on a sorted slice; adapters are created on the toolkit thread and registered from the AT-SPI worker, so an out-of-order registration made every later lookup unreliable. A duplicate id now replaces its entry rather than shadowing it. | +| `accesskit_atspi_common` | `src/adapter.rs` | The two `adapter_index(...).unwrap()` calls (in `add_subtree`'s root branch and in `register_tree`) skip the root announcement when the adapter is not in the app context, instead of panicking. The crate is built with `panic = "abort"`, so that miss aborted the whole JVM (`called Result::unwrap() on an Err value`, SIGABRT / exit 134) while a client was walking the AT-SPI tree of an application creating several windows at once. `src/node.rs` already treats the same miss as `Error::Defunct`, which is the behaviour these two sites now share. | + +## Bump procedure + +1. Copy the new crate sources over the vendored trees. +2. `grep -rn 'PATCH(nucleus)' vendor/accesskit_*` on the *previous* tree to + recover the list, and re-apply each one, checking whether upstream fixed it + first (upstream issue for the abort: + push/`binary_search` mismatch in `AppContext`). +3. `cargo check` from `src/main/native`, then run + `./gradlew :decorated-window-tao:taoHeadfulTest` with the a11y bus enabled + (`busctl --user set-property org.a11y.Bus /org/a11y/bus org.a11y.Status + IsEnabled b true`) — the abort only shows up while an assistive client is + attached. diff --git a/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/adapter.rs b/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/adapter.rs index 360cfd6c2..40ac73121 100644 --- a/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/adapter.rs +++ b/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/adapter.rs @@ -60,13 +60,18 @@ impl<'a> AdapterChangeHandler<'a> { self.adapter.register_interfaces(node.id(), interfaces); self.adapter.emit_cache_added(node.id()); if is_root && role == Role::Window { - let adapter_index = self + // PATCH(nucleus): skip the announcement when this adapter is not in + // the app context rather than unwrapping. The crate is built with + // `panic = "abort"`, so the miss took the whole application down — + // `node.rs` treats the same miss as `Error::Defunct`. + if let Ok(adapter_index) = self .adapter .context .read_app_context() .adapter_index(self.adapter.id) - .unwrap(); - self.adapter.window_created(adapter_index, node.id()); + { + self.adapter.window_created(adapter_index, node.id()); + } } let live = wrapper.live(); @@ -569,7 +574,10 @@ impl Adapter { let mut app_context = self.context.write_app_context(); app_context.toolkit_name = Some(tree_state.toolkit_name().to_string()); app_context.toolkit_version = tree_state.toolkit_version().map(|s| s.to_string()); - let adapter_index = app_context.adapter_index(self.id).unwrap(); + // PATCH(nucleus): see the miss handling above — an adapter whose + // registration has not been processed yet publishes its tree + // without the root announcement instead of aborting. + let adapter_index = app_context.adapter_index(self.id).ok(); let root = tree_state.root(); let root_id = root.id(); let wrapper = NodeWrapper(&root); @@ -581,7 +589,9 @@ impl Adapter { for (id, interfaces) in objects_to_add { self.register_interfaces(id, interfaces); if id == root_id { - self.window_created(adapter_index, id); + if let Some(index) = adapter_index { + self.window_created(index, id); + } } } } diff --git a/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/context.rs b/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/context.rs index 79e5fd77a..a72aa6207 100644 --- a/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/context.rs +++ b/decorated-window-tao/src/main/native/vendor/accesskit_atspi_common/src/context.rs @@ -110,8 +110,19 @@ impl AppContext { self.adapters.binary_search_by(|adapter| adapter.0.cmp(&id)) } + // PATCH(nucleus): keep `adapters` ordered by id. `adapter_index` searches it + // with `binary_search_by`, which is only defined on a sorted slice, while + // this pushed to the end — so an id registered out of order (adapters are + // created on the toolkit thread but registered from the AT-SPI worker) made + // every later lookup unreliable, and the two `unwrap()`s on that lookup + // aborted the whole process. Inserting at the searched position keeps the + // invariant the search assumes; a duplicate id replaces its entry rather + // than shadowing it. pub(crate) fn push_adapter(&mut self, id: usize, context: &Arc) { - self.adapters.push((id, Arc::clone(context))); + match self.adapter_index(id) { + Ok(index) => self.adapters[index] = (id, Arc::clone(context)), + Err(index) => self.adapters.insert(index, (id, Arc::clone(context))), + } } pub(crate) fn remove_adapter(&mut self, id: usize) { diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-linux-outer-geometry-placeholder.patch b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-linux-outer-geometry-placeholder.patch new file mode 100644 index 000000000..2aa0212a3 --- /dev/null +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/0007-linux-outer-geometry-placeholder.patch @@ -0,0 +1,45 @@ +--- a/src/platform_impl/linux/window.rs ++++ b/src/platform_impl/linux/window.rs +@@ -533,13 +533,41 @@ impl Window { + inner_size_clone.0.store(w as i32, Ordering::Release); + inner_size_clone.1.store(h as i32, Ordering::Release); + ++ // PATCH(nucleus): `gdk_window_get_frame_extents` answers with its ++ // (0, 0, 1, 1) placeholder until the window is mapped and — under a ++ // reparenting WM — framed. Storing it pins a 1x1 window at the screen ++ // origin in `outer_position` / `outer_size` until the *next* configure, ++ // which on a software-rendered X server under a lightweight WM (Xvfb + ++ // openbox, the CI Linux leg) is seconds away or never comes at all. ++ // Every consumer of the outer frame reads that instead: a torn-off ++ // window 1 dp wide, a satellite anchored against a 1px-tall child, a ++ // pointer aimed at a negative screen coordinate. ++ // ++ // Take the size from the configure event itself — its own size is the ++ // whole surface, shadow included, which is what the frame is for a ++ // client-side-decorated window, and unlike `configure_client_size` below ++ // it subtracts no decoration insets. Keep the last known *position*: ++ // every substitute for it is wrong in a way that is worse than being ++ // stale. `event.position()` is frame-relative under a reparenting WM ++ // (so it reads (0, 0)), `root_origin` is implemented through ++ // `frame_extents` and answers the placeholder too, and ++ // `gdk_window_get_origin` names the client rather than the frame, so ++ // anchoring one window against another mixes two different rectangles. + let (x, y, w, h) = window + .window() + .map(|w| { + let rect = w.frame_extents(); + (rect.x(), rect.y(), rect.width(), rect.height()) + }) +- .unwrap_or((x, y, w as i32, h as i32)); ++ .filter(|(_, _, w, h)| *w > 1 && *h > 1) ++ .unwrap_or_else(|| { ++ ( ++ outer_position_clone.0.load(Ordering::Acquire), ++ outer_position_clone.1.load(Ordering::Acquire), ++ ew as i32, ++ eh as i32, ++ ) ++ }); + + outer_position_clone.0.store(x, Ordering::Release); + outer_position_clone.1.store(y, Ordering::Release); diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch b/decorated-window-tao/src/main/native/vendor/tao-patches/0008-macos-scroll-phase-and-horizontal-sign.patch similarity index 100% rename from decorated-window-tao/src/main/native/vendor/tao-patches/0007-macos-scroll-phase-and-horizontal-sign.patch rename to decorated-window-tao/src/main/native/vendor/tao-patches/0008-macos-scroll-phase-and-horizontal-sign.patch diff --git a/decorated-window-tao/src/main/native/vendor/tao-patches/README.md b/decorated-window-tao/src/main/native/vendor/tao-patches/README.md index 4485214a1..170ec9c5e 100644 --- a/decorated-window-tao/src/main/native/vendor/tao-patches/README.md +++ b/decorated-window-tao/src/main/native/vendor/tao-patches/README.md @@ -20,7 +20,8 @@ Tao 0.35.0 is already vendored; this file is the living list of patches. | 0004 | `0004-linux-drain-draw-queue.patch` | 4 | Linux | `run_return`: treat pending redraws like pending events (don't park in the blocking `gtk_main_iteration` while `draws` is non-empty) and drain the whole draw channel per cycle instead of one redraw per wakeup. Fixes multi-window frame starvation (each window rendered at ~refresh/N). | | 0005 | `0005-linux-restore-activation-timestamp.patch` | 5 | Linux | Stamp `Focus` and `Minimized(false)` activations with a real X server timestamp (`gdk_x11_get_server_time`). Mutter's focus-stealing prevention drops `_NET_ACTIVE_WINDOW` requests carrying `GDK_CURRENT_TIME` (0) and keeps a deiconified window Iconic with `_NET_WM_STATE_DEMANDS_ATTENTION`, so restore/focus silently no-op and `EVENT_MINIMIZED(false)` never fires on GNOME X11/XWayland (openbox honors the 0 timestamp, which is why CI never saw it). No-op on Wayland. | | 0006 | `0006-linux-cursor-ignore-events-region.patch` | 6 | Linux | `CursorIgnoreEvents`: install a genuinely *empty* input region instead of upstream's 1x1 rectangle at the origin (which leaves the top-left pixel clickable), and clear it through the same `GdkWindow` with a NULL region. Upstream cleared it on the `GtkWidget`, which never undid a shape installed on the `GdkWindow`, so click-through could not be switched back off. | -| 0007 | `0007-macos-scroll-phase-and-horizontal-sign.patch` | 7 | macOS (+ field on all backends) | `WindowEvent::MouseWheel` gains `scroll_phase: ScrollPhase` — the full AppKit `phase` / `momentumPhase` of a trackpad scroll (`None` for a wheel, and on Windows / Linux), which `TouchPhase` cannot express; Nucleus routes gesture steps to Compose Pan events (#654). Also stops negating `scrollingDeltaX` in `scroll_wheel`: AppKit's sign already matches `MouseScrollDelta`'s documented convention (and winit), and the extra flip reversed horizontal trackpad scrolling once the consumer applied the AWT convention (#652). `PixelDelta` carries AppKit's logical points instead of `x backing scale`: AWT's `preciseWheelRotation` never sees the display scale (#653), and undoing the multiplication downstream with a second scale cache disagreed with the view's for a frame during display hops. | +| 0007 | `0007-linux-outer-geometry-placeholder.patch` | 7 | Linux | Stop latching GDK's `(0, 0, 1, 1)` frame-extents placeholder into `outer_position` / `outer_size`. `gdk_window_get_frame_extents` answers with it until the window is mapped and framed, so a `configure-event` that lands in that window pins it until the *next* one — seconds away, or never, on a software-rendered X server under a lightweight WM (the CI Xvfb + openbox leg). Consumers then read a 1x1 window at the screen origin: a torn-off window 1 dp wide, a satellite anchored against a 1px-tall child, a pointer aimed at a negative screen coordinate. The size falls back to the configure event's own (the whole surface, shadow included — what the frame is under CSD, and what `configure_client_size` subtracts the insets from); the position is kept as it was, since every substitute is wrong in a worse way — `event.position()` is frame-relative under a reparenting WM, `root_origin` goes back through `frame_extents`, and `gdk_window_get_origin` names the client rather than the frame. | +| 0008 | `0008-macos-scroll-phase-and-horizontal-sign.patch` | 8 | macOS (+ field on all backends) | `WindowEvent::MouseWheel` gains `scroll_phase: ScrollPhase` — the full AppKit `phase` / `momentumPhase` of a trackpad scroll (`None` for a wheel, and on Windows / Linux), which `TouchPhase` cannot express; Nucleus routes gesture steps to Compose Pan events (#654). Also stops negating `scrollingDeltaX` in `scroll_wheel`: AppKit's sign already matches `MouseScrollDelta`'s documented convention (and winit), and the extra flip reversed horizontal trackpad scrolling once the consumer applied the AWT convention (#652). `PixelDelta` carries AppKit's logical points instead of `x backing scale`: AWT's `preciseWheelRotation` never sees the display scale (#653), and undoing the multiplication downstream with a second scale cache disagreed with the view's for a frame during display hops. | ## Bump procedure (e.g. 0.35 → 0.36) diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs index 7e151f692..80b649b3f 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform/unix.rs @@ -98,6 +98,11 @@ pub trait WindowExtUnix { /// point leaves the candidate window free to sit on top of the composition. /// Callers that know the caret's size should use this. fn set_ime_cursor_area, S: Into>(&self, position: P, size: S); + + /// Nucleus patch: anchor a popup overlay (`with_popup_transient_for`) at a + /// logical point of its parent so GDK maps it as a compositor-positioned + /// `xdg_popup`. See the platform `Window::popup_anchor`. + fn popup_anchor(&self, x: i32, y: i32, width: i32, height: i32, shadow: (i32, i32, i32, i32)); } impl WindowExtUnix for Window { @@ -128,6 +133,10 @@ impl WindowExtUnix for Window { fn set_ime_cursor_area, S: Into>(&self, position: P, size: S) { self.window.set_ime_cursor_area(position, size); } + + fn popup_anchor(&self, x: i32, y: i32, width: i32, height: i32, shadow: (i32, i32, i32, i32)) { + self.window.popup_anchor(x, y, width, height, shadow); + } } pub trait WindowBuilderExtUnix { diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs index 6cf94fb63..5c62c5e51 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/event_loop.rs @@ -53,6 +53,23 @@ use super::{ use taskbar::TaskbarIndicator; +/// Whether GTK focus sits on a widget Nucleus did not create — an embedded +/// native view (`NativeView`), which the widget bridge never marks with +/// `nucleus_tao_input_box` the way it marks its own capture boxes. Keys then +/// belong to the embed: no IME filtering on its behalf, no delivery to +/// Compose, plain GTK propagation to the focus widget. Without this the +/// toplevel's `GtkIMContext` consumed every printable key and the handler +/// stopped propagation, so a `WebKitWebView` or a `GtkEntry` the user had +/// clicked into never received a single character. +fn embed_owns_keyboard(window: >k::Window) -> bool { + let Some(focus) = window.focused_widget() else { + return false; + }; + // SAFETY: only the presence of the key is read; the pointer stored under it + // (a non-null marker set by the widget bridge) is never dereferenced. + unsafe { glib::prelude::ObjectExt::data::<()>(&focus, "nucleus_tao_input_box").is_none() } +} + #[derive(Clone)] pub struct EventLoopWindowTarget { /// Gdk display @@ -325,6 +342,13 @@ impl EventLoop { match request { WindowRequest::Title(title) => window.set_title(&title), WindowRequest::Position((x, y)) => window.move_(x, y), + WindowRequest::PopupAnchor { + x, + y, + width, + height, + shadow, + } => popup_anchor(&window, x, y, width, height, shadow), WindowRequest::Size((w, h)) => { // Nucleus patch: `gtk_window_resize` is a no-op on non-resizable // windows (GTK follows the content's natural size instead); route @@ -1136,7 +1160,10 @@ impl EventLoop { let handler = keyboard_handler.clone(); let ime_ = ime.clone(); let ime_state_press = ime_state.clone(); - window.connect_key_press_event(move |_, event_key| { + window.connect_key_press_event(move |window, event_key| { + if embed_owns_keyboard(window) { + return glib::Propagation::Proceed; + } // The IME gets first refusal, and a key it consumed must not also // reach Compose — otherwise the Enter that confirms a conversion // also inserts a newline, and the BackSpace that edits the @@ -1151,12 +1178,19 @@ impl EventLoop { } handler(event_key.to_owned(), ElementState::Pressed); - glib::Propagation::Proceed + // Compose owns the keyboard and has the key: stop here so GtkWindow's + // own bindings do not run on it too — an arrow or a Tab would + // otherwise `move-focus` into an embedded native view, which then + // steals every following keystroke from the Compose text field. + glib::Propagation::Stop }); let handler = keyboard_handler.clone(); let ime_state_release = ime_state; - window.connect_key_release_event(move |_, event_key| { + window.connect_key_release_event(move |window, event_key| { + if embed_owns_keyboard(window) { + return glib::Propagation::Proceed; + } let filtered = ime.filter_keypress(event_key); if !ime_state_release .borrow_mut() @@ -1165,7 +1199,7 @@ impl EventLoop { return glib::Propagation::Stop; } handler(event_key.to_owned(), ElementState::Released); - glib::Propagation::Proceed + glib::Propagation::Stop }); let tx_clone = event_tx.clone(); @@ -1614,3 +1648,77 @@ impl ResizeDirection { } } } + +/// Nucleus patch: the compositor-positioned popup behind +/// `Window::popup_anchor`. `gdk_window_move_to_rect` arrived in GDK 3.24; it +/// is resolved at run time so the library still loads against 3.22, where the +/// request degrades to the plain move a subsurface popup gets. +fn popup_anchor( + window: >k::Window, + x: i32, + y: i32, + width: i32, + height: i32, + shadow: (i32, i32, i32, i32), +) { + use glib::translate::ToGlibPtr; + type MoveToRect = unsafe extern "C" fn( + *mut gdk::ffi::GdkWindow, + *const gdk::ffi::GdkRectangle, + i32, + i32, + i32, + i32, + i32, + ); + extern "C" { + fn dlsym(handle: *mut std::ffi::c_void, symbol: *const std::os::raw::c_char) -> *mut std::ffi::c_void; + } + const GDK_GRAVITY_NORTH_WEST: i32 = 1; + const GDK_ANCHOR_FLIP_X: i32 = 1 << 0; + const GDK_ANCHOR_FLIP_Y: i32 = 1 << 1; + const GDK_ANCHOR_SLIDE_X: i32 = 1 << 2; + const GDK_ANCHOR_SLIDE_Y: i32 = 1 << 3; + let (left, right, top, bottom) = shadow; + // RTLD_DEFAULT: GDK is already loaded into the process. + let symbol = unsafe { dlsym(std::ptr::null_mut(), b"gdk_window_move_to_rect\0".as_ptr() as *const _) }; + if symbol.is_null() { + window.move_(x - left, y - top); + return; + } + let move_to_rect: MoveToRect = unsafe { std::mem::transmute(symbol) }; + // A popup menu maps as an xdg_popup on Wayland even where GDK would ignore + // the positioner; harmless on X11 (a menu-typed override-redirect window). + window.set_type_hint(gdk::WindowTypeHint::PopupMenu); + // The positioner GDK builds at map time takes the window's geometry as it + // stands, so the real size must be in place *before* `move_to_rect` — hence + // the size request, the realize and the resize pass here rather than a + // separate `WindowRequest::Size`. Popup overlays are non-resizable, where + // `gtk_window_resize` is a no-op and the size request is what counts. + if width > 0 && height > 0 { + window.set_size_request(width, height); + window.resize(width, height); + } + if !window.is_realized() { + window.realize(); + } + window.check_resize(); + let Some(gdk_window) = window.window() else { + return; + }; + // GTK only manages the shadow width of client-decorated windows, so this + // sticks: the xdg window geometry becomes the content, margins excluded. + gdk_window.set_shadow_width(left, right, top, bottom); + let rect = gdk::Rectangle::new(x, y, 1, 1); + unsafe { + move_to_rect( + gdk_window.to_glib_none().0, + rect.to_glib_none().0, + GDK_GRAVITY_NORTH_WEST, + GDK_GRAVITY_NORTH_WEST, + GDK_ANCHOR_FLIP_X | GDK_ANCHOR_FLIP_Y | GDK_ANCHOR_SLIDE_X | GDK_ANCHOR_SLIDE_Y, + 0, + 0, + ); + } +} diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs index c1310cabd..6b25dc9e6 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/linux/window.rs @@ -533,13 +533,41 @@ impl Window { inner_size_clone.0.store(w as i32, Ordering::Release); inner_size_clone.1.store(h as i32, Ordering::Release); + // PATCH(nucleus): `gdk_window_get_frame_extents` answers with its + // (0, 0, 1, 1) placeholder until the window is mapped and — under a + // reparenting WM — framed. Storing it pins a 1x1 window at the screen + // origin in `outer_position` / `outer_size` until the *next* configure, + // which on a software-rendered X server under a lightweight WM (Xvfb + + // openbox, the CI Linux leg) is seconds away or never comes at all. + // Every consumer of the outer frame reads that instead: a torn-off + // window 1 dp wide, a satellite anchored against a 1px-tall child, a + // pointer aimed at a negative screen coordinate. + // + // Take the size from the configure event itself — its own size is the + // whole surface, shadow included, which is what the frame is for a + // client-side-decorated window, and unlike `configure_client_size` below + // it subtracts no decoration insets. Keep the last known *position*: + // every substitute for it is wrong in a way that is worse than being + // stale. `event.position()` is frame-relative under a reparenting WM + // (so it reads (0, 0)), `root_origin` is implemented through + // `frame_extents` and answers the placeholder too, and + // `gdk_window_get_origin` names the client rather than the frame, so + // anchoring one window against another mixes two different rectangles. let (x, y, w, h) = window .window() .map(|w| { let rect = w.frame_extents(); (rect.x(), rect.y(), rect.width(), rect.height()) }) - .unwrap_or((x, y, w as i32, h as i32)); + .filter(|(_, _, w, h)| *w > 1 && *h > 1) + .unwrap_or_else(|| { + ( + outer_position_clone.0.load(Ordering::Acquire), + outer_position_clone.1.load(Ordering::Acquire), + ew as i32, + eh as i32, + ) + }); outer_position_clone.0.store(x, Ordering::Release); outer_position_clone.1.store(y, Ordering::Release); @@ -993,6 +1021,36 @@ impl Window { /// off it, which is why the caret's *size* matters here and not on Windows. /// GDK works in logical pixels, so the caller's physical rect is scaled down /// on the way in. + /// Nucleus patch: anchor a `GTK_WINDOW_POPUP` overlay at a point of its + /// transient parent through `gdk_window_move_to_rect`, so GDK maps it as an + /// `xdg_popup` the compositor keeps on screen (flipped above the point when + /// there is no room below, slid along an edge) instead of a `wl_subsurface` + /// it lets hang off the display. `(x, y)` are logical parent-window + /// coordinates of the content's top-left; `shadow` = (left, right, top, + /// bottom) transparent margins the surface carries around that content, + /// declared as the popup's shadow width so the compositor constrains the + /// content, not the margin. `width`/`height` are the whole surface in + /// logical pixels, applied here rather than left to a separate size request: + /// GDK builds the `xdg_positioner` from the window's *current* geometry, so a + /// popup still sized 1×1 at this point asks the compositor to constrain a + /// 1×1 rectangle and never gets flipped. GDK positions a popup once, at map: + /// call before the window is shown. + pub fn popup_anchor(&self, x: i32, y: i32, width: i32, height: i32, shadow: (i32, i32, i32, i32)) { + if let Err(e) = self.window_requests_tx.send(( + self.window_id, + WindowRequest::PopupAnchor { + x, + y, + width, + height, + shadow, + }, + )) + { + log::warn!("Fail to send popup anchor request: {}", e); + } + } + pub fn set_ime_cursor_area, S: Into>(&self, position: P, size: S) { let scale_factor = self.scale_factor(); let (x, y): (i32, i32) = position.into().to_logical::(scale_factor).into(); @@ -1334,6 +1392,15 @@ pub enum WindowRequest { /// Nucleus patch (nucleusframework#558): the rectangle the caret occupies, /// in window-local logical pixels, for the input method to steer clear of. SetImeCursorArea((i32, i32, i32, i32)), + /// Nucleus patch: anchor a popup overlay at a point of its transient parent + /// through `gdk_window_move_to_rect` — see `Window::popup_anchor`. + PopupAnchor { + x: i32, + y: i32, + width: i32, + height: i32, + shadow: (i32, i32, i32, i32), + }, WireUpEvents { transparent: bool, fullscreen: bool, diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/app.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/app.rs index a83363eef..abf6c6add 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/app.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/app.rs @@ -21,10 +21,33 @@ pub static APP_CLASS: Lazy = Lazy::new(|| unsafe { ClassDecl::new(CStr::from_bytes_with_nul(b"TaoApp\0").unwrap(), superclass).unwrap(); decl.add_method(sel!(sendEvent:), send_event as extern "C" fn(_, _, _)); + decl.add_method(sel!(terminate:), terminate as extern "C" fn(_, _, _)); AppClass(decl.register()) }); +extern "C" { + // Nucleus: defined in the nucleus_tao crate (platform/macos/main_thread.rs). + fn nucleus_tao_post_quit_requested() -> bool; +} + +// Nucleus: every system quit — Dock → Quit, the app menu's Quit item, an +// AppleScript `quit`, logout / restart / shutdown — lands here. Like +// Electron's `-[ElectronApplication terminate:]`, it only *asks* the app to +// quit (each window's close request, see `TaoApplication.requestQuit`) and +// returns, so the quit Apple event is answered "OK" and loginwindow waits for +// the process to exit instead of aborting the logout at once. Once the event +// loop is gone (the fatal-error dialog after it) the real terminate runs. +extern "C" fn terminate(this: &NSApplication, _sel: Sel, sender: *mut objc2::runtime::AnyObject) { + if unsafe { nucleus_tao_post_quit_requested() } { + return; + } + unsafe { + let superclass = util::superclass(this); + let _: () = msg_send![super(this, superclass), terminate: sender]; + } +} + // Normally, holding Cmd + any key never sends us a `keyUp` event for that key. // Overriding `sendEvent:` like this fixes that. (https://stackoverflow.com/a/15294196) // Fun fact: Firefox still has this bug! (https://bugzilla.mozilla.org/show_bug.cgi?id=1299553) diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/util/async.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/util/async.rs index 1dd2d09e5..424157887 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/util/async.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/util/async.rs @@ -4,19 +4,21 @@ use std::{ ops::Deref, - sync::{Mutex, Weak}, + sync::{Arc, Mutex, Weak}, }; use core_graphics::base::CGFloat; -use dispatch2::{DispatchQueue, DispatchQueueAttr}; +use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchTime}; +use objc2::rc::Retained; +use std::time::{Duration, Instant}; use objc2::{rc::autoreleasepool, Message}; use objc2_app_kit::{NSScreen, NSView, NSWindow, NSWindowStyleMask}; -use objc2_foundation::{MainThreadMarker, NSPoint, NSSize, NSString}; +use objc2_foundation::{MainThreadMarker, NSPoint, NSRect, NSSize, NSString}; use crate::{ dpi::LogicalSize, platform_impl::platform::{ - ffi::{self, id, NO, YES}, + ffi::{self, id, YES}, window::SharedState, }, }; @@ -179,69 +181,39 @@ pub unsafe fn set_maximized_async( let mut shared_state_lock = shared_state.lock().unwrap(); // Save the standard frame sized if it is not zoomed. - // PATCH(nucleus): only when actually maximizing — an unmaximize issued - // while the zoom animation is still running arrives with - // `is_zoomed == false` (frame mid-flight), and saving here would - // overwrite the real pre-zoom frame with a half-grown one, making the - // restore target wrong. - if !is_zoomed && maximized { + // PATCH(nucleus): only when actually maximizing and no zoom animation + // is in flight — a request issued mid-animation sees `is_zoomed == + // false` (frame mid-flight), and saving here would overwrite the real + // pre-zoom frame with a half-grown one, making the restore target wrong. + if !is_zoomed && maximized && !shared_state_lock.zoom_animating { shared_state_lock.standard_frame = Some(NSWindow::frame(&ns_window)); } shared_state_lock.maximized = maximized; - let curr_mask = ns_window.styleMask(); if shared_state_lock.fullscreen.is_some() { // Handle it in window_did_exit_fullscreen return; - } else if curr_mask.contains(NSWindowStyleMask::Resizable) - && curr_mask.contains(NSWindowStyleMask::Titled) - { - // PATCH(nucleus): upstream calls `ns_window.zoom(None)` here. AppKit's - // `zoom:` runs its resize animation SYNCHRONOUSLY on the main thread - // (~350 ms) in a private run-loop mode that services neither the main - // dispatch queue nor observers registered on `kCFRunLoopCommonModes`. - // Tao's run-loop observer therefore never drains the queued - // `WindowEvent::Resized` (windowDidResize: fires per animation step) - // until the animation completes — the embedder sees a single Resized - // at the end, so the content is stretched for the whole animation and - // snaps into place at the end. - // - // Instead, compute the zoom target frame ourselves (the same frames - // `zoom:` uses: screen visibleFrame ⇄ saved standard frame) and - // animate via the NSWindow animator proxy, which is non-blocking: the - // run loop keeps turning in common modes, windowDidResize: fires per - // step, and the embedder receives live Resized events throughout. - // `is_zoomed()` is frame-based (see window.rs) so bypassing `zoom:` - // keeps the maximized-state tracking consistent. + } + // PATCH(nucleus): upstream calls `ns_window.zoom(None)` on a resizable + // titled window and `setFrame:display:NO animate:YES` otherwise — both + // AppKit's blocking animator, which `TaoWindow` reroutes to + // `animate_frame` below (`set_frame_display_animate`, window.rs). Zoom + // between the frames `zoom:` uses: screen visibleFrame ⇄ saved standard + // frame. `is_zoomed()` is frame-based (see window.rs) so bypassing + // `zoom:` keeps the maximized-state tracking consistent. + let target = if maximized { let mtm = MainThreadMarker::new_unchecked(); - let screen = ns_window.screen().or_else(|| NSScreen::mainScreen(mtm)); - let target = if maximized { - match screen { - Some(screen) => NSScreen::visibleFrame(&screen), - None => return, - } - } else { - shared_state_lock.saved_standard_frame() - }; - let duration: f64 = msg_send![&*ns_window, animationResizeTime: target]; - let _: () = msg_send![class!(NSAnimationContext), beginGrouping]; - let ctx: id = msg_send![class!(NSAnimationContext), currentContext]; - let _: () = msg_send![ctx, setDuration: duration]; - let animator: id = msg_send![&*ns_window, animator]; - let _: () = msg_send![animator, setFrame: target, display: YES]; - let _: () = msg_send![class!(NSAnimationContext), endGrouping]; + match ns_window.screen().or_else(|| NSScreen::mainScreen(mtm)) { + Some(screen) => NSScreen::visibleFrame(&screen), + None => return, + } } else { - // if it's not resizable, we set the frame directly - let new_rect = if maximized { - let mtm = MainThreadMarker::new_unchecked(); - let screen = NSScreen::mainScreen(mtm).unwrap(); - NSScreen::visibleFrame(&screen) - } else { - shared_state_lock.saved_standard_frame() - }; - let _: () = msg_send![&*ns_window, setFrame:new_rect, display:NO, animate: YES]; - } + shared_state_lock.saved_standard_frame() + }; + // `animate_frame` takes the lock itself. + drop(shared_state_lock); + let _: () = msg_send![&*ns_window, setFrame: target, display: YES, animate: YES]; trace!("Unlocked shared state in `set_maximized`"); } @@ -305,3 +277,91 @@ pub unsafe fn set_ignore_mouse_events(ns_window: &NSWindow, ignore: bool) { ns_window.setIgnoresMouseEvents(ignore); }); } + +// PATCH(nucleus): tao's frame animation — what `setFrame:display:animate:YES` +// resolves to on a `TaoWindow` (window.rs): `set_maximized_async` above, and +// the zooms AppKit starts on its own — a double-click on a resize edge +// (`_zoomToScreenEdge:`), the Window-menu tiling (`_zoomLeft:` and friends), +// `zoom:`. AppKit's own animator runs SYNCHRONOUSLY on the main thread +// (~250 ms) in a private run-loop mode that services neither the main +// dispatch queue nor observers registered on `kCFRunLoopCommonModes`, so every +// step's `windowDidResize:` only queued a `Resized` and the embedder got the +// whole run once the window already sat at the target: the content snapped +// into place (Nucleus #576). Animating through the NSWindow animator proxy +// instead delivered a Resized per step, but the steps are Core Animation's: +// overlapping requests run overlapping animations whose final frame is +// whichever finishes last, and presenting the content synchronously on each +// step left them stopping mid-flight. +// +// So step the frame ourselves, on the main queue, 60 times a second, easing +// between the current frame and the target over `animationResizeTime:`. Every +// step is a plain `setFrame:display:`, so the embedder gets one Resized per +// step and can present the content for it in the same turn; a new request +// bumps `zoom_generation`, which stops the chain in flight and starts over +// from the frame it had reached. +pub(crate) unsafe fn animate_frame( + ns_window: &NSWindow, + shared_state: &Arc>, + target: NSRect, +) { + let duration: f64 = msg_send![ns_window, animationResizeTime: target]; + let (generation, from) = { + let mut state = shared_state.lock().unwrap(); + state.zoom_generation += 1; + state.zoom_animating = true; + (state.zoom_generation, NSWindow::frame(ns_window)) + }; + zoom_step( + MainThreadSafe(ns_window.retain()), + MainThreadSafe(Arc::downgrade(shared_state)), + generation, + from, + target, + Instant::now(), + duration.max(ZOOM_MIN_DURATION_SECS), + ); +} + +// One step of `animate_frame`; re-schedules itself until the target is +// reached or a newer request has bumped `zoom_generation`. +const ZOOM_STEP_MS: u64 = 16; +const ZOOM_MIN_DURATION_SECS: f64 = 0.05; + +unsafe fn zoom_step( + ns_window: MainThreadSafe>, + shared_state: MainThreadSafe>>, + generation: u64, + from: NSRect, + to: NSRect, + started: Instant, + duration: f64, +) { + let when = DispatchTime::try_from(Duration::from_millis(ZOOM_STEP_MS)).unwrap_or(DispatchTime::NOW); + let _ = DispatchQueue::main().after(when, move || { + let Some(state) = shared_state.upgrade() else { + return; + }; + if state.lock().unwrap().zoom_generation != generation { + return; + } + let t = (started.elapsed().as_secs_f64() / duration).min(1.0); + // Ease in-out, like AppKit's own window frame animation. + let e = 0.5 - 0.5 * (std::f64::consts::PI * t).cos(); + let frame = NSRect::new( + NSPoint::new( + from.origin.x + (to.origin.x - from.origin.x) * e, + from.origin.y + (to.origin.y - from.origin.y) * e, + ), + NSSize::new( + from.size.width + (to.size.width - from.size.width) * e, + from.size.height + (to.size.height - from.size.height) * e, + ), + ); + ns_window.setFrame_display(frame, true); + if t < 1.0 { + zoom_step(ns_window, shared_state, generation, from, to, started, duration); + } else { + state.lock().unwrap().zoom_animating = false; + } + }); +} diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs index b3b19bd3b..30a38434c 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/view.rs @@ -1152,14 +1152,24 @@ extern "C" fn right_mouse_up(this: &NSView, _sel: Sel, event: &NSEvent) { mouse_click(this, event, MouseButton::Right, ElementState::Released); } +// Nucleus: `otherMouseDown:` fires for every button past the right one, so +// read `buttonNumber` instead of assuming Middle — 3/4 are the back/forward +// side buttons, surfaced as `Other(3)` / `Other(4)`. +fn other_mouse_button(event: &NSEvent) -> MouseButton { + match event.buttonNumber() { + 2 => MouseButton::Middle, + n => MouseButton::Other(n as u16), + } +} + extern "C" fn other_mouse_down(this: &NSView, _sel: Sel, event: &NSEvent) { mouse_motion(this, event); - mouse_click(this, event, MouseButton::Middle, ElementState::Pressed); + mouse_click(this, event, other_mouse_button(event), ElementState::Pressed); } extern "C" fn other_mouse_up(this: &NSView, _sel: Sel, event: &NSEvent) { mouse_motion(this, event); - mouse_click(this, event, MouseButton::Middle, ElementState::Released); + mouse_click(this, event, other_mouse_button(event), ElementState::Released); } fn mouse_motion(this: &NSView, event: &NSEvent) { @@ -1218,7 +1228,7 @@ extern "C" fn other_mouse_dragged(this: &NSView, _sel: Sel, event: &NSEvent) { mouse_motion(this, event); } -extern "C" fn mouse_entered(this: &Object, _sel: Sel, _event: id) { +extern "C" fn mouse_entered(this: &NSView, _sel: Sel, event: &NSEvent) { trace!("Triggered `mouseEntered`"); unsafe { let state_ptr: *mut c_void = *this.get_ivar("taoState"); @@ -1233,12 +1243,79 @@ extern "C" fn mouse_entered(this: &Object, _sel: Sel, _event: id) { AppState::queue_event(EventWrapper::StaticEvent(enter_event)); } + // PATCH(nucleus): publish *where* the cursor entered, which AppKit hands us + // in the event and tao drops. `CursorEntered` carries no position, so a + // consumer that tracks the pointer (Compose's hover) only learns it on the + // next `mouseMoved:` — and a pointer that *rests* after entering sends none. + mouse_motion(this, event); trace!("Completed `mouseEntered`"); } -extern "C" fn mouse_exited(this: &Object, _sel: Sel, _event: id) { +/// Is the cursor still over this view, whatever AppKit just claimed? +/// +/// Inside the view's own bounds, and the window on top at that screen point is +/// this one — or one of its **child** windows, which is how Nucleus hosts a +/// native popup layer. A popup that opens over the pointer must not take the +/// owner's hover with it: the two are one scene to the app, and the owner +/// answers for the pointer everywhere the popup's content does not. +unsafe fn cursor_is_still_inside(this: &NSView, event: &NSEvent) -> bool { + let view_point = this.convertPoint_fromView(event.locationInWindow(), None); + let bounds = NSView::bounds(this); + let inside = view_point.x >= 0.0 + && view_point.y >= 0.0 + && view_point.x <= bounds.size.width + && view_point.y <= bounds.size.height; + if !inside { + return false; + } + let window: id = msg_send![this, window]; + if window.is_null() { + return false; + } + let screen_point: NSPoint = msg_send![class!(NSEvent), mouseLocation]; + let top: NSInteger = msg_send![ + class!(NSWindow), + windowNumberAtPoint: screen_point + belowWindowWithWindowNumber: 0 as NSInteger + ]; + let mine: NSInteger = msg_send![window, windowNumber]; + if top == mine { + return true; + } + let children: id = msg_send![window, childWindows]; + if children.is_null() { + return false; + } + let count: NSUInteger = msg_send![children, count]; + for index in 0..count { + let child: id = msg_send![children, objectAtIndex: index]; + let child_number: NSInteger = msg_send![child, windowNumber]; + if child_number == top { + return true; + } + } + false +} + +extern "C" fn mouse_exited(this: &NSView, _sel: Sel, event: &NSEvent) { trace!("Triggered `mouseExited`"); unsafe { + // PATCH(nucleus): AppKit fires `mouseExited:` for a cursor that never left + // — measured on macOS 26 with the pointer parked over a tab strip: enter → + // exit → enter → exit at one screen point, the exits raised by a hover + // card's own popup panel rising over the pointer (a child window of ours) + // and by tracking-rect rebuilds. Compose takes the exit at face value and + // drops its hover state, and a *resting* pointer sends nothing afterwards + // to correct it: hover effects and hover cards stay dead until the user + // moves the mouse. Worse, a card that dies on its own exit reopens and + // exits again, which is a loop no pointer can break. Trust the geometry + // over the event: re-publish the position instead, and keep `CursorLeft` + // for a cursor that really is somewhere else. + if cursor_is_still_inside(this, event) { + trace!("Ignored a `mouseExited` with the cursor still inside"); + mouse_motion(this, event); + return; + } let state_ptr: *mut c_void = *this.get_ivar("taoState"); let state = &mut *(state_ptr as *mut ViewState); diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window.rs index 827b5c3eb..db7501813 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window.rs @@ -30,7 +30,7 @@ use crate::{ monitor::{self, MonitorHandle, VideoMode}, util::{self, IdRef}, view::{self, new_view, CursorState}, - window_delegate::new_delegate, + window_delegate::{new_delegate, shared_state_of}, OsError, }, set_badge_label, set_progress_indicator, @@ -421,6 +421,10 @@ static WINDOW_CLASS: Lazy = Lazy::new(|| unsafe { is_focusable as extern "C" fn(_, _) -> _, ); decl.add_method(sel!(sendEvent:), send_event as extern "C" fn(_, _, _)); + decl.add_method( + sel!(setFrame:display:animate:), + set_frame_display_animate as extern "C" fn(_, _, _, _, _), + ); // progress bar states, follows ProgressState decl.add_ivar::(CStr::from_bytes_with_nul(b"focusable\0").unwrap()); WindowClass(decl.register()) @@ -449,6 +453,39 @@ extern "C" fn send_event(this: &Object, _sel: Sel, event: &NSEvent) { } } +// PATCH(nucleus): every animated frame change — AppKit's own (the double-click +// on a resize edge, `_zoomToScreenEdge:`; the Window-menu tiling; `zoom:`) and +// tao's `set_maximized` — runs through `util::animate_frame` instead of +// AppKit's blocking animator, whose private run-loop mode hands the embedder +// every step's `Resized` only once the window sits at the target (Nucleus +// #576; the why is on `animate_frame`). Not during a fullscreen transition, +// which is AppKit's own animation (#327). +extern "C" fn set_frame_display_animate( + this: &Object, + _: Sel, + frame: NSRect, + display: Bool, + animate: Bool, +) { + unsafe { + let ns_window = &*(this as *const Object as *const NSWindow); + if animate.as_bool() { + if let Some(shared_state) = shared_state_of(ns_window) { + let own = { + let state = shared_state.lock().unwrap(); + !state.in_fullscreen_transition && state.fullscreen.is_none() + }; + if own { + util::animate_frame(ns_window, &shared_state, frame); + return; + } + } + } + let superclass = util::superclass(this); + let _: () = msg_send![super(this, superclass), setFrame: frame, display: display, animate: animate]; + } +} + #[derive(Default)] pub struct SharedState { pub resizable: bool, @@ -462,6 +499,13 @@ pub struct SharedState { pub target_fullscreen: Option>, pub maximized: bool, pub standard_frame: Option, + // PATCH(nucleus): the stepped zoom animation of `set_maximized_async`. + // Bumped by every request; a step whose generation is stale stops, so a + // new request cancels the animation in flight and restarts from the + // current frame. `zoom_animating` guards `standard_frame` against being + // overwritten with a mid-flight frame. + pub zoom_generation: u64, + pub zoom_animating: bool, is_simple_fullscreen: bool, pub saved_style: Option, /// Presentation options saved before entering `set_simple_fullscreen`, and @@ -1035,6 +1079,18 @@ impl UnownedWindow { // which *does* call `zoom:` and produces exactly the animation we // just avoided. Frame comparison gives a consistent answer regardless // of how the maximized state was applied. + // + // While the stepped zoom of `set_maximized_async` is in flight the frame + // is half-way between the two states, so report the one it is heading to + // — the state the app asked for. A frame-based answer mid-animation reads + // "floating" half-way through a maximize, and the state-sync layer's + // un-zoom then no-ops because its bookkeeping already says Floating. + // `try_lock`: never block behind a caller holding the state. + if let Ok(state) = self.shared_state.try_lock() { + if state.zoom_animating { + return state.maximized; + } + } unsafe { if let Some(screen) = self.ns_window.screen() { let frame = self.ns_window.frame(); diff --git a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window_delegate.rs b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window_delegate.rs index e4f46d2cb..a9bb3ed8b 100644 --- a/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window_delegate.rs +++ b/decorated-window-tao/src/main/native/vendor/tao/src/platform_impl/macos/window_delegate.rs @@ -6,7 +6,7 @@ use std::{ f64, ffi::CStr, os::raw::c_void, - sync::{Arc, Weak}, + sync::{Arc, Mutex, Weak}, }; use objc2::{ @@ -30,7 +30,7 @@ use crate::{ ffi::{id, nil, BOOL, NO, YES}, util::{self, IdRef}, view::ViewState, - window::{get_ns_theme, get_window_id, UnownedWindow}, + window::{get_ns_theme, get_window_id, SharedState, UnownedWindow}, }, window::{Fullscreen, WindowId}, }; @@ -277,6 +277,24 @@ static WINDOW_DELEGATE_CLASS: Lazy = Lazy::new(|| unsafe { WindowDelegateClass(decl.register()) }); +// PATCH(nucleus): the shared state behind a `TaoWindow`, read through its +// delegate — for the window class's own overrides (`setFrame:display:animate:` +// in window.rs). `None` when the delegate is not ours. +pub fn shared_state_of(ns_window: &NSWindow) -> Option>> { + #[allow(deprecated)] // TODO: Use define_class! + unsafe { + let delegate: id = msg_send![ns_window, delegate]; + if delegate.is_null() || !std::ptr::eq((*delegate).class() as *const Class, WINDOW_DELEGATE_CLASS.0) { + return None; + } + let state_ptr: *mut c_void = *(*delegate).get_ivar("taoState"); + (*(state_ptr as *mut WindowDelegateState)) + .window + .upgrade() + .map(|window| window.shared_state.clone()) + } +} + // This function is definitely unsafe, but labeling that would increase // boilerplate and wouldn't really clarify anything... fn with_state T, T>(this: &Object, callback: F) { diff --git a/decorated-window-tao/src/main/native/windows/fetch-angle.sh b/decorated-window-tao/src/main/native/windows/fetch-angle.sh deleted file mode 100644 index 715c53cc4..000000000 --- a/decorated-window-tao/src/main/native/windows/fetch-angle.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# Fetches the ANGLE runtime DLLs (libEGL.dll + libGLESv2.dll) used by the Tao -# Windows backend's Direct3D-11 render path, and drops them into the gitignored -# native resource directories. -# -# ANGLE (BSD-licensed, https://chromium.googlesource.com/angle/angle) translates -# the OpenGL ES calls Skia issues into Direct3D 11. This gives the Tao backend a -# DirectX render path with a guaranteed WARP software fallback (works on RDP / VM -# / driverless boxes where the native WGL path can only obtain a GL 1.1 context -# that Skia's DirectContext.makeGL() rejects). -# -# We do NOT commit the DLLs (binaries never live in git, see .gitignore) and we -# do NOT build ANGLE from source (depot_tools + GN, hours). Instead we extract -# them from a PINNED Electron release — a stable, versioned, BSD/MIT source that -# publishes an official SHASUMS256.txt. The expected hashes are also pinned here -# as defense-in-depth, so a tampered mirror is caught even if SHASUMS256.txt is -# swapped too. -# -# Runs both locally (plain bash) and in CI (build-natives.yaml, shell: bash). -# Usage: fetch-angle.sh [x64|arm64|all] (default: all) -set -euo pipefail - -ELECTRON_VERSION="v42.3.3" -BASE_URL="https://github.com/electron/electron/releases/download/${ELECTRON_VERSION}" - -# SHA-256 of the Electron release zips (from the official SHASUMS256.txt for -# ${ELECTRON_VERSION}). Pinned here so the download is verified twice. -SHA_X64="d204d1aaf76e80db6102c482a2f7cc6d20c6c570e9ac6ac5bfee61155467e6a0" -SHA_ARM64="2f62636597a6a9693f51b428be73322713e970e348c5c4d0cccf37bf148c9c2f" - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -RES_DIR="$(cd "${SCRIPT_DIR}/../../resources/nucleus/native" && pwd)" - -want="${1:-all}" - -sha256_of() { - # Cross-platform sha256: prefer sha256sum (git-bash/Linux), fall back to certutil (Windows). - if command -v sha256sum >/dev/null 2>&1; then - sha256sum "$1" | awk '{print $1}' - else - certutil -hashfile "$1" SHA256 | sed -n 2p | tr -d ' \r' - fi -} - -fetch_arch() { - local arch="$1" expected_sha="$2" out_subdir="$3" - local zip_name="electron-${ELECTRON_VERSION}-win32-${arch}.zip" - local url="${BASE_URL}/${zip_name}" - local out_dir="${RES_DIR}/${out_subdir}" - local tmp; tmp="$(mktemp -d)" - trap 'rm -rf "${tmp}"' RETURN - - echo "==> Fetching ANGLE (${arch}) from ${zip_name}" - curl -fL --retry 3 -o "${tmp}/${zip_name}" "${url}" - - local actual_sha; actual_sha="$(sha256_of "${tmp}/${zip_name}")" - if [ "${actual_sha,,}" != "${expected_sha,,}" ]; then - echo "ERROR: SHA-256 mismatch for ${zip_name}" >&2 - echo " expected: ${expected_sha}" >&2 - echo " actual: ${actual_sha}" >&2 - exit 1 - fi - echo " SHA-256 OK (${actual_sha})" - - mkdir -p "${out_dir}" - # -j: flatten (the DLLs sit at the zip root); -o: overwrite. - unzip -j -o "${tmp}/${zip_name}" "libEGL.dll" "libGLESv2.dll" -d "${out_dir}" - echo " Extracted libEGL.dll + libGLESv2.dll -> ${out_dir}" -} - -case "${want}" in - x64) fetch_arch "x64" "${SHA_X64}" "win32-x64" ;; - arm64) fetch_arch "arm64" "${SHA_ARM64}" "win32-aarch64" ;; - all) - fetch_arch "x64" "${SHA_X64}" "win32-x64" - fetch_arch "arm64" "${SHA_ARM64}" "win32-aarch64" - ;; - *) echo "Usage: $0 [x64|arm64|all]" >&2; exit 2 ;; -esac - -echo "ANGLE DLLs ready." diff --git a/decorated-window-tao/src/main/native/windows/nucleus_tao_dnd.c b/decorated-window-tao/src/main/native/windows/nucleus_tao_dnd.c index 9373f295c..a9bda144a 100644 --- a/decorated-window-tao/src/main/native/windows/nucleus_tao_dnd.c +++ b/decorated-window-tao/src/main/native/windows/nucleus_tao_dnd.c @@ -18,6 +18,7 @@ #define INITGUID #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -217,9 +218,7 @@ static HRESULT STDMETHODCALLTYPE NDT_DragEnter( effect = (*env)->CallIntMethod( env, t->callbackRef, g_method_on_enter, (jlong)(intptr_t)t->hwnd, x, y, (jint)grfKeyState, JNI_TRUE); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { effect = DROPEFFECT_NONE_LOCAL; } } @@ -253,9 +252,7 @@ static HRESULT STDMETHODCALLTYPE NDT_DragOver( effect = (*env)->CallIntMethod( env, t->callbackRef, g_method_on_over, (jlong)(intptr_t)t->hwnd, x, y, (jint)grfKeyState, JNI_TRUE); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { effect = DROPEFFECT_NONE_LOCAL; } } @@ -272,10 +269,7 @@ static HRESULT STDMETHODCALLTYPE NDT_DragLeave(IDropTarget *self) { if (env && g_method_on_leave) { (*env)->CallVoidMethod(env, t->callbackRef, g_method_on_leave, (jlong)(intptr_t)t->hwnd); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } detach_if_needed(attached); t->hasAcceptableData = FALSE; @@ -303,9 +297,7 @@ static HRESULT STDMETHODCALLTYPE NDT_Drop( effect = (*env)->CallIntMethod( env, t->callbackRef, g_method_on_drop, (jlong)(intptr_t)t->hwnd, x, y, (jint)grfKeyState, files); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { effect = DROPEFFECT_NONE_LOCAL; } } @@ -701,14 +693,12 @@ static void pump_host(NucleusDropSource *s) { JNIEnv *env = attach_thread(&attached); if (!env) return; (*env)->CallVoidMethod(env, s->pumpRef, s->pumpMethod); - if ((*env)->ExceptionCheck(env)) { + if (nucleus_jni_clear_exception(env)) { /* Must not leave a pending exception across the COM return: the OLE * drag loop calls straight back into us and JNI would abort. */ - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); /* Whatever broke (GL context, Skia recording) will break again on the * very next mouse-move, and we are called once per move — latch the - * pump off so one failure reports once instead of flooding stderr with + * pump off so one failure reports once instead of flooding logs with * thousands of traces. The drag degrades to the old frozen-but-quiet * behaviour and still completes normally. */ s->pumpMethod = NULL; @@ -828,7 +818,7 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsDndBridge_nativeStartDr src->pumpRef = (*env)->NewGlobalRef(env, pump); if (!src->pumpRef) src->pumpMethod = NULL; } - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* DoDragDrop pumps its own modal loop until the user releases or escapes. diff --git a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_deco.c b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_deco.c index b433e1159..decfc1f2c 100644 --- a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_deco.c +++ b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_deco.c @@ -16,6 +16,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -547,7 +548,7 @@ static void ensureDecoJVMCached(JNIEnv *env) { sDecoOnFullscreenSize = (*env)->GetStaticMethodID( env, sDecoBridgeClass, "onFullscreenSizeChanged", "(JII)V"); } - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* Calls NativeTaoWindowsDecoBridge.onFullscreenSizeChanged(hwnd, w, h) and @@ -569,10 +570,7 @@ static void notifyFullscreenSizeChanged(HWND hwnd, int w, int h) { if (!env) return; (*env)->CallStaticVoidMethod(env, sDecoBridgeClass, sDecoOnFullscreenSize, (jlong)(uintptr_t)hwnd, (jint)w, (jint)h); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionDescribe(env); - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } /* WndProc subclass */ @@ -1632,6 +1630,15 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsDecoBridge_nativeSetOwn #else SetWindowLongW(child, GWLP_HWNDPARENT, (LONG)(LONG_PTR)owner); #endif + if (!owner) return; + /* Re-stack the child above its owner. Win32 only enforces "owned windows + * sit above their owner" when the owner gets *activated*; SW_MAXIMIZE / + * SW_RESTORE / a fullscreen SetWindowPos on an already-active owner puts + * it at HWND_TOP, above its own satellites. Re-setting the same + * GWLP_HWNDPARENT alone moves nothing. Async: this often runs from inside + * the owner's WM_SIZE, i.e. nested in its own SetWindowPos. */ + SetWindowPos(child, HWND_TOP, 0, 0, 0, 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS); } /* Returns the primary monitor's scale factor as `(scale * 1000)`. Falls back @@ -1710,6 +1717,135 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsDecoBridge_nativeOwnerM return arr; } +/* ---------------- Multi-monitor enumeration ---------------- + * + * One tab-separated descriptor per monitor: + * id \t name \t x \t y \t w \t h \t workX \t workY \t workW \t workH + * \t scaleMilli \t primary + * Geometry is physical pixels in virtual-screen space (the process is + * per-monitor-v2 DPI aware, so GetMonitorInfo already reports physical). + * `id` is the GDI device name (\\.\DISPLAY1) — stable across enumerations for + * as long as the monitor stays attached; `name` is the friendly device string. + * + * No CRT here (/NODEFAULTLIB), so formatting goes through user32's wsprintfW. + */ + +#define NUCLEUS_MAX_MONITORS 32 +#define NUCLEUS_MONITOR_ROW_CHARS 512 + +typedef struct { + WCHAR rows[NUCLEUS_MAX_MONITORS][NUCLEUS_MONITOR_ROW_CHARS]; + int count; +} MonitorRows; + +/* Replaces the row separators in-place so a display name can't corrupt the + * encoding. */ +static void sanitizeRowField(WCHAR *text) { + if (!text) return; + for (; *text; text++) { + if (*text == L'\t' || *text == L'\n' || *text == L'\r') *text = L' '; + } +} + +static UINT getMonitorDpi(HMONITOR mon) { + typedef HRESULT (WINAPI *PFN_GetDpiForMonitor)(HMONITOR, int, UINT *, UINT *); + static PFN_GetDpiForMonitor pGetDpiForMonitor = NULL; + static BOOL resolved = FALSE; + if (!resolved) { + resolved = TRUE; + HMODULE shcore = LoadLibraryW(L"shcore.dll"); + if (shcore) { + pGetDpiForMonitor = + (PFN_GetDpiForMonitor)GetProcAddress(shcore, "GetDpiForMonitor"); + } + } + if (pGetDpiForMonitor && mon) { + UINT dpiX = 0, dpiY = 0; + /* MDT_EFFECTIVE_DPI */ + if (pGetDpiForMonitor(mon, 0, &dpiX, &dpiY) == S_OK && dpiX > 0) return dpiX; + } + HDC hdc = GetDC(NULL); + UINT dpi = 96; + if (hdc) { + int caps = GetDeviceCaps(hdc, LOGPIXELSX); + if (caps > 0) dpi = (UINT)caps; + ReleaseDC(NULL, hdc); + } + return dpi; +} + +static BOOL CALLBACK collectMonitorProc(HMONITOR mon, HDC hdc, LPRECT clip, LPARAM data) { + (void)hdc; (void)clip; + MonitorRows *out = (MonitorRows *)data; + if (!out || out->count >= NUCLEUS_MAX_MONITORS) return FALSE; + + MONITORINFOEXW mi; + memset(&mi, 0, sizeof(mi)); + mi.cbSize = sizeof(mi); + if (!GetMonitorInfoW(mon, (LPMONITORINFO)&mi)) return TRUE; + + DISPLAY_DEVICEW dd; + memset(&dd, 0, sizeof(dd)); + dd.cb = sizeof(dd); + WCHAR name[128]; + name[0] = L'\0'; + if (EnumDisplayDevicesW(mi.szDevice, 0, &dd, 0)) { + lstrcpynW(name, dd.DeviceString, 128); + } + if (name[0] == L'\0') lstrcpynW(name, mi.szDevice, 128); + sanitizeRowField(name); + sanitizeRowField(mi.szDevice); + + UINT dpi = getMonitorDpi(mon); + if (dpi == 0) dpi = 96; + + wsprintfW(out->rows[out->count], + L"%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d", + mi.szDevice, + name, + (int)mi.rcMonitor.left, + (int)mi.rcMonitor.top, + (int)(mi.rcMonitor.right - mi.rcMonitor.left), + (int)(mi.rcMonitor.bottom - mi.rcMonitor.top), + (int)mi.rcWork.left, + (int)mi.rcWork.top, + (int)(mi.rcWork.right - mi.rcWork.left), + (int)(mi.rcWork.bottom - mi.rcWork.top), + (int)((dpi * 1000) / 96), + (mi.dwFlags & MONITORINFOF_PRIMARY) ? 1 : 0); + out->count++; + return TRUE; +} + +/* Returns one descriptor String per attached monitor, or NULL when the + * enumeration fails. See the format comment above. */ +JNIEXPORT jobjectArray JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsDecoBridge_nativeGetMonitors( + JNIEnv *env, jclass clazz) +{ + (void)clazz; + /* Static, not stack: 32 KB of locals would need the CRT's __chkstk probe, + * which /NODEFAULTLIB doesn't link. Safe because every entry point of this + * bridge is called from the Tao event-loop thread. */ + static MonitorRows rows; + rows.count = 0; + EnumDisplayMonitors(NULL, NULL, collectMonitorProc, (LPARAM)&rows); + if (rows.count <= 0) return NULL; + + jclass stringClass = (*env)->FindClass(env, "java/lang/String"); + if (!stringClass) return NULL; + jobjectArray arr = (*env)->NewObjectArray(env, rows.count, stringClass, NULL); + if (!arr) return NULL; + for (int i = 0; i < rows.count; i++) { + jstring row = (*env)->NewString(env, + (const jchar *)rows.rows[i], (jsize)lstrlenW(rows.rows[i])); + if (!row) return NULL; + (*env)->SetObjectArrayElement(env, arr, i, row); + (*env)->DeleteLocalRef(env, row); + } + return arr; +} + /* Returns [x, y, width, height] of the primary monitor's work area (full * screen minus the taskbar) in physical pixels. Used by DecoratedWindow to * resolve [WindowPosition.Aligned] for the initial outer position. */ @@ -1938,7 +2074,48 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsDecoBridge_nativeSetWin SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE); } +#ifndef SPI_GETFONTSMOOTHINGTYPE +#define SPI_GETFONTSMOOTHINGTYPE 0x200A +#endif +#ifndef FE_FONTSMOOTHINGCLEARTYPE +#define FE_FONTSMOOTHINGCLEARTYPE 0x0002 +#endif +#ifndef SPI_GETFONTSMOOTHINGORIENTATION +#define SPI_GETFONTSMOOTHINGORIENTATION 0x2012 +#endif +#ifndef FE_FONTSMOOTHINGORIENTATIONBGR +#define FE_FONTSMOOTHINGORIENTATIONBGR 0x0000 +#endif +#ifndef FE_FONTSMOOTHINGORIENTATIONRGB +#define FE_FONTSMOOTHINGORIENTATIONRGB 0x0001 +#endif +/* 0 = grayscale / unknown, 1 = RGB_H, 2 = BGR_H. Used by Tao LCD text. */ +JNIEXPORT jint JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsDecoBridge_nativeFontSmoothingPixelGeometry( + JNIEnv *env, jclass clazz) +{ + (void)env; (void)clazz; + BOOL smoothing = FALSE; + if (!SystemParametersInfo(SPI_GETFONTSMOOTHING, 0, &smoothing, 0) || !smoothing) { + return 0; + } + UINT type = 0; + if (!SystemParametersInfo(SPI_GETFONTSMOOTHINGTYPE, 0, &type, 0) || + type != FE_FONTSMOOTHINGCLEARTYPE) { + return 0; + } + UINT orientation = 0; + if (!SystemParametersInfo(SPI_GETFONTSMOOTHINGORIENTATION, 0, &orientation, 0)) { + /* Unknown stripe order: degrade to grayscale, never assume RGB — + * a wrong guess on a BGR panel inverts every fringe. */ + return 0; + } + if (orientation == FE_FONTSMOOTHINGORIENTATIONBGR) { + return 2; + } + return 1; +} /* Issue #631: apply the requested topmost z-order directly and synchronously. * tao caches its WindowFlags and only issues the z-order SetWindowPos when a * flag *diff* appears (and then with SWP_ASYNCWINDOWPOS), so a WS_EX_TOPMOST @@ -1971,3 +2148,4 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsDecoBridge_nativeIsTopm LONG_PTR ex = GetWindowLongPtrW(hwnd, GWL_EXSTYLE); return (ex & WS_EX_TOPMOST) ? JNI_TRUE : JNI_FALSE; } + diff --git a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_native_view.c b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_native_view.c index 5439b43e5..d975dd5e1 100644 --- a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_native_view.c +++ b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_native_view.c @@ -168,8 +168,17 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_native /* Compose physical pixels (top-left, parent-client) → a mouse message * on the embedded child. When [childHwnd] is not a window (WebView2 - * CompositionController, hwnd=0) the message is posted to [parentHwnd] - * so a parent subclass (sample_webview.cpp SendMouseInput) still sees it. */ + * CompositionController, hwnd=0) the message is sent to [parentHwnd] + * so a parent subclass (sample_webview.cpp SendMouseInput) still sees it. + * + * A real child gets the message *posted*: its handler may run a modal + * loop — an EDIT opens its context menu from WM_RBUTTONUP and does not + * return until the menu is dismissed — and that loop must not nest inside + * the Compose pointer dispatch this call is made from. Posted messages + * keep their order and run before the next input message, so a forwarded + * press still reaches the child before the release Win32 delivers to it + * directly once it has captured the mouse. The parent keeps SendMessage: + * the host guards the synchronous echo through Tao's WndProc. */ JNIEXPORT void JNICALL Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeDispatchPointer( JNIEnv *env, jclass clazz, @@ -182,11 +191,13 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_native if (!IsWindow(parent)) return; HWND target = IsWindow(child) ? child : parent; if (type == 1 && IsWindow(child)) SetFocus(child); - if (nucleus_tao_replay_last_native_input(target)) return; - POINT pt = { (LONG)xPx, (LONG)yPx }; - if (target != parent) { - MapWindowPoints(parent, target, &pt, 1); - } + /* A press goes out synchronously so the capture the child takes on it can + * be handed straight back (below); everything else is posted, because a + * child's handler may run a modal loop — an EDIT opens its context menu + * from WM_RBUTTONUP and does not return until it is dismissed — which + * must not nest inside the Compose pointer dispatch we are called from. + * The parent is always sent to: the host guards that synchronous echo. */ + BOOL post = (target != parent) && (type != 1); UINT msg; if (type == 1) { msg = (button == 2) ? WM_RBUTTONDOWN : @@ -197,6 +208,11 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_native } else { msg = WM_MOUSEMOVE; } + if (nucleus_tao_replay_last_native_input(target, msg, post)) return; + POINT pt = { (LONG)xPx, (LONG)yPx }; + if (target != parent) { + MapWindowPoints(parent, target, &pt, 1); + } WPARAM mk = 0; if (button == 2 || (pressed == JNI_TRUE && button == 2)) mk |= MK_RBUTTON; else if (button == 3 || (pressed == JNI_TRUE && button == 3)) mk |= MK_MBUTTON; @@ -204,7 +220,19 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_native if (GetKeyState(VK_SHIFT) & 0x8000) mk |= MK_SHIFT; if (GetKeyState(VK_CONTROL) & 0x8000) mk |= MK_CONTROL; LPARAM lp = MAKELPARAM((short)pt.x, (short)pt.y); - SendMessageW(target, msg, mk, lp); + if (post) { + PostMessageW(target, msg, mk, lp); + } else { + SendMessageW(target, msg, mk, lp); + } + /* Compose routes this pointer, not the embed: a child that captured the + * mouse on the press would take every later message off the window — + * neither the Compose scene nor its blending overlay would see the + * pointer again, and the whole UI reads as dead. */ + if (type == 1) { + HWND capture = GetCapture(); + if (capture && capture != parent && IsChild(parent, capture)) ReleaseCapture(); + } } JNIEXPORT void JNICALL @@ -218,12 +246,158 @@ Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_native HWND child = hwnd_from_jlong(childHwnd); if (!IsWindow(parent)) return; HWND target = IsWindow(child) ? child : parent; - if (nucleus_tao_replay_last_native_input(target)) return; - POINT pt = { (LONG)xPx, (LONG)yPx }; - ClientToScreen(parent, &pt); + BOOL post = (target != parent); UINT msg = (dx != 0.0f && (dy == 0.0f || (dx > dy || dx < -dy))) ? WM_MOUSEHWHEEL : WM_MOUSEWHEEL; + if (nucleus_tao_replay_last_native_input(target, msg, post)) return; + POINT pt = { (LONG)xPx, (LONG)yPx }; + ClientToScreen(parent, &pt); /* Compose/AWT deltas are already negated vs Win32. */ short delta = (short)(msg == WM_MOUSEHWHEEL ? (-dx * 120.0f) : (-dy * 120.0f)); - SendMessageW(target, msg, MAKEWPARAM(0, delta), MAKELPARAM((short)pt.x, (short)pt.y)); + if (post) { + PostMessageW(target, msg, MAKEWPARAM(0, delta), MAKELPARAM((short)pt.x, (short)pt.y)); + } else { + SendMessageW(target, msg, MAKEWPARAM(0, delta), MAKELPARAM((short)pt.x, (short)pt.y)); + } +} + +/* Compose kept a press, so the keyboard is Compose's: hands Win32 focus + * back to the Tao HWND when an embedded child (an EDIT, WebView2) holds it. + * Win32 never moves focus on a click into a plain client area by itself, so + * a child clicked into earlier would keep every keystroke while Compose + * shows a focused text field. */ +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeClaimKeyboardForCompose( + JNIEnv *env, jclass clazz, jlong parentHwnd) { + (void)env; (void)clazz; + HWND parent = hwnd_from_jlong(parentHwnd); + if (!IsWindow(parent)) return JNI_FALSE; + HWND focused = GetFocus(); + if (!focused || focused == parent || !IsChild(parent, focused)) return JNI_FALSE; + SetFocus(parent); + return JNI_TRUE; +} + +/* The mouse buttons down as this thread's message queue knows them: bit 0 + * left, bit 1 right, bit 2 middle. A press forwarded to a child HWND makes + * the child SetCapture, so the release goes to the child alone and Compose + * never hears of it — the host asks Win32 which buttons are really down + * instead of trusting the last event it saw. */ +JNIEXPORT jint JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeQueryPointerButtons( + JNIEnv *env, jclass clazz) { + (void)env; (void)clazz; + jint mask = 0; + if (GetKeyState(VK_LBUTTON) & 0x8000) mask |= 1; + if (GetKeyState(VK_RBUTTON) & 0x8000) mask |= 2; + if (GetKeyState(VK_MBUTTON) & 0x8000) mask |= 4; + return mask; +} + +/* A child HWND that captured the mouse on a forwarded press (an EDIT does, + * so does WebView2) keeps every later mouse message on itself — the Tao + * window and its blending overlay stop hearing from the pointer entirely and + * the whole Compose UI reads as dead. Compose owns the pointer, so the host + * hands the capture back as soon as the gesture the child was given ends. + * Returns whether a capture was taken away. */ +JNIEXPORT jboolean JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeReleaseChildCapture( + JNIEnv *env, jclass clazz, jlong parentHwnd) { + (void)env; (void)clazz; + HWND parent = hwnd_from_jlong(parentHwnd); + if (!IsWindow(parent)) return JNI_FALSE; + HWND capture = GetCapture(); + if (!capture || capture == parent || !IsChild(parent, capture)) return JNI_FALSE; + ReleaseCapture(); + return JNI_TRUE; +} + +/* The pointer's position in [parentHwnd]'s client pixels, packed as + * `(x << 32) | (y & 0xffffffff)`, or LLONG_MIN when it cannot be read. + * + * Tao reports a button without a position, so the scene places it where the + * last `CursorMoved` left the pointer — and Tao drops a `WM_MOUSEMOVE` whose + * coordinate equals the last one *it* saw. Every move over a `NativeView` + * goes to the blending overlay instead, so Tao's idea of the position goes + * stale and a click that comes back to a point it saw before is placed where + * the pointer no longer is. The host asks Win32 instead. */ +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeCursorPosInClient( + JNIEnv *env, jclass clazz, jlong parentHwnd) { + (void)env; (void)clazz; + HWND parent = hwnd_from_jlong(parentHwnd); + if (!IsWindow(parent)) return MININT64; + POINT pt; + if (!GetCursorPos(&pt)) return MININT64; + if (!ScreenToClient(parent, &pt)) return MININT64; + return ((jlong)pt.x << 32) | ((jlong)pt.y & 0xffffffffLL); +} + +/* ── Diagnostics for the headful suite ────────────────────────────────── + * + * A NativeView case needs a real, focusable child HWND — one that takes + * Win32 keyboard focus on click and shows an I-beam — to race against + * Compose. The test module cannot create one itself, so these hand out a + * plain single-line EDIT control and read the focus and the text back. + * Nothing here is used by NativeView proper. */ + +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeDiagCreateEdit( + JNIEnv *env, jclass clazz) { + (void)env; (void)clazz; + /* Hidden top-level: nativeAttach flips it to WS_CHILD and reparents, + * exactly the path a user-created control takes. */ + HWND edit = CreateWindowExW( + 0, L"EDIT", L"", + WS_POPUP | ES_LEFT | ES_AUTOHSCROLL, + 0, 0, 64, 24, + NULL, NULL, GetModuleHandleW(NULL), NULL); + return (jlong)(uintptr_t)edit; +} + +JNIEXPORT void JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeDiagDestroyWindow( + JNIEnv *env, jclass clazz, jlong hwnd) { + (void)env; (void)clazz; + HWND h = hwnd_from_jlong(hwnd); + if (IsWindow(h)) DestroyWindow(h); +} + +JNIEXPORT jlong JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeDiagFocusedHwnd( + JNIEnv *env, jclass clazz) { + (void)env; (void)clazz; + return (jlong)(uintptr_t)GetFocus(); +} + +JNIEXPORT jstring JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeDiagWindowText( + JNIEnv *env, jclass clazz, jlong hwnd) { + (void)clazz; + HWND h = hwnd_from_jlong(hwnd); + if (!IsWindow(h)) return NULL; + WCHAR buf[512]; + int len = GetWindowTextW(h, buf, 512); + if (len < 0) len = 0; + return (*env)->NewString(env, (const jchar *)buf, (jsize)len); +} + +/* The control's rectangle in its parent's client coordinates (physical + * px, top-left origin) as `[x, y, w, h]`, or null when not a window. */ +JNIEXPORT jintArray JNICALL +Java_dev_nucleusframework_window_tao_ffi_NativeTaoWindowsNativeViewBridge_nativeDiagWindowFrame( + JNIEnv *env, jclass clazz, jlong hwnd) { + (void)clazz; + HWND h = hwnd_from_jlong(hwnd); + if (!IsWindow(h)) return NULL; + HWND parent = GetParent(h); + RECT rect; + if (!GetWindowRect(h, &rect)) return NULL; + POINT corners[2] = { { rect.left, rect.top }, { rect.right, rect.bottom } }; + if (parent) MapWindowPoints(NULL, parent, corners, 2); + jint out[4] = { corners[0].x, corners[0].y, corners[1].x - corners[0].x, corners[1].y - corners[0].y }; + jintArray result = (*env)->NewIntArray(env, 4); + if (result == NULL) return NULL; + (*env)->SetIntArrayRegion(env, result, 0, 4, out); + return result; } diff --git a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay.c b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay.c index d9b88bb48..44494547d 100644 --- a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay.c +++ b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay.c @@ -21,6 +21,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include "nucleus_tao_windows_overlay_internal.h" @@ -194,7 +195,7 @@ static void dispatchPointer(OverlayState *s, int type, int button, LPARAM lParam int y = (short)HIWORD(lParam); (*env)->CallVoidMethod(env, s->pointerCb, sOnPointerMethod, (jint)type, (jfloat)x, (jfloat)y, (jint)button, (jint)modifierMask()); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static UINT gLastInputMsg; @@ -211,8 +212,13 @@ void nucleus_tao_remember_native_input(HWND hwnd, UINT msg, WPARAM w, LPARAM l) gHasLastInput = TRUE; } -BOOL nucleus_tao_replay_last_native_input(HWND target) { +BOOL nucleus_tao_replay_last_native_input(HWND target, UINT expectedMsg, BOOL post) { if (!gHasLastInput || !IsWindow(target)) return FALSE; + /* Only the event being forwarded is worth replaying verbatim. A press + * dispatched in-process (no overlay message behind it) or a move that + * reached the scene through the owner HWND's capture would otherwise + * replay whatever the overlay saw last — a stale press, say. */ + if (gLastInputMsg != expectedMsg) return FALSE; LPARAM lp = gLastInputL; if (gLastInputMsg != WM_MOUSEWHEEL && gLastInputMsg != WM_MOUSEHWHEEL && target != gLastInputHwnd && IsWindow(gLastInputHwnd)) { @@ -220,7 +226,11 @@ BOOL nucleus_tao_replay_last_native_input(HWND target) { MapWindowPoints(gLastInputHwnd, target, &pt, 1); lp = MAKELPARAM((short)pt.x, (short)pt.y); } - SendMessageW(target, gLastInputMsg, gLastInputW, lp); + if (post) { + PostMessageW(target, gLastInputMsg, gLastInputW, lp); + } else { + SendMessageW(target, gLastInputMsg, gLastInputW, lp); + } return TRUE; } @@ -231,7 +241,7 @@ static void dispatchScroll(OverlayState *s, int xLocal, int yLocal, if (!env || !sOnScrollMethod) return; (*env)->CallVoidMethod(env, s->pointerCb, sOnScrollMethod, (jfloat)xLocal, (jfloat)yLocal, (jfloat)dx, (jfloat)dy); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static LRESULT CALLBACK overlayWndProc(HWND hwnd, UINT msg, WPARAM w, LPARAM l) { diff --git a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay_internal.h b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay_internal.h index 2ddd27df4..46f4047df 100644 --- a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay_internal.h +++ b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_overlay_internal.h @@ -74,7 +74,13 @@ void nucleus_tao_remember_native_input(HWND hwnd, UINT msg, WPARAM w, LPARAM l); /** Replay the stashed message onto [target]. Wheel LPARAMs stay * screen-space; mouse LPARAMs are mapped from the source HWND. */ -BOOL nucleus_tao_replay_last_native_input(HWND target); +/* Replays the remembered message onto [target] when it is of kind + * [expectedMsg] (the message the caller would otherwise synthesise); returns + * FALSE when nothing matching is remembered. [post] queues it with + * PostMessageW instead of SendMessageW — for a child HWND, whose handler may + * open a modal loop (an EDIT's context menu on WM_RBUTTONUP) that must not + * run inside the Compose pointer dispatch that is forwarding the event. */ +BOOL nucleus_tao_replay_last_native_input(HWND target, UINT expectedMsg, BOOL post); #ifdef __cplusplus } diff --git a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_popup.c b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_popup.c index 0143f3302..970b09c7e 100644 --- a/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_popup.c +++ b/decorated-window-tao/src/main/native/windows/nucleus_tao_windows_popup.c @@ -31,6 +31,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -155,7 +156,7 @@ static JNIEnv *attachThread(void) { static jclass globalRefNamedClass(JNIEnv *env, const char *name) { jclass local = (*env)->FindClass(env, name); if (!local) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return NULL; } jclass global = (*env)->NewGlobalRef(env, local); @@ -178,7 +179,7 @@ static void ensureEventCallbackCache(JNIEnv *env, jobject sample) { sOnPointerMethod = m1; sOnScrollMethod = m2; sOnKeyMethod = m3; InterlockedOr(&sCacheInitedBits, 1); } else { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteGlobalRef(env, global); } } @@ -195,7 +196,7 @@ static void ensureOutsideCallbackCache(JNIEnv *env, jobject sample) { sOutsideClass = global; sOnOutsideClickMethod = m; InterlockedOr(&sCacheInitedBits, 2); } else { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteGlobalRef(env, global); } } @@ -269,7 +270,7 @@ static void dispatchPointer(PopupState *p, int type, int button, LPARAM lParam) int y = (short)HIWORD(lParam); (*env)->CallVoidMethod(env, p->eventCb, sOnPointerMethod, (jint)type, (jfloat)x, (jfloat)y, (jint)button, (jint)modifierMask()); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static void dispatchScroll(PopupState *p, int xLocal, int yLocal, @@ -279,7 +280,7 @@ static void dispatchScroll(PopupState *p, int xLocal, int yLocal, if (!env) return; (*env)->CallVoidMethod(env, p->eventCb, sOnScrollMethod, (jfloat)xLocal, (jfloat)yLocal, (jfloat)dx, (jfloat)dy); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static void fireOutsideClick(PopupState *p, int button) { @@ -288,7 +289,7 @@ static void fireOutsideClick(PopupState *p, int button) { if (!env) return; (*env)->CallVoidMethod(env, p->outsideListener, sOnOutsideClickMethod, (jint)1 /* press */, (jint)button); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } /* WH_MOUSE hook proc: observes every mouse message scheduled for @@ -581,7 +582,7 @@ static LRESULT CALLBACK popupWndProc(HWND hwnd, UINT msg, WPARAM w, LPARAM l) { if (envK) { (*envK)->CallVoidMethod(envK, p->eventCb, sOnKeyMethod, (jint)type, (jint)vk, (jint)codePoint, (jint)mods); - if ((*envK)->ExceptionCheck(envK)) (*envK)->ExceptionClear(envK); + nucleus_jni_clear_exception(envK); } return 0; } diff --git a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json index 56360162f..03963bf7c 100644 --- a/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json +++ b/decorated-window-tao/src/main/resources/META-INF/native-image/dev.nucleusframework/nucleus.decorated-window-tao/reachability-metadata.json @@ -262,6 +262,10 @@ "int", "int" ] + }, + { + "name": "nativeFontSmoothingPixelGeometry", + "parameterTypes": [] } ] }, @@ -448,6 +452,13 @@ { "name": "onEvent", "parameterTypes": ["int","int","int","int","int"] } ] }, + { + "type": "dev.nucleusframework.window.tao.ffi.NativeTaoLinuxWidgetBridge$ToplevelDrawCallback", + "jniAccessible": true, + "methods": [ + { "name": "onToplevelDraw", "parameterTypes": [] } + ] + }, { "type": "dev.nucleusframework.window.tao.ffi.NativeTaoLinuxClipboardBridge", "jniAccessible": true diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/ChromeLogicTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/ChromeLogicTest.kt index 7000dd97a..8c62033e9 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/ChromeLogicTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/ChromeLogicTest.kt @@ -111,6 +111,48 @@ class ChromeLogicTest { assertEquals(WindowControlType.ExitFullscreen, stillFullscreen?.type) } + @Test + fun `resolveWindowControl hides minimize when the window is not minimizable`() { + val idle = DecoratedWindowState.of(resizable = true) + val pinned = TaoWindow(handle = 0L, isMinimizable = false) + assertNull( + resolveWindowControl(WindowControlSlot.Minimize, pinned, idle, isFullscreen = false, null), + ) + val regular = TaoWindow(handle = 0L) + assertEquals( + WindowControlType.Minimize, + resolveWindowControl(WindowControlSlot.Minimize, regular, idle, isFullscreen = false, null)?.type, + ) + } + + @Test + fun `resolveWindowControl hides maximize when the window is resizable but not maximizable`() { + val idle = DecoratedWindowState.of(resizable = true) + val palette = TaoWindow(handle = 0L, isResizable = true, isMaximizable = false) + assertNull( + resolveWindowControl(WindowControlSlot.Maximize, palette, idle, isFullscreen = false, null), + ) + val stillFullscreen = + resolveWindowControl( + WindowControlSlot.Maximize, + palette, + idle, + isFullscreen = true, + ) { } + assertEquals(WindowControlType.ExitFullscreen, stillFullscreen?.type) + val regular = TaoWindow(handle = 0L) + assertEquals( + WindowControlType.Maximize, + resolveWindowControl(WindowControlSlot.Maximize, regular, idle, isFullscreen = false, null)?.type, + ) + // A window the WM maximized anyway must still offer Restore. + val maximized = DecoratedWindowState.of(resizable = true).copy(maximized = true) + assertEquals( + WindowControlType.Restore, + resolveWindowControl(WindowControlSlot.Maximize, palette, maximized, isFullscreen = false, null)?.type, + ) + } + @Test fun `titleBarPadding matches the host platform chrome contract`() { val regular = titleBarPadding(40.dp, isFullscreen = false, controlIsRtl = false, linuxControlsOnRight = true) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/ComposableTargetIsolationFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/ComposableTargetIsolationFixture.kt index 264288c27..062aae774 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/ComposableTargetIsolationFixture.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/ComposableTargetIsolationFixture.kt @@ -8,16 +8,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition +import dev.nucleusframework.window.tao.v2.rememberWindowState /** * Compile-time regression fixture for #636 — Tao counterpart of the one in * `nucleus-application`. * - * [DecoratedWindow], [DecoratedDialog] and [TaoStandalonePopup] each host their - * content in a fresh `ComposeScene`, so they are declared - * `@ComposableOpenTarget(-1)` with a `@UiComposable` content lambda: callable - * from any applier, always composing UI content. `compileTestKotlin` escalates - * `COMPOSE_APPLIER_CALL_MISMATCH` to an error (see build.gradle.kts). + * Every opener below hosts its content in a fresh `ComposeScene`, so each is + * `@ComposableOpenTarget(-1)` with `@UiComposable` content lambdas — callable + * from any applier, always composing UI. `compileTestKotlin` escalates + * `COMPOSE_APPLIER_CALL_MISMATCH` to an error (see build.gradle.kts), so the + * calls below fail the build if that isolation regresses. */ @Composable @ComposableTarget(applier = "org.example.FakeApplier") @@ -25,16 +26,40 @@ private fun rememberNonUiTargetedState(): Any = remember { Any() } @Suppress("UnusedPrivateMember") private fun windowsStayUiRegardlessOfTheScopeApplier() { - taoApplication { + taoApplication(exitProcessOnExit = false) { // Binds the application scope's applier to a non-UI one. rememberNonUiTargetedState() DecoratedWindow(onCloseRequest = ::exitApplication) { Box(Modifier) } DecoratedDialog(onCloseRequest = ::exitApplication) { Box(Modifier) } + DecoratedWindow( + onCloseRequest = ::exitApplication, + state = rememberWindowState(), + ) { Box(Modifier) } + SatelliteWindow(onCloseRequest = ::exitApplication) { Box(Modifier) } TaoStandalonePopup( visible = false, position = WindowPosition.Absolute(0.dp, 0.dp), size = DpSize(1.dp, 1.dp), ) { Box(Modifier) } + + // Every composable lambda of an opener, not just `content`: an + // unannotated one drags the caller's applier back in. + val satellites = rememberSatelliteWorkspace() + Satellite( + workspace = satellites, + id = "inspector", + title = "Inspector", + floatingContentWrapper = { body -> Box(Modifier) { body() } }, + header = { Box(Modifier) }, + ) { Box(Modifier) } + + val tabs = rememberTabWorkspace() + TabWindows( + workspace = tabs, + strip = { Box(Modifier) }, + windowContentWrapper = { body -> Box(Modifier) { body() } }, + ) + Tab(workspace = tabs, id = "first", title = "First") { Box(Modifier) } } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt new file mode 100644 index 000000000..49b07bec6 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/DockLandingRectTest.kt @@ -0,0 +1,481 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.DockDropZone +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Where a drop preview is drawn ([DockLayoutState.landingRectPx]): along the + * side's band, not the whole layout; inside the layers already docked on a + * layered side; on the stack itself when the panel joins a split stack. + * + * The geometry is the reader layout — right side first and layered, bottom + * inside it — laid out at 1000 × 600 px, offset in the window by (20, 40). + */ +class DockLandingRectTest { + private val host = TaoWindow(handle = 1L) + private val workspace = SatelliteWorkspace().apply { join(host) } + private val state = + DockLayoutState(workspace).apply { + layeredSides = setOf(DockSide.Right) + layoutBoundsInWindowPx = Rect(20f, 40f, 1020f, 640f) + // The right band is the whole layout; the bottom band stops at the right stack. + bandBoundsInWindowPx[DockSide.Right] = Rect(20f, 40f, 1020f, 640f) + bandBoundsInWindowPx[DockSide.Bottom] = Rect(20f, 40f, 720f, 640f) + bandBoundsInWindowPx[DockSide.Left] = Rect(20f, 40f, 720f, 440f) + bandBoundsInWindowPx[DockSide.Top] = Rect(220f, 40f, 720f, 440f) + } + + private fun docked( + id: String, + side: DockSide, + order: Int, + boundsInWindowPx: Rect, + ): SatelliteEntry { + val entry = + workspace.register( + id, + id, + SatellitePlacement.Docked(side, order, extent = 100.dp), + initiallyOpen = true, + ) + entry.dockedBoundsInWindowPx = boundsInWindowPx + return entry + } + + @Test + fun `a bottom preview spans the bottom band, not the layout`() { + state.docked = emptyList() + assertEquals(Rect(0f, 540f, 700f, 600f), state.landingRectPx(DockSide.Bottom, 60f, joinsStack = true)) + } + + @Test + fun `a layered side previews a new innermost layer`() { + state.docked = + listOf( + docked("tree", DockSide.Right, 0, Rect(920f, 40f, 1020f, 640f)), + docked("toc", DockSide.Right, 1, Rect(820f, 40f, 920f, 640f)), + ) + assertEquals(Rect(740f, 0f, 800f, 600f), state.landingRectPx(DockSide.Right, 60f, joinsStack = true)) + } + + @Test + fun `a split side with a stack previews the stack the panel joins`() { + state.docked = listOf(docked("targum", DockSide.Left, 0, Rect(20f, 40f, 220f, 440f))) + // The stack at the thickness the side takes once the panel joins it (#695): + // its own when nothing changes, wider when the newcomer's limits widen it. + assertEquals(Rect(0f, 0f, 200f, 400f), state.landingRectPx(DockSide.Left, 200f, joinsStack = true)) + assertEquals(Rect(0f, 0f, 260f, 400f), state.landingRectPx(DockSide.Left, 260f, joinsStack = true)) + assertEquals(Rect(0f, 0f, 200f, 400f), state.landingRectPx(DockSide.Left, 0f, joinsStack = true)) + // The idle outline stays a strip at the edge of the band. + assertEquals(Rect(0f, 0f, 60f, 400f), state.landingRectPx(DockSide.Left, 60f, joinsStack = false)) + } + + @Test + fun `an empty side previews a strip at the edge of its band`() { + state.docked = emptyList() + assertEquals(Rect(200f, 0f, 700f, 60f), state.landingRectPx(DockSide.Top, 60f, joinsStack = true)) + assertEquals(Rect(0f, 0f, 60f, 400f), state.landingRectPx(DockSide.Left, 60f, joinsStack = true)) + } + + @Test + fun `the side the dragged panel frees is counted as already gone`() { + // The reader shape: the bottom band stops at the layered right stack, + // and the left band stops above the bottom panel. + val comments = docked("comments", DockSide.Bottom, 0, Rect(20f, 440f, 720f, 640f)) + state.docked = listOf(comments) + + // Previewing the left side while dragging the *only* bottom panel: the + // bottom frees up, so the drop will run the full height of the band. + assertEquals( + Rect(0f, 0f, 60f, 600f), + state.landingRectPx(DockSide.Left, 60f, joinsStack = true, dragged = comments), + ) + // Without the drag it is the band as measured, above the panel. + assertEquals(Rect(0f, 0f, 60f, 400f), state.landingRectPx(DockSide.Left, 60f, joinsStack = true)) + } + + @Test + fun `a side the dragged panel shares with another is not freed`() { + val comments = docked("comments", DockSide.Bottom, 0, Rect(20f, 440f, 380f, 640f)) + val sources = docked("sources", DockSide.Bottom, 1, Rect(380f, 440f, 720f, 640f)) + state.docked = listOf(comments, sources) + + assertEquals( + Rect(0f, 0f, 60f, 400f), + state.landingRectPx(DockSide.Left, 60f, joinsStack = true, dragged = comments), + "the bottom side keeps its extent, so the left band is unchanged", + ) + } + + @Test + fun `without a measured band the layout itself is the band`() { + val bare = DockLayoutState(workspace).apply { layoutBoundsInWindowPx = Rect(0f, 0f, 400f, 300f) } + assertEquals(Rect(340f, 0f, 400f, 300f), bare.landingRectPx(DockSide.Right, 60f, joinsStack = true)) + } +} + +/** + * Which sides a drag is offered ([hintedSides]): all four, minus the one the + * dragged panel already occupies in the very window being hinted. + */ +class DockZoneHintSidesTest { + private val host = TaoWindow(handle = 1L) + private val other = TaoWindow(handle = 2L) + private val workspace = SatelliteWorkspace().apply { join(host) } + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + @Test + fun `a floating satellite is offered every side`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + assertEquals(DockSide.entries, hintedSides(entry, host, workspace.satellites)) + } + + @Test + fun `a docked panel is not offered the side it is alone on`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + workspace.dock("tools", DockSide.Bottom, host = host) + assertEquals( + listOf(DockSide.Left, DockSide.Right, DockSide.Top), + hintedSides(entry, host, workspace.satellites), + ) + } + + @Test + fun `a docked panel with a neighbour is offered its own side, to be ranked among them`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + val other = workspace.register("colors", "Colors", floating, initiallyOpen = true) + other.content = {} + workspace.dock("tools", DockSide.Bottom, host = host) + workspace.dock("colors", DockSide.Bottom, host = host) + assertEquals(DockSide.entries, hintedSides(entry, host, workspace.satellites)) + // A closed neighbour is not shown, so there is nothing to rank against. + workspace.close("colors") + assertEquals( + listOf(DockSide.Left, DockSide.Right, DockSide.Top), + hintedSides(entry, host, workspace.satellites), + ) + } + + @Test + fun `another window offers the side too, since dropping there is a move`() { + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + workspace.join(other) + workspace.dock("tools", DockSide.Bottom, host = host) + assertEquals(DockSide.entries, hintedSides(entry, other, workspace.satellites)) + } +} + +/** + * The ranks a drop can take among the panels of a side + * ([DockLayoutState.dropSlotsPx]) and the space drawn for one + * ([DockLayoutState.dropRectPx]), on the reader layout of + * [DockLandingRectTest]: layered right side, split bottom, layout px. + */ +class DockDropSlotsTest { + private val host = TaoWindow(handle = 1L) + private val workspace = SatelliteWorkspace().apply { join(host) } + private val state = + DockLayoutState(workspace).apply { + layeredSides = setOf(DockSide.Right) + layoutBoundsInWindowPx = Rect(20f, 40f, 1020f, 640f) + bandBoundsInWindowPx[DockSide.Right] = Rect(20f, 40f, 1020f, 640f) + bandBoundsInWindowPx[DockSide.Bottom] = Rect(20f, 40f, 720f, 640f) + } + + private fun docked( + id: String, + side: DockSide, + order: Int, + boundsInLayoutPx: Rect, + ): SatelliteEntry { + val entry = + workspace.register(id, id, SatellitePlacement.Docked(side, order, extent = 100.dp), initiallyOpen = true) + entry.content = {} + entry.dockedBoundsInWindowPx = boundsInLayoutPx.translate(Offset(20f, 40f)) + return entry + } + + private val tree = docked("tree", DockSide.Right, 0, Rect(900f, 0f, 1000f, 600f)) + private val toc = docked("toc", DockSide.Right, 1, Rect(800f, 0f, 900f, 600f)) + private val comments = docked("comments", DockSide.Bottom, 0, Rect(0f, 540f, 350f, 600f)) + private val sources = docked("sources", DockSide.Bottom, 1, Rect(350f, 540f, 700f, 600f)) + + init { + state.docked = listOf(tree, toc, comments, sources) + } + + @Test + fun `a layered side is cut at the layers' centres, from its edge through the strip`() { + val strip = state.landingRectPx(DockSide.Right, 60f, joinsStack = false) + assertEquals(Rect(740f, 0f, 800f, 600f), strip) + assertEquals( + listOf(Rect(950f, 0f, 1000f, 600f), Rect(850f, 0f, 950f, 600f), Rect(740f, 0f, 850f, 600f)), + state.dropSlotsPx(DockSide.Right, strip, dragged = null), + ) + // The dragged layer is left out: its neighbours' centres are the cuts, + // and the region it stands in is the rank it already holds. + assertEquals( + listOf(Rect(950f, 0f, 1000f, 600f), Rect(740f, 0f, 950f, 600f)), + state.dropSlotsPx(DockSide.Right, strip, dragged = toc), + ) + assertEquals( + 1, + DockDropZone(strip, state.dropSlotsPx(DockSide.Right, strip, dragged = toc)).slotAt(Offset(850f, 300f)), + ) + assertEquals(DockTarget(host, DockSide.Right, 1), workspace.ownTarget(toc, host)) + } + + @Test + fun `a split side is cut along its length, from the band's start`() { + val strip = state.landingRectPx(DockSide.Bottom, 60f, joinsStack = false) + assertEquals( + listOf(Rect(0f, 540f, 175f, 600f), Rect(175f, 540f, 525f, 600f), Rect(525f, 540f, 700f, 600f)), + state.dropSlotsPx(DockSide.Bottom, strip, dragged = null), + ) + } + + @Test + fun `no slots without another panel, or before it is placed`() { + val strip = state.landingRectPx(DockSide.Left, 60f, joinsStack = false) + assertEquals(emptyList(), state.dropSlotsPx(DockSide.Left, strip, dragged = null)) + tree.dockedBoundsInWindowPx = null + assertEquals(emptyList(), state.dropSlotsPx(DockSide.Right, strip, dragged = null)) + assertNull(DockDropZone(strip).slotAt(Offset.Zero)) + } + + @Test + fun `the pointer picks the slot it is in, else the nearest end`() { + val zone = + DockDropZone( + strip = Rect(0f, 540f, 700f, 600f), + slots = listOf(Rect(0f, 540f, 175f, 600f), Rect(175f, 540f, 525f, 600f), Rect(525f, 540f, 700f, 600f)), + ) + assertEquals(1, zone.slotAt(Offset(300f, 570f))) + assertEquals(0, zone.slotAt(Offset(-50f, 570f)), "past the start") + assertEquals(2, zone.slotAt(Offset(900f, 570f)), "past the end") + assertEquals(1, zone.slotAt(Offset(300f, 100f)), "off the stack: the rank under the pointer's x") + } + + @Test + fun `a pinned layer hides the ranks in front of it, for itself and for the others`() { + val pinned = + workspace.register( + "pinned", + "pinned", + SatellitePlacement.Docked(DockSide.Left, order = 0, extent = 100.dp), + initiallyOpen = true, + reorderable = false, + ) + pinned.content = {} + pinned.dockedBoundsInWindowPx = Rect(20f, 40f, 120f, 640f) + // The helper takes layout px; the pinned entry above is set in window px. + val movable = docked("movable", DockSide.Left, 1, Rect(100f, 0f, 200f, 600f)) + state.layeredSides = state.layeredSides + DockSide.Left + state.docked = listOf(pinned, movable) + state.bandBoundsInWindowPx[DockSide.Left] = Rect(20f, 40f, 1020f, 640f) + val strip = state.landingRectPx(DockSide.Left, 60f, joinsStack = false) + assertEquals(Rect(200f, 0f, 260f, 600f), strip, "inset behind the two layers") + + // Dragging the movable layer: rank 0 would push the pinned one in, so + // it is not on offer — an empty rect keeping the ranks aligned — and + // rank 1 covers the whole region, the pinned layer included. + assertEquals( + listOf(Rect.Zero, Rect(0f, 0f, 260f, 600f)), + state.dropSlotsPx(DockSide.Left, strip, dragged = movable), + ) + val zone = DockDropZone(strip, state.dropSlotsPx(DockSide.Left, strip, dragged = movable)) + assertEquals(1, zone.slotAt(Offset(50f, 300f)), "aimed at the pinned layer, it lands behind it") + // Aimed in front of it, the movable layer is shown right behind it. + assertEquals(Rect(100f, 0f, 160f, 600f), state.dropRectPx(DockSide.Left, movable, 0, 60f)) + // The pinned layer itself is offered no rank at all. + assertEquals(emptyList(), state.dropSlotsPx(DockSide.Left, strip, dragged = pinned)) + } + + @Test + fun `a layer dropped at a rank is drawn where that rank puts it, at its own extent`() { + // Layered right: rank 1 is between the tree (900..1000) and the toc, which moves in to make room. + assertEquals(Rect(840f, 0f, 900f, 600f), state.dropRectPx(DockSide.Right, null, 1, 60f)) + assertEquals(Rect(940f, 0f, 1000f, 600f), state.dropRectPx(DockSide.Right, null, 0, 60f), "the side's edge") + assertEquals(Rect(740f, 0f, 800f, 600f), state.dropRectPx(DockSide.Right, null, 2, 60f), "past the innermost") + // The toc dragged to rank 0: only the tree stays, behind it. + assertEquals(Rect(940f, 0f, 1000f, 600f), state.dropRectPx(DockSide.Right, toc, 0, 60f)) + } + + @Test + fun `a panel dropped in a split stack is drawn as the share the weights give it`() { + // Split bottom, the sources dragged: they and the comments share the length again. + assertEquals(Rect(350f, 540f, 700f, 600f), state.dropRectPx(DockSide.Bottom, sources, 1, 60f)) + assertEquals(Rect(0f, 540f, 350f, 600f), state.dropRectPx(DockSide.Bottom, sources, 0, 60f)) + // A third panel, weight 1, in the middle: a third each. + val notes = workspace.register("notes", "notes", SatellitePlacement.Floating(), initiallyOpen = true) + assertRectEquals(Rect(700f / 3, 540f, 1400f / 3, 600f), state.dropRectPx(DockSide.Bottom, notes, 1, 60f)) + // Twice the weight of each of the others, between them: half the stack. + workspace.dock("notes", DockSide.Left, host = host) + workspace.setDockedWeight("notes", 2f) + assertRectEquals(Rect(175f, 540f, 525f, 600f), state.dropRectPx(DockSide.Bottom, notes, 1, 60f)) + } + + private fun assertRectEquals( + expected: Rect, + actual: Rect, + ) { + val close = + listOf(expected.left to actual.left, expected.top to actual.top) + .plus(expected.right to actual.right) + .plus(expected.bottom to actual.bottom) + .all { (e, a) -> abs(e - a) < 0.01f } + assertTrue(close, "expected $expected, was $actual") + } + + @Test + fun `dropped on an empty side, the space is the strip along its edge`() { + assertEquals(Rect(0f, 0f, 60f, 600f), state.dropRectPx(DockSide.Left, null, 0, 60f)) + } +} + +/** + * Which zone a drag resolves to ([SatelliteWorkspace.dockTargetAt] with the + * dragged rect): the zone the satellite on screen has been brought against, + * with the pointer as a second trigger and as the tie-break. + * + * Host a's layout is (100, 140)-(900, 700) on screen, zone width 64 px. + */ +class DockTargetFromDraggedRectTest { + private val a = TaoWindow(handle = 1L) + + private fun workspace(): SatelliteWorkspace = + SatelliteWorkspace().apply { + join(a) + dockHosts.register( + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + } + + @Test + fun `the dragged rect decides the zone, not the pointer`() { + val workspace = workspace() + + // A 200 x 300 palette pushed against the left edge: its own edge is in + // the zone while the pointer sits in the middle of the palette, far + // from any edge of the layout. + val atLeft = Rect(120f, 300f, 320f, 600f) + assertEquals( + DockTarget(a, DockSide.Left), + workspace.dockTargetAt(atLeft, atLeft.center), + "the palette's own edge has entered the left zone", + ) + assertNull(workspace.dockTargetAt(atLeft.center), "the pointer alone is over the content") + + // Aligned from the outside too: pushed 20 px past the edge is still + // brought against it. + val justOver = Rect(80f, 300f, 280f, 600f) + assertEquals(DockTarget(a, DockSide.Left), workspace.dockTargetAt(justOver, justOver.center)) + + // Deep past the edge is no longer an alignment — but the pointer, now + // over the left strip itself, still is. + val overhanging = Rect(20f, 300f, 220f, 600f) + assertEquals(DockTarget(a, DockSide.Left), workspace.dockTargetAt(overhanging, Offset(120f, 450f))) + assertNull(workspace.dockTargetAt(overhanging, Offset(200f, 450f)), "neither edge nor pointer is at a zone") + + // Over the middle: no zone, whatever the pointer does. + val middle = Rect(400f, 350f, 600f, 500f) + assertNull(workspace.dockTargetAt(middle, middle.center), "nothing has entered a zone") + + // Beside the layout, not on it: no drop, even with the pointer inside. + val beside = Rect(950f, 300f, 1150f, 600f) + assertNull(workspace.dockTargetAt(beside, beside.center)) + + // Aligned with two sides at once: the pointer decides. + val topLeftCorner = Rect(120f, 150f, 320f, 250f) + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(topLeftCorner, Offset(300f, 240f))) + } + + @Test + fun `an inset zone is the target, not the window's own edge`() { + val workspace = workspace() + // What a layered right side draws while two columns are already + // docked: the strip is inset 200 px behind them, not at x 900. + val geometry = requireNotNull(workspace.dockHostGeometry(a)) + geometry.zoneBoundsInWindowPx = mapOf(DockSide.Right to DockDropZone(Rect(540f, 40f, 604f, 600f))) + + // The palette brought against the drawn strip docks… + val onStrip = Rect(440f, 300f, 700f, 600f) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockTargetAt(onStrip, onStrip.center)) + // …while the window's own right edge, behind the columns, is nothing. + val atWindowEdge = Rect(700f, 300f, 900f, 600f) + assertNull(workspace.dockTargetAt(atWindowEdge, atWindowEdge.center)) + // The pointer in the drawn strip is a target too. + assertNull(workspace.dockTargetAt(atWindowEdge, Offset(880f, 400f)), "the window edge is not a zone") + assertEquals( + DockTarget(a, DockSide.Right), + workspace.dockTargetAt(atWindowEdge, Offset(670f, 400f)), + "the pointer inside the drawn strip", + ) + // A side the layout does not draw is not a target at all. + assertNull(workspace.dockTargetAt(Rect(120f, 300f, 320f, 600f), Offset(120f, 400f)), "no left zone is drawn") + } + + @Test + fun `the pointer over a stack picks a rank, and beats a strip across its corner`() { + val workspace = workspace() + val geometry = requireNotNull(workspace.dockHostGeometry(a)) + // A split left side with two panels (window px 0..200 wide, 40..600 + // tall) and an empty top side whose strip runs across the stack's top. + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = + listOf( + Rect(0f, 40f, 200f, 180f), + Rect(0f, 180f, 200f, 460f), + Rect(0f, 460f, 200f, 600f), + ), + ), + DockSide.Top to DockDropZone(Rect(0f, 40f, 800f, 104f)), + ) + // The dragged ghost sits over the content, the pointer over the stack. + val ghost = Rect(400f, 300f, 600f, 450f) + assertEquals(DockTarget(a, DockSide.Left, 1), workspace.dockTargetAt(ghost, Offset(250f, 400f))) + assertEquals(DockTarget(a, DockSide.Left, 2), workspace.dockTargetAt(ghost, Offset(250f, 650f))) + // In the corner both the top strip and the first rank hold the pointer: the rank wins. + assertEquals(DockTarget(a, DockSide.Left, 0), workspace.dockTargetAt(ghost, Offset(250f, 160f))) + // Brought against the strip with the pointer away from the stack: the nearest rank along it. + val atLeft = Rect(120f, 300f, 320f, 600f) + assertEquals(DockTarget(a, DockSide.Left, 1), workspace.dockTargetAt(atLeft, Offset(220f, 450f))) + // An unranked side stays unranked. + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(ghost, Offset(500f, 170f))) + } + + @Test + fun `a dragged rect covering every zone is resolved by the pointer`() { + val workspace = workspace() + // Larger than the layout: every side is within reach at once. + val covering = Rect(50f, 100f, 950f, 750f) + + assertEquals(DockTarget(a, DockSide.Left), workspace.dockTargetAt(covering, Offset(120f, 400f))) + assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockTargetAt(covering, Offset(500f, 690f))) + // Pointer off the layout: the closest alignment decides instead — the + // covering rect overhangs the top by the least. + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(covering, Offset(0f, 0f))) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/EventLoopHangDetectorTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/EventLoopHangDetectorTest.kt new file mode 100644 index 000000000..9d1924565 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/EventLoopHangDetectorTest.kt @@ -0,0 +1,113 @@ +package dev.nucleusframework.window.tao + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +private const val GRACE_MS = 5_000L + +private fun ms(millis: Long): Long = millis * 1_000_000L + +/** + * The watchdog's state machine (#643): a stall must be reported exactly once — + * a hang that lasts minutes must not fill the log with one SEVERE dump every + * poll — and a loop that comes back must re-arm, so a second stall is reported + * again. + */ +class EventLoopHangDetectorTest { + @Test + fun `a hang shorter than the grace period is not reported`() { + val detector = EventLoopHangDetector(GRACE_MS) + + assertNull(detector.sample(hung = true, nowNanos = ms(0))) + assertNull(detector.sample(hung = true, nowNanos = ms(2_000))) + assertNull(detector.sample(hung = true, nowNanos = ms(4_999))) + } + + @Test + fun `a hang past the grace period is reported once`() { + val detector = EventLoopHangDetector(GRACE_MS) + + assertNull(detector.sample(hung = true, nowNanos = ms(0))) + val stalled = detector.sample(hung = true, nowNanos = ms(6_000)) + assertEquals(HangTransition.Stalled(durationMs = 6_000), stalled) + + // Still hung, poll after poll: nothing more, or a permanent deadlock + // would emit a thread dump every two seconds. + assertNull(detector.sample(hung = true, nowNanos = ms(8_000))) + assertNull(detector.sample(hung = true, nowNanos = ms(60_000))) + } + + @Test + fun `pumping again after a reported stall reports the recovery`() { + val detector = EventLoopHangDetector(GRACE_MS) + + detector.sample(hung = true, nowNanos = ms(0)) + detector.sample(hung = true, nowNanos = ms(6_000)) + + val recovered = detector.sample(hung = false, nowNanos = ms(9_000)) + assertEquals(HangTransition.Recovered(durationMs = 9_000), recovered) + } + + @Test + fun `a hang that never reached the grace period reports no recovery`() { + val detector = EventLoopHangDetector(GRACE_MS) + + detector.sample(hung = true, nowNanos = ms(0)) + assertNull(detector.sample(hung = false, nowNanos = ms(3_000))) + } + + @Test + fun `a second stall after a recovery is reported again`() { + val detector = EventLoopHangDetector(GRACE_MS) + + detector.sample(hung = true, nowNanos = ms(0)) + detector.sample(hung = true, nowNanos = ms(6_000)) + detector.sample(hung = false, nowNanos = ms(7_000)) + + assertNull(detector.sample(hung = true, nowNanos = ms(10_000))) + val second = detector.sample(hung = true, nowNanos = ms(20_000)) + assertTrue(second is HangTransition.Stalled, "second stall must be reported, was $second") + // Timed from the new stall, not from the first one. + assertEquals(10_000, second.durationMs) + } + + @Test + fun `a reset closes a reported stall so every unresponsive keeps its responsive`() { + val detector = EventLoopHangDetector(GRACE_MS) + + detector.sample(hung = true, nowNanos = ms(0)) + detector.sample(hung = true, nowNanos = ms(6_000)) + + // What the watchdog does when a sample straddles a system suspend: the + // episode is abandoned, but a stall the app was told about is closed. + assertEquals( + HangTransition.Recovered(durationMs = 7_000), + detector.reset(nowNanos = ms(7_000)), + ) + + assertNull(detector.sample(hung = false, nowNanos = ms(7_000))) + // And the next stall is timed from scratch. + assertNull(detector.sample(hung = true, nowNanos = ms(8_000))) + assertEquals( + HangTransition.Stalled(durationMs = 6_000), + detector.sample(hung = true, nowNanos = ms(14_000)), + ) + } + + @Test + fun `a reset with nothing reported claims nothing`() { + val detector = EventLoopHangDetector(GRACE_MS) + + detector.sample(hung = true, nowNanos = ms(0)) + assertNull(detector.reset(nowNanos = ms(2_000))) + } + + @Test + fun `a healthy loop never reports anything`() { + val detector = EventLoopHangDetector(GRACE_MS) + + repeat(10) { i -> assertNull(detector.sample(hung = false, nowNanos = ms(i * 2_000L))) } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NativePopupLayersTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NativePopupLayersTest.kt new file mode 100644 index 000000000..b0e4d6cde --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NativePopupLayersTest.kt @@ -0,0 +1,170 @@ +@file:OptIn(androidx.compose.ui.InternalComposeUiApi::class) + +package dev.nucleusframework.window.tao + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionContext +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.pointer.PointerButton +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.scene.ComposeSceneLayer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import dev.nucleusframework.window.tao.scene.runTaoSceneTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [NativePopupLayers] on a real `CanvasLayersComposeScene` — the scene a + * window without `nativePopupLayers` runs on. The window's factory is a + * recording fake: what matters here is *which* pipeline a `Popup` ends up in, + * not what the native layer draws. + */ +class NativePopupLayersTest { + @Test + fun `a Popup inside NativePopupLayers is built by the window's native layer factory`() { + val factory = RecordingLayerFactory() + runTaoSceneTest(width = 100, height = 100) { + setContent { + CompositionLocalProvider(LocalTaoNativePopupLayerFactory provides factory::create) { + Box(Modifier.fillMaxSize().background(Color.White)) { + NativePopupLayers { + Popup(offset = IntOffset(20, 20), properties = PopupProperties(focusable = true)) { + Box(Modifier.size(30.dp).background(Color.Blue)) + } + } + } + } + } + frame() + val layer = factory.layers.single() + assertTrue(layer.contentSet, "Popup content must be handed to the native layer") + assertTrue(layer.focusable, "the Popup's properties must reach the native layer") + // The in-scene pipeline was bypassed: nothing paints the popup here. + assertEquals(WHITE, pixelAt(30, 30)) + } + } + + @Test + fun `a Popup outside NativePopupLayers keeps drawing in the scene`() { + val factory = RecordingLayerFactory() + runTaoSceneTest(width = 100, height = 100) { + setContent { + CompositionLocalProvider(LocalTaoNativePopupLayerFactory provides factory::create) { + Box(Modifier.fillMaxSize().background(Color.White)) { + NativePopupLayers { } + Popup(offset = IntOffset(20, 20)) { + Box(Modifier.size(30.dp).background(Color.Blue)) + } + } + } + } + frame() + assertTrue(factory.layers.isEmpty(), "the opt-in must not leak out of its subtree") + assertEquals(BLUE, pixelAt(30, 30)) + } + } + + @Test + fun `without a native layer factory NativePopupLayers is a no-op`() { + runTaoSceneTest(width = 100, height = 100) { + setContent { + Box(Modifier.fillMaxSize().background(Color.White)) { + NativePopupLayers { + Popup(offset = IntOffset(20, 20)) { + Box(Modifier.size(30.dp).background(Color.Blue)) + } + } + } + } + frame() + assertEquals(BLUE, pixelAt(30, 30)) + } + } + + @Test + fun `closing the Popup closes the native layer`() { + val factory = RecordingLayerFactory() + runTaoSceneTest(width = 100, height = 100) { + setContent { + CompositionLocalProvider(LocalTaoNativePopupLayerFactory provides factory::create) { + NativePopupLayers { + Popup { Box(Modifier.size(30.dp)) } + } + } + } + frame() + assertFalse(factory.layers.single().closed) + setContent { } + frame() + assertTrue(factory.layers.single().closed) + } + } +} + +private const val WHITE = 0xFFFFFFFF.toInt() +private const val BLUE = 0xFF0000FF.toInt() + +private class RecordingLayerFactory { + val layers = mutableListOf() + + fun create( + density: Density, + layoutDirection: LayoutDirection, + focusable: Boolean, + consumePointerInputOutside: Boolean, + ): ComposeSceneLayer = + RecordingLayer(density, layoutDirection, focusable, consumePointerInputOutside).also { layers += it } +} + +/** A [ComposeSceneLayer] that records what Compose asks of it and composes nothing. */ +private class RecordingLayer( + override var density: Density, + override var layoutDirection: LayoutDirection, + override var focusable: Boolean, + override var consumePointerInputOutside: Boolean, +) : ComposeSceneLayer { + override var boundsInWindow: IntRect = IntRect.Zero + override var compositionLocalContext: CompositionLocalContext? = null + override var scrimColor: Color? = null + var contentSet = false + var closed = false + + override fun close() { + closed = true + } + + override fun setContent( + parentCompositionContext: CompositionContext, + content: @Composable () -> Unit, + ) { + contentSet = true + } + + override fun setKeyEventListener( + onPreviewKeyEvent: ((KeyEvent) -> Boolean)?, + onKeyEvent: ((KeyEvent) -> Boolean)?, + ) = Unit + + override fun setOutsidePointerEventListener( + onOutsidePointerEvent: ((eventType: PointerEventType, button: PointerButton?) -> Unit)?, + ) = Unit + + override fun calculateLocalPosition(positionInWindow: IntOffset): IntOffset = + positionInWindow - boundsInWindow.topLeft +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NucleusWindowV2BridgeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NucleusWindowV2BridgeTest.kt new file mode 100644 index 000000000..8e9ba1822 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/NucleusWindowV2BridgeTest.kt @@ -0,0 +1,291 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import dev.nucleusframework.window.tao.v2.DialogState +import dev.nucleusframework.window.tao.v2.WindowBoundsProvider +import dev.nucleusframework.window.tao.v2.WindowPositionProvider +import dev.nucleusframework.window.tao.v2.WindowScreenProvider +import dev.nucleusframework.window.tao.v2.WindowSizeProvider +import dev.nucleusframework.window.tao.v2.WindowState +import dev.nucleusframework.window.tao.v2.WindowStateWithBounds +import dev.nucleusframework.window.tao.v2.evaluateScreen +import dev.nucleusframework.window.tao.v2.screenScope +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * The AWT-free clone's whole point: providers that are inert on Compose's own + * v2 types (they need an AWT `WindowGeometryProviderScope`) resolve here. + */ +class NucleusWindowV2BridgeTest { + private val primaryAvailable get() = screenScope(window = null).defaultScreen.availableBounds + + @Test + fun fixedSizeAndAbsolutePositionAreApplied() { + val state = + WindowState( + initialBoundsProvider = + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(640.dp, 480.dp), + positionProvider = WindowPositionProvider.Absolute(40.dp, 60.dp), + ), + ) + val v1 = nucleusWindowStateToV1(state) + assertEquals(DpSize(640.dp, 480.dp), v1.size) + assertEquals(WindowPosition.Absolute(40.dp, 60.dp), v1.position) + } + + @Test + fun requestSizeIsHonoured() { + // The regression this clone exists for: WindowState.requestSize builds a + // WindowBoundsProvider(sizeProvider, positionProvider), which Compose can + // only evaluate with an AWT window. + val state = WindowState() + state.requestSize(DpSize(1280.dp, 800.dp)) + assertEquals(DpSize(1280.dp, 800.dp), nucleusWindowStateToV1(state).size) + } + + @Test + fun sizeOnlyProviderLeavesTheInitialPositionToThePlatform() { + // `WindowBoundsProvider(sizeProvider = …)` / `requestSize` pair the size + // with WindowPositionProvider.Current; before the window exists that + // must mean "platform default", not a pinned point — same as v1's + // `rememberWindowState(size = …)`. + val state = WindowState(initialBoundsProvider = WindowBoundsProvider(WindowSizeProvider.Fixed(1024.dp, 720.dp))) + val v1 = nucleusWindowStateToV1(state) + assertEquals(DpSize(1024.dp, 720.dp), v1.size) + assertEquals(WindowPosition.PlatformDefault, v1.position) + + val requested = WindowState() + requested.requestSize(DpSize(1280.dp, 800.dp)) + assertEquals(WindowPosition.PlatformDefault, nucleusWindowStateToV1(requested).position) + } + + @Test + fun positionOnlyRequestKeepsTheDefaultSize() { + val state = WindowState() + state.requestPosition(DpOffset(10.dp, 20.dp)) + assertEquals(DpSize(800.dp, 600.dp), nucleusWindowStateToV1(state).size) + } + + @Test + fun requestPositionIsHonoured() { + val state = WindowState() + state.requestPosition(DpOffset(120.dp, 140.dp)) + assertEquals(WindowPosition.Absolute(120.dp, 140.dp), nucleusWindowStateToV1(state).position) + } + + @Test + fun centeredOnScreenResolvesAgainstTheScreenWorkArea() { + val size = DpSize(400.dp, 300.dp) + val state = + WindowState( + initialBoundsProvider = + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(size), + positionProvider = WindowPositionProvider.CenteredOnScreen, + ), + ) + val position = assertIs(nucleusWindowStateToV1(state).position) + val available = primaryAvailable + val expectedX = available.left + ((available.right - available.left - size.width).value / 2f).dp + val expectedY = available.top + ((available.bottom - available.top - size.height).value / 2f).dp + assertEquals(expectedX.value, position.x.value, absoluteTolerance = 1f) + assertEquals(expectedY.value, position.y.value, absoluteTolerance = 1f) + } + + @Test + fun alignedToScreenPlacesTheWindowInsideTheWorkArea() { + val size = DpSize(300.dp, 200.dp) + val state = + WindowState( + initialBoundsProvider = + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(size), + positionProvider = WindowPositionProvider.AlignedToScreen(Alignment.BottomEnd), + ), + ) + val position = assertIs(nucleusWindowStateToV1(state).position) + val available = primaryAvailable + assertEquals((available.right - size.width).value, position.x.value, absoluteTolerance = 1f) + assertEquals((available.bottom - size.height).value, position.y.value, absoluteTolerance = 1f) + } + + @Test + fun scopedLambdaProviderCanReadWindowMetrics() { + // WindowBoundsProvider { windowMetrics.… } is the shape that logs + // "Ignoring a Compose WindowBoundsProvider…" on the Compose v2 path. + val state = + WindowState( + initialBoundsProvider = + WindowBoundsProvider { + val available = windowMetrics.screen.availableBounds + DpRect( + left = available.left + 10.dp, + top = available.top + 20.dp, + right = available.left + 810.dp, + bottom = available.top + 620.dp, + ) + }, + ) + val v1 = nucleusWindowStateToV1(state) + val available = primaryAvailable + assertEquals(WindowPosition.Absolute(available.left + 10.dp, available.top + 20.dp), v1.position) + assertEquals(DpSize(800.dp, 600.dp), v1.size) + } + + @Test + fun unconstrainedSizeBecomesWrapContent() { + val state = WindowState(initialBoundsProvider = WindowBoundsProvider(WindowSizeProvider.Unconstrained)) + val size = nucleusWindowStateToV1(state).size + assertEquals(Dp.Unspecified, size.width) + assertEquals(Dp.Unspecified, size.height) + } + + @Test + fun preferredWidthWrapsOnlyThatAxis() { + val state = + WindowState(initialBoundsProvider = WindowBoundsProvider(WindowSizeProvider.PreferredWidth(480.dp))) + val size = nucleusWindowStateToV1(state).size + assertEquals(Dp.Unspecified, size.width) + assertEquals(480.dp, size.height) + } + + @Test + fun defaultProvidersKeepThePlatformDefault() { + val v1 = nucleusWindowStateToV1(WindowState()) + assertEquals(WindowPosition.PlatformDefault, v1.position) + assertEquals(DpSize(800.dp, 600.dp), v1.size) + } + + @Test + fun placementAndMinimizedRequestsSurviveTheConversion() { + val state = + WindowState( + initialPlacement = WindowPlacement.Maximized, + initiallyMinimized = true, + ) + val v1 = nucleusWindowStateToV1(state) + assertEquals(WindowPlacement.Maximized, v1.placement) + assertTrue(v1.isMinimized) + } + + @Test + fun initialConversionIsIdempotent() { + // Draining the request channels is destructive: a window that leaves and + // re-enters composition before ever being shown must still land on the + // geometry it asked for. + val state = WindowStateWithBounds(initialSize = DpSize(640.dp, 480.dp), initiallyMinimized = true) + val first = nucleusWindowStateToV1(state) + val second = nucleusWindowStateToV1(state) + assertEquals(first.size, second.size) + assertEquals(first.position, second.position) + assertEquals(DpSize(640.dp, 480.dp), second.size) + assertTrue(second.isMinimized) + } + + @Test + fun screenProviderPicksAnAttachedScreen() { + val scope = screenScope(window = null) + val target = scope.screens.last() + val state = WindowState(initialScreenProvider = WindowScreenProvider.ById(target.id)) + // The screen only shows up in the conversion through the geometry it + // constrains, so assert on the provider itself as well. + assertEquals(target, scope.evaluateScreen(WindowScreenProvider.ById(target.id))) + assertNotNull(nucleusWindowStateToV1(state)) + } + + @Test + fun unknownScreenIdFallsBackToTheDefaultScreen() { + val scope = screenScope(window = null) + assertEquals(scope.defaultScreen, scope.evaluateScreen(WindowScreenProvider.ById("no-such-display"))) + } + + @Test + fun screenInsetsMatchTheWorkArea() { + val screen = screenScope(window = null).defaultScreen + assertEquals(screen.availableBounds.left - screen.bounds.left, screen.insets.left) + assertEquals(screen.bounds.bottom - screen.availableBounds.bottom, screen.insets.bottom) + } + + @Test + fun dialogStateResolvesItsOwnProviders() { + val state = DialogState() + state.requestSize(DpSize(500.dp, 400.dp)) + assertEquals(DpSize(500.dp, 400.dp), nucleusDialogStateToV1(state).size) + } + + @Test + fun uninitializedStateRefusesToReportGeometry() { + val state = WindowState() + assertFailsWith { state.bounds } + assertFailsWith { state.screenId } + assertFailsWith { state.placement } + } + + @Test + fun absoluteProviderRejectsUnspecifiedBounds() { + assertFailsWith { + WindowBoundsProvider.Absolute(DpRect(Dp.Unspecified, 0.dp, 100.dp, 100.dp)) + } + assertFailsWith { + WindowSizeProvider.Fixed(DpSize.Unspecified) + } + } + + @Test + fun windowStateSaverRoundTripsAnInitializedState() { + val state = WindowState() + state.isInitialized = true + state.screenIdOrNull = "display-1" + state.placementOrNull = WindowPlacement.Maximized + state.minimizedOrNull = false + state.boundsOrNull = DpRect(10.dp, 20.dp, 810.dp, 620.dp) + + val saved = with(WindowState.Saver) { AlwaysSaveScope.save(state) } + val restored = assertNotNull(WindowState.Saver.restore(assertNotNull(saved))) + assertEquals("display-1", restored.screenId) + assertEquals(WindowPlacement.Maximized, restored.placement) + assertEquals(DpRect(10.dp, 20.dp, 810.dp, 620.dp), restored.bounds) + } + + @Test + fun windowStateSaverDropsAnUninitializedState() { + // Nothing observed yet, nothing to persist: listSaver turns the empty + // list into "no saved value", so the state is rebuilt from its initial + // providers on restore. + assertNull(with(WindowState.Saver) { AlwaysSaveScope.save(WindowState()) }) + } + + @Test + fun dialogStateSaverRoundTrips() { + val state = DialogState() + state.isInitialized = true + state.screenIdOrNull = "display-2" + state.boundsOrNull = DpRect(1.dp, 2.dp, 3.dp, 4.dp) + val saved = with(DialogState.Saver) { AlwaysSaveScope.save(state) } + val restored = assertNotNull(DialogState.Saver.restore(assertNotNull(saved))) + assertEquals("display-2", restored.screenId) + assertEquals(DpRect(1.dp, 2.dp, 3.dp, 4.dp), restored.bounds) + } + + private object AlwaysSaveScope : SaverScope { + override fun canBeSaved(value: Any): Boolean = true + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/OutboundDragPumpNativeSmokeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/OutboundDragPumpNativeSmokeTest.kt index cc5005466..1dc7c7375 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/OutboundDragPumpNativeSmokeTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/OutboundDragPumpNativeSmokeTest.kt @@ -81,7 +81,14 @@ class OutboundDragPumpNativeSmokeTest { handle = 0L, files = null, text = null, + privateData = null, allowedEffects = NativeTaoLinuxDndBridge.DROP_EFFECT_COPY, + iconArgb = null, + iconWidth = 0, + iconHeight = 0, + iconScale = 1f, + iconHotX = 0, + iconHotY = 0, pump = LinuxPump, ), ) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockRankTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockRankTest.kt new file mode 100644 index 000000000..2954d3d67 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockRankTest.kt @@ -0,0 +1,218 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.DockDropZone +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * The ranks of a dock side ([SatellitePlacement.Docked.order]) as + * [SatelliteWorkspace.dock] and [SatelliteWorkspace.undock] keep them: + * contiguous from `0`, inserted at the index asked for, and remembered per + * side so a satellite floated and docked again comes back to its place. + */ +class SatelliteDockRankTest { + private val a = TaoWindow(handle = 1L) + private val panelOrigin = SatelliteDragOrigin.DockedPanel(a) + + private val floatingRight = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + @Test + fun `dock order inserts at that rank and keeps the side contiguous`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("one", "One", floatingRight, initiallyOpen = true) + workspace.register("two", "Two", floatingRight, initiallyOpen = true) + workspace.register("three", "Three", floatingRight, initiallyOpen = true) + + workspace.dock("one", DockSide.Left) + workspace.dock("two", DockSide.Left) + // Out of range on either end clamps: the first rank, then the last. + workspace.dock("three", DockSide.Left, order = -5) + assertEquals(listOf("three", "one", "two"), workspace.ranksOn(DockSide.Left)) + workspace.dock("three", DockSide.Left, order = 99) + assertEquals(listOf("one", "two", "three"), workspace.ranksOn(DockSide.Left)) + workspace.dock("three", DockSide.Left, order = 1) + assertEquals(listOf("one", "three", "two"), workspace.ranksOn(DockSide.Left)) + // A re-dock on the same side with no rank keeps the one it has. + workspace.dock("three", DockSide.Left) + assertEquals(listOf("one", "three", "two"), workspace.ranksOn(DockSide.Left)) + } + + @Test + fun `a satellite docked again on the side it left returns to its rank`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + for ((rank, id) in listOf("tree", "toc", "notes").withIndex()) { + workspace.register(id, id, SatellitePlacement.Docked(DockSide.Right, order = rank), initiallyOpen = true) + } + + workspace.undock("toc") + // The gap closes behind it… + assertEquals(listOf("tree", "notes"), workspace.ranksOn(DockSide.Right)) + assertEquals(1, (workspace.satellite("notes")!!.placement as SatellitePlacement.Docked).order) + // …and it opens again where it was, through every path that names no rank. + workspace.dock("toc", DockSide.Right) + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + + // The rank it *leaves* with is the one remembered, not the declared one. + workspace.dock("tree", DockSide.Right, order = 2) + assertEquals(listOf("toc", "notes", "tree"), workspace.ranksOn(DockSide.Right)) + workspace.undock("tree") + workspace.dock("notes", DockSide.Left) + workspace.dock("tree", DockSide.Right) + assertEquals(listOf("toc", "tree"), workspace.ranksOn(DockSide.Right)) + } + + @Test + fun `a satellite new to a side is appended there and keeps its rank elsewhere`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tree", "Tree", SatellitePlacement.Docked(DockSide.Right, order = 0), initiallyOpen = true) + workspace.register("toc", "Toc", SatellitePlacement.Docked(DockSide.Right, order = 1), initiallyOpen = true) + workspace.register( + "targum", + "Targum", + SatellitePlacement.Docked(DockSide.Left, order = 0), + initiallyOpen = true, + ) + + // Moved to a side it never sat on: after what is there. + workspace.dock("tree", DockSide.Left) + assertEquals(listOf("targum", "tree"), workspace.ranksOn(DockSide.Left)) + assertEquals(listOf("toc"), workspace.ranksOn(DockSide.Right)) + assertEquals(0, (workspace.satellite("toc")!!.placement as SatellitePlacement.Docked).order) + // Back to the right: at the rank it left, ahead of the toc. + workspace.dock("tree", DockSide.Right) + assertEquals(listOf("tree", "toc"), workspace.ranksOn(DockSide.Right)) + // A floating satellite that was never docked appends too. + workspace.register("notes", "Notes", floatingRight, initiallyOpen = true) + workspace.dock("notes", DockSide.Right) + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + } + + @Test + fun `a closed panel keeps its rank and the weight comes back with it`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tree", "Tree", SatellitePlacement.Docked(DockSide.Right, order = 0), initiallyOpen = true) + workspace.register("toc", "Toc", SatellitePlacement.Docked(DockSide.Right, order = 1), initiallyOpen = true) + workspace.register("notes", "Notes", SatellitePlacement.Docked(DockSide.Right, order = 2), initiallyOpen = true) + workspace.setDockedWeight("toc", 3f) + + workspace.close("toc") + workspace.undock("notes") + workspace.dock("notes", DockSide.Right) + // The closed toc still holds rank 1; the notes return behind it. + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + + workspace.undock("toc") + workspace.dock("toc", DockSide.Right) + assertEquals(3f, (workspace.satellite("toc")!!.placement as SatellitePlacement.Docked).weight) + assertEquals(listOf("tree", "toc", "notes"), workspace.ranksOn(DockSide.Right)) + } + + /** The ids docked on [side] of the owner, in rank order; every rank is asserted contiguous from 0. */ + private fun SatelliteWorkspace.ranksOn(side: DockSide): List { + val stack = + satellites + .filter { (it.placement as? SatellitePlacement.Docked)?.side == side } + .sortedBy { (it.placement as SatellitePlacement.Docked).order } + assertEquals( + stack.indices.toList(), + stack.map { (it.placement as SatellitePlacement.Docked).order }, + "ranks on $side", + ) + return stack.map { it.id } + } + + /** + * Host `a` as the drag test sees it: outer frame at (100, 100), 800×600, + * content the same size, DockLayout below a 40 px bar — so its screen rect + * is (100, 140)–(900, 700), scale 1. + */ + private fun SatelliteWorkspace.registerHostA(): HostGeometry { + join(a) + val geometry = + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + } + dockHosts.register(geometry) + return geometry + } + + @Test + fun `a docked drag dropped on its own stack takes the rank under the pointer`() { + val workspace = SatelliteWorkspace() + val geometry = workspace.registerHostA() + val ids = listOf("tree", "toc", "notes") + for (id in ids) { + workspace.register(id, id, floatingRight, initiallyOpen = true).content = {} + workspace.dock(id, DockSide.Left) + } + // Stacked down the left side, 200 px wide, in window px. + val bounds = + listOf(Rect(0f, 40f, 200f, 226f), Rect(0f, 226f, 200f, 413f), Rect(0f, 413f, 200f, 600f)) + ids.forEachIndexed { index, id -> + workspace.satellite(id)!!.dockedBoundsInWindowPx = bounds[index] + workspace.satellite(id)!!.dockHostContainerSizePx = IntSize(800, 600) + } + // What the layout publishes while the notes are dragged: the tree and + // the toc cut at their centres, three ranks. + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = + listOf( + Rect(0f, 40f, 200f, 133f), + Rect(0f, 133f, 200f, 319.5f), + Rect(0f, 319.5f, 200f, 600f), + ), + ), + ) + + // Over its own rank: nothing to preview, and a release leaves it alone. + var session = requireNotNull(workspace.beginDrag("notes", panelOrigin, Offset(200f, 550f))) + session.update(Offset(210f, 560f)) + assertNull(workspace.dockPreview, "its own slot is not a target") + session.end(Offset(210f, 560f)) + assertEquals(ids, workspace.ranksOn(DockSide.Left)) + + // Over the top of the tree: first rank. + session = requireNotNull(workspace.beginDrag("notes", panelOrigin, Offset(200f, 550f))) + session.update(Offset(250f, 200f)) + assertEquals(DockTarget(a, DockSide.Left, 0), workspace.dockPreview) + session.end(Offset(250f, 200f)) + assertEquals(listOf("notes", "tree", "toc"), workspace.ranksOn(DockSide.Left)) + assertSame(a, workspace.satellite("notes")!!.dockHost) + + // A closed panel keeps its rank in the middle while the shown ones are aimed between. + workspace.close("tree") + // Shown: notes, toc. Dropping the toc at shown rank 0 lands ahead of both. + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = listOf(Rect(0f, 40f, 200f, 320f), Rect(0f, 320f, 200f, 600f)), + ), + ) + session = requireNotNull(workspace.beginDrag("toc", panelOrigin, Offset(200f, 550f))) + session.end(Offset(250f, 200f)) + assertEquals(listOf("toc", "notes", "tree"), workspace.ranksOn(DockSide.Left)) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockSidesTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockSidesTest.kt new file mode 100644 index 000000000..9a61b3b55 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockSidesTest.kt @@ -0,0 +1,161 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * A satellite declared for some sides only ([SatelliteEntry.dockSides]): the + * others are refused by [SatelliteWorkspace.dock], never previewed by a drag, + * not offered as hints, and not applied from a snapshot. + */ +class SatelliteDockSidesTest { + private val a = TaoWindow(handle = 1L) + private val notTop = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom) + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + /** Host `a`: layout (100, 140)–(900, 700) on screen, scale 1. */ + private fun workspace(): SatelliteWorkspace = + SatelliteWorkspace().apply { + join(a) + dockHosts.register( + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + } + + @Test + fun `dock refuses a side the satellite was not declared for`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + + workspace.dock("tools", DockSide.Top) + assertEquals(floating, entry.placement, "a refused dock changes nothing") + + workspace.dock("tools", DockSide.Left) + assertEquals(DockSide.Left, assertIs(entry.placement).side) + workspace.dock("tools", DockSide.Top) + assertEquals(DockSide.Left, assertIs(entry.placement).side, "still where it was") + } + + @Test + fun `floating-only never docks, the preferred side follows the declaration`() { + val workspace = workspace() + val never = workspace.register("hud", "Hud", floating, initiallyOpen = true, dockSides = emptySet()) + workspace.dock("hud", DockSide.Right) + assertEquals(floating, never.placement) + + val leftOnly = + workspace.register( + "nav", + "Nav", + floating, + initiallyOpen = true, + dockSides = setOf(DockSide.Left), + ) + assertEquals(DockSide.Left, leftOnly.preferredDockSide, "the right side is not allowed: the first allowed one") + val notTopEntry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + assertEquals(DockSide.Right, notTopEntry.preferredDockSide) + } + + @Test + fun `a declared docked placement must name an allowed side`() { + val workspace = workspace() + assertFailsWith { + workspace.register( + "tools", + "Tools", + SatellitePlacement.Docked(DockSide.Top), + initiallyOpen = true, + dockSides = notTop, + ) + } + val ok = + workspace.register( + "nav", + "Nav", + SatellitePlacement.Docked(DockSide.Left), + initiallyOpen = true, + dockSides = notTop, + ) + assertEquals(DockSide.Left, assertIs(ok.placement).side) + } + + @Test + fun `a refused side is not hinted nor previewed, a release there keeps it floating`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + assertEquals( + listOf(DockSide.Left, DockSide.Right, DockSide.Bottom), + hintedSides(entry, a, workspace.satellites), + ) + + // The bare edges are a target for anyone… + val atTop = Rect(400f, 150f, 600f, 300f) + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(atTop, atTop.center)) + // …but not for this satellite. + assertNull(workspace.dockTargetFor(entry, atTop, atTop.center)) + + val satellite = TaoWindow(handle = 3L) + val origin = + SatelliteDragOrigin.FloatingWindow( + window = satellite, + outerBoundsPx = { longArrayOf(400L, 300L, 200L, 150L) }, + move = { _, _ -> }, + ) + val session = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + session.update(Offset(500f, 160f)) + assertNull(workspace.dockPreview, "the top zone is not previewed for a satellite that may not dock there") + session.end(Offset(500f, 160f)) + assertIs(entry.placement) + + val again = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + again.update(Offset(500f, 690f)) + assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockPreview) + again.end(Offset(500f, 690f)) + assertEquals(DockSide.Bottom, assertIs(entry.placement).side) + } + + @Test + fun `a snapshot naming a refused side leaves the placement alone`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true, dockSides = notTop) + workspace.restore( + SatelliteLayoutSnapshot( + satellites = + mapOf( + "tools" to SatelliteSnapshot(SatellitePlacement.Docked(DockSide.Top), isOpen = false), + ), + dockExtents = emptyMap(), + ), + ) + assertEquals(floating, entry.placement) + assertEquals(false, entry.isOpen, "the open state is still applied") + + workspace.restore( + SatelliteLayoutSnapshot( + satellites = + mapOf( + "tools" to SatelliteSnapshot(SatellitePlacement.Docked(DockSide.Left), isOpen = true), + ), + dockExtents = emptyMap(), + ), + ) + assertEquals(DockSide.Left, assertIs(entry.placement).side) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockedGeometryTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockedGeometryTest.kt new file mode 100644 index 000000000..80b53e64e --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteDockedGeometryTest.kt @@ -0,0 +1,151 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The per-panel geometry a [SatellitePlacement.Docked] carries — its own + * extent on a layered side, its weight on a split side — and how + * [SatelliteWorkspace] seeds, clamps and persists it. Driven without any + * native window, like [SatelliteWorkspaceTest]. + */ +class SatelliteDockedGeometryTest { + private val a = TaoWindow(handle = 1L) + private val b = TaoWindow(handle = 2L) + + private val floatingRight = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + // ── per-panel geometry: layered extents and split weights ──────────── + + @Test + fun `docking from a floating window brings its size along as the panel extent`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + + workspace.dock("tools", DockSide.Right) + val docked = assertIs(workspace.satellite("tools")?.placement) + assertEquals(200.dp, docked.extent, "a right layer is as wide as the window was") + assertEquals(1f, docked.weight) + + workspace.undock("tools") + workspace.dock("tools", DockSide.Bottom) + val bottom = assertIs(workspace.satellite("tools")?.placement) + assertEquals(300.dp, bottom.extent, "a bottom layer is as tall as the window was") + } + + @Test + fun `re-docking keeps the extent along the same axis and re-seeds it across axes`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Right) + workspace.setDockedExtent("tools", 240.dp) + workspace.setDockedWeight("tools", 2.5f) + + workspace.dock("tools", DockSide.Left) + val left = assertIs(workspace.satellite("tools")?.placement) + assertEquals(240.dp, left.extent, "left and right share the width axis") + assertEquals(2.5f, left.weight, "the weight travels with the panel") + + workspace.dock("tools", DockSide.Top) + val top = assertIs(workspace.satellite("tools")?.placement) + assertEquals(300.dp, top.extent, "a width is no height: the floating size seeds the top layer") + assertEquals(2.5f, top.weight) + } + + @Test + fun `a panel moved between docks seeds its new side with the width it had`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("comments", "Comments", floatingRight, initiallyOpen = true) + workspace.dock("comments", DockSide.Bottom) + workspace.setDockedExtent("comments", 220.dp) + + // The top side has no extent of its own: the arriving panel gives it + // the height it had at the bottom, and the preview promises exactly + // that — the two must agree, or the drop lands somewhere the preview + // did not show. + val entry = requireNotNull(workspace.satellite("comments")) + assertEquals(220.dp, workspace.plannedDockExtent(entry, DockSide.Top), "the preview height") + workspace.dock("comments", DockSide.Top) + assertEquals(220.dp, workspace.dockExtent(DockSide.Top), "the side took the panel's height") + assertEquals(220.dp, assertIs(entry.placement).extent) + + // Across the axes a height is no width: the floating size seeds it, + // and again the preview says the same. + assertEquals(200.dp, workspace.plannedDockExtent(entry, DockSide.Left)) + workspace.dock("comments", DockSide.Left) + assertEquals(200.dp, workspace.dockExtent(DockSide.Left)) + + // A side that already has an extent keeps it. + workspace.setDockExtent(DockSide.Right, 150.dp) + assertEquals(150.dp, workspace.plannedDockExtent(entry, DockSide.Right)) + workspace.dock("comments", DockSide.Right) + assertEquals(150.dp, workspace.dockExtent(DockSide.Right)) + } + + @Test + fun `docked extent and weight are clamped and ignored for a floating satellite`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + + workspace.setDockedExtent("tools", 10.dp) + assertIs(workspace.satellite("tools")?.placement, "floating: untouched") + + workspace.dock("tools", DockSide.Right) + workspace.setDockedExtent("tools", 10.dp) + workspace.setDockedWeight("tools", -3f) + val docked = assertIs(workspace.satellite("tools")?.placement) + assertEquals(SatelliteWorkspace.MinDockExtent, docked.extent) + assertTrue(docked.weight > 0f, "a weight is never zero or negative: ${docked.weight}") + assertEquals(DockSide.Right, docked.side) + assertEquals(0, docked.order) + } + + @Test + fun `a snapshot carries every panel's own extent and weight`() { + val source = SatelliteWorkspace() + source.join(a) + source.register("tree", "Tree", floatingRight, initiallyOpen = true) + source.register("toc", "Toc", floatingRight, initiallyOpen = true) + source.dock("tree", DockSide.Right) + source.dock("toc", DockSide.Right) + source.setDockedExtent("tree", 180.dp) + source.setDockedExtent("toc", 130.dp) + source.setDockedWeight("toc", 3f) + + val target = SatelliteWorkspace() + target.join(b) + target.restore(source.snapshot()) + val tree = assertIs(target.register("tree", "Tree", floatingRight, true).placement) + val toc = assertIs(target.register("toc", "Toc", floatingRight, true).placement) + assertEquals(SatellitePlacement.Docked(DockSide.Right, 0, 180.dp, 1f), tree) + assertEquals(SatellitePlacement.Docked(DockSide.Right, 1, 130.dp, 3f), toc) + } + + @Test + fun `a docked placement refuses a weight that is not positive`() { + assertFailsWith { SatellitePlacement.Docked(DockSide.Left, weight = 0f) } + } + + @Test + fun `every side has an opposite across the content`() { + for (side in DockSide.entries) { + assertNotEquals(side, side.opposite) + assertEquals(side, side.opposite.opposite) + assertEquals(side.isVertical, side.opposite.isVertical) + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteExtentRangeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteExtentRangeTest.kt new file mode 100644 index 000000000..3c21f2f8e --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteExtentRangeTest.kt @@ -0,0 +1,79 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * A panel's own thickness limits ([SatelliteEntry.minExtent] / + * [SatelliteEntry.maxExtent]): on the side it shares, on its own layer, and on + * the preview of docking it — without a window, since all of it is arithmetic + * on the workspace. + */ +class SatelliteExtentRangeTest { + @Test + fun `a panel's range clamps its thickness, the side it joins, and the preview`() { + val workspace = SatelliteWorkspace() + val wide = + workspace.register( + "wide", + "Wide", + SatellitePlacement.Docked(DockSide.Right), + initiallyOpen = true, + minExtent = 200.dp, + maxExtent = 300.dp, + ) + val narrow = + workspace.register( + "narrow", + "Narrow", + SatellitePlacement.Floating(size = DpSize(120.dp, 400.dp)), + initiallyOpen = true, + maxExtent = 250.dp, + ) + + workspace.setDockExtent(DockSide.Right, 100.dp) + assertEquals(200.dp, workspace.dockExtent(DockSide.Right), "the side cannot go under its panel's minimum") + workspace.setDockExtent(DockSide.Right, 500.dp) + assertEquals(300.dp, workspace.dockExtent(DockSide.Right), "nor over its maximum") + + // The side is 300 dp; the newcomer allows 250 at most, so the preview + // says 250 — and the drop produces 250. + assertEquals(250.dp, workspace.plannedDockExtent(narrow, DockSide.Right)) + workspace.dock("narrow", DockSide.Right) + assertEquals(250.dp, workspace.dockExtent(DockSide.Right), "the drop is what the preview promised") + + // A panel's own layer obeys its own range. + workspace.setDockedExtent("wide", 50.dp) + assertEquals(200.dp, (wide.placement as SatellitePlacement.Docked).extent) + } + + @Test + fun `a restore bounds a side by the panels it puts there, not the ones it moves away`() { + val workspace = SatelliteWorkspace() + workspace.register( + "wide", + "Wide", + SatellitePlacement.Docked(DockSide.Left), + initiallyOpen = true, + minExtent = 400.dp, + ) + workspace.register("plain", "Plain", SatellitePlacement.Floating(), initiallyOpen = true) + + // The wide panel floats in the snapshot and the plain one takes the + // left side at 250 dp: nothing there asks for 400 any more. + workspace.restore( + SatelliteLayoutSnapshot( + satellites = + mapOf( + "wide" to SatelliteSnapshot(SatellitePlacement.Floating(), isOpen = true), + "plain" to SatelliteSnapshot(SatellitePlacement.Docked(DockSide.Left), isOpen = true), + ), + dockExtents = mapOf(DockSide.Left to 250.dp), + ), + ) + + assertEquals(250.dp, workspace.dockExtent(DockSide.Left), "a panel moved away still bounded the side") + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt new file mode 100644 index 000000000..4c2b470ba --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteFixedPanelTest.kt @@ -0,0 +1,211 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.DockDropZone +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs +import kotlin.test.assertNull + +/** + * A fixed panel: [SatelliteEntry.isFloatable] `false` keeps it out of a + * window of its own — [SatelliteWorkspace.undock] refuses it, a drag released + * over the content leaves it docked and shows no tear-out ghost, a snapshot + * that floats it is ignored — and [SatelliteEntry.isReorderable] `false` pins + * its rank: its own drag is offered none, and another panel can only be + * dropped after it. + */ +class SatelliteFixedPanelTest { + private val a = TaoWindow(handle = 1L) + private val panelOrigin = SatelliteDragOrigin.DockedPanel(a) + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + /** Host `a`: layout (100, 140)–(900, 700) on screen, scale 1. */ + private fun workspace(): Pair { + val workspace = SatelliteWorkspace() + workspace.join(a) + val geometry = + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + } + workspace.dockHosts.register(geometry) + return workspace to geometry + } + + /** A fixed panel of the left side, with a rect the drag code can read. */ + private fun SatelliteWorkspace.fixedPanel( + id: String, + order: Int = 0, + boundsInWindowPx: Rect = Rect(0f, 40f, 200f, 600f), + ): SatelliteEntry { + val entry = + register( + id, + id, + SatellitePlacement.Docked(DockSide.Left, order = order), + initiallyOpen = true, + dockSides = setOf(DockSide.Left), + floatable = false, + reorderable = false, + ) + entry.content = {} + entry.dockedBoundsInWindowPx = boundsInWindowPx + entry.dockHostContainerSizePx = IntSize(800, 600) + return entry + } + + @Test + fun `undock refuses a fixed panel`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + workspace.undock("tree") + assertEquals(DockSide.Left, assertIs(entry.placement).side) + + workspace.undock("tree", floating) + assertIs(entry.placement, "an explicit placement is refused too") + } + + @Test + fun `a fixed satellite must be declared docked`() { + val (workspace, _) = workspace() + assertFailsWith { + workspace.register("tree", "Tree", floating, initiallyOpen = true, floatable = false) + } + assertFailsWith { + workspace.register("toc", "Toc", floating, initiallyOpen = true, reorderable = false) + } + } + + @Test + fun `a drag released over the content leaves a fixed panel docked, with no ghost`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + val session = requireNotNull(workspace.beginDrag("tree", panelOrigin, Offset(150f, 300f))) + session.update(Offset(500f, 400f)) + assertNull(workspace.dragGhost, "a fixed panel shows no tear-out ghost") + assertNull(workspace.dockPreview, "the middle of the layout is no zone") + session.end(Offset(500f, 400f)) + assertEquals(DockSide.Left, assertIs(entry.placement).side) + assertNull(workspace.draggedSatellite) + + // Outside every layout — where a floating panel would be torn out. + val away = requireNotNull(workspace.beginDrag("tree", panelOrigin, Offset(150f, 300f))) + away.end(Offset(2_000f, 2_000f)) + assertIs(entry.placement, "released off every window, it stays docked") + } + + @Test + fun `a transfer drag with no record leaves a fixed panel docked`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + val session = requireNotNull(workspace.beginTransferDrag("tree", panelOrigin)) + session.end() + assertEquals(DockSide.Left, assertIs(entry.placement).side) + } + + @Test + fun `a snapshot that floats a fixed panel is ignored, but its open state is not`() { + val (workspace, _) = workspace() + val entry = workspace.fixedPanel("tree") + + workspace.restore( + SatelliteLayoutSnapshot( + satellites = mapOf("tree" to SatelliteSnapshot(floating, isOpen = false)), + dockExtents = emptyMap(), + ), + ) + assertIs(entry.placement) + assertEquals(false, entry.isOpen) + } + + @Test + fun `a pinned panel is offered no rank and its drag changes nothing`() { + val (workspace, geometry) = workspace() + val tree = workspace.fixedPanel("tree", order = 0, boundsInWindowPx = Rect(0f, 40f, 200f, 320f)) + val toc = workspace.fixedPanel("toc", order = 1, boundsInWindowPx = Rect(0f, 320f, 200f, 600f)) + // Declared for the left side only, and pinned there: nothing to offer. + assertEquals(emptyList(), hintedSides(toc, a, workspace.satellites)) + assertEquals(DockTarget(a, DockSide.Left), workspace.ownTarget(toc, a), "its own side, at no rank") + geometry.zoneBoundsInWindowPx = + mapOf( + DockSide.Left to + DockDropZone( + strip = Rect(0f, 40f, 64f, 600f), + slots = listOf(Rect(0f, 40f, 200f, 180f), Rect(0f, 180f, 200f, 600f)), + ), + ) + + val session = requireNotNull(workspace.beginDrag("toc", panelOrigin, Offset(200f, 550f))) + session.update(Offset(250f, 200f)) + assertNull(workspace.dockPreview, "a pinned panel takes no rank, so its own side is no target") + session.end(Offset(250f, 200f)) + assertEquals(0, assertIs(tree.placement).order) + assertEquals(1, assertIs(toc.placement).order) + } + + @Test + fun `another panel is docked after the pinned ones, whatever rank it asks for`() { + val (workspace, _) = workspace() + workspace.fixedPanel("tree", order = 0) + workspace.fixedPanel("toc", order = 1) + val notes = + workspace.register( + "notes", + "Notes", + SatellitePlacement.Docked(DockSide.Left, order = 2), + initiallyOpen = true, + ) + notes.content = {} + + workspace.dock("notes", DockSide.Left, order = 0) + assertEquals(2, assertIs(notes.placement).order, "pushed past the pinned pair") + assertEquals(0, assertIs(workspace.satellite("tree")!!.placement).order) + assertEquals(1, assertIs(workspace.satellite("toc")!!.placement).order) + + // A pinned panel re-docked takes its own rank back, ahead of the movable one. + workspace.dock("tree", DockSide.Left) + assertEquals(0, assertIs(workspace.satellite("tree")!!.placement).order) + assertEquals(2, assertIs(notes.placement).order) + } + + @Test + fun `a panel that can go nowhere is no drag handle`() { + val (workspace, _) = workspace() + val tree = workspace.fixedPanel("tree") + assertEquals(false, workspace.canBeDragged(tree), "alone, one side, pinned: nothing a drag could do") + + // A second panel on the side gives a movable one somewhere to go… + val notes = + workspace.register( + "notes", + "Notes", + SatellitePlacement.Docked(DockSide.Left, order = 1), + initiallyOpen = true, + dockSides = setOf(DockSide.Left), + floatable = false, + ) + notes.content = {} + assertEquals(true, workspace.canBeDragged(notes)) + // …but not to the pinned one, which still cannot take another rank. + assertEquals(false, workspace.canBeDragged(tree)) + + // Another member's dock is somewhere to go, for either of them. + workspace.join(TaoWindow(handle = 2L)) + assertEquals(true, workspace.canBeDragged(tree)) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt new file mode 100644 index 000000000..c257f9853 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/SatelliteWorkspaceTest.kt @@ -0,0 +1,724 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Ownership, docking and layout-persistence rules of [SatelliteWorkspace], + * driven without any native window: members are bare [TaoWindow] handles + * (listener registration is pure Kotlin) and focus is fed through + * [SatelliteWorkspace.noteFocus]. The headful suite covers the real windows. + */ +class SatelliteWorkspaceTest { + private companion object { + /** Enough repetitions to expose accumulated drift, still instant. */ + const val CHURN_CYCLES = 50 + } + + /** Side and order of a docked placement; the extent it was seeded with is the floating size, not the point. */ + private fun assertDockedAt( + side: DockSide, + order: Int, + placement: SatellitePlacement, + message: String? = null, + ) { + val docked = assertIs(placement, message) + assertEquals(side, docked.side, message) + assertEquals(order, docked.order, message) + } + + private val a = TaoWindow(handle = 1L) + private val b = TaoWindow(handle = 2L) + + private val panelOrigin = SatelliteDragOrigin.DockedPanel(a) + + private val floatingRight = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + @Test + fun `the first member to join owns the satellites until focus moves`() { + val workspace = SatelliteWorkspace() + assertNull(workspace.owner) + + workspace.join(a) + workspace.join(b) + assertSame(a, workspace.owner) + + workspace.noteFocus(b) + assertSame(b, workspace.owner) + + workspace.leave(b) + assertSame(a, workspace.owner) + assertEquals(listOf(a), workspace.members) + } + + @Test + fun `pinning overrides focus until released`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.join(b) + workspace.noteFocus(b) + + workspace.pinTo(a) + assertSame(a, workspace.owner) + + workspace.pinTo(null) + assertSame(b, workspace.owner) + } + + @Test + fun `without follow focus the owner is the pinned or first member`() { + val workspace = SatelliteWorkspace(followFocus = false) + workspace.join(a) + workspace.join(b) + workspace.noteFocus(b) + assertSame(a, workspace.owner) + + workspace.pinTo(b) + assertSame(b, workspace.owner) + } + + @Test + fun `docking a floating satellite seeds the side extent and hosts it in the owner`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + assertFalse(entry.isDocked) + assertEquals(SatelliteWorkspace.DefaultDockExtent, workspace.dockExtent(DockSide.Right)) + + workspace.dock("tools", DockSide.Right) + + val docked = assertIs(entry.placement) + assertEquals(DockSide.Right, docked.side) + assertEquals(0, docked.order) + assertSame(a, entry.dockHost) + assertEquals(200.dp, workspace.dockExtent(DockSide.Right)) + assertEquals(DockSide.Right, entry.preferredDockSide) + } + + @Test + fun `undock without host geometry returns to the last floating placement`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + // The user dragged the window: that offset is what docking remembers. + entry.windowState.offsetFromParent = DpOffset(40.dp, 50.dp) + entry.windowState.size = DpSize(240.dp, 320.dp) + + workspace.dock("tools", DockSide.Bottom) + workspace.undock("tools") + + val floating = assertIs(entry.placement) + assertEquals(DpSize(240.dp, 320.dp), floating.size) + assertEquals(WindowAnchor.TopLeft, floating.positioner.parentAnchor) + assertEquals(WindowAnchor.TopLeft, floating.positioner.childAnchor) + assertEquals(DpOffset(40.dp, 50.dp), floating.positioner.offset) + assertNull(entry.dockHost) + assertNull(entry.windowState.offsetFromParent) + assertEquals(DockSide.Bottom, entry.preferredDockSide) + } + + @Test + fun `a member leaving rehosts the satellites docked into it`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + workspace.join(b) + workspace.noteFocus(b) + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Left) + assertSame(b, workspace.satellite("tools")!!.dockHost) + + workspace.leave(b) + assertSame(a, workspace.satellite("tools")!!.dockHost) + + workspace.leave(a) + assertNull(workspace.satellite("tools")!!.dockHost) + + // The next window to join picks the orphaned panel up. + workspace.join(b) + assertSame(b, workspace.satellite("tools")!!.dockHost) + } + + @Test + fun `open close and toggle only touch the open flag`() { + val workspace = SatelliteWorkspace() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + + workspace.close("tools") + assertFalse(entry.isOpen) + workspace.toggle("tools") + assertTrue(entry.isOpen) + workspace.close("tools") + workspace.open("tools") + assertTrue(entry.isOpen) + assertEquals(floatingRight, entry.placement) + } + + @Test + fun `restore clamps a dock extent that would make the splitter unreachable`() { + val workspace = SatelliteWorkspace() + workspace.restore( + SatelliteLayoutSnapshot( + satellites = emptyMap(), + dockExtents = mapOf(DockSide.Left to 0.dp, DockSide.Top to 4_000.dp), + ), + ) + + assertEquals(SatelliteWorkspace.MinDockExtent, workspace.dockExtent(DockSide.Left)) + assertEquals(4_000.dp, workspace.dockExtent(DockSide.Top)) + } + + @Test + fun `the planned extent of an untouched side is the satellite's own size`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + + // floatingRight is 200 x 300: a vertical side takes the width, a + // horizontal one the height — which is what a drop seeds and what the + // preview has to draw. + assertEquals(200.dp, workspace.plannedDockExtent(entry, DockSide.Left)) + assertEquals(300.dp, workspace.plannedDockExtent(entry, DockSide.Bottom)) + + workspace.setDockExtent(DockSide.Left, 123.dp) + assertEquals(123.dp, workspace.plannedDockExtent(entry, DockSide.Left), "an adopted extent wins") + } + + @Test + fun `snapshot and restore round trip including a satellite declared later`() { + val source = SatelliteWorkspace() + source.join(a) + source.register("tools", "Tools", floatingRight, initiallyOpen = true) + val colors = source.register("colors", "Colors", floatingRight, initiallyOpen = true) + colors.windowState.offsetFromParent = DpOffset(10.dp, 20.dp) + source.dock("tools", DockSide.Left) + source.setDockExtent(DockSide.Left, 333.dp) + source.close("colors") + + val snapshot = source.snapshot() + + val target = SatelliteWorkspace() + target.restore(snapshot) + target.join(b) + val tools = target.register("tools", "Tools", floatingRight, initiallyOpen = true) + val restoredColors = target.register("colors", "Colors", floatingRight, initiallyOpen = true) + + assertDockedAt(DockSide.Left, 0, tools.placement) + assertSame(b, tools.dockHost) + assertEquals(333.dp, target.dockExtent(DockSide.Left)) + assertFalse(restoredColors.isOpen) + val floating = assertIs(restoredColors.placement) + assertEquals(DpOffset(10.dp, 20.dp), floating.positioner.offset) + assertEquals(WindowConstraintAdjustment.Slide, floating.positioner.constraintAdjustment) + } + + /** + * Host `a` as the drag tests see it: outer frame at (100, 100), 800×600, + * content the same size (client origin = outer origin), DockLayout below a + * 40 px bar — so its screen rect is (100, 140)–(900, 700), scale 1. + */ + private fun SatelliteWorkspace.registerHostA(): HostGeometry { + join(a) + val geometry = + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + } + dockHosts.register(geometry) + return geometry + } + + @Test + fun `dock target is the zone strip inside each edge of a registered layout`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + + assertEquals(DockTarget(a, DockSide.Left), workspace.dockTargetAt(Offset(120f, 400f))) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockTargetAt(Offset(880f, 400f))) + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(Offset(500f, 150f))) + assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockTargetAt(Offset(500f, 690f))) + // Nearest edge wins in a corner. + assertEquals(DockTarget(a, DockSide.Top), workspace.dockTargetAt(Offset(130f, 150f))) + assertNull(workspace.dockTargetAt(Offset(500f, 400f)), "content area is not a zone") + assertNull(workspace.dockTargetAt(Offset(50f, 50f)), "outside the layout") + assertNull(workspace.dockTargetAt(Offset(500f, 120f)), "the bar above the layout is not a zone") + } + + @Test + fun `a floating drag moves the window along and docks where it is released`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val satellite = TaoWindow(handle = 3L) + val moves = mutableListOf>() + val origin = + SatelliteDragOrigin.FloatingWindow( + window = satellite, + outerBoundsPx = { longArrayOf(400L, 300L, 200L, 150L) }, + move = { x, y -> moves += x to y }, + ) + + // Grabbed 50 px right of and 10 px below the window's corner. + val session = requireNotNull(workspace.beginDrag("tools", origin, Offset(450f, 310f))) + assertSame(entry, workspace.draggedSatellite, "the zone hints need the drag to be published") + session.update(Offset(600f, 400f)) + assertEquals(listOf(550 to 390), moves) + assertNull(workspace.dockPreview) + + session.update(Offset(880f, 400f)) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockPreview) + + session.end(Offset(880f, 400f)) + assertNull(workspace.dockPreview) + assertNull(workspace.draggedSatellite, "the hints must go away when the drag ends") + assertDockedAt(DockSide.Right, 0, entry.placement) + assertSame(a, entry.dockHost) + } + + @Test + fun `a docked drag released over content lifts the panel out under the pointer`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Left) + // The panel as DockLayout laid it out: full height of the layout, 220 px wide. + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + entry.dockHostContainerSizePx = IntSize(800, 600) + + // Grabbed at screen (150, 200) = 50 px into the panel, 60 px down. + val session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) + + // Hovering the panel's own zone is not a drop target, but the panel is + // already out: the ghost follows from the first move. + session.update(Offset(120f, 400f)) + assertNull(workspace.dockPreview) + assertEquals(Rect(Offset(70f, 340f), Size(220f, 560f)), workspace.dragGhost?.screenRectPx) + + // Over the content: the ghost follows the pointer, in screen px, with + // the grab point held under it. + session.update(Offset(500f, 400f)) + assertNull(workspace.dockPreview) + assertEquals( + DragGhost(entry, Rect(Offset(450f, 340f), Size(220f, 560f)), scaleFactor = 1f), + workspace.dragGhost, + ) + + session.end(Offset(500f, 400f)) + assertNull(workspace.dragGhost) + assertNull(workspace.draggedSatellite) + val floating = assertIs(entry.placement) + assertEquals(DpOffset(350.dp, 240.dp), floating.positioner.offset) + assertEquals(DpSize(220.dp, 560.dp), floating.size) + assertNull(entry.dockHost) + } + + @Test + fun `a docked drag released in another zone re-docks and inside its own panel stays`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Left) + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + entry.dockHostContainerSizePx = IntSize(800, 600) + + var session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) + session.update(Offset(160f, 300f)) + session.end(Offset(160f, 300f)) + assertDockedAt(DockSide.Left, 0, entry.placement, "released inside its own panel") + + session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) + assertSame(entry, workspace.draggedSatellite) + session.update(Offset(500f, 690f)) + assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockPreview) + session.end(Offset(500f, 690f)) + assertDockedAt(DockSide.Bottom, 0, entry.placement) + assertSame(a, entry.dockHost) + assertNull(workspace.dockPreview) + assertNull(workspace.draggedSatellite) + } + + @Test + fun `a cancelled drag leaves no feedback and no placement change`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Left) + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + entry.dockHostContainerSizePx = IntSize(800, 600) + + val session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) + session.update(Offset(500f, 400f)) + session.cancel() + + assertNull(workspace.draggedSatellite) + assertNull(workspace.dockPreview) + assertNull(workspace.dragGhost) + assertDockedAt(DockSide.Left, 0, entry.placement) + } + + // ── Adversarial drags: teleporting pointers, overlapping gestures, + // ── unusable coordinates, hosts and satellites disappearing mid-drag. + + @Test + fun `a teleporting pointer lands on the zone it was released in`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val moves = mutableListOf>() + val session = + requireNotNull( + workspace.beginDrag("tools", floatingOrigin(moves), Offset(450f, 310f)), + ) + + // No intermediate samples at all: straight from one edge of the desktop + // to the other, across and out of the layout, several times. + session.update(Offset(-5_000f, -5_000f)) + assertNull(workspace.dockPreview, "far off-screen is not a dock zone") + session.update(Offset(120f, 400f)) + assertEquals(DockTarget(a, DockSide.Left), workspace.dockPreview) + session.update(Offset(9_000f, 9_000f)) + assertNull(workspace.dockPreview) + session.update(Offset(500f, 690f)) + assertEquals(DockTarget(a, DockSide.Bottom), workspace.dockPreview) + + session.end(Offset(880f, 400f)) + assertDockedAt(DockSide.Right, 0, entry.placement) + assertNull(workspace.draggedSatellite) + // Every jump moved the window, and none of them overflowed. + assertTrue(moves.all { (x, y) -> x in -1_000_000..1_000_000 && y in -1_000_000..1_000_000 }, "moves=$moves") + } + + @Test + fun `non-finite pointer samples are ignored and leave the last position standing`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val moves = mutableListOf>() + val session = + requireNotNull( + workspace.beginDrag("tools", floatingOrigin(moves), Offset(450f, 310f)), + ) + + session.update(Offset(880f, 400f)) + val afterGoodSample = moves.size + assertEquals(DockTarget(a, DockSide.Right), workspace.dockPreview) + + session.update(Offset.Unspecified) + session.update(Offset(Float.NaN, 400f)) + session.update(Offset(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY)) + + // The preview still names the last usable position, and the window was + // asked to go back to it rather than somewhere undefined. + assertEquals(DockTarget(a, DockSide.Right), workspace.dockPreview) + assertTrue(moves.size > afterGoodSample) + assertEquals(moves[afterGoodSample - 1], moves.last(), "moves=$moves") + + // A release carrying garbage still drops where the pointer last was. + session.end(Offset.Unspecified) + assertDockedAt(DockSide.Right, 0, requireNotNull(workspace.satellite("tools")).placement) + } + + @Test + fun `a superseded drag stops acting and cannot clear the live one`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val tools = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val colors = workspace.register("colors", "Colors", floatingRight, initiallyOpen = true) + val staleMoves = mutableListOf>() + val stale = requireNotNull(workspace.beginDrag("tools", floatingOrigin(staleMoves), Offset(450f, 310f))) + stale.update(Offset(880f, 400f)) + val movesBeforeSupersede = staleMoves.size + + // A second grab starts while the first was never released. + val live = requireNotNull(workspace.beginDrag("colors", floatingOrigin(), Offset(450f, 310f))) + assertSame(colors, workspace.draggedSatellite) + + // The abandoned session is inert: no window moves, no feedback writes. + stale.update(Offset(120f, 400f)) + assertEquals(movesBeforeSupersede, staleMoves.size) + assertSame(colors, workspace.draggedSatellite) + stale.end(Offset(120f, 400f)) + assertFalse(tools.isDocked, "a stale release must not dock anything") + assertSame(colors, workspace.draggedSatellite, "and must not clear the live drag") + + // The live one still works. + live.update(Offset(880f, 400f)) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockPreview) + live.end(Offset(880f, 400f)) + assertDockedAt(DockSide.Right, 0, colors.placement) + assertNull(workspace.draggedSatellite) + } + + @Test + fun `ending or cancelling twice is a no-op`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val session = requireNotNull(workspace.beginDrag("tools", floatingOrigin(), Offset(450f, 310f))) + + session.end(Offset(880f, 400f)) + val docked = entry.placement + assertDockedAt(DockSide.Right, 0, docked) + + // A duplicated release (a replayed event, a second finally block) must + // not re-dock, re-order or resurrect the feedback. + session.end(Offset(500f, 690f)) + session.cancel() + session.update(Offset(120f, 400f)) + assertEquals(docked, entry.placement) + assertNull(workspace.draggedSatellite) + assertNull(workspace.dockPreview) + assertNull(workspace.dragGhost) + } + + @Test + fun `the tear-out ghost carries the host scale, not the composition's`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + // A 2x host: the panel rect is in physical pixels, and the ghost window + // is placed in logical ones, so the scale has to travel with the rect. + val geometry = + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 1600L, 1200L) }, scaleFactor = { 2f }).apply { + layoutBoundsInWindowPx = Rect(0f, 80f, 1600f, 1200f) + containerSizePx = IntSize(1600, 1200) + } + workspace.dockHosts.register(geometry) + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Left) + entry.dockedBoundsInWindowPx = Rect(0f, 80f, 440f, 1200f) + entry.dockHostContainerSizePx = IntSize(1600, 1200) + + val session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(200f, 300f))) + session.update(Offset(900f, 700f)) + + val ghost = requireNotNull(workspace.dragGhost) + assertEquals(2f, ghost.scaleFactor) + assertEquals(Size(440f, 1120f), ghost.screenRectPx.size, "the rect stays in physical pixels") + } + + @Test + fun `a drag whose host leaves mid-gesture still resolves`() { + val workspace = SatelliteWorkspace() + val geometry = workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Left) + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + entry.dockHostContainerSizePx = IntSize(800, 600) + val session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) + session.update(Offset(500f, 400f)) + + // The window the panel is being torn out of goes away underneath. + workspace.dockHosts.unregister(geometry) + workspace.leave(a) + + session.end(Offset(500f, 400f)) + assertIs(entry.placement) + assertNull(entry.dockHost) + assertNull(workspace.draggedSatellite) + assertNull(workspace.dragGhost) + } + + @Test + fun `a drag whose satellite is closed mid-gesture changes nothing`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val session = requireNotNull(workspace.beginDrag("tools", floatingOrigin(), Offset(450f, 310f))) + session.update(Offset(880f, 400f)) + + val placementBeforeClose = entry.placement + workspace.close("tools") + workspace.unregister(entry) + + session.end(Offset(880f, 400f)) + + // Closing does not un-register the entry from the workspace, so the + // drop still resolves — what must hold is that the satellite is closed + // and that nothing is left published. + assertFalse(entry.isOpen) + assertNull(workspace.draggedSatellite) + assertNull(workspace.dockPreview) + assertNull(workspace.dragGhost) + assertNotEquals( + placementBeforeClose, + entry.placement, + "the drop was over a dock zone, so it should have taken effect", + ) + assertIs(entry.placement) + } + + @Test + fun `dock and undock churn keeps one consistent placement`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val sides = DockSide.entries + + repeat(CHURN_CYCLES) { index -> + val side = sides[index % sides.size] + workspace.dock("tools", side) + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + entry.dockHostContainerSizePx = IntSize(800, 600) + assertEquals(side, (entry.placement as SatellitePlacement.Docked).side) + assertSame(a, entry.dockHost) + workspace.undock("tools") + assertIs(entry.placement) + assertNull(entry.dockHost) + assertEquals(side, entry.preferredDockSide) + } + + // No accumulated order drift: it is still the only panel on its side. + workspace.dock("tools", DockSide.Right) + assertDockedAt(DockSide.Right, 0, entry.placement) + assertNull(workspace.draggedSatellite, "churn must not leave a drag behind") + } + + @Test + fun `interleaved drags of two satellites keep their own placements`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val tools = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + val colors = workspace.register("colors", "Colors", floatingRight, initiallyOpen = true) + + repeat(CHURN_CYCLES) { + val first = requireNotNull(workspace.beginDrag("tools", floatingOrigin(), Offset(450f, 310f))) + first.update(Offset(120f, 400f)) + first.end(Offset(120f, 400f)) + val second = requireNotNull(workspace.beginDrag("colors", floatingOrigin(), Offset(450f, 310f))) + second.update(Offset(880f, 400f)) + second.end(Offset(880f, 400f)) + workspace.undock("tools") + workspace.undock("colors") + } + + workspace.dock("tools", DockSide.Left) + workspace.dock("colors", DockSide.Left) + assertDockedAt(DockSide.Left, 0, tools.placement) + assertDockedAt(DockSide.Left, 1, colors.placement) + assertNull(workspace.draggedSatellite) + assertNull(workspace.dragGhost) + } + + @Test + fun `a drop resolves against the state a restore left behind`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + val entry = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Left) + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + entry.dockHostContainerSizePx = IntSize(800, 600) + val snapshot = workspace.snapshot() + + val session = requireNotNull(workspace.beginDrag("tools", panelOrigin, Offset(150f, 200f))) + session.update(Offset(500f, 400f)) + workspace.undock("tools") + workspace.restore(snapshot) + assertDockedAt(DockSide.Left, 0, entry.placement) + + // The release reads the *current* placement, not the one the gesture + // started from: released over the content, it tears the restored panel + // out again rather than replaying the drop it was set up for. + session.end(Offset(500f, 400f)) + assertNull(workspace.draggedSatellite) + assertNull(workspace.dragGhost) + assertIs(entry.placement) + assertNull(entry.dockHost) + } + + /** A floating origin whose geometry is fixed and whose moves are recorded. */ + private fun floatingOrigin(moves: MutableList> = mutableListOf()) = + SatelliteDragOrigin.FloatingWindow( + window = TaoWindow(handle = 9L), + outerBoundsPx = { longArrayOf(400L, 300L, 200L, 150L) }, + move = { x, y -> moves += x to y }, + ) + + @Test + fun `re-registering an id keeps the workspace's memory of it`() { + val workspace = SatelliteWorkspace() + workspace.join(a) + val first = workspace.register("tools", "Tools", floatingRight, initiallyOpen = true) + workspace.dock("tools", DockSide.Top) + workspace.unregister(first) + + val again = workspace.register("tools", "Renamed", floatingRight, initiallyOpen = false) + + assertSame(first, again) + assertEquals("Renamed", again.title) + assertTrue(again.isOpen) + assertTrue(again.isDocked) + } + + @Test + fun `a minimized member is skipped as a drop target`() { + val workspace = SatelliteWorkspace() + var minimized = false + workspace.join(a) + workspace.dockHosts.register( + HostGeometry( + a, + outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, + scaleFactor = { 1f }, + minimized = { minimized }, + ).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + val rightZone = Offset(880f, 400f) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockTargetAt(rightZone)) + + // The frame is still on record while minimized, but nothing of it is on + // screen: a drop there must not dock into an invisible window. + minimized = true + assertNull(workspace.dockTargetAt(rightZone)) + minimized = false + assertEquals(DockTarget(a, DockSide.Right), workspace.dockTargetAt(rightZone)) + } + + @Test + fun `overlapping layouts resolve to the owner then the last focused member`() { + val workspace = SatelliteWorkspace() + workspace.registerHostA() + workspace.join(b) + // Same screen rect as host a: two windows exactly on top of each other. + workspace.dockHosts.register( + HostGeometry(b, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + val rightZone = Offset(880f, 400f) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockTargetAt(rightZone), "the first member owns") + + workspace.noteFocus(b) + assertEquals(DockTarget(b, DockSide.Right), workspace.dockTargetAt(rightZone), "focus moved the owner") + + workspace.pinTo(a) + assertEquals(DockTarget(a, DockSide.Right), workspace.dockTargetAt(rightZone), "the pin wins") + workspace.pinTo(null) + + // Neither window is the owner's layout at this point, so recency decides: + // b was focused after a joined. + workspace.join(TaoWindow(handle = 3L)) + workspace.noteFocus(TaoWindow(handle = 3L)) + assertEquals(DockTarget(b, DockSide.Right), workspace.dockTargetAt(rightZone)) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/StandalonePanelNativeSmokeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/StandalonePanelNativeSmokeTest.kt index 7cafe4633..81ebc83dc 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/StandalonePanelNativeSmokeTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/StandalonePanelNativeSmokeTest.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.window.tao import dev.nucleusframework.window.tao.ffi.NativeTaoGlBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDndBridge import dev.nucleusframework.window.tao.ffi.PopupNativeBridgeWindows import org.jetbrains.skia.DirectContext @@ -63,6 +64,15 @@ class StandalonePanelNativeSmokeTest { val rc = NativeTaoWindowsDndBridge.nativeRegister(hwnd, NoOpInboundDnDCallback()) assertEquals(0, rc, "RegisterDragDrop on standalone panel failed (rc=$rc)") NativeTaoWindowsDndBridge.nativeRevoke(hwnd) + + assertTrue(NativeTaoWindowsDecoBridge.isLoaded, "nucleus_tao_windows_deco failed to load") + val geometry = NativeTaoWindowsDecoBridge.nativeFontSmoothingPixelGeometry() + assertTrue( + geometry == NativeTaoWindowsDecoBridge.FONT_SMOOTHING_UNKNOWN || + geometry == NativeTaoWindowsDecoBridge.FONT_SMOOTHING_RGB || + geometry == NativeTaoWindowsDecoBridge.FONT_SMOOTHING_BGR, + "unexpected font-smoothing geometry $geometry", + ) } finally { PopupNativeBridgeWindows.nativeRelease(panel) } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabHoverPreviewTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabHoverPreviewTest.kt new file mode 100644 index 000000000..d441589c3 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabHoverPreviewTest.kt @@ -0,0 +1,212 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * The hover card of a tab strip, without a window: what the strip reports as + * the hovered tab, and where the card is placed against the tab's own slot. + * + * The headful suite covers the pointer actually travelling along a real strip; + * everything here is the state machine and the geometry behind it. + */ +class TabHoverPreviewTest { + private companion object { + /** Three placed tabs, left to right: "a" at 0..100, "b" at 100..200, "c" at 200..300. */ + val Slots = + listOf( + Rect(0f, 0f, 100f, 40f), + Rect(100f, 0f, 200f, 40f), + Rect(200f, 0f, 300f, 40f), + ) + val WindowSize = IntSize(width = 800, height = 600) + val Below = IntOffset(x = 0, y = 4) + val CardSize = IntSize(width = 260, height = 150) + } + + private fun strip(): TabStripScope { + val workspace = TabWorkspace() + for (id in listOf("a", "b", "c")) workspace.register(id, id.uppercase(), groupId = null) + val group = requireNotNull(workspace.groups.firstOrNull()) + group.slotsInWindowPx = Slots + // Said out loud rather than inherited from the declaration order — a + // tab is only hoverable while it is not the one being read, so which + // one is selected decides what every case below may hover. + workspace.select("a") + return TabStripScopeImpl(workspace, group) + } + + @Test + fun `a picture the app assigns stands until the workspace takes one`() { + val workspace = TabWorkspace(captureThumbnails = true) + val tab = workspace.register("a", "A", groupId = null) + val picture = ImageBitmap(width = 4, height = 4) + + tab.thumbnail = picture + // A capture is a request the shown body answers with a readback; the + // request alone drops nothing, so the picture stands until then. + workspace.captureThumbnail("a") + + assertEquals(1, tab.thumbnailRequest, "the workspace asked for a picture of its own") + assertSame(picture, TabHoverPreviewScopeImpl(workspace, requireNotNull(tab.group), tab).thumbnail) + } + + @Test + fun `the strip reports the tab the pointer rests on, and nothing once it leaves`() { + val strip = strip() + + strip.group.noteHoverEnter("b") + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "the hovered tab is the one entered") + + // Another tab's exit is not this one's: the pointer crossing a + // neighbour on its way out must not put the card away. + strip.group.noteHoverExit("c") + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "a neighbour's exit took the hover with it") + + strip.group.noteHoverExit("b") + assertNull(strip.hoveredTab, "the hover outlived the pointer") + } + + @Test + fun `a press puts the card away until the pointer has been elsewhere`() { + val strip = strip() + strip.group.noteHoverEnter("b") + strip.group.noteHoverPress("b") + + assertNull(strip.hoveredTab, "a card stayed under a tab being clicked") + + // Moving on to another tab is a new hover, and a browser shows its card. + strip.group.noteHoverEnter("c") + assertSame(strip.workspace.tab("c"), strip.hoveredTab, "the click blocked the next tab's card too") + } + + @Test + fun `a press on a tab the pointer is not on changes nothing`() { + val strip = strip() + strip.group.noteHoverEnter("b") + + strip.group.noteHoverPress("c") + + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "a press elsewhere took this tab's card") + } + + @Test + fun `no card while a tab is being dragged`() { + val strip = strip() + strip.group.noteHoverEnter("b") + + // Carrying a tab passes it over its neighbours; every one of them is + // hovered on the way, and none of them is being pointed at. + strip.workspace.draggedTab = strip.workspace.tab("a") + assertNull(strip.hoveredTab, "a card followed a tab being carried") + + strip.workspace.draggedTab = null + assertSame(strip.workspace.tab("b"), strip.hoveredTab, "the hover did not come back after the drag") + } + + @Test + fun `a tab that has left the group is no longer hovered`() { + val strip = strip() + strip.group.noteHoverEnter("b") + + strip.workspace.close("b") + + assertNull(strip.hoveredTab, "a closed tab kept the hover, and its card an anchor") + } + + @Test + fun `the anchor of a card is the tab's own slot, and nothing before it is placed`() { + val strip = strip() + + assertEquals(Slots[1], strip.group.slotInWindowPx("b"), "the slot of a placed tab") + assertNull(strip.group.slotInWindowPx("nobody"), "an unknown tab has no slot") + + val unplaced = TabWorkspace() + unplaced.register("a", "A", groupId = null) + val fresh = requireNotNull(unplaced.groups.firstOrNull()) + assertNull(fresh.slotInWindowPx("a"), "a tab the strip has not placed yet has no anchor") + } + + @Test + fun `the card hangs from the tab's leading edge, below it`() { + val position = TabHoverPreviewPosition(anchorPx = Slots[1], offsetPx = Below) + + val at = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = WindowSize, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = CardSize, + ) + + assertEquals(IntOffset(x = 100, y = 44), at, "the card is not under the left edge of its tab") + } + + @Test + fun `a right-to-left strip hangs the card from the tab's right edge`() { + // The third slot, 200..300: a card mirrored off the second one would + // start at -60 and be slid back to 0 by the clamp, which is the same + // number a left-aligned card at the window's edge gives — it would + // pass whether the mirroring worked or not. + val position = TabHoverPreviewPosition(anchorPx = Slots[2], offsetPx = Below) + + val at = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = WindowSize, + layoutDirection = LayoutDirection.Rtl, + popupContentSize = CardSize, + ) + + // The card grows into the reading direction: its right edge on the + // tab's right edge, so it runs leftwards under the tabs that follow. + assertEquals(IntOffset(x = 300 - CardSize.width, y = 44), at, "the card was not mirrored") + } + + @Test + fun `the selected tab has no card`() { + val strip = strip() + strip.group.noteHoverEnter("a") + + assertNull(strip.hoveredTab, "a card was offered for the tab already on screen") + + // Selecting another one leaves this tab off screen, and a card of it + // is worth something again — without the pointer having moved. + strip.workspace.select("b") + assertSame(strip.workspace.tab("a"), strip.hoveredTab, "the tab left behind never got its card") + } + + @Test + fun `a card that would run off the window is slid back in`() { + val nearTheEdge = Rect(700f, 0f, 800f, 40f) + val position = TabHoverPreviewPosition(anchorPx = nearTheEdge, offsetPx = Below) + + val at = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = WindowSize, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = CardSize, + ) + + assertEquals(IntOffset(x = WindowSize.width - CardSize.width, y = 44), at, "the card hung off the window") + + // And a card wider than the window keeps its leading edge visible. + val wider = + position.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = IntSize(width = 200, height = 600), + layoutDirection = LayoutDirection.Ltr, + popupContentSize = CardSize, + ) + assertEquals(IntOffset(x = 0, y = 44), wider, "a card wider than the window lost its start") + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt new file mode 100644 index 000000000..989815952 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TabWorkspaceTest.kt @@ -0,0 +1,1009 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Tab model, drop resolution and drag sessions of [TabWorkspace], driven + * without any native window: group windows are bare [TaoWindow] handles and + * strip geometry is published by hand. The headful suite covers real windows. + */ +@Suppress("LargeClass") // one model, and the adversarial half of one gesture +class TabWorkspaceTest { + private companion object { + /** Enough repetitions to expose accumulated drift, still instant. */ + const val CHURN_CYCLES = 50 + + /** A window frame whose client area starts at its own origin. */ + val FirstWindowFrame = longArrayOf(0L, 0L, 800L, 600L) + val SecondWindowFrame = longArrayOf(1000L, 0L, 800L, 600L) + } + + private val firstWindow = TaoWindow(handle = 1L) + private val secondWindow = TaoWindow(handle = 2L) + + // ── Declaration and placement ──────────────────────────────────────── + + @Test + fun `a right-to-left strip resolves its insertion indices from the right`() { + val workspace = TabWorkspace() + val group = workspace.rtlStrip() + + // Slots run from high x to low: "a" is the rightmost tab. + // Right of every midpoint is the first place; left of every one, the last. + assertEquals(0, workspace.insertionIndex(group, 295f, exclude = null)) + assertEquals(1, workspace.insertionIndex(group, 205f, exclude = null)) + assertEquals(2, workspace.insertionIndex(group, 105f, exclude = null)) + assertEquals(3, workspace.insertionIndex(group, 5f, exclude = null)) + // The dragged tab's own slot is not counted, so the index it would land + // at is the one it already has. + assertEquals(0, workspace.insertionIndex(group, 295f, exclude = workspace.tab("a"))) + assertEquals(1, workspace.insertionIndex(group, 105f, exclude = workspace.tab("b"))) + } + + @Test + fun `a single-tab strip inserts by the direction it published`() { + val workspace = TabWorkspace() + workspace.register("x", "Xray", groupId = "right") + val group = requireNotNull(workspace.group("right")) + workspace.attachWindow(group, secondWindow) + workspace.publishStrip(group, SecondWindowFrame, tabCount = 1) + requireNotNull(workspace.stripGeometry(group)).layoutDirection = LayoutDirection.Rtl + + // The one slot is 0..100: right of its middle is *before* it in a + // right-to-left strip, left of it after — an order of one cannot say so. + assertEquals(0, workspace.insertionIndex(group, 90f, exclude = null)) + assertEquals(1, workspace.insertionIndex(group, 10f, exclude = null)) + } + + /** Three placed tabs, laid out right to left: "a" at 200..300, "b" at 100..200, "c" at 0..100. */ + private fun TabWorkspace.rtlStrip(): TabWindowGroup { + for (id in listOf("a", "b", "c")) register(id, id.uppercase(), groupId = null) + val group = requireNotNull(groups.firstOrNull()) + group.slotsInWindowPx = + listOf(Rect(200f, 0f, 300f, 40f), Rect(100f, 0f, 200f, 40f), Rect(0f, 0f, 100f, 40f)) + return group + } + + @Test + fun `the first tab opens a window and the next ones join it`() { + val workspace = TabWorkspace() + + val alpha = workspace.register("a", "Alpha", groupId = null) + assertEquals(1, workspace.groups.size) + val group = workspace.groups.single() + assertSame(group, alpha.group) + assertEquals("a", group.selectedId, "the first tab of a window is selected") + + workspace.register("b", "Beta", groupId = null) + assertEquals(1, workspace.groups.size, "a second tab must not open a second window") + assertEquals(listOf("a", "b"), group.ids) + assertEquals("b", group.selectedId, "an arriving tab is selected") + } + + @Test + fun `a named group is created on demand and keeps its name`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + workspace.register("b", "Beta", groupId = "right") + workspace.register("c", "Gamma", groupId = "left") + + assertEquals(listOf("left", "right"), workspace.groups.map { it.id }) + assertEquals(listOf("a", "c"), workspace.group("left")?.ids) + assertEquals(listOf("b"), workspace.group("right")?.ids) + } + + @Test + fun `re-registering an id keeps its place and only refreshes the title`() { + val workspace = TabWorkspace() + val first = workspace.register("a", "Alpha", groupId = null) + workspace.register("b", "Beta", groupId = null) + workspace.select("a") + + val again = workspace.register("a", "Renamed", groupId = "somewhere-else") + + assertSame(first, again) + assertEquals("Renamed", again.title) + assertEquals(1, workspace.groups.size, "an already known id must not open a window") + assertEquals(listOf("a", "b"), workspace.groups.single().ids) + assertEquals("a", workspace.groups.single().selectedId, "the selection is left alone") + } + + // ── Selection and closing ──────────────────────────────────────────── + + @Test + fun `closing the selected tab selects its right neighbour, then its left`() { + val workspace = TabWorkspace() + listOf("a" to "Alpha", "b" to "Beta", "c" to "Gamma").forEach { (id, title) -> + workspace.register(id, title, groupId = null) + } + val group = workspace.groups.single() + workspace.select("b") + + workspace.close("b") + assertEquals("c", group.selectedId, "the neighbour to the right takes over") + assertEquals(listOf("a", "c"), group.ids) + + workspace.select("c") + workspace.close("c") + assertEquals("a", group.selectedId, "nothing to the right: the one to the left") + } + + @Test + fun `closing an unselected tab leaves the selection alone`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + workspace.register("b", "Beta", groupId = null) + workspace.select("a") + + workspace.close("b") + + assertEquals("a", workspace.groups.single().selectedId) + } + + @Test + fun `the last tab of a window takes the window with it`() { + val workspace = TabWorkspace() + val entry = workspace.register("a", "Alpha", groupId = null) + val group = workspace.groups.single() + workspace.attachWindow(group, firstWindow) + + workspace.close("a") + + assertTrue(workspace.groups.isEmpty(), "the group is dropped with its last tab") + assertNull(group.window, "and its window is forgotten") + assertNull(group.selectedId) + assertNull(entry.group) + assertNull(workspace.tab("a"), "a closed tab is gone, not hidden") + } + + @Test + fun `closing an unknown tab is a no-op`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + + workspace.close("nope") + workspace.close("a") + workspace.close("a") + + assertTrue(workspace.groups.isEmpty()) + } + + // ── Moving ─────────────────────────────────────────────────────────── + + @Test + fun `a move to another group inserts at the index and selects there`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + workspace.register("b", "Beta", groupId = "left") + workspace.register("x", "Xray", groupId = "right") + workspace.register("y", "Yankee", groupId = "right") + val left = requireNotNull(workspace.group("left")) + val right = requireNotNull(workspace.group("right")) + workspace.select("x") + + workspace.move("b", right, index = 1) + + assertEquals(listOf("a"), left.ids) + assertEquals(listOf("x", "b", "y"), right.ids) + assertEquals("b", right.selectedId, "the arriving tab is selected") + assertSame(right, workspace.tab("b")?.group) + assertEquals("a", left.selectedId, "the group it left selects a neighbour") + } + + @Test + fun `a move index beyond the strip appends and a negative one prepends`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + workspace.register("x", "Xray", groupId = "right") + workspace.register("y", "Yankee", groupId = "right") + val right = requireNotNull(workspace.group("right")) + + workspace.move("a", right, index = 99) + assertEquals(listOf("x", "y", "a"), right.ids) + + workspace.move("a", right, index = -5) + assertEquals(listOf("a", "x", "y"), right.ids, "a reorder clamps the same way") + } + + @Test + fun `a move within its own group is a reorder and keeps the selection`() { + val workspace = TabWorkspace() + listOf("a", "b", "c").forEach { workspace.register(it, it, groupId = null) } + val group = workspace.groups.single() + workspace.select("a") + + workspace.move("c", group, index = 0) + + assertEquals(listOf("c", "a", "b"), group.ids) + assertEquals("a", group.selectedId, "reordering does not change which tab shows") + assertEquals(1, workspace.groups.size, "and does not open or drop a window") + } + + @Test + fun `a move into a dropped group and of an unknown tab are both no-ops`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + workspace.register("x", "Xray", groupId = "right") + val left = requireNotNull(workspace.group("left")) + val right = requireNotNull(workspace.group("right")) + + // Emptying `right` drops it; a stale reference to it must not resurrect it. + workspace.move("x", left) + assertEquals(listOf("left"), workspace.groups.map { it.id }) + + workspace.move("a", right) + assertSame(left, workspace.tab("a")?.group, "the tab stays where it was") + assertEquals(listOf("left"), workspace.groups.map { it.id }) + + workspace.move("nope", left) + assertEquals(listOf("a", "x"), left.ids) + } + + // ── Tearing off ────────────────────────────────────────────────────── + + @Test + fun `tearing a tab off a multi-tab window opens a window at the rect`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + workspace.register("b", "Beta", groupId = null) + val source = workspace.groups.single() + + val torn = assertNotNull(workspace.tearOff("b", Rect(200f, 100f, 1000f, 700f), scaleFactor = 2f)) + + assertEquals(2, workspace.groups.size) + assertEquals(listOf("b"), torn.ids) + assertEquals("b", torn.selectedId) + assertEquals(listOf("a"), source.ids) + // The rect is physical px; a window is placed in logical ones. + assertEquals(DpOffset(100.dp, 50.dp), torn.position) + assertEquals(DpSize(400.dp, 300.dp), torn.size) + } + + @Test + fun `tearing off the only tab of a window moves that window instead`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + val group = workspace.groups.single() + + val torn = workspace.tearOff("a", Rect(300f, 200f, 1100f, 800f), scaleFactor = 1f) + + assertSame(group, torn, "no second window for a tab that already had one") + assertEquals(1, workspace.groups.size) + assertEquals(DpOffset(300.dp, 200.dp), group.position) + assertEquals(DpSize(800.dp, 600.dp), group.size) + } + + @Test + fun `a tear-off rect measured at an unusable scale falls back to one`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + workspace.register("b", "Beta", groupId = null) + + val torn = assertNotNull(workspace.tearOff("b", Rect(10f, 20f, 210f, 170f), scaleFactor = 0f)) + + assertEquals(DpOffset(10.dp, 20.dp), torn.position) + assertEquals(DpSize(200.dp, 150.dp), torn.size) + } + + @Test + fun `tearing off an unknown tab changes nothing`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + + assertNull(workspace.tearOff("nope", Rect(0f, 0f, 100f, 100f), scaleFactor = 1f)) + assertEquals(1, workspace.groups.size) + } + + // ── Drop resolution ────────────────────────────────────────────────── + + /** + * The workspace as the drag tests see it: two windows side by side, each + * with a strip 40 px tall across the top of its client area, holding + * 100 px-wide tabs. Window 1 is at (0, 0), window 2 at (1000, 0). + */ + private fun TabWorkspace.twoStripWindows(): Pair { + register("a", "Alpha", groupId = "left") + register("b", "Beta", groupId = "left") + register("x", "Xray", groupId = "right") + val left = requireNotNull(group("left")) + val right = requireNotNull(group("right")) + attachWindow(left, firstWindow) + attachWindow(right, secondWindow) + publishStrip(left, FirstWindowFrame, tabCount = 2) + publishStrip(right, SecondWindowFrame, tabCount = 1) + return left to right + } + + private fun TabWorkspace.publishStrip( + group: TabWindowGroup, + frame: LongArray, + tabCount: Int, + minimized: () -> Boolean = { false }, + scale: Float = 1f, + ) { + stripHosts.register( + HostGeometry( + requireNotNull(group.window), + outerBoundsPx = { frame }, + scaleFactor = { scale }, + minimized = minimized, + ).apply { + layoutBoundsInWindowPx = Rect(0f, 0f, frame[2].toFloat(), 40f) + containerSizePx = IntSize(frame[2].toInt(), frame[3].toInt()) + }, + ) + group.slotsInWindowPx = List(tabCount) { index -> Rect(index * 100f, 0f, (index + 1) * 100f, 40f) } + } + + @Test + fun `the card entering a strip is a drop before the pointer reaches it`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + // Pointer below the right strip (which spans y 0..40), the card it + // carries reaching up into it: the drop is previewed already. + val pointer = Offset(1020f, 60f) + val card = Rect(1020f, 20f, 1120f, 60f) + assertNull(workspace.dropTargetAt(pointer), "the pointer alone is below the strip") + assertEquals(TabDropTarget(right, 0), workspace.dropTargetAt(card, pointer)) + + // The pointer still wins where both answer: it is in the left strip + // while the card overlaps the right one. + assertEquals( + TabDropTarget(left, 1), + workspace.dropTargetAt(Rect(1020f, 0f, 1120f, 40f), Offset(80f, 20f)), + ) + + // Clear of every strip, card included: no drop. + assertNull(workspace.dropTargetAt(Rect(400f, 300f, 500f, 340f), Offset(400f, 340f))) + } + + @Test + fun `a dragged window's own strip never answers for the card either`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + // The card is the dragged window's own strip, laid over the other's: + // its own group is skipped and the search carries on to the one below. + assertEquals( + TabDropTarget(right, 0), + workspace.dropTargetAt(Rect(1000f, 0f, 1800f, 40f), Offset(1020f, 20f), excludeGroup = left), + ) + } + + @Test + fun `a drop resolves to the strip under the pointer and the index it falls at`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + // Left of the first tab's midpoint: index 0. Past it: index 1. + assertEquals(TabDropTarget(left, 0), workspace.dropTargetAt(Offset(20f, 20f))) + assertEquals(TabDropTarget(left, 1), workspace.dropTargetAt(Offset(80f, 20f))) + assertEquals(TabDropTarget(left, 2), workspace.dropTargetAt(Offset(400f, 20f)), "past every tab: the end") + assertEquals(TabDropTarget(right, 0), workspace.dropTargetAt(Offset(1020f, 20f))) + assertEquals(TabDropTarget(right, 1), workspace.dropTargetAt(Offset(1080f, 20f))) + + assertNull(workspace.dropTargetAt(Offset(400f, 300f)), "below the strip is not a drop") + assertNull(workspace.dropTargetAt(Offset(900f, 20f)), "between the two windows") + } + + @Test + fun `the dragged tab's own slot is counted out of the index`() { + val workspace = TabWorkspace() + val (left, _) = workspace.twoStripWindows() + val beta = requireNotNull(workspace.tab("b")) + + // Hovering its own slot resolves to the index it already has, so the + // strip does not offer to move it by one. + assertEquals(TabDropTarget(left, 1), workspace.dropTargetAt(Offset(180f, 20f), exclude = beta)) + // And the first slot is still index 0 with the second one discounted. + assertEquals(TabDropTarget(left, 0), workspace.dropTargetAt(Offset(20f, 20f), exclude = beta)) + assertEquals(TabDropTarget(left, 1), workspace.dropTargetAt(Offset(80f, 20f), exclude = beta)) + } + + @Test + fun `a minimized window is never a drop target`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + val group = workspace.groups.single() + workspace.attachWindow(group, firstWindow) + var minimized = false + workspace.publishStrip(group, FirstWindowFrame, tabCount = 1, minimized = { minimized }) + + assertEquals(TabDropTarget(group, 1), workspace.dropTargetAt(Offset(80f, 20f))) + // The frame is still on record while minimized, but nothing of it is on + // screen: a drop there would land in an invisible window. + minimized = true + assertNull(workspace.dropTargetAt(Offset(80f, 20f))) + minimized = false + assertEquals(TabDropTarget(group, 1), workspace.dropTargetAt(Offset(80f, 20f))) + } + + @Test + fun `overlapping strips resolve to the window focused most recently`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + workspace.register("x", "Xray", groupId = "right") + val left = requireNotNull(workspace.group("left")) + val right = requireNotNull(workspace.group("right")) + workspace.attachWindow(left, firstWindow) + workspace.attachWindow(right, secondWindow) + // Same frame: two windows exactly on top of each other. + workspace.publishStrip(left, FirstWindowFrame, tabCount = 1) + workspace.publishStrip(right, FirstWindowFrame, tabCount = 1) + + val onTheStrip = Offset(20f, 20f) + assertEquals(left, workspace.dropTargetAt(onTheStrip)?.group, "the first window joined owns") + + secondWindow.let(workspace::noteWindowFocus) + assertEquals(right, workspace.dropTargetAt(onTheStrip)?.group, "focus moved the front window") + + firstWindow.let(workspace::noteWindowFocus) + assertEquals(left, workspace.dropTargetAt(onTheStrip)?.group) + } + + @Test + fun `an excluded group is skipped for the strip underneath it`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + workspace.register("x", "Xray", groupId = "right") + val left = requireNotNull(workspace.group("left")) + val right = requireNotNull(workspace.group("right")) + workspace.attachWindow(left, firstWindow) + workspace.attachWindow(right, secondWindow) + // Exactly on top of each other, with the excluded one in front: the + // shape of a single-tab window being dragged over another window's + // strip — its own strip travels under the pointer, and it is the + // focused window, so it answers first. + workspace.publishStrip(left, FirstWindowFrame, tabCount = 1) + workspace.publishStrip(right, FirstWindowFrame, tabCount = 1) + secondWindow.let(workspace::noteWindowFocus) + + val onTheStrip = Offset(20f, 20f) + assertEquals(right, workspace.dropTargetAt(onTheStrip)?.group, "the focused window answers") + assertEquals( + left, + workspace.dropTargetAt(onTheStrip, excludeGroup = right)?.group, + "excluding it must look past it, not give up", + ) + assertNull( + workspace.dropTargetAt(onTheStrip, excludeGroup = left)?.group?.takeIf { it === left }, + "the excluded group is never the answer", + ) + } + + @Test + fun `a strip with no slots published yet resolves to index zero`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + val group = workspace.groups.single() + workspace.attachWindow(group, firstWindow) + workspace.publishStrip(group, FirstWindowFrame, tabCount = 0) + + assertEquals(TabDropTarget(group, 0), workspace.dropTargetAt(Offset(400f, 20f))) + } + + // ── Drag sessions ──────────────────────────────────────────────────── + + /** A strip origin whose window geometry is fixed and whose moves are recorded. */ + private fun stripOrigin( + window: TaoWindow, + frame: LongArray, + moves: MutableList> = mutableListOf(), + ) = TabDragOrigin.Strip(window, outerBoundsPx = { frame }, move = { x, y -> moves += x to y }) + + /** + * The grip covers the whole tab and claims the press before the tab's own + * click gesture, so a click whose pointer drifts past the touch slop + * becomes a drag. Ending it where it started must therefore still leave + * the tab selected — otherwise that click did nothing at all, which is how + * a strip comes to feel like it swallows clicks. + */ + @Test + fun `a drag selects the tab it lifted, so a click that drifts is never lost`() { + val workspace = TabWorkspace() + val (left, _) = workspace.twoStripWindows() + workspace.select("a") + assertEquals("a", left.selectedId, "the tab the drift starts from is not the selected one") + + val session = + assertNotNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + ) + + assertEquals("b", left.selectedId, "lifting a tab did not select it") + + // Released where it was grabbed: nothing moves, and the selection the + // lift made stands. + session.end(Offset(110f, 20f)) + assertEquals(listOf("a", "b"), left.ids, "a drag that went nowhere reordered the strip") + assertEquals("b", left.selectedId, "the selection was undone by the release") + } + + /** The same, for the local strip gesture a window without screen placement uses. */ + @Test + fun `taking a tab in hand inside its own strip selects it too`() { + val workspace = TabWorkspace() + val (left, _) = workspace.twoStripWindows() + workspace.select("a") + + assertNotNull(workspace.takeInStrip("b")) + + assertEquals("b", left.selectedId, "the local strip gesture left the click lost") + } + + @Test + fun `a drag says how it is carried, and the slot it opens knows the tab`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + val beta = requireNotNull(workspace.tab("b")) + assertNull(workspace.dragKind) + + // Grabbed in a right-to-left strip: the ghost is laid out the way the tab was drawn. + requireNotNull(workspace.stripGeometry(left)).layoutDirection = LayoutDirection.Rtl + val session = + assertNotNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + ) + assertEquals(WorkspaceDragKind.Window, workspace.dragKind) + + session.update(Offset(1020f, 20f)) + val ghost = assertNotNull(workspace.dragGhost) + assertEquals(LayoutDirection.Rtl, ghost.layoutDirection, "the ghost carries its strip's direction") + val slot = assertNotNull(TabStripScopeImpl(workspace, right).dropGhost, "the strip under the card opens a slot") + assertSame(beta, slot.tab, "the slot's card is drawn for the tab in flight") + assertNull(TabStripScopeImpl(workspace, left).dropGhost, "the strip it left shows no slot") + + session.cancel() + assertNull(workspace.dragKind) + } + + @Test + fun `a transfer drag is carried by the platform session, one held in its strip by none`() { + val workspace = TabWorkspace() + workspace.twoStripWindows() + + assertNotNull(workspace.takeInStrip("b")) + assertNull(workspace.dragKind, "held inside its strip, the tab is in the strip's hands") + + val drag = assertNotNull(workspace.beginTransferDrag("b", firstWindow)) + assertEquals(WorkspaceDragKind.Transfer, workspace.dragKind) + assertNull(workspace.dragGhost, "a transfer publishes no ghost") + + drag.cancel() + assertNull(workspace.dragKind) + } + + @Test + fun `dragging one of several tabs shows a ghost and inserts where it is dropped`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + val beta = requireNotNull(workspace.tab("b")) + + // Grabbed 10 px into the second tab of the left window. + val session = + assertNotNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + ) + assertSame(beta, workspace.draggedTab) + + session.update(Offset(1020f, 20f)) + val ghost = assertNotNull(workspace.dragGhost, "a tab dragged out of a strip is previewed") + assertSame(beta, ghost.tab) + assertTrue(ghost.screenRectPx.contains(Offset(1020f, 20f)), "the ghost sits under the pointer") + assertEquals(TabDropTarget(right, 0), workspace.dropPreview) + + session.end(Offset(1020f, 20f)) + + assertEquals(listOf("b", "x"), right.ids, "dropped before the tab it was over") + assertEquals("b", right.selectedId) + assertEquals(listOf("a"), left.ids) + assertNull(workspace.draggedTab) + assertNull(workspace.dragGhost) + assertNull(workspace.dropPreview) + } + + @Test + fun `dragging one of several tabs into empty space tears off a window under the pointer`() { + val workspace = TabWorkspace() + val (left, _) = workspace.twoStripWindows() + + val session = + assertNotNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + ) + // Clear of both strips. + session.update(Offset(500f, 400f)) + session.end(Offset(500f, 400f)) + + assertEquals(listOf("a"), left.ids) + val torn = assertNotNull(workspace.groups.firstOrNull { it.ids == listOf("b") }) + // Grabbed 10 px right and 20 px down inside the tab, so the window's + // top-left lands that far up and left of the drop. + assertEquals(DpOffset(490.dp, 380.dp), torn.position) + assertEquals(DpSize(800.dp, 600.dp), torn.size, "the new window inherits the size of the old one") + assertNull(workspace.dragGhost) + } + + @Test + fun `dragging the only tab of a window moves the window and shows no ghost`() { + val workspace = TabWorkspace() + workspace.register("x", "Xray", groupId = "right") + val right = requireNotNull(workspace.group("right")) + workspace.attachWindow(right, secondWindow) + workspace.publishStrip(right, SecondWindowFrame, tabCount = 1) + val moves = mutableListOf>() + + val session = + assertNotNull( + workspace.beginDrag("x", stripOrigin(secondWindow, SecondWindowFrame, moves), Offset(1020f, 20f)), + ) + // The handle feeds the grab position first, then every move. + session.update(Offset(1020f, 20f)) + session.update(Offset(1120f, 60f)) + + assertEquals(listOf(1000 to 0, 1100 to 40), moves, "the window follows the pointer") + assertNull(workspace.dragGhost, "a ghost would be a second copy of the window's only tab") + assertNull(workspace.dropPreview, "its own strip is not a target") + + session.end(Offset(1120f, 60f)) + assertEquals(1, workspace.groups.size, "dropped in empty space: the window just stays there") + assertEquals(listOf("x"), right.ids) + } + + @Test + fun `dropping the only tab of a window on another strip merges and closes it`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + // Make the right window single-tab and the left one the merge target. + assertEquals(listOf("x"), right.ids) + val moves = mutableListOf>() + + val session = + assertNotNull( + workspace.beginDrag("x", stripOrigin(secondWindow, SecondWindowFrame, moves), Offset(1020f, 20f)), + ) + session.update(Offset(80f, 20f)) + assertEquals(TabDropTarget(left, 1), workspace.dropPreview) + session.end(Offset(80f, 20f)) + + assertEquals(listOf("a", "x", "b"), left.ids) + assertEquals("x", left.selectedId) + assertEquals(listOf("left"), workspace.groups.map { it.id }, "the emptied window is gone") + assertNull(right.window) + } + + @Test + fun `a teleporting pointer lands on the strip it was released over`() { + val workspace = TabWorkspace() + val (_, right) = workspace.twoStripWindows() + + val session = + assertNotNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + ) + // One sample each, nothing in between: far off screen, back onto a + // strip, off again, then onto the other one. + listOf( + Offset(-50_000f, -50_000f), + Offset(20f, 20f), + Offset(200_000f, 200_000f), + Offset(1080f, 20f), + ).forEach(session::update) + + assertEquals(TabDropTarget(right, 1), workspace.dropPreview) + session.end(Offset(1080f, 20f)) + assertEquals(listOf("x", "b"), right.ids) + } + + @Test + fun `non-finite samples are ignored and leave the last position standing`() { + val workspace = TabWorkspace() + val (_, right) = workspace.twoStripWindows() + val moves = mutableListOf>() + + // A tear-off drag: the ghost must not move to NaN. + val session = + assertNotNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + ) + session.update(Offset(1020f, 20f)) + val good = assertNotNull(workspace.dragGhost).screenRectPx + session.update(Offset(Float.NaN, Float.NaN)) + session.update(Offset(Float.POSITIVE_INFINITY, 20f)) + assertEquals(good, workspace.dragGhost?.screenRectPx) + assertEquals(TabDropTarget(right, 0), workspace.dropPreview) + session.end(Offset(Float.NaN, Float.NaN)) + assertEquals(listOf("b", "x"), right.ids, "the release resolves at the last usable position") + + // And a window drag: no NaN may reach window geometry. + val single = requireNotNull(workspace.group("right")) + workspace.publishStrip(single, SecondWindowFrame, tabCount = 2) + workspace.move("b", requireNotNull(workspace.group("left"))) + val windowSession = + assertNotNull( + workspace.beginDrag("x", stripOrigin(secondWindow, SecondWindowFrame, moves), Offset(1020f, 20f)), + ) + windowSession.update(Offset(1020f, 20f)) + windowSession.update(Offset(Float.NaN, 5f)) + windowSession.update(Offset(2f, Float.NEGATIVE_INFINITY)) + windowSession.cancel() + assertEquals(listOf(1000 to 0, 1000 to 0, 1000 to 0), moves, "garbage samples reached window geometry") + } + + @Test + fun `a beginDrag with a non-finite pointer is refused`() { + val workspace = TabWorkspace() + workspace.twoStripWindows() + + assertNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(Float.NaN, Float.NaN)), + ) + assertNull(workspace.draggedTab) + } + + @Test + fun `a drag is refused while the strip has published no geometry`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + workspace.register("b", "Beta", groupId = null) + val group = workspace.groups.single() + workspace.attachWindow(group, firstWindow) + + assertNull( + workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f)), + "no strip on screen yet, so no grab offset to speak of", + ) + assertNull(workspace.beginDrag("nope", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f))) + } + + @Test + fun `a superseded drag stops acting and cannot clear the live one`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + val first = + assertNotNull(workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f))) + first.update(Offset(1020f, 20f)) + val second = + assertNotNull(workspace.beginDrag("a", stripOrigin(firstWindow, FirstWindowFrame), Offset(10f, 20f))) + second.update(Offset(1080f, 20f)) + + first.update(Offset(400f, 400f)) + assertEquals(TabDropTarget(right, 1), workspace.dropPreview, "the superseded drag stole the live preview") + first.end(Offset(400f, 400f)) + assertEquals(listOf("a", "b"), left.ids, "the superseded drag moved a tab") + assertSame(requireNotNull(workspace.tab("a")), workspace.draggedTab) + + second.end(Offset(1080f, 20f)) + assertEquals(listOf("x", "a"), right.ids) + assertNull(workspace.draggedTab) + } + + @Test + fun `ending or cancelling twice is a no-op`() { + val workspace = TabWorkspace() + val (_, right) = workspace.twoStripWindows() + + val session = + assertNotNull(workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f))) + session.update(Offset(1020f, 20f)) + session.end(Offset(1020f, 20f)) + assertEquals(listOf("b", "x"), right.ids) + + session.end(Offset(20f, 20f)) + session.cancel() + session.update(Offset(20f, 20f)) + + assertEquals(listOf("b", "x"), right.ids, "a late release must not move the tab again") + assertNull(workspace.dragGhost) + assertNull(workspace.dropPreview) + } + + @Test + fun `a drag whose window closes mid-gesture still resolves`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + val session = + assertNotNull(workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f))) + session.update(Offset(1020f, 20f)) + + // The target window goes away under the pointer. + workspace.close("x") + assertTrue(workspace.groups.none { it === right }) + + session.end(Offset(1020f, 20f)) + + // Nothing to drop into there any more, so it tore off instead. + assertEquals(listOf("a"), left.ids) + assertEquals(listOf("b"), workspace.groups.first { it !== left }.ids) + assertNull(workspace.dragGhost) + } + + @Test + fun `a drag whose tab is closed mid-gesture leaves the workspace alone`() { + val workspace = TabWorkspace() + val (left, right) = workspace.twoStripWindows() + + val session = + assertNotNull(workspace.beginDrag("b", stripOrigin(firstWindow, FirstWindowFrame), Offset(110f, 20f))) + session.update(Offset(1020f, 20f)) + workspace.close("b") + + session.end(Offset(1020f, 20f)) + + assertNull(workspace.tab("b"), "a closed tab stays closed") + assertEquals(listOf("a"), left.ids) + assertEquals(listOf("x"), right.ids, "and does not come back in the drop target") + assertNull(workspace.draggedTab) + assertNull(workspace.dragGhost) + } + + @Test + fun `tear-off and merge churn keeps every tab in exactly one window`() { + val workspace = TabWorkspace() + val (left, _) = workspace.twoStripWindows() + + repeat(CHURN_CYCLES) { + val torn = assertNotNull(workspace.tearOff("b", Rect(400f, 300f, 1200f, 900f), scaleFactor = 1f)) + assertEquals(listOf("b"), torn.ids) + workspace.move("b", left, index = 1) + } + + assertEquals(listOf("left", "right"), workspace.groups.map { it.id }.sorted()) + assertEquals(listOf("a", "b"), left.ids) + assertEquals(1, workspace.tabs.count { it.id == "b" }) + assertSame(left, workspace.tab("b")?.group) + } + + // ── Snapshots ──────────────────────────────────────────────────────── + + @Test + fun `snapshot and restore round trip including a tab declared later`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + workspace.register("b", "Beta", groupId = "left") + workspace.register("x", "Xray", groupId = "right") + workspace.select("a") + val snapshot = workspace.snapshot() + assertEquals(listOf("left", "right"), snapshot.groups.map { it.id }) + assertEquals(listOf("a", "b"), snapshot.groups.first().tabIds) + assertEquals("a", snapshot.groups.first().selectedId) + + // The user rearranges everything, then asks for the layout back. + val fresh = TabWorkspace() + fresh.register("a", "Alpha", groupId = null) + fresh.register("b", "Beta", groupId = null) + fresh.restore(snapshot) + + assertEquals(listOf("a", "b"), requireNotNull(fresh.group("left")).ids) + assertEquals("a", requireNotNull(fresh.group("left")).selectedId) + assertNull(fresh.group("right"), "a group with no declared tab waits for one") + + fresh.register("x", "Xray", groupId = null) + assertEquals(listOf("x"), requireNotNull(fresh.group("right")).ids, "declared later, restored anyway") + assertEquals(listOf("a", "b"), requireNotNull(fresh.group("left")).ids) + } + + @Test + fun `a tear-off never takes the id of a restored window`() { + // Last session tore two windows off; their ids come back with the snapshot, while the + // new process counts its own tear-offs from zero again. + val snapshot = + TabLayoutSnapshot( + listOf( + TabGroupSnapshot("group-0", listOf("a", "b"), "a", null, TabWorkspace.DefaultWindowSize), + TabGroupSnapshot("group-1", listOf("x"), "x", null, TabWorkspace.DefaultWindowSize), + ), + ) + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = null) + workspace.register("b", "Beta", groupId = null) + workspace.restore(snapshot) + + val torn = assertNotNull(workspace.tearOff("b", Rect(0f, 0f, 800f, 600f), scaleFactor = 1f)) + + assertTrue(torn.id !in setOf("group-0", "group-1"), "restored id reused: ${torn.id}") + assertEquals(listOf("a"), requireNotNull(workspace.group("group-0")).ids) + // "group-1" is still waiting for its tab: it must not have been handed to the tear-off. + workspace.register("x", "Xray", groupId = null) + assertEquals(listOf("x"), requireNotNull(workspace.group("group-1")).ids) + assertEquals(listOf("b"), torn.ids) + assertEquals( + workspace.groups.size, + workspace.groups + .map { it.id } + .toSet() + .size, + "duplicate group ids", + ) + } + + @Test + fun `a restore rebuilds strip order whatever order the tabs are declared in`() { + val workspace = TabWorkspace() + listOf("a", "b", "c").forEach { workspace.register(it, it, groupId = "one") } + workspace.select("b") + val snapshot = workspace.snapshot() + + val fresh = TabWorkspace() + fresh.restore(snapshot) + // Declared back to front. + listOf("c", "b", "a").forEach { fresh.register(it, it, groupId = null) } + + assertEquals(listOf("a", "b", "c"), requireNotNull(fresh.group("one")).ids) + assertEquals("b", requireNotNull(fresh.group("one")).selectedId) + assertEquals(1, fresh.groups.size) + } + + @Test + fun `a restore moves a window that is already open and bumps its placement`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + val left = requireNotNull(workspace.group("left")) + val before = left.placementRevision + + workspace.restore( + TabLayoutSnapshot( + groups = + listOf( + TabGroupSnapshot( + id = "left", + tabIds = listOf("a"), + selectedId = "a", + position = DpOffset(320.dp, 240.dp), + size = DpSize(500.dp, 400.dp), + ), + ), + ), + ) + + assertEquals(DpOffset(320.dp, 240.dp), left.position) + assertEquals(DpSize(500.dp, 400.dp), left.size) + assertTrue(left.placementRevision > before, "the window has to be told to move") + } + + @Test + fun `a snapshot falls back to the recorded placement without a live window`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + val left = requireNotNull(workspace.group("left")) + left.requestPlacement(DpOffset(64.dp, 48.dp), DpSize(500.dp, 400.dp)) + // A handle with no native window behind it reports no frame, which is + // also the state of a group whose window has not been mapped yet. + workspace.attachWindow(left, firstWindow) + check(firstWindow.outerBoundsPx() == null) { "this fixture assumes an unmapped window" } + + val recorded = workspace.snapshot().groups.single() + + assertEquals(DpOffset(64.dp, 48.dp), recorded.position) + assertEquals(DpSize(500.dp, 400.dp), recorded.size) + } + + @Test + fun `restoring an empty snapshot leaves the workspace alone`() { + val workspace = TabWorkspace() + workspace.register("a", "Alpha", groupId = "left") + + workspace.restore(TabLayoutSnapshot(groups = emptyList())) + + assertEquals(listOf("left"), workspace.groups.map { it.id }) + assertEquals(listOf("a"), requireNotNull(workspace.group("left")).ids) + assertFalse(workspace.tabs.isEmpty()) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoApplicationExitTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoApplicationExitTest.kt new file mode 100644 index 000000000..12ccf1642 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoApplicationExitTest.kt @@ -0,0 +1,195 @@ +package dev.nucleusframework.window.tao + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertSame +import kotlin.test.assertTrue + +class TaoApplicationExitTest { + /** Runs [block] against a fresh quit state; the deferred settle is run by hand via the returned list. */ + private fun quit( + vararg open: TaoWindow, + scope: MutableList = open.toMutableList(), + block: (settle: () -> Unit, exits: () -> Int) -> Unit = { settle, _ -> settle() }, + ): Int { + var exits = 0 + val pending = mutableListOf<() -> Unit>() + TaoApplication.resetQuit() + TaoApplication.quitExit = { exits++ } + TaoApplication.afterQuitRequests = { pending += it } + try { + TaoApplication.requestQuit(scope) + block({ pending.toList().also { pending.clear() }.forEach { it() } }, { exits }) + return exits + } finally { + TaoApplication.resetQuit() + } + } + + private fun window( + handle: Long, + log: MutableList, + accept: Boolean, + ) = TaoWindow(handle).also { w -> + w.onCloseRequested { + log += handle + if (accept) w.isClosing = true + } + } + + @Test + fun `quit asks every window newest first and exits once all closed`() { + val asked = mutableListOf() + val exits = quit(window(1, asked, true), window(3, asked, true), window(2, asked, true)) + assertEquals(listOf(3L, 2L, 1L), asked) + assertEquals(1, exits) + } + + @Test + fun `a window that stays open cancels the quit`() { + val asked = mutableListOf() + val exits = + quit(window(1, asked, true), window(2, asked, false)) { settle, _ -> + assertTrue(TaoApplication.isQuitting) + settle() + assertFalse(TaoApplication.isQuitting) + } + assertEquals(listOf(2L, 1L), asked) + assertEquals(0, exits) + } + + @Test + fun `a second quit while one is in flight is ignored`() { + val asked = mutableListOf() + val w = window(1, asked, false) + quit(w) { settle, _ -> + TaoApplication.requestQuit(listOf(w)) + settle() + } + assertEquals(listOf(1L), asked) + } + + @Test + fun `exitApplication from a close request consents without overriding another veto`() { + val asked = mutableListOf() + val main = + TaoWindow(1).also { w -> + w.onCloseRequested { + asked += 1 + check(TaoApplication.consentToQuit()) + } + } + val doc = window(2, asked, false) + val exits = quit(main, doc) + assertEquals(listOf(2L, 1L), asked) + assertEquals(0, exits) + assertFalse(TaoApplication.consentToQuit(), "consent is only absorbed inside a close request") + } + + @Test + fun `exitApplication consent from every window completes the quit`() { + val main = TaoWindow(1).also { w -> w.onCloseRequested { TaoApplication.consentToQuit() } } + assertEquals(1, quit(main)) + } + + @Test + fun `a window opened during the quit defers the exit until it closes`() { + val asked = mutableListOf() + val scope = mutableListOf() + val ask = TaoWindow(9) + val doc = + TaoWindow(1).also { w -> + w.onCloseRequested { + asked += 1 + w.isClosing = true + scope += ask // the "Save?" window it opens on its way out + } + } + scope += doc + val exits = + quit(doc, scope = scope) { settle, exits -> + settle() + assertEquals(0, exits(), "the new window keeps the app alive") + assertTrue(TaoApplication.isQuitting) + ask.isClosing = true + TaoApplication.remove(ask.handle) + } + assertEquals(listOf(1L), asked) + assertEquals(1, exits) + } + + @Test + fun `a new quit while waiting for a window asks it`() { + val asked = mutableListOf() + val scope = mutableListOf() + val ask = window(9, asked, false) + val doc = + TaoWindow(1).also { w -> + w.onCloseRequested { + w.isClosing = true + scope += ask + } + } + scope += doc + quit(doc, scope = scope) { settle, _ -> + settle() + TaoApplication.requestQuit(scope) + settle() + assertFalse(TaoApplication.isQuitting, "the window it asked refused") + } + assertEquals(listOf(9L), asked) + } + + @Test + fun `quit exits at once when no app window is open`() { + val asked = mutableListOf() + val palette = window(1, asked, false).apply { closesOnQuit = false } + val closing = window(2, asked, false).apply { isClosing = true } + val exits = quit(palette, closing) { _, exits -> assertEquals(1, exits()) } + assertEquals(emptyList(), asked) + assertEquals(1, exits) + } + + @Test + fun `default finish exits 0 after a normal quit`() { + val exits = mutableListOf() + finishTaoApplication(exitProcessOnExit = true, failure = null, exit = { exits += it }) + assertEquals(listOf(0), exits) + } + + @Test + fun `default finish exits 1 after a failure`() { + val exits = mutableListOf() + finishTaoApplication( + exitProcessOnExit = true, + failure = IllegalStateException("boom"), + exit = { exits += it }, + ) + assertEquals(listOf(1), exits) + } + + @Test + fun `exitProcessOnExit false returns after a normal quit`() { + val exits = mutableListOf() + finishTaoApplication(exitProcessOnExit = false, failure = null, exit = { exits += it }) + assertTrue(exits.isEmpty()) + } + + @Test + fun `exitProcessOnExit false rethrows after a failure`() { + val exits = mutableListOf() + val failure = IllegalStateException("boom") + val thrown = + assertFailsWith { + finishTaoApplication( + exitProcessOnExit = false, + failure = failure, + exit = { exits += it }, + ) + } + assertSame(failure, thrown) + assertTrue(exits.isEmpty()) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdogMonkeyTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdogMonkeyTest.kt new file mode 100644 index 000000000..a4bdc0656 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdogMonkeyTest.kt @@ -0,0 +1,605 @@ +package dev.nucleusframework.window.tao + +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Handler +import java.util.logging.LogRecord +import java.util.logging.Logger +import kotlin.concurrent.thread +import kotlin.random.Random +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.fail + +/** Replays a red run: `-Dnucleus.tao.watchdogMonkeySeed=`. */ +private const val SEED_PROPERTY = "nucleus.tao.watchdogMonkeySeed" + +/** Restricts a run to one profile: `-Dnucleus.tao.watchdogMonkeyProfile=Hostile`. */ +private const val PROFILE_PROPERTY = "nucleus.tao.watchdogMonkeyProfile" + +/** Sweeps N seeds in one JVM: `-Dnucleus.tao.watchdogMonkeySeeds=200`. */ +private const val SEED_COUNT_PROPERTY = "nucleus.tao.watchdogMonkeySeeds" + +/** Fixed so a green run stays green; override the properties to explore. */ +private val DEFAULT_SEEDS = listOf(1L, 4_242L, 20_260_924L) + +private const val QUIESCE_TIMEOUT_MS = 8_000L +private const val REARM_TIMEOUT_MS = 8_000L +private const val JOURNAL_DEPTH = 48 +private const val WORKER_JOIN_TIMEOUT_MS = 60_000L + +/** Longer than the watchdog's bounded park: past this, a missing recovery is lost, not late. */ +private const val PAIRING_TIMEOUT_MS = 35_000L + +/** + * Concurrency monkey for the hang watchdog (#643) — the deliberately vicious + * one. + * + * Four rounds of review found five lifecycle races by reading; the first run of + * this test found a sixth by playing (a throwing JUL handler swallowed + * `onUnresponsive` while the detector had already marked the stall reported, so + * the app got a `responsive` for a stall it never heard about). The profiles + * below exist to keep finding that class of thing: they hammer the watchdog + * from several threads at once with a fake probe and a sub-millisecond poll + * ([WatchdogTestHooks]), including the moves an app really does make and that + * nothing else in the suite covers — calling back into `start` / `stop` / + * `expectUnresponsive` **from inside a callback**, and interrupting the + * watchdog thread the way a shutdown hook sweeping threads by name would. + * + * It asserts nothing about *what* happened — for a random sequence there is no + * right answer — only that nothing wedges and nothing is left behind: + * + * 1. the storm terminates (a join timeout dumps every stack: that is the + * deadlock detector, and the lock the watchdog took to make teardown safe is + * exactly what could produce one), + * 2. nothing escapes the watchdog's surface, whatever a listener, a log handler + * or a sample throws, + * 3. every `unresponsive` is eventually paired with a `responsive`, + * 4. no watchdog thread is left alive, + * 5. and the watchdog still reports afterwards — what the generation token is + * for. + * + * A failure prints the profile, the seed and the last actions; + * `-D$SEED_PROPERTY` and `-D$PROFILE_PROPERTY` replay it. + */ +class TaoEventLoopWatchdogMonkeyTest { + /** + * Cumulative across every storm, on purpose: an event queued before a + * `stop()` is delivered *after* it, to whatever handler is installed by + * then — so the pairing invariant only means anything process-wide. Per + * storm it would flag the queue's own latency as a lost event. + */ + private val unresponsive = AtomicInteger() + private val responsive = AtomicInteger() + + /** + * The last callbacks, with their arrival time and thread. A count that ends + * one short says only that; this says *when* the orphan arrived and what + * delivered it — the difference between "the recovery is late" and "the run + * that opened the episode never closed it". + */ + private val events = ConcurrentLinkedDeque() + + private fun record(event: String) { + events.addLast("$event @${System.currentTimeMillis() % EVENT_CLOCK_WRAP}ms on ${Thread.currentThread().name}") + while (events.size > EVENT_DEPTH) events.pollFirst() + } + + @AfterTest + fun tearDown() { + TaoEventLoopWatchdog.stop() + WatchdogTestHooks.reset() + System.clearProperty("nucleus.tao.watchdogGraceMs") + System.clearProperty("nucleus.tao.watchdog") + } + + @Test + fun `lifecycle storms leave the watchdog armed and every stall closed`() { + val seeds = + System.getProperty(SEED_PROPERTY)?.toLongOrNull()?.let { listOf(it) } + // A sweep: hundreds of storms in one JVM, which is the only way + // to reach the interleavings a handful of seeds never hit. + ?: System.getProperty(SEED_COUNT_PROPERTY)?.toIntOrNull()?.let { count -> + (1..count).map { it * SWEEP_STRIDE } + } + ?: DEFAULT_SEEDS + val profiles = + System.getProperty(PROFILE_PROPERTY)?.let { name -> + listOf(MonkeyProfile.valueOf(name)) + } ?: MonkeyProfile.entries + profiles.forEach { profile -> seeds.forEach { seed -> storm(profile, seed) } } + } + + @Suppress("LongMethod", "CyclomaticComplexMethod") // one flat storm: setup, workers, invariants + private fun storm( + profile: MonkeyProfile, + seed: Long, + ) { + val ctx = + StormContext( + hung = AtomicBoolean(false), + unresponsive = unresponsive, + responsive = responsive, + onEvent = ::record, + ) + val journal = ConcurrentLinkedDeque() + val failures = ConcurrentLinkedDeque() + + WatchdogTestHooks.probe = { ctx.hung.get() } + WatchdogTestHooks.pollIntervalMs = profile.pollMs + System.setProperty("nucleus.tao.watchdogGraceMs", profile.graceMs.toString()) + // Forced: a test JVM may itself run under a debug agent, which the + // watchdog otherwise (correctly) stays out of. + System.setProperty("nucleus.tao.watchdog", "true") + ctx.installCountingHandlers() + + // A log handler that throws. JUL propagates that into the watchdog's own + // reporting path, which must survive it — and must not let it swallow + // the app's notification. + val watchdogLogger = Logger.getLogger(TaoEventLoopWatchdog::class.java.name) + val hostileHandler = HostileLogHandler(profile.hostileLogEvery) + watchdogLogger.addHandler(hostileHandler) + + val start = CountDownLatch(1) + val workers = + (0 until profile.workers).map { worker -> + thread(name = "watchdog-monkey-$worker", isDaemon = true) { + val random = Random(seed * PRIME + worker) + start.await() + repeat(profile.ops) { step -> + val action = profile.pick(random) + journal.addLast("w$worker#$step $action") + while (journal.size > JOURNAL_DEPTH) journal.pollFirst() + try { + action.run(random, ctx) + } catch (t: Throwable) { + // The whole point: nothing the monkey does may throw + // out of the watchdog's public surface. + failures.addLast(t) + } + } + } + } + start.countDown() + + fun bail(reason: String): Nothing = + fail( + buildString { + appendLine(reason) + appendLine(" profile: $profile, seed: $seed") + appendLine(" replay: -D$PROFILE_PROPERTY=$profile -D$SEED_PROPERTY=$seed") + appendLine(" app saw: unresponsive=${ctx.unresponsive.get()} responsive=${ctx.responsive.get()}") + appendLine( + " watchdog produced: stalls=${WatchdogTestHooks.stallsProduced.get()} " + + "recoveries=${WatchdogTestHooks.recoveriesProduced.get()}", + ) + val live = + Thread + .getAllStackTraces() + .entries + .filter { (t, _) -> t.isAlive && t.name.startsWith("nucleus-tao-watchdog") } + appendLine(" ${live.size} watchdog thread(s) alive, where they sit:") + live.take(LEAK_STACKS_SHOWN).forEach { (t, stack) -> + appendLine(" \"${t.name}\" ${t.state}") + stack.take(LEAK_FRAMES).forEach { appendLine(" at $it") } + } + appendLine(" watchdog trace:") + WatchdogTestHooks.trace.forEach { appendLine(" $it") } + appendLine(" last ${events.size} callbacks:") + events.forEach { appendLine(" $it") } + appendLine(" last ${journal.size} actions:") + journal.forEach { appendLine(" $it") } + failures.take(FAILURES_SHOWN).forEach { appendLine(" threw: $it") } + }, + ) + + // 1 — the storm terminates. A join that times out is a deadlock until + // proven otherwise, and the stacks are the only thing that can say + // which lock it was. + val deadline = System.currentTimeMillis() + WORKER_JOIN_TIMEOUT_MS + workers.forEach { worker -> + val left = deadline - System.currentTimeMillis() + if (left > 0) worker.join(left) + if (worker.isAlive) { + val stacks = + Thread + .getAllStackTraces() + .entries + .filter { (t, _) -> t.name.startsWith("watchdog-monkey") || t.name.startsWith("nucleus-tao") } + .joinToString("\n\n") { (t, stack) -> + "\"${t.name}\" ${t.state}" + stack.joinToString("") { "\n\tat $it" } + } + bail("the storm wedged — ${worker.name} still alive after ${WORKER_JOIN_TIMEOUT_MS}ms\n$stacks") + } + } + + // Settle: clean handlers again (the storm installs throwing ones), a + // healthy loop, and a live watchdog to close whatever is still open. + ctx.installCountingHandlers() + ctx.hung.set(false) + TaoEventLoopWatchdog.start() + TaoEventLoopWatchdog.registerWindow(SETTLE_WINDOW) + awaitQuiet(ctx, profile) + TaoEventLoopWatchdog.stop() + awaitQuiet(ctx, profile) + watchdogLogger.removeHandler(hostileHandler) + + if (failures.isNotEmpty()) bail("the watchdog's surface threw ${failures.size} time(s)") + + // 3 — pairing. An app holding a prompt or a telemetry span on + // `unresponsive` must always hear the end of the episode. + // + // Waited out past the longest latency the design allows — a straggler + // parked on the bounded park still closes its episode when it wakes — + // so that a failure here means the recovery was *lost*, not late. A + // 1800-storm sweep ended one short exactly once (Torture/902766, which + // does not reproduce alone); without this wait there is no way to tell + // that apart from a real leak, and a monkey that cannot tell is noise. + val pairingDeadline = System.currentTimeMillis() + PAIRING_TIMEOUT_MS + while (ctx.unresponsive.get() != ctx.responsive.get() && System.currentTimeMillis() < pairingDeadline) { + Thread.sleep(profile.pollMs) + } + if (ctx.unresponsive.get() != ctx.responsive.get()) bail("unresponsive/responsive left unpaired") + + // 4 — nothing left behind. Polled, not sampled once: `stop()` does not + // join, so a thread it has just signalled still needs its moment to + // reacquire the lock and leave. What must never happen is runs + // *accumulating* threads — which is exactly what a sweep caught before + // the park was bounded (150 alive at once). + val threadDeadline = System.currentTimeMillis() + THREAD_EXIT_MS + while (liveWatchdogThreads().isNotEmpty() && System.currentTimeMillis() < threadDeadline) { + Thread.sleep(profile.pollMs) + } + val leaked = liveWatchdogThreads() + if (leaked.isNotEmpty()) { + val where = + Thread + .getAllStackTraces() + .entries + .filter { (t, _) -> t.name == "nucleus-tao-watchdog" } + .take(LEAK_STACKS_SHOWN) + .joinToString(separator = "\n\n") { (t, stack) -> + val frames = stack.take(LEAK_FRAMES).joinToString(separator = "") { "\n\tat $it" } + "\"${t.name}\" ${t.state}$frames" + } + bail("${leaked.size} watchdog threads still alive after stop, e.g.\n$where") + } + // The callback thread is deliberately process-wide — tearing it down per + // run meant racing its teardown and dropping the callback that closes an + // episode — so the invariant is that runs never *accumulate* one. + val leakedEvents = liveEventThreads() + if (leakedEvents.size > 1) bail("callback threads accumulated across runs: $leakedEvents") + + // 5 — still armed. The storm's start/stop interleavings are exactly what + // let a straggler disarm the next run before the generation token. + val rearmed = AtomicInteger() + // Counts into the shared tally too: the pairing invariant spans the + // whole test, and the recovery of *this* stall lands in it. + TaoApplication.onUnresponsive { + unresponsive.incrementAndGet() + rearmed.incrementAndGet() + record("unresponsive(rearm)") + } + TaoEventLoopWatchdog.start() + TaoEventLoopWatchdog.registerWindow(SETTLE_WINDOW) + ctx.hung.set(true) + val rearmDeadline = System.currentTimeMillis() + REARM_TIMEOUT_MS + while (rearmed.get() == 0 && System.currentTimeMillis() < rearmDeadline) Thread.sleep(profile.pollMs) + ctx.hung.set(false) + TaoEventLoopWatchdog.stop() + if (rearmed.get() == 0) bail("the watchdog no longer reports after the storm") + } + + /** Waits until the counters stop moving and agree, or lets the assertions speak. */ + private fun awaitQuiet( + ctx: StormContext, + profile: MonkeyProfile, + ) { + val deadline = System.currentTimeMillis() + QUIESCE_TIMEOUT_MS + var last = -1 to -1 + var stableSince = System.currentTimeMillis() + while (System.currentTimeMillis() < deadline) { + val now = ctx.unresponsive.get() to ctx.responsive.get() + if (now != last) { + last = now + stableSince = System.currentTimeMillis() + } else if (System.currentTimeMillis() - stableSince > QUIET_MS && now.first == now.second) { + return + } + Thread.sleep(profile.pollMs) + } + } + + private fun liveEventThreads(): List = + Thread + .getAllStackTraces() + .keys + .filter { it.isAlive && it.name == "nucleus-tao-watchdog-events" } + .map { it.name } + + private fun liveWatchdogThreads(): List = + Thread + .getAllStackTraces() + .keys + .filter { it.isAlive && it.name == "nucleus-tao-watchdog" } + .map { it.name } + + /** Shared state of one storm: the fake loop's health and the paired counters. */ + private class StormContext( + val hung: AtomicBoolean, + val unresponsive: AtomicInteger, + val responsive: AtomicInteger, + val onEvent: (String) -> Unit, + ) { + fun installCountingHandlers() { + TaoApplication.onUnresponsive { + unresponsive.incrementAndGet() + onEvent("unresponsive") + } + TaoApplication.onResponsive { + responsive.incrementAndGet() + onEvent("responsive") + } + } + + /** Counts, then throws: pairing still holds, and the watchdog must survive. */ + fun installHostileHandler() { + TaoApplication.onUnresponsive { + unresponsive.incrementAndGet() + onEvent("unresponsive(hostile)") + error("hostile listener") + } + } + + /** + * A listener that drives the watchdog from inside its own callback — + * an app whose crash reporter tears the run down on a hang. Reentrancy + * on the event thread, which is where a lock-ordering mistake shows up. + */ + fun installReentrantHandler(random: Random) { + TaoApplication.onUnresponsive { + unresponsive.incrementAndGet() + onEvent("unresponsive(reentrant)") + when (random.nextInt(REENTRANT_MOVES)) { + 0 -> TaoEventLoopWatchdog.stop() + 1 -> TaoEventLoopWatchdog.start() + 2 -> TaoApplication.expectUnresponsive { TaoEventLoopWatchdog.registerWindow(SETTLE_WINDOW) } + else -> TaoEventLoopWatchdog.unregisterWindow(SETTLE_WINDOW) + } + } + } + } + + private class HostileLogHandler( + private val every: Int, + ) : Handler() { + private val records = AtomicInteger() + + override fun publish(record: LogRecord) { + if (every > 0 && records.incrementAndGet() % every == 0) error("hostile log handler") + } + + override fun flush() = Unit + + override fun close() = Unit + } + + /** + * How mean a storm is. Each profile leans on a different failure mode; they + * all run every seed. + */ + private enum class MonkeyProfile( + val workers: Int, + val ops: Int, + val pollMs: Long, + val graceMs: Long, + val hostileLogEvery: Int, + val actions: List, + ) { + /** Everything, evenly. */ + Balanced( + workers = 4, + ops = 400, + pollMs = 2, + graceMs = 4, + hostileLogEvery = 8, + actions = MonkeyAction.entries, + ), + + /** Nothing but lifecycle: the restart race, as hard as threads allow. */ + Thrash( + workers = 8, + ops = 600, + pollMs = 1, + graceMs = 1, + hostileLogEvery = 0, + actions = listOf(MonkeyAction.Start, MonkeyAction.Stop, MonkeyAction.RegisterWindow), + ), + + /** The probe flips constantly: episodes open and close on top of each other. */ + Flapping( + workers = 6, + ops = 600, + pollMs = 1, + graceMs = 0, + hostileLogEvery = 16, + actions = + listOf( + MonkeyAction.Freeze, + MonkeyAction.Thaw, + MonkeyAction.RegisterWindow, + MonkeyAction.UnregisterWindow, + MonkeyAction.Start, + MonkeyAction.Stop, + ), + ), + + /** Everything throws, and the callbacks call back in. */ + Hostile( + workers = 6, + ops = 400, + pollMs = 1, + graceMs = 1, + hostileLogEvery = 2, + actions = + listOf( + MonkeyAction.HostileListener, + MonkeyAction.ReentrantListener, + MonkeyAction.ExpectStallThatThrows, + MonkeyAction.Freeze, + MonkeyAction.Start, + MonkeyAction.Stop, + MonkeyAction.RegisterWindow, + MonkeyAction.CleanListener, + ), + ), + + /** Everything at once, sixteen threads deep, nothing sleeps. */ + Torture( + workers = 16, + ops = 300, + pollMs = 1, + graceMs = 0, + hostileLogEvery = 3, + actions = MonkeyAction.entries - MonkeyAction.Breathe, + ), + + /** Someone else's shutdown hook interrupts threads by name. */ + Interrupted( + workers = 4, + ops = 300, + pollMs = 1, + graceMs = 1, + hostileLogEvery = 8, + actions = + listOf( + MonkeyAction.InterruptWatchdog, + MonkeyAction.Start, + MonkeyAction.Stop, + MonkeyAction.RegisterWindow, + MonkeyAction.Freeze, + MonkeyAction.Thaw, + ), + ), + ; + + fun pick(random: Random): MonkeyAction = actions[random.nextInt(actions.size)] + } + + /** One move of the storm. Every one of them is legal API use. */ + private enum class MonkeyAction { + Start { + override fun run( + random: Random, + ctx: StormContext, + ) = TaoEventLoopWatchdog.start() + }, + Stop { + override fun run( + random: Random, + ctx: StormContext, + ) = TaoEventLoopWatchdog.stop() + }, + RegisterWindow { + override fun run( + random: Random, + ctx: StormContext, + ) = TaoEventLoopWatchdog.registerWindow(random.nextLong(1, WINDOW_HANDLES)) + }, + UnregisterWindow { + override fun run( + random: Random, + ctx: StormContext, + ) = TaoEventLoopWatchdog.unregisterWindow(random.nextLong(1, WINDOW_HANDLES)) + }, + Freeze { + override fun run( + random: Random, + ctx: StormContext, + ) = ctx.hung.set(true) + }, + Thaw { + override fun run( + random: Random, + ctx: StormContext, + ) = ctx.hung.set(false) + }, + ExpectStall { + override fun run( + random: Random, + ctx: StormContext, + ) { + TaoApplication.expectUnresponsive { Thread.sleep(random.nextLong(0, 3)) } + } + }, + ExpectStallThatThrows { + override fun run( + random: Random, + ctx: StormContext, + ) { + // The scope must unwind even when the work explodes, or the next + // run starts permanently disarmed. + runCatching { TaoApplication.expectUnresponsive { error("boom") } } + } + }, + HostileListener { + override fun run( + random: Random, + ctx: StormContext, + ) = ctx.installHostileHandler() + }, + ReentrantListener { + override fun run( + random: Random, + ctx: StormContext, + ) = ctx.installReentrantHandler(random) + }, + CleanListener { + override fun run( + random: Random, + ctx: StormContext, + ) = ctx.installCountingHandlers() + }, + InterruptWatchdog { + override fun run( + random: Random, + ctx: StormContext, + ) { + // What a shutdown hook sweeping threads by name does to us. + Thread + .getAllStackTraces() + .keys + .filter { it.name.startsWith("nucleus-tao-watchdog") } + .forEach { it.interrupt() } + } + }, + Breathe { + override fun run( + random: Random, + ctx: StormContext, + ) = Thread.sleep(random.nextLong(0, 4)) + }, ; + + abstract fun run( + random: Random, + ctx: StormContext, + ) + } + + private companion object { + const val WINDOW_HANDLES = 6L + const val SETTLE_WINDOW = 99L + const val QUIET_MS = 200L + const val FAILURES_SHOWN = 3 + const val EVENT_DEPTH = 24 + const val EVENT_CLOCK_WRAP = 1_000_000L + const val REENTRANT_MOVES = 4 + const val PRIME = 31L + const val SWEEP_STRIDE = 7_919L + const val THREAD_EXIT_MS = 5_000L + const val LEAK_STACKS_SHOWN = 2 + const val LEAK_FRAMES = 8 + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdogSmokeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdogSmokeTest.kt new file mode 100644 index 000000000..549883cf2 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoEventLoopWatchdogSmokeTest.kt @@ -0,0 +1,119 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.core.runtime.Platform +import kotlinx.coroutines.delay +import java.util.concurrent.CopyOnWriteArrayList +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Opt-in end-to-end test (set `NUCLEUS_TAO_SMOKE=1`) for the hang watchdog + * (#643): opens a real Tao window, then **really** stops the message pump by + * sleeping on the event-loop thread, and asserts that the watchdog logged + * `SEVERE` with a thread dump — the report that #640 never produced. + * + * Not run by default: it takes over the calling thread with the native event + * loop, needs a display, and freezes it for ~[FREEZE_MS] on purpose. Windows + * only, like the watchdog itself. + */ +class TaoEventLoopWatchdogSmokeTest { + @Test + @Suppress("SwallowedException") + fun aFrozenEventLoopIsReportedWithAThreadDump() { + if (System.getenv("NUCLEUS_TAO_SMOKE") == null || Platform.Current != Platform.Windows) { + println("SKIPPED: set NUCLEUS_TAO_SMOKE=1 on Windows to run the watchdog e2e test") + return + } + // The OS sets its hung flag after ~5 s without pumping; keep the extra + // grace short so the freeze does not have to outlast the default one. + System.setProperty("nucleus.tao.watchdogGraceMs", "1000") + + val reports = CopyOnWriteArrayList() + val logger = Logger.getLogger(TaoEventLoopWatchdog::class.java.name) + val collector = + object : Handler() { + override fun publish(record: LogRecord) { + reports += record + } + + override fun flush() = Unit + + override fun close() = Unit + } + logger.addHandler(collector) + + // Same halt-on-hang guard the other smoke tests use: the loop takes + // over this thread, so a test that never reaches exitApplication would + // hang the forked JVM with no timeout. + val bailout = + thread(isDaemon = true, name = "tao-watchdog-smoke-bailout") { + try { + Thread.sleep(BAILOUT_MS) + } catch (_: InterruptedException) { + return@thread + } + Runtime.getRuntime().halt(BAILOUT_EXIT_CODE) + } + + try { + taoApplication(exitProcessOnExit = false) { + DecoratedWindow(onCloseRequest = ::exitApplication, title = "watchdog-smoke") { + LaunchedEffect(Unit) { + delay(SETTLE_MS) // let the window map and paint + // Runs on Dispatchers.Main — i.e. the event-loop + // thread, which stops pumping for real. This is the + // shape of #640, without needing its deadlock. + Thread.sleep(FREEZE_MS) + delay(DRAIN_MS) // let the watchdog's last sample land + exitApplication() + } + } + } + } finally { + bailout.interrupt() + logger.removeHandler(collector) + System.clearProperty("nucleus.tao.watchdogGraceMs") + } + + val stall = reports.firstOrNull { it.level == Level.SEVERE } + assertTrue( + stall != null, + "the watchdog logged nothing while the event loop was frozen for $FREEZE_MS ms " + + "(records: ${reports.map { "${it.level}: ${it.message.lineSequence().first()}" }})", + ) + assertTrue( + "has not pumped messages" in stall.message, + "unexpected watchdog report: ${stall.message.lineSequence().first()}", + ) + assertTrue( + "(Tao event loop)" in stall.message && "nativeRunBlocking" in stall.message, + "the report must carry a thread dump naming the event-loop thread:\n${stall.message}", + ) + // The stall is reported once, not once per poll. + assertTrue( + reports.count { it.level == Level.SEVERE } == 1, + "expected exactly one SEVERE report, got ${reports.count { it.level == Level.SEVERE }}", + ) + // And the recovery is reported when the loop pumps again. + assertTrue( + reports.any { it.level == Level.INFO && "responded again" in it.message }, + "the watchdog did not report the recovery", + ) + } + + private companion object { + const val SETTLE_MS = 3_000L + + /** Well past the OS's ~5 s hung threshold plus the grace above. */ + const val FREEZE_MS = 15_000L + const val DRAIN_MS = 4_000L + const val BAILOUT_MS = 120_000L + const val BAILOUT_EXIT_CODE = 42 + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoMonitorsTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoMonitorsTest.kt new file mode 100644 index 000000000..d9c715004 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoMonitorsTest.kt @@ -0,0 +1,121 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.IntRect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TaoMonitorsTest { + private fun row( + id: String = "\\\\.\\DISPLAY1", + name: String = "Generic PnP Monitor", + bounds: String = "0\t0\t3840\t2160", + work: String = "0\t0\t3840\t2100", + scaleMilli: String = "2000", + primary: String = "1", + ) = "$id\t$name\t$bounds\t$work\t$scaleMilli\t$primary" + + @Test + fun parsesAWellFormedRow() { + val monitor = TaoMonitors.parseMonitor(row()) + requireNotNull(monitor) + assertEquals("\\\\.\\DISPLAY1", monitor.id) + assertEquals("Generic PnP Monitor", monitor.name) + assertEquals(IntRect(0, 0, 3840, 2160), monitor.boundsPx) + assertEquals(IntRect(0, 0, 3840, 2100), monitor.workAreaPx) + assertEquals(2f, monitor.scaleFactor) + assertTrue(monitor.isPrimary) + } + + /** + * `all` is documented never to be empty, so an `isEmpty()` guard on it is + * dead code — and the popup screen clamp used to lean on exactly that. The + * invariant is pinned here so a caller can read `all` as "always something" + * and `reported` as "only what the platform said". + */ + @Test + fun allNeverReportsAnEmptyList() { + assertTrue(TaoMonitors.all().isNotEmpty()) + } + + /** + * The synthetic monitor `all` falls back to is a guess — a fixed 1920x1080 + * rectangle at the origin when even [TaoScreenGeometry] has nothing — and a + * popup clamped into it would be dragged onto a display that does not + * exist. `reported` is what the clamp asks, so it must never invent one. + */ + @Test + fun reportedIsEmptyWhenThePlatformNamesNoMonitor() { + val reported = TaoMonitors.reported() + val all = TaoMonitors.all() + if (reported.isEmpty()) { + assertEquals(1, all.size, "the synthetic fallback is one monitor") + assertEquals("primary", all.single().id) + } else { + assertEquals(reported.map { it.id }.toSet(), all.map { it.id }.toSet()) + } + } + + @Test + fun convertsToDpWithTheGivenScale() { + val monitor = requireNotNull(TaoMonitors.parseMonitor(row())) + // Its own scale: 3840 physical px at 2.0 → 1920dp. + assertEquals(1920f, monitor.boundsDp().right.value) + // A window on a 1.0 monitor reads the same rectangle in its own space. + assertEquals(3840f, monitor.boundsDp(scale = 1f).right.value) + } + + @Test + fun negativeOriginsSurviveTheRoundTrip() { + val monitor = + requireNotNull( + TaoMonitors.parseMonitor( + row(bounds = "-1920\t-120\t1920\t1080", work = "-1920\t-120\t1920\t1040", scaleMilli = "1000"), + ), + ) + assertEquals(IntRect(-1920, -120, 0, 960), monitor.boundsPx) + assertEquals(-1920f, monitor.boundsDp().left.value) + } + + @Test + fun fallsBackToFullBoundsWhenTheWorkAreaIsEmpty() { + val monitor = requireNotNull(TaoMonitors.parseMonitor(row(work = "0\t0\t0\t0"))) + assertEquals(monitor.boundsPx, monitor.workAreaPx) + } + + @Test + fun rejectsMalformedRows() { + assertNull(TaoMonitors.parseMonitor("")) + assertNull(TaoMonitors.parseMonitor("too\tfew\tfields")) + assertNull(TaoMonitors.parseMonitor(row(bounds = "0\t0\tnot-a-number\t2160"))) + // A zero-sized monitor is not something the geometry math can use. + assertNull(TaoMonitors.parseMonitor(row(bounds = "0\t0\t0\t0"))) + } + + @Test + fun containsPxIsHalfOpen() { + val monitor = requireNotNull(TaoMonitors.parseMonitor(row(scaleMilli = "1000"))) + assertTrue(monitor.containsPx(0, 0)) + assertTrue(monitor.containsPx(3839, 2159)) + assertTrue(!monitor.containsPx(3840, 2160)) + } + + @Test + fun enumerationNeverReportsZeroMonitors() { + // Without a platform bridge (headless CI) this falls back to a single + // synthesized monitor — a screen picker must never see an empty list. + val monitors = TaoMonitors.all() + assertTrue(monitors.isNotEmpty()) + assertTrue(monitors.any { it.isPrimary }) + assertEquals(TaoMonitors.primary().id, monitors.first { it.isPrimary }.id) + } + + @Test + fun identityIsTheId() { + val a = requireNotNull(TaoMonitors.parseMonitor(row())) + val b = requireNotNull(TaoMonitors.parseMonitor(row(name = "Other", scaleMilli = "1000"))) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoMouseButtonWireDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoMouseButtonWireDriftTest.kt new file mode 100644 index 000000000..389b83e8b --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoMouseButtonWireDriftTest.kt @@ -0,0 +1,40 @@ +package dev.nucleusframework.window.tao + +import java.io.File +import java.lang.reflect.Modifier +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.fail + +/** + * Mouse-button codes are written by hand on both sides of the JNI boundary: + * `events.rs` `MOUSE_BUTTON_*` and [TaoMouseButton]. A drift is silent — the + * Rust "other" code once shared its number with [TaoMouseButton.BACK], so + * every extra button reached Compose as Back — so compare them here. + */ +class TaoMouseButtonWireDriftTest { + @Test + fun `Rust MOUSE_BUTTON codes match TaoMouseButton`() { + val rust = + RUST_CODE + .findAll(eventsRs().readText()) + .associate { it.groupValues[1] to it.groupValues[2].toInt() } + val kotlin = + TaoMouseButton::class.java.declaredFields + .filter { Modifier.isStatic(it.modifiers) && it.type == Integer.TYPE && it.name != "\$stable" } + .associate { it.name to it.getInt(null) } + assertEquals(kotlin, rust, "events.rs MOUSE_BUTTON_* vs TaoMouseButton") + } + + private fun eventsRs(): File { + val relative = "src/main/native/src/events.rs" + // Module directory first (Gradle), then the repository root (IDE). + val candidates = listOf(File(relative), File("decorated-window-tao", relative)) + return candidates.firstOrNull { it.isFile } + ?: fail("cannot find $relative from ${File("").absolutePath} (tried ${candidates.map { it.path }})") + } + + private companion object { + val RUST_CODE = Regex("""pub\(crate\) const MOUSE_BUTTON_(\w+): jint = (\d+);""") + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoRuntimeResizableSmokeTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoRuntimeResizableSmokeTest.kt index cb57923f7..6167878d6 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoRuntimeResizableSmokeTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoRuntimeResizableSmokeTest.kt @@ -46,7 +46,7 @@ class TaoRuntimeResizableSmokeTest { Runtime.getRuntime().halt(WATCHDOG_EXIT_CODE) } - taoApplication { + taoApplication(exitProcessOnExit = false) { var resizable by remember { mutableStateOf(true) } DecoratedWindow( onCloseRequest = ::exitApplication, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt index ebe35ccd4..64b87c39c 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBattery.kt @@ -9,10 +9,13 @@ import dev.nucleusframework.window.tao.event.MacOsWheelDeltaTest import dev.nucleusframework.window.tao.event.TaoKeyMappingTest import dev.nucleusframework.window.tao.event.TaoKeyboardModifiersDecodeTest import dev.nucleusframework.window.tao.event.TaoSyntheticMouseWheelEventTest +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSessionTest import dev.nucleusframework.window.tao.event.TaoWheelPinchZoomTest import dev.nucleusframework.window.tao.event.Win32WheelDeltaTest +import dev.nucleusframework.window.tao.popup.MacPopupPictureCullTest import dev.nucleusframework.window.tao.popup.StandaloneFramePumpTest import dev.nucleusframework.window.tao.popup.StandalonePopupRenderReentryTest +import dev.nucleusframework.window.tao.scene.LcdTextTest import dev.nucleusframework.window.tao.scene.TaoSceneAnimationTest import dev.nucleusframework.window.tao.scene.TaoSceneContentSwapTest import dev.nucleusframework.window.tao.scene.TaoSceneExceptionHandlerTest @@ -27,7 +30,13 @@ import dev.nucleusframework.window.tao.scene.TaoSceneRenderTest import dev.nucleusframework.window.tao.scene.TaoSceneScrollTest import dev.nucleusframework.window.tao.scene.TaoSceneSemanticsTest import dev.nucleusframework.window.tao.scene.TaoSceneTrackpadPanTest +import dev.nucleusframework.window.tao.scene.TaoSceneTrackpadScaleTest import dev.nucleusframework.window.tao.scene.TaoTrackpadPanRouterTest +import dev.nucleusframework.window.tao.workspace.DragControllerTest +import dev.nucleusframework.window.tao.workspace.HostGeometryTest +import dev.nucleusframework.window.tao.workspace.RelocatingSaveableStateRegistryTest +import dev.nucleusframework.window.tao.workspace.TransferDragTest +import dev.nucleusframework.window.tao.workspace.WindowGroupTest /** * Programmatic, reflection-free registry of the stage-1 offscreen battery so @@ -37,6 +46,7 @@ import dev.nucleusframework.window.tao.scene.TaoTrackpadPanRouterTest * entry is missing, stale, or a new test class is neither registered here * nor declared JVM-only. */ +@Suppress("LargeClass") // flat generated registry public object TaoSceneTestBattery { public class CaseResult( public val name: String, @@ -422,9 +432,95 @@ public object TaoSceneTestBattery { run("TaoSceneTrackpadPanTest: an orphaned momentum tail scrolls as wheel events instead of stalling") { TaoSceneTrackpadPanTest().`an orphaned momentum tail scrolls as wheel events instead of stalling`() } + run("TaoSceneTrackpadScaleTest: legacy two-touch pinch plants contacts 120 px off the cursor") { + TaoSceneTrackpadScaleTest().`legacy two-touch pinch plants contacts 120 px off the cursor`() + } + run("TaoSceneTrackpadScaleTest: legacy two-touch pinch at a map edge hits the neighbouring chrome") { + TaoSceneTrackpadScaleTest().`legacy two-touch pinch at a map edge hits the neighbouring chrome`() + } + run("TaoSceneTrackpadScaleTest: legacy two-touch pinch delays a 1 percent zoom behind touch slop") { + TaoSceneTrackpadScaleTest().`legacy two-touch pinch delays a 1 percent zoom behind touch slop`() + } + run( + "TaoSceneTrackpadScaleTest: legacy two-touch pinch needs about 15 percent before " + + "detectTransformGestures zooms", + ) { + TaoSceneTrackpadScaleTest() + .`legacy two-touch pinch needs about 15 percent before detectTransformGestures zooms`() + } + run("TaoSceneTrackpadScaleTest: magnify is dispatched as ScaleStart ScaleChange ScaleEnd at the cursor") { + TaoSceneTrackpadScaleTest().`magnify is dispatched as ScaleStart ScaleChange ScaleEnd at the cursor`() + } + run("TaoSceneTrackpadScaleTest: scale events at a map edge hit only the map under the cursor") { + TaoSceneTrackpadScaleTest().`scale events at a map edge hit only the map under the cursor`() + } + run("TaoSceneTrackpadScaleTest: a 1 percent scale change zooms transformable immediately") { + TaoSceneTrackpadScaleTest().`a 1 percent scale change zooms transformable immediately`() + } + run( + "TaoSceneTrackpadScaleTest: detectTransformGestures is not the Scale path and stays quiet " + + "on a 1 percent pinch", + ) { + TaoSceneTrackpadScaleTest() + .`detectTransformGestures is not the Scale path and stays quiet on a 1 percent pinch`() + } + run("TaoSceneTrackpadScaleTest: host-shaped magnify stream zooms transformable without slop") { + TaoSceneTrackpadScaleTest().`host-shaped magnify stream zooms transformable without slop`() + } + run("TaoTrackpadScaleSessionTest: startChangeEndEmitsScaleSequence") { + TaoTrackpadScaleSessionTest().startChangeEndEmitsScaleSequence() + } + run("TaoTrackpadScaleSessionTest: changeOpensTheGestureIfNeeded") { + TaoTrackpadScaleSessionTest().changeOpensTheGestureIfNeeded() + } + run("TaoTrackpadScaleSessionTest: identityFactorIsNotAMove") { + TaoTrackpadScaleSessionTest().identityFactorIsNotAMove() + } + run("TaoTrackpadScaleSessionTest: magnifyByUsesOnePlusDelta") { + TaoTrackpadScaleSessionTest().magnifyByUsesOnePlusDelta() + } + run("TaoTrackpadScaleSessionTest: magnifyByFloorsACollapse") { + TaoTrackpadScaleSessionTest().magnifyByFloorsACollapse() + } + run("TaoTrackpadScaleSessionTest: smartMagnifyIsAClosedBurst") { + TaoTrackpadScaleSessionTest().smartMagnifyIsAClosedBurst() + } + run("TaoTrackpadScaleSessionTest: endWithoutStartIsANoOp") { + TaoTrackpadScaleSessionTest().endWithoutStartIsANoOp() + } + run("TaoTrackpadScaleSessionTest: aSecondStartIsIgnoredWhileActive") { + TaoTrackpadScaleSessionTest().aSecondStartIsIgnoredWhileActive() + } run("TaoSceneScrollTest: one wheel unit scrolls ten dp on macOS") { TaoSceneScrollTest().`one wheel unit scrolls ten dp on macOS`() } + run("NativePopupLayersTest: a Popup inside NativePopupLayers is built by the window's native layer factory") { + NativePopupLayersTest().`a Popup inside NativePopupLayers is built by the window's native layer factory`() + } + run("NativePopupLayersTest: a Popup outside NativePopupLayers keeps drawing in the scene") { + NativePopupLayersTest().`a Popup outside NativePopupLayers keeps drawing in the scene`() + } + run("NativePopupLayersTest: without a native layer factory NativePopupLayers is a no-op") { + NativePopupLayersTest().`without a native layer factory NativePopupLayers is a no-op`() + } + run("NativePopupLayersTest: closing the Popup closes the native layer") { + NativePopupLayersTest().`closing the Popup closes the native layer`() + } + run("MacPopupPictureCullTest: a dimmed popup keeps its content") { + MacPopupPictureCullTest().`a dimmed popup keeps its content`() + } + run("MacPopupPictureCullTest: an origin-rooted cull rect drops a dimmed popup's whole frame") { + MacPopupPictureCullTest().`an origin-rooted cull rect drops a dimmed popup's whole frame`() + } + run("MacPopupPictureCullTest: a dimmed popup records more than one op") { + MacPopupPictureCullTest().`a dimmed popup records more than one op`() + } + run("MacPopupPictureCullTest: an undimmed popup keeps its content") { + MacPopupPictureCullTest().`an undimmed popup keeps its content`() + } + run("MacPopupPictureCullTest: a bare Compose scene records as one op and is unrolled") { + MacPopupPictureCullTest().`a bare Compose scene records as one op and is unrolled`() + } run("TaoScenePopupTest: popup renders above the window content") { TaoScenePopupTest().`popup renders above the window content`() } @@ -563,6 +659,612 @@ public object TaoSceneTestBattery { TaoA11yProjectionTest().`projected snapshot round-trips through the v7 wire format`() } + run("LcdTextTest: transparent windows disable LCD surface props") { + LcdTextTest().`transparent windows disable LCD surface props`() + } + run("LcdTextTest: opaque windows on Windows keep RGB or BGR geometry") { + LcdTextTest().`opaque windows on Windows keep RGB or BGR geometry`() + } + run("LcdTextTest: macOS and Linux stay grayscale") { + LcdTextTest().`macOS and Linux stay grayscale`() + } + run("LcdTextTest: ClearType off means no LCD surface props") { + LcdTextTest().`ClearType off means no LCD surface props`() + } + run("LcdTextTest: Compose LCD text on an RGB surface has chromatic edges") { + LcdTextTest().`Compose LCD text on an RGB surface has chromatic edges`() + } + + run("WindowPositionerTest: right to left anchoring hangs the child off the right edge of the parent") { + WindowPositionerTest().`right to left anchoring hangs the child off the right edge of the parent`() + } + run("WindowPositionerTest: offset is applied after the anchors meet") { + WindowPositionerTest().`offset is applied after the anchors meet`() + } + run("WindowPositionerTest: centre to centre puts the child on the middle of the parent") { + WindowPositionerTest().`centre to centre puts the child on the middle of the parent`() + } + run("WindowPositionerTest: a sub-rectangle of the parent anchors the child to that rectangle") { + WindowPositionerTest().`a sub-rectangle of the parent anchors the child to that rectangle`() + } + run("WindowPositionerTest: the anchor point is clamped to the parent rectangle") { + WindowPositionerTest().`the anchor point is clamped to the parent rectangle`() + } + run("WindowPositionerTest: no adjustment leaves the child outside the work area") { + WindowPositionerTest().`no adjustment leaves the child outside the work area`() + } + run("WindowPositionerTest: flip mirrors the child to the other side when it would overhang") { + WindowPositionerTest().`flip mirrors the child to the other side when it would overhang`() + } + run("WindowPositionerTest: slide translates the child back inside the work area") { + WindowPositionerTest().`slide translates the child back inside the work area`() + } + run("WindowPositionerTest: flip is preferred over slide") { + WindowPositionerTest().`flip is preferred over slide`() + } + run("WindowPositionerTest: resize shrinks the child when nothing else fits") { + WindowPositionerTest().`resize shrinks the child when nothing else fits`() + } + run("WindowPositionerTest: vertical flip mirrors a bottom anchored child upwards") { + WindowPositionerTest().`vertical flip mirrors a bottom anchored child upwards`() + } + run("WindowPositionerTest: an unconstrained placement is returned untouched by every adjustment") { + WindowPositionerTest().`an unconstrained placement is returned untouched by every adjustment`() + } + + run( + "SatelliteDockedGeometryTest: docking from a floating window brings its size along as the panel extent", + ) { + SatelliteDockedGeometryTest() + .`docking from a floating window brings its size along as the panel extent`() + } + run( + "SatelliteDockedGeometryTest: re-docking keeps the extent along the same axis and re-seeds it across axes", + ) { + SatelliteDockedGeometryTest() + .`re-docking keeps the extent along the same axis and re-seeds it across axes`() + } + run( + "SatelliteDockedGeometryTest: docked extent and weight are clamped and ignored for a floating satellite", + ) { + SatelliteDockedGeometryTest().`docked extent and weight are clamped and ignored for a floating satellite`() + } + run("SatelliteDockedGeometryTest: a panel moved between docks seeds its new side with the width it had") { + SatelliteDockedGeometryTest().`a panel moved between docks seeds its new side with the width it had`() + } + run("SatelliteDockedGeometryTest: a snapshot carries every panel's own extent and weight") { + SatelliteDockedGeometryTest().`a snapshot carries every panel's own extent and weight`() + } + run("SatelliteDockedGeometryTest: a docked placement refuses a weight that is not positive") { + SatelliteDockedGeometryTest().`a docked placement refuses a weight that is not positive`() + } + run("SatelliteDockedGeometryTest: every side has an opposite across the content") { + SatelliteDockedGeometryTest().`every side has an opposite across the content`() + } + run("DockLandingRectTest: a bottom preview spans the bottom band, not the layout") { + DockLandingRectTest().`a bottom preview spans the bottom band, not the layout`() + } + run("DockLandingRectTest: a layered side previews a new innermost layer") { + DockLandingRectTest().`a layered side previews a new innermost layer`() + } + run("DockLandingRectTest: a split side with a stack previews the stack the panel joins") { + DockLandingRectTest().`a split side with a stack previews the stack the panel joins`() + } + run("DockLandingRectTest: an empty side previews a strip at the edge of its band") { + DockLandingRectTest().`an empty side previews a strip at the edge of its band`() + } + run("DockLandingRectTest: the side the dragged panel frees is counted as already gone") { + DockLandingRectTest().`the side the dragged panel frees is counted as already gone`() + } + run("DockLandingRectTest: a side the dragged panel shares with another is not freed") { + DockLandingRectTest().`a side the dragged panel shares with another is not freed`() + } + run("DockLandingRectTest: without a measured band the layout itself is the band") { + DockLandingRectTest().`without a measured band the layout itself is the band`() + } + run("DockZoneHintSidesTest: a floating satellite is offered every side") { + DockZoneHintSidesTest().`a floating satellite is offered every side`() + } + run("DockZoneHintSidesTest: a docked panel is not offered the side it is alone on") { + DockZoneHintSidesTest().`a docked panel is not offered the side it is alone on`() + } + run("DockZoneHintSidesTest: a docked panel with a neighbour is offered its own side, to be ranked among them") { + DockZoneHintSidesTest().`a docked panel with a neighbour is offered its own side, to be ranked among them`() + } + run( + "DockDropSlotsTest: a layered side is cut at the layers' centres, from its edge through the strip", + ) { + DockDropSlotsTest() + .`a layered side is cut at the layers' centres, from its edge through the strip`() + } + run("DockDropSlotsTest: a split side is cut along its length, from the band's start") { + DockDropSlotsTest().`a split side is cut along its length, from the band's start`() + } + run("DockDropSlotsTest: no slots without another panel, or before it is placed") { + DockDropSlotsTest().`no slots without another panel, or before it is placed`() + } + run("DockDropSlotsTest: the pointer picks the slot it is in, else the nearest end") { + DockDropSlotsTest().`the pointer picks the slot it is in, else the nearest end`() + } + run("DockDropSlotsTest: a pinned layer hides the ranks in front of it, for itself and for the others") { + DockDropSlotsTest().`a pinned layer hides the ranks in front of it, for itself and for the others`() + } + run("DockDropSlotsTest: a layer dropped at a rank is drawn where that rank puts it, at its own extent") { + DockDropSlotsTest().`a layer dropped at a rank is drawn where that rank puts it, at its own extent`() + } + run("DockDropSlotsTest: a panel dropped in a split stack is drawn as the share the weights give it") { + DockDropSlotsTest().`a panel dropped in a split stack is drawn as the share the weights give it`() + } + run("DockDropSlotsTest: dropped on an empty side, the space is the strip along its edge") { + DockDropSlotsTest().`dropped on an empty side, the space is the strip along its edge`() + } + run("DockZoneHintSidesTest: another window offers the side too, since dropping there is a move") { + DockZoneHintSidesTest().`another window offers the side too, since dropping there is a move`() + } + run("DockTargetFromDraggedRectTest: the dragged rect decides the zone, not the pointer") { + DockTargetFromDraggedRectTest().`the dragged rect decides the zone, not the pointer`() + } + run("DockTargetFromDraggedRectTest: an inset zone is the target, not the window's own edge") { + DockTargetFromDraggedRectTest().`an inset zone is the target, not the window's own edge`() + } + run( + "DockTargetFromDraggedRectTest: the pointer over a stack picks a rank, and beats a strip across its corner", + ) { + DockTargetFromDraggedRectTest() + .`the pointer over a stack picks a rank, and beats a strip across its corner`() + } + run("DockTargetFromDraggedRectTest: a dragged rect covering every zone is resolved by the pointer") { + DockTargetFromDraggedRectTest().`a dragged rect covering every zone is resolved by the pointer`() + } + + run("SatelliteWorkspaceTest: the first member to join owns the satellites until focus moves") { + SatelliteWorkspaceTest().`the first member to join owns the satellites until focus moves`() + } + run("SatelliteWorkspaceTest: pinning overrides focus until released") { + SatelliteWorkspaceTest().`pinning overrides focus until released`() + } + run("SatelliteWorkspaceTest: without follow focus the owner is the pinned or first member") { + SatelliteWorkspaceTest().`without follow focus the owner is the pinned or first member`() + } + run("SatelliteWorkspaceTest: docking a floating satellite seeds the side extent and hosts it in the owner") { + SatelliteWorkspaceTest().`docking a floating satellite seeds the side extent and hosts it in the owner`() + } + run("WorkspaceDragKindTest: a pointer drag is carried by the window, and the kind clears with it") { + WorkspaceDragKindTest().`a pointer drag is carried by the window, and the kind clears with it`() + } + run("WorkspaceDragKindTest: a transfer drag is carried by the platform session, and publishes no ghost") { + WorkspaceDragKindTest().`a transfer drag is carried by the platform session, and publishes no ghost`() + } + run("WorkspaceDragKindTest: a window that is not a native Wayland surface places on screen") { + WorkspaceDragKindTest().`a window that is not a native Wayland surface places on screen`() + } + run("SatelliteExtentRangeTest: a panel's range clamps its thickness, the side it joins, and the preview") { + SatelliteExtentRangeTest().`a panel's range clamps its thickness, the side it joins, and the preview`() + } + run( + "SatelliteExtentRangeTest: a restore bounds a side by the panels it puts there, not the ones it moves away", + ) { + SatelliteExtentRangeTest() + .`a restore bounds a side by the panels it puts there, not the ones it moves away`() + } + run("SatelliteFixedPanelTest: undock refuses a fixed panel") { + SatelliteFixedPanelTest().`undock refuses a fixed panel`() + } + run("SatelliteFixedPanelTest: a fixed satellite must be declared docked") { + SatelliteFixedPanelTest().`a fixed satellite must be declared docked`() + } + run("SatelliteFixedPanelTest: a drag released over the content leaves a fixed panel docked, with no ghost") { + SatelliteFixedPanelTest().`a drag released over the content leaves a fixed panel docked, with no ghost`() + } + run("SatelliteFixedPanelTest: a transfer drag with no record leaves a fixed panel docked") { + SatelliteFixedPanelTest().`a transfer drag with no record leaves a fixed panel docked`() + } + run("SatelliteFixedPanelTest: a snapshot that floats a fixed panel is ignored, but its open state is not") { + SatelliteFixedPanelTest().`a snapshot that floats a fixed panel is ignored, but its open state is not`() + } + run( + "SatelliteFixedPanelTest: a pinned panel is offered no rank and its drag changes nothing", + ) { + SatelliteFixedPanelTest() + .`a pinned panel is offered no rank and its drag changes nothing`() + } + run("SatelliteFixedPanelTest: another panel is docked after the pinned ones, whatever rank it asks for") { + SatelliteFixedPanelTest().`another panel is docked after the pinned ones, whatever rank it asks for`() + } + run("SatelliteFixedPanelTest: a panel that can go nowhere is no drag handle") { + SatelliteFixedPanelTest().`a panel that can go nowhere is no drag handle`() + } + run("SatelliteDockSidesTest: dock refuses a side the satellite was not declared for") { + SatelliteDockSidesTest().`dock refuses a side the satellite was not declared for`() + } + run( + "SatelliteDockSidesTest: floating-only never docks, the preferred side follows the declaration", + ) { + SatelliteDockSidesTest() + .`floating-only never docks, the preferred side follows the declaration`() + } + run("SatelliteDockSidesTest: a declared docked placement must name an allowed side") { + SatelliteDockSidesTest().`a declared docked placement must name an allowed side`() + } + run( + "SatelliteDockSidesTest: a refused side is not hinted nor previewed, a release there keeps it floating", + ) { + SatelliteDockSidesTest() + .`a refused side is not hinted nor previewed, a release there keeps it floating`() + } + run("SatelliteDockSidesTest: a snapshot naming a refused side leaves the placement alone") { + SatelliteDockSidesTest().`a snapshot naming a refused side leaves the placement alone`() + } + run("SatelliteDockRankTest: dock order inserts at that rank and keeps the side contiguous") { + SatelliteDockRankTest().`dock order inserts at that rank and keeps the side contiguous`() + } + run("SatelliteDockRankTest: a satellite docked again on the side it left returns to its rank") { + SatelliteDockRankTest().`a satellite docked again on the side it left returns to its rank`() + } + run( + "SatelliteDockRankTest: a satellite new to a side is appended there and keeps its rank elsewhere", + ) { + SatelliteDockRankTest() + .`a satellite new to a side is appended there and keeps its rank elsewhere`() + } + run("SatelliteDockRankTest: a closed panel keeps its rank and the weight comes back with it") { + SatelliteDockRankTest().`a closed panel keeps its rank and the weight comes back with it`() + } + run("SatelliteWorkspaceTest: undock without host geometry returns to the last floating placement") { + SatelliteWorkspaceTest().`undock without host geometry returns to the last floating placement`() + } + run("SatelliteWorkspaceTest: a member leaving rehosts the satellites docked into it") { + SatelliteWorkspaceTest().`a member leaving rehosts the satellites docked into it`() + } + run("SatelliteWorkspaceTest: open close and toggle only touch the open flag") { + SatelliteWorkspaceTest().`open close and toggle only touch the open flag`() + } + run("SatelliteWorkspaceTest: restore clamps a dock extent that would make the splitter unreachable") { + SatelliteWorkspaceTest().`restore clamps a dock extent that would make the splitter unreachable`() + } + run("SatelliteWorkspaceTest: the planned extent of an untouched side is the satellite's own size") { + SatelliteWorkspaceTest().`the planned extent of an untouched side is the satellite's own size`() + } + run("SatelliteWorkspaceTest: snapshot and restore round trip including a satellite declared later") { + SatelliteWorkspaceTest().`snapshot and restore round trip including a satellite declared later`() + } + run("SatelliteWorkspaceTest: dock target is the zone strip inside each edge of a registered layout") { + SatelliteWorkspaceTest().`dock target is the zone strip inside each edge of a registered layout`() + } + run("SatelliteWorkspaceTest: a floating drag moves the window along and docks where it is released") { + SatelliteWorkspaceTest().`a floating drag moves the window along and docks where it is released`() + } + run("SatelliteWorkspaceTest: a docked drag released over content lifts the panel out under the pointer") { + SatelliteWorkspaceTest().`a docked drag released over content lifts the panel out under the pointer`() + } + run("SatelliteWorkspaceTest: a docked drag released in another zone re-docks and inside its own panel stays") { + SatelliteWorkspaceTest().`a docked drag released in another zone re-docks and inside its own panel stays`() + } + run("SatelliteDockRankTest: a docked drag dropped on its own stack takes the rank under the pointer") { + SatelliteDockRankTest().`a docked drag dropped on its own stack takes the rank under the pointer`() + } + run("SatelliteWorkspaceTest: a cancelled drag leaves no feedback and no placement change") { + SatelliteWorkspaceTest().`a cancelled drag leaves no feedback and no placement change`() + } + run("SatelliteWorkspaceTest: a teleporting pointer lands on the zone it was released in") { + SatelliteWorkspaceTest().`a teleporting pointer lands on the zone it was released in`() + } + run("SatelliteWorkspaceTest: non-finite pointer samples are ignored and leave the last position standing") { + SatelliteWorkspaceTest().`non-finite pointer samples are ignored and leave the last position standing`() + } + run("SatelliteWorkspaceTest: a superseded drag stops acting and cannot clear the live one") { + SatelliteWorkspaceTest().`a superseded drag stops acting and cannot clear the live one`() + } + run("SatelliteWorkspaceTest: ending or cancelling twice is a no-op") { + SatelliteWorkspaceTest().`ending or cancelling twice is a no-op`() + } + run("SatelliteWorkspaceTest: the tear-out ghost carries the host scale, not the composition's") { + SatelliteWorkspaceTest().`the tear-out ghost carries the host scale, not the composition's`() + } + run("SatelliteWorkspaceTest: a drag whose host leaves mid-gesture still resolves") { + SatelliteWorkspaceTest().`a drag whose host leaves mid-gesture still resolves`() + } + run("SatelliteWorkspaceTest: a drag whose satellite is closed mid-gesture changes nothing") { + SatelliteWorkspaceTest().`a drag whose satellite is closed mid-gesture changes nothing`() + } + run("SatelliteWorkspaceTest: dock and undock churn keeps one consistent placement") { + SatelliteWorkspaceTest().`dock and undock churn keeps one consistent placement`() + } + run("SatelliteWorkspaceTest: interleaved drags of two satellites keep their own placements") { + SatelliteWorkspaceTest().`interleaved drags of two satellites keep their own placements`() + } + run("SatelliteWorkspaceTest: a drop resolves against the state a restore left behind") { + SatelliteWorkspaceTest().`a drop resolves against the state a restore left behind`() + } + run("SatelliteWorkspaceTest: re-registering an id keeps the workspace's memory of it") { + SatelliteWorkspaceTest().`re-registering an id keeps the workspace's memory of it`() + } + run("SatelliteWorkspaceTest: a minimized member is skipped as a drop target") { + SatelliteWorkspaceTest().`a minimized member is skipped as a drop target`() + } + run("SatelliteWorkspaceTest: overlapping layouts resolve to the owner then the last focused member") { + SatelliteWorkspaceTest().`overlapping layouts resolve to the owner then the last focused member`() + } + + run("RelocatingSaveableStateRegistryTest: keys relocate across hosts by rotation of the anchor delta") { + RelocatingSaveableStateRegistryTest().`keys relocate across hosts by rotation of the anchor delta`() + } + run("RelocatingSaveableStateRegistryTest: values keep their order when providers unregister in reverse") { + RelocatingSaveableStateRegistryTest().`values keep their order when providers unregister in reverse`() + } + run("RelocatingSaveableStateRegistryTest: a re-registering provider keeps its place among the values") { + RelocatingSaveableStateRegistryTest().`a re-registering provider keeps its place among the values`() + } + run("RelocatingSaveableStateRegistryTest: restored values never consumed survive another host change") { + RelocatingSaveableStateRegistryTest().`restored values never consumed survive another host change`() + } + run("RelocatingSaveableStateRegistryTest: a slot snapshot prefers the live registry over the last save") { + RelocatingSaveableStateRegistryTest().`a slot snapshot prefers the live registry over the last save`() + } + + run("WindowGroupTest: the owner is the pinned member, else the last focused, else the first joined") { + WindowGroupTest().`the owner is the pinned member, else the last focused, else the first joined`() + } + run("WindowGroupTest: a leaving owner hands over to the member focused before it") { + WindowGroupTest().`a leaving owner hands over to the member focused before it`() + } + run("WindowGroupTest: members by recency put the owner first and never-focused members last in join order") { + WindowGroupTest().`members by recency put the owner first and never-focused members last in join order`() + } + run("WindowGroupTest: a pin to a non-member is kept but ignored until it joins") { + WindowGroupTest().`a pin to a non-member is kept but ignored until it joins`() + } + run("WindowGroupTest: join is idempotent, leaving a stranger is a no-op, and the hooks see both") { + WindowGroupTest().`join is idempotent, leaving a stranger is a no-op, and the hooks see both`() + } + run("WindowGroupTest: without follow focus the owner ignores focus and takes the pin or the first member") { + WindowGroupTest().`without follow focus the owner ignores focus and takes the pin or the first member`() + } + + run("HostGeometryTest: client origin splits the side borders evenly and matches them at the bottom") { + HostGeometryTest().`client origin splits the side borders evenly and matches them at the bottom`() + } + run("HostGeometryTest: screen rect is unknown until both the container size and the outer frame are") { + HostGeometryTest().`screen rect is unknown until both the container size and the outer frame are`() + } + run("HostGeometryTest: scale falls back to one while the window reports none") { + HostGeometryTest().`scale falls back to one while the window reports none`() + } + run("HostGeometryTest: the registry keeps one geometry per window and only that one can unregister") { + HostGeometryTest().`the registry keeps one geometry per window and only that one can unregister`() + } + run("HostGeometryTest: ordered lists the given hosts first and the rest in registration order") { + HostGeometryTest().`ordered lists the given hosts first and the rest in registration order`() + } + + run("DragControllerTest: begin supersedes the live session and clears the feedback once") { + DragControllerTest().`begin supersedes the live session and clears the feedback once`() + } + run("DragControllerTest: release ignores a session that is not live and is idempotent for the live one") { + DragControllerTest().`release ignores a session that is not live and is idempotent for the live one`() + } + run("DragControllerTest: release of null ends whichever session is live") { + DragControllerTest().`release of null ends whichever session is live`() + } + + run("TransferDragTest: nearest edge within the zone wins") { + TransferDragTest().`nearest edge within the zone wins`() + } + run("TransferDragTest: a corner resolves to the closer of its two edges") { + TransferDragTest().`a corner resolves to the closer of its two edges`() + } + run("TransferDragTest: content and points outside the layout are no zone") { + TransferDragTest().`content and points outside the layout are no zone`() + } + run("TransferDragTest: a zone wider than the layout still resolves to exactly one side") { + TransferDragTest().`a zone wider than the layout still resolves to exactly one side`() + } + run("TransferDragTest: the private payload round-trips under its own flavor only") { + TransferDragTest().`the private payload round-trips under its own flavor only`() + } + run("TransferDragTest: an ordinary transferable carries no token") { + TransferDragTest().`an ordinary transferable carries no token`() + } + + run("TransferDragTest: the transfer ends the drag when the platform reports the session over") { + TransferDragTest().`the transfer ends the drag when the platform reports the session over`() + } + run("TransferDragTest: the transfer carries the private token and a Move action only") { + TransferDragTest().`the transfer carries the private token and a Move action only`() + } + run("TransferDragTest: the decoration offset puts the hotspot under the pointer, clamped to the icon") { + TransferDragTest().`the decoration offset puts the hotspot under the pointer, clamped to the icon`() + } + + run("TransferDragTest: without a picture the icon is the title card, one to one") { + TransferDragTest().`without a picture the icon is the title card, one to one`() + } + run("TransferDragTest: a picture is shown reduced and capped on its longer edge") { + TransferDragTest().`a picture is shown reduced and capped on its longer edge`() + } + run("TransferDragTest: the hotspot follows the grab point into the reduced picture of a region") { + TransferDragTest().`the hotspot follows the grab point into the reduced picture of a region`() + } + + run("TabHoverPreviewTest: the strip reports the tab the pointer rests on, and nothing once it leaves") { + TabHoverPreviewTest().`the strip reports the tab the pointer rests on, and nothing once it leaves`() + } + run("TabHoverPreviewTest: a press puts the card away until the pointer has been elsewhere") { + TabHoverPreviewTest().`a press puts the card away until the pointer has been elsewhere`() + } + run("TabHoverPreviewTest: a press on a tab the pointer is not on changes nothing") { + TabHoverPreviewTest().`a press on a tab the pointer is not on changes nothing`() + } + run("TabHoverPreviewTest: no card while a tab is being dragged") { + TabHoverPreviewTest().`no card while a tab is being dragged`() + } + run("TabHoverPreviewTest: a tab that has left the group is no longer hovered") { + TabHoverPreviewTest().`a tab that has left the group is no longer hovered`() + } + run("TabHoverPreviewTest: the anchor of a card is the tab's own slot, and nothing before it is placed") { + TabHoverPreviewTest().`the anchor of a card is the tab's own slot, and nothing before it is placed`() + } + run("TabHoverPreviewTest: the card hangs from the tab's leading edge, below it") { + TabHoverPreviewTest().`the card hangs from the tab's leading edge, below it`() + } + run("TabHoverPreviewTest: a right-to-left strip hangs the card from the tab's right edge") { + TabHoverPreviewTest().`a right-to-left strip hangs the card from the tab's right edge`() + } + run("TabHoverPreviewTest: a card that would run off the window is slid back in") { + TabHoverPreviewTest().`a card that would run off the window is slid back in`() + } + run("TabHoverPreviewTest: the selected tab has no card") { + TabHoverPreviewTest().`the selected tab has no card`() + } + run("TabHoverPreviewTest: a picture the app assigns stands until the workspace takes one") { + TabHoverPreviewTest().`a picture the app assigns stands until the workspace takes one`() + } + + run("TabWorkspaceTest: a drag selects the tab it lifted, so a click that drifts is never lost") { + TabWorkspaceTest().`a drag selects the tab it lifted, so a click that drifts is never lost`() + } + run("TabWorkspaceTest: taking a tab in hand inside its own strip selects it too") { + TabWorkspaceTest().`taking a tab in hand inside its own strip selects it too`() + } + run("TabWorkspaceTest: a right-to-left strip resolves its insertion indices from the right") { + TabWorkspaceTest().`a right-to-left strip resolves its insertion indices from the right`() + } + run("TabWorkspaceTest: the first tab opens a window and the next ones join it") { + TabWorkspaceTest().`the first tab opens a window and the next ones join it`() + } + run("TabWorkspaceTest: a named group is created on demand and keeps its name") { + TabWorkspaceTest().`a named group is created on demand and keeps its name`() + } + run("TabWorkspaceTest: re-registering an id keeps its place and only refreshes the title") { + TabWorkspaceTest().`re-registering an id keeps its place and only refreshes the title`() + } + run("TabWorkspaceTest: closing the selected tab selects its right neighbour, then its left") { + TabWorkspaceTest().`closing the selected tab selects its right neighbour, then its left`() + } + run("TabWorkspaceTest: closing an unselected tab leaves the selection alone") { + TabWorkspaceTest().`closing an unselected tab leaves the selection alone`() + } + run("TabWorkspaceTest: the last tab of a window takes the window with it") { + TabWorkspaceTest().`the last tab of a window takes the window with it`() + } + run("TabWorkspaceTest: closing an unknown tab is a no-op") { + TabWorkspaceTest().`closing an unknown tab is a no-op`() + } + run("TabWorkspaceTest: a move to another group inserts at the index and selects there") { + TabWorkspaceTest().`a move to another group inserts at the index and selects there`() + } + run("TabWorkspaceTest: a move index beyond the strip appends and a negative one prepends") { + TabWorkspaceTest().`a move index beyond the strip appends and a negative one prepends`() + } + run("TabWorkspaceTest: a move within its own group is a reorder and keeps the selection") { + TabWorkspaceTest().`a move within its own group is a reorder and keeps the selection`() + } + run("TabWorkspaceTest: a move into a dropped group and of an unknown tab are both no-ops") { + TabWorkspaceTest().`a move into a dropped group and of an unknown tab are both no-ops`() + } + run("TabWorkspaceTest: tearing a tab off a multi-tab window opens a window at the rect") { + TabWorkspaceTest().`tearing a tab off a multi-tab window opens a window at the rect`() + } + run("TabWorkspaceTest: tearing off the only tab of a window moves that window instead") { + TabWorkspaceTest().`tearing off the only tab of a window moves that window instead`() + } + run("TabWorkspaceTest: a tear-off rect measured at an unusable scale falls back to one") { + TabWorkspaceTest().`a tear-off rect measured at an unusable scale falls back to one`() + } + run("TabWorkspaceTest: tearing off an unknown tab changes nothing") { + TabWorkspaceTest().`tearing off an unknown tab changes nothing`() + } + run("TabWorkspaceTest: the card entering a strip is a drop before the pointer reaches it") { + TabWorkspaceTest().`the card entering a strip is a drop before the pointer reaches it`() + } + run("TabWorkspaceTest: a dragged window's own strip never answers for the card either") { + TabWorkspaceTest().`a dragged window's own strip never answers for the card either`() + } + run("TabWorkspaceTest: a drop resolves to the strip under the pointer and the index it falls at") { + TabWorkspaceTest().`a drop resolves to the strip under the pointer and the index it falls at`() + } + run("TabWorkspaceTest: the dragged tab's own slot is counted out of the index") { + TabWorkspaceTest().`the dragged tab's own slot is counted out of the index`() + } + run("TabWorkspaceTest: a minimized window is never a drop target") { + TabWorkspaceTest().`a minimized window is never a drop target`() + } + run("TabWorkspaceTest: overlapping strips resolve to the window focused most recently") { + TabWorkspaceTest().`overlapping strips resolve to the window focused most recently`() + } + run("TabWorkspaceTest: an excluded group is skipped for the strip underneath it") { + TabWorkspaceTest().`an excluded group is skipped for the strip underneath it`() + } + run("TabWorkspaceTest: a strip with no slots published yet resolves to index zero") { + TabWorkspaceTest().`a strip with no slots published yet resolves to index zero`() + } + run("TabWorkspaceTest: dragging one of several tabs shows a ghost and inserts where it is dropped") { + TabWorkspaceTest().`dragging one of several tabs shows a ghost and inserts where it is dropped`() + } + run("TabWorkspaceTest: dragging one of several tabs into empty space tears off a window under the pointer") { + TabWorkspaceTest().`dragging one of several tabs into empty space tears off a window under the pointer`() + } + run("TabWorkspaceTest: dragging the only tab of a window moves the window and shows no ghost") { + TabWorkspaceTest().`dragging the only tab of a window moves the window and shows no ghost`() + } + run("TabWorkspaceTest: dropping the only tab of a window on another strip merges and closes it") { + TabWorkspaceTest().`dropping the only tab of a window on another strip merges and closes it`() + } + run("TabWorkspaceTest: a teleporting pointer lands on the strip it was released over") { + TabWorkspaceTest().`a teleporting pointer lands on the strip it was released over`() + } + run("TabWorkspaceTest: non-finite samples are ignored and leave the last position standing") { + TabWorkspaceTest().`non-finite samples are ignored and leave the last position standing`() + } + run("TabWorkspaceTest: a beginDrag with a non-finite pointer is refused") { + TabWorkspaceTest().`a beginDrag with a non-finite pointer is refused`() + } + run("TabWorkspaceTest: a drag is refused while the strip has published no geometry") { + TabWorkspaceTest().`a drag is refused while the strip has published no geometry`() + } + run("TabWorkspaceTest: a superseded drag stops acting and cannot clear the live one") { + TabWorkspaceTest().`a superseded drag stops acting and cannot clear the live one`() + } + run("TabWorkspaceTest: ending or cancelling twice is a no-op") { + TabWorkspaceTest().`ending or cancelling twice is a no-op`() + } + run("TabWorkspaceTest: a drag whose window closes mid-gesture still resolves") { + TabWorkspaceTest().`a drag whose window closes mid-gesture still resolves`() + } + run("TabWorkspaceTest: a drag whose tab is closed mid-gesture leaves the workspace alone") { + TabWorkspaceTest().`a drag whose tab is closed mid-gesture leaves the workspace alone`() + } + run("TabWorkspaceTest: tear-off and merge churn keeps every tab in exactly one window") { + TabWorkspaceTest().`tear-off and merge churn keeps every tab in exactly one window`() + } + run("TabWorkspaceTest: snapshot and restore round trip including a tab declared later") { + TabWorkspaceTest().`snapshot and restore round trip including a tab declared later`() + } + run("TabWorkspaceTest: a tear-off never takes the id of a restored window") { + TabWorkspaceTest().`a tear-off never takes the id of a restored window`() + } + run("TabWorkspaceTest: a restore rebuilds strip order whatever order the tabs are declared in") { + TabWorkspaceTest().`a restore rebuilds strip order whatever order the tabs are declared in`() + } + run("TabWorkspaceTest: a restore moves a window that is already open and bumps its placement") { + TabWorkspaceTest().`a restore moves a window that is already open and bumps its placement`() + } + run("TabWorkspaceTest: a snapshot falls back to the recorded placement without a live window") { + TabWorkspaceTest().`a snapshot falls back to the recorded placement without a live window`() + } + run("TabWorkspaceTest: restoring an empty snapshot leaves the workspace alone") { + TabWorkspaceTest().`restoring an empty snapshot leaves the workspace alone`() + } + run("TabWorkspaceTest: a single-tab strip inserts by the direction it published") { + TabWorkspaceTest().`a single-tab strip inserts by the direction it published`() + } + run("TabWorkspaceTest: a drag says how it is carried, and the slot it opens knows the tab") { + TabWorkspaceTest().`a drag says how it is carried, and the slot it opens knows the tab`() + } + run("TabWorkspaceTest: a transfer drag is carried by the platform session, one held in its strip by none") { + TabWorkspaceTest().`a transfer drag is carried by the platform session, one held in its strip by none`() + } + return results } } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt index bcab9428a..290fa7b94 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/TaoSceneTestBatteryDriftTest.kt @@ -9,10 +9,13 @@ import dev.nucleusframework.window.tao.event.MacOsWheelDeltaTest import dev.nucleusframework.window.tao.event.TaoKeyMappingTest import dev.nucleusframework.window.tao.event.TaoKeyboardModifiersDecodeTest import dev.nucleusframework.window.tao.event.TaoSyntheticMouseWheelEventTest +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSessionTest import dev.nucleusframework.window.tao.event.TaoWheelPinchZoomTest import dev.nucleusframework.window.tao.event.Win32WheelDeltaTest import dev.nucleusframework.window.tao.popup.StandaloneFramePumpTest import dev.nucleusframework.window.tao.popup.StandalonePopupRenderReentryTest +import dev.nucleusframework.window.tao.scene.LcdTextCaptureTest +import dev.nucleusframework.window.tao.scene.LcdTextTest import dev.nucleusframework.window.tao.scene.TaoSceneAnimationTest import dev.nucleusframework.window.tao.scene.TaoSceneContentSwapTest import dev.nucleusframework.window.tao.scene.TaoSceneExceptionHandlerTest @@ -28,7 +31,13 @@ import dev.nucleusframework.window.tao.scene.TaoSceneRenderTest import dev.nucleusframework.window.tao.scene.TaoSceneScrollTest import dev.nucleusframework.window.tao.scene.TaoSceneSemanticsTest import dev.nucleusframework.window.tao.scene.TaoSceneTrackpadPanTest +import dev.nucleusframework.window.tao.scene.TaoSceneTrackpadScaleTest import dev.nucleusframework.window.tao.scene.TaoTrackpadPanRouterTest +import dev.nucleusframework.window.tao.workspace.DragControllerTest +import dev.nucleusframework.window.tao.workspace.HostGeometryTest +import dev.nucleusframework.window.tao.workspace.RelocatingSaveableStateRegistryTest +import dev.nucleusframework.window.tao.workspace.TransferDragTest +import dev.nucleusframework.window.tao.workspace.WindowGroupTest import java.io.File import kotlin.test.Test import kotlin.test.assertEquals @@ -51,6 +60,8 @@ class TaoSceneTestBatteryDriftTest { private val batteryClasses: List> = listOf( TaoKeyMappingTest::class.java, + NativePopupLayersTest::class.java, + dev.nucleusframework.window.tao.popup.MacPopupPictureCullTest::class.java, TaoKeyboardModifiersDecodeTest::class.java, TaoSyntheticMouseWheelEventTest::class.java, Win32WheelDeltaTest::class.java, @@ -70,6 +81,8 @@ class TaoSceneTestBatteryDriftTest { TaoSceneScrollTest::class.java, TaoSceneTrackpadPanTest::class.java, TaoTrackpadPanRouterTest::class.java, + TaoSceneTrackpadScaleTest::class.java, + TaoTrackpadScaleSessionTest::class.java, TaoScenePopupTest::class.java, TaoSceneOuterLocalsBridgeTest::class.java, TaoSceneAnimationTest::class.java, @@ -79,6 +92,26 @@ class TaoSceneTestBatteryDriftTest { TaoSceneSemanticsTest::class.java, TaoA11yProjectionTest::class.java, TitleBarHitTestTest::class.java, + LcdTextTest::class.java, + WindowPositionerTest::class.java, + SatelliteWorkspaceTest::class.java, + SatelliteDockedGeometryTest::class.java, + DockLandingRectTest::class.java, + DockZoneHintSidesTest::class.java, + DockDropSlotsTest::class.java, + SatelliteDockRankTest::class.java, + SatelliteDockSidesTest::class.java, + WorkspaceDragKindTest::class.java, + SatelliteExtentRangeTest::class.java, + SatelliteFixedPanelTest::class.java, + DockTargetFromDraggedRectTest::class.java, + RelocatingSaveableStateRegistryTest::class.java, + WindowGroupTest::class.java, + HostGeometryTest::class.java, + DragControllerTest::class.java, + TransferDragTest::class.java, + TabWorkspaceTest::class.java, + TabHoverPreviewTest::class.java, ) /** Classes that must stay out of the battery, with the reason. */ @@ -102,15 +135,37 @@ class TaoSceneTestBatteryDriftTest { TaoTransferableAccessGuardTest::class.java to "Compose interop ABI guard, not a scene behaviour", TaoScrollWireDriftTest::class.java to "reads popup_panel.m / events.rs from the repo; wire guard, not a scene behaviour", + TaoMouseButtonWireDriftTest::class.java to + "reads events.rs from the repo; wire guard, not a scene behaviour", dev.nucleusframework.window.tao.scene.TaoKeepScreenOnTest::class.java to "acquires real EnergyManager awake handles against the host OS", TaoSceneTestBatteryDriftTest::class.java to "meta-test for the battery itself", + EventLoopHangDetectorTest::class.java to + "pure-function hang state machine (#643); no ComposeScene", + TaoEventLoopWatchdogMonkeyTest::class.java to + "threads a real watchdog against a fake probe (#643); no ComposeScene", + TaoEventLoopWatchdogSmokeTest::class.java to + "opt-in headful e2e (NUCLEUS_TAO_SMOKE=1); freezes the real event loop", dev.nucleusframework.window.tao.scene.WaylandBufferScaleTest::class.java to "pure-function buffer alignment; already covered via TaoScenePopupTest in the battery", XdgPortalParentTest::class.java to "pure-Kotlin portal parent / xdg_foreign handle formatting, no scene", dev.nucleusframework.window.ChromeLogicTest::class.java to "unit tests for chrome helpers; no ComposeScene", + NucleusWindowV2BridgeTest::class.java to + "pure state mapping + geometry provider evaluation, no ComposeScene", + TaoMonitorsTest::class.java to + "parses the native monitor wire format; no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupScreenClampTest::class.java to + "pure-function popup screen clamp geometry (#569); no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupDrawInflateTest::class.java to + "pure-function popup draw margin geometry (#569); no ComposeScene", + dev.nucleusframework.window.tao.popup.PopupScrimRegistryTest::class.java to + "scrim bookkeeping + raster blend on a CPU bitmap (#569); no ComposeScene", + LcdTextCaptureTest::class.java to + "writes an AWT comparison PNG; diagnostic, not a scene behaviour", + TaoApplicationExitTest::class.java to + "pure finishTaoApplication / exitProcessOnExit mapping (#667); no ComposeScene", ) private fun testMethodNames(cls: Class<*>): List = diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/WindowPositionerTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/WindowPositionerTest.kt new file mode 100644 index 000000000..d585cfa85 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/WindowPositionerTest.kt @@ -0,0 +1,207 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Placement arithmetic behind `SatelliteWindow`. Pure geometry — no windows, no + * native calls — so the constraint-adjustment cascade (flip → slide → resize) + * can be pinned down exactly, while the headful suite covers the real + * two-window behaviour. + */ +class WindowPositionerTest { + private val workArea = DpRect(0.dp, 0.dp, 1000.dp, 800.dp) + private val parent = DpRect(100.dp, 100.dp, 500.dp, 400.dp) + private val child = DpSize(200.dp, 100.dp) + + @Test + fun `right to left anchoring hangs the child off the right edge of the parent`() { + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + ).place(child, parent, parent, workArea) + + // Parent right edge, vertically centred on the parent. + assertEquals(500.dp, placed.left) + assertEquals(250.dp - 50.dp, placed.top) + assertEquals(child.width, placed.width) + assertEquals(child.height, placed.height) + } + + @Test + fun `offset is applied after the anchors meet`() { + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.TopRight, + childAnchor = WindowAnchor.TopLeft, + offset = DpOffset(12.dp, (-8).dp), + ).place(child, parent, parent, workArea) + + assertEquals(512.dp, placed.left) + assertEquals(92.dp, placed.top) + } + + @Test + fun `centre to centre puts the child on the middle of the parent`() { + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.Center, + childAnchor = WindowAnchor.Center, + ).place(child, parent, parent, workArea) + + assertEquals(300.dp - 100.dp, placed.left) + assertEquals(250.dp - 50.dp, placed.top) + } + + @Test + fun `a sub-rectangle of the parent anchors the child to that rectangle`() { + val toolbarButton = DpRect(140.dp, 100.dp, 180.dp, 140.dp) + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.BottomLeft, + childAnchor = WindowAnchor.TopLeft, + ).place(child, toolbarButton, parent, workArea) + + assertEquals(140.dp, placed.left) + assertEquals(140.dp, placed.top) + } + + @Test + fun `the anchor point is clamped to the parent rectangle`() { + // An anchor rect that sticks far out of its parent must not fling the + // child across the screen. + val runaway = DpRect(900.dp, 700.dp, 950.dp, 750.dp) + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.TopLeft, + childAnchor = WindowAnchor.TopLeft, + constraintAdjustment = WindowConstraintAdjustment.None, + ).place(child, runaway, parent, workArea) + + assertEquals(parent.right, placed.left) + assertEquals(parent.bottom, placed.top) + } + + @Test + fun `no adjustment leaves the child outside the work area`() { + val atRightEdge = DpRect(800.dp, 100.dp, 990.dp, 400.dp) + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + constraintAdjustment = WindowConstraintAdjustment.None, + ).place(child, atRightEdge, atRightEdge, workArea) + + assertEquals(990.dp, placed.left) + assertTrue(placed.right > workArea.right, "expected the child to overhang: $placed") + } + + @Test + fun `flip mirrors the child to the other side when it would overhang`() { + val atRightEdge = DpRect(800.dp, 100.dp, 990.dp, 400.dp) + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + offset = DpOffset(10.dp, 0.dp), + constraintAdjustment = WindowConstraintAdjustment.Flip, + ).place(child, atRightEdge, atRightEdge, workArea) + + // Mirrored: anchored to the parent's *left* edge, and the offset flips + // with it, so the gap stays on the outside of the parent. + assertEquals(800.dp - 10.dp - child.width, placed.left) + assertTrue(placed.left >= workArea.left) + assertTrue(placed.right <= workArea.right) + } + + @Test + fun `slide translates the child back inside the work area`() { + val atRightEdge = DpRect(800.dp, 100.dp, 990.dp, 400.dp) + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + constraintAdjustment = WindowConstraintAdjustment.Slide, + ).place(child, atRightEdge, atRightEdge, workArea) + + // Pushed left until the right edge touches the work area, size intact. + assertEquals(workArea.right - child.width, placed.left) + assertEquals(child.width, placed.width) + } + + @Test + fun `flip is preferred over slide`() { + val atRightEdge = DpRect(800.dp, 100.dp, 990.dp, 400.dp) + val flipAndSlide = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + constraintAdjustment = WindowConstraintAdjustment.FlipAndSlide, + ).place(child, atRightEdge, atRightEdge, workArea) + val flipOnly = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + constraintAdjustment = WindowConstraintAdjustment.Flip, + ).place(child, atRightEdge, atRightEdge, workArea) + + assertEquals(flipOnly, flipAndSlide) + } + + @Test + fun `resize shrinks the child when nothing else fits`() { + // Wider than the work area: neither flipping nor sliding can help. + val huge = DpSize(1200.dp, 100.dp) + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.Center, + childAnchor = WindowAnchor.Center, + constraintAdjustment = WindowConstraintAdjustment.All, + ).place(huge, parent, parent, workArea) + + // Centred on the parent it would span -300..900; the overhanging edge + // is clipped to the work area and the window is never grown to fill it. + assertEquals(workArea.left, placed.left) + assertEquals(900.dp, placed.right) + assertTrue(placed.width < huge.width, "expected the child to shrink: $placed") + assertEquals(huge.height, placed.height) + } + + @Test + fun `vertical flip mirrors a bottom anchored child upwards`() { + val atBottom = DpRect(100.dp, 600.dp, 400.dp, 780.dp) + val placed = + WindowPositioner( + parentAnchor = WindowAnchor.Bottom, + childAnchor = WindowAnchor.Top, + constraintAdjustment = WindowConstraintAdjustment.Flip, + ).place(child, atBottom, atBottom, workArea) + + assertEquals(600.dp - child.height, placed.top) + assertTrue(placed.bottom <= workArea.bottom) + } + + @Test + fun `an unconstrained placement is returned untouched by every adjustment`() { + val positioner = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + constraintAdjustment = WindowConstraintAdjustment.All, + ) + val relaxed = positioner.copy(constraintAdjustment = WindowConstraintAdjustment.None) + + assertEquals( + relaxed.place(child, parent, parent, workArea), + positioner.place(child, parent, parent, workArea), + ) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/WorkspaceDragKindTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/WorkspaceDragKindTest.kt new file mode 100644 index 000000000..5987079cc --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/WorkspaceDragKindTest.kt @@ -0,0 +1,89 @@ +package dev.nucleusframework.window.tao + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.workspace.HostGeometry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * What an app can ask about a drag in flight ([SatelliteWorkspace.dragKind]) + * and about the window it draws in ([TaoWindow.canPlaceOnScreen]) — the two + * public answers chrome needs to tell "move the window" from "move the + * satellite". + */ +class WorkspaceDragKindTest { + private val a = TaoWindow(handle = 1L) + + private val floating = + SatellitePlacement.Floating( + positioner = WindowPositioner(parentAnchor = WindowAnchor.Right, childAnchor = WindowAnchor.Left), + size = DpSize(200.dp, 300.dp), + ) + + private fun workspace(): SatelliteWorkspace = + SatelliteWorkspace().apply { + join(a) + dockHosts.register( + HostGeometry(a, outerBoundsPx = { longArrayOf(100L, 100L, 800L, 600L) }, scaleFactor = { 1f }).apply { + layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + containerSizePx = IntSize(800, 600) + }, + ) + } + + private val satellite = TaoWindow(handle = 3L) + private val origin = + SatelliteDragOrigin.FloatingWindow( + window = satellite, + outerBoundsPx = { longArrayOf(400L, 300L, 200L, 150L) }, + move = { _, _ -> }, + ) + + @Test + fun `a pointer drag is carried by the window, and the kind clears with it`() { + val workspace = workspace() + workspace.register("tools", "Tools", floating, initiallyOpen = true) + assertNull(workspace.dragKind, "nothing is dragging") + + val session = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + assertEquals(WorkspaceDragKind.Window, workspace.dragKind) + session.update(Offset(500f, 690f)) + assertEquals(WorkspaceDragKind.Window, workspace.dragKind, "still the window's own drag") + session.end(Offset(500f, 690f)) + assertNull(workspace.dragKind, "the release clears it") + + val cancelled = requireNotNull(workspace.beginDrag("tools", origin, Offset(500f, 310f))) + cancelled.cancel() + assertNull(workspace.dragKind) + } + + @Test + fun `a transfer drag is carried by the platform session, and publishes no ghost`() { + val workspace = workspace() + val entry = workspace.register("tools", "Tools", floating, initiallyOpen = true) + entry.content = {} + workspace.dock("tools", DockSide.Left) + entry.dockedBoundsInWindowPx = Rect(0f, 40f, 220f, 600f) + + val session = requireNotNull(workspace.beginTransferDrag("tools", SatelliteDragOrigin.DockedPanel(a))) + assertEquals(WorkspaceDragKind.Transfer, workspace.dragKind) + assertEquals(entry, workspace.draggedSatellite, "the satellite is published either way") + assertNull(workspace.dragGhost, "no window follows a transfer drag") + session.end() + assertNull(workspace.dragKind) + } + + @Test + fun `a window that is not a native Wayland surface places on screen`() { + // Without a native surface the kind is unknown, which is the answer + // every platform but Wayland gives: the app places its own windows. + assertTrue(a.canPlaceOnScreen) + assertEquals(!a.isNativeWaylandSurface, a.canPlaceOnScreen) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScaleSessionTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScaleSessionTest.kt new file mode 100644 index 000000000..d5a97df54 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/event/TaoTrackpadScaleSessionTest.kt @@ -0,0 +1,103 @@ +package dev.nucleusframework.window.tao.event + +import androidx.compose.ui.input.pointer.PointerEventType +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TaoTrackpadScaleSessionTest { + @Test + fun startChangeEndEmitsScaleSequence() { + val h = Harness() + h.session.start() + h.session.change(1.05f) + h.session.end() + assertEquals( + listOf( + PointerEventType.ScaleStart to 1f, + PointerEventType.ScaleChange to 1.05f, + PointerEventType.ScaleEnd to 1f, + ), + h.sent, + ) + assertFalse(h.session.active) + } + + @Test + fun changeOpensTheGestureIfNeeded() { + val h = Harness() + h.session.change(1.02f) + assertTrue(h.session.active) + assertEquals( + listOf( + PointerEventType.ScaleStart to 1f, + PointerEventType.ScaleChange to 1.02f, + ), + h.sent, + ) + } + + @Test + fun identityFactorIsNotAMove() { + val h = Harness() + h.session.start() + h.session.change(1f) + h.session.magnifyBy(0f) + assertEquals(listOf(PointerEventType.ScaleStart to 1f), h.sent) + } + + @Test + fun magnifyByUsesOnePlusDelta() { + val h = Harness() + h.session.magnifyBy(0.01f) + assertEquals(PointerEventType.ScaleChange to 1.01f, h.sent.last()) + h.session.magnifyBy(-0.5f) + assertEquals(PointerEventType.ScaleChange to 0.5f, h.sent.last()) + } + + @Test + fun magnifyByFloorsACollapse() { + val h = Harness() + h.session.magnifyBy(-2f) + assertEquals( + TaoTrackpadScaleSession.MIN_GESTURE_SCALE, + h.sent.last().second, + ) + } + + @Test + fun smartMagnifyIsAClosedBurst() { + val h = Harness() + h.session.smartMagnify() + assertEquals( + listOf( + PointerEventType.ScaleStart to 1f, + PointerEventType.ScaleChange to TaoTrackpadScaleSession.SMART_MAGNIFY_FACTOR, + PointerEventType.ScaleEnd to 1f, + ), + h.sent, + ) + assertFalse(h.session.active) + } + + @Test + fun endWithoutStartIsANoOp() { + val h = Harness() + h.session.end() + assertTrue(h.sent.isEmpty()) + } + + @Test + fun aSecondStartIsIgnoredWhileActive() { + val h = Harness() + h.session.start() + h.session.start() + assertEquals(listOf(PointerEventType.ScaleStart to 1f), h.sent) + } + + private class Harness { + val sent = mutableListOf>() + val session = TaoTrackpadScaleSession { type, factor -> sent += type to factor } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/AnimatedWindowSizeHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/AnimatedWindowSizeHeadfulCases.kt index fa418def2..e5ad0dfd6 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/AnimatedWindowSizeHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/AnimatedWindowSizeHeadfulCases.kt @@ -22,10 +22,14 @@ import androidx.compose.ui.layout.positionInWindow import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.WindowState +import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.window.TitleBar +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.scene.TaoPresentDiagnostics import java.io.File import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.atomic.AtomicBoolean @@ -43,7 +47,8 @@ import kotlin.math.roundToInt * vs Compose layout/scene each frame, and gates the tremble metric. */ internal object AnimatedWindowSizeHeadfulCases { - fun all(): List = listOf(animatedHeightDoesNotTremble()) + fun all(): List = + listOf(animatedHeightDoesNotTremble(), zoomPresentsEveryStep(), appKitAnimatorDispatchesEveryStepInTime()) private data class LayoutPx( var x: Int = 0, @@ -74,6 +79,77 @@ internal object AnimatedWindowSizeHeadfulCases { val contentH: Int, ) + /** + * The title-bar double-click path (#576): a maximize / restore zoom is a + * run of frame steps, and each must have its content presented before + * the next arrives — otherwise the content trails the window edge for + * the whole animation. On macOS tao steps the zoom itself (vendored + * `set_maximized_async`), so the steps are plain resizes; on Windows the + * maximize is instant — one size change each way, and DWM stretches the + * previous frame over the new client area until it is presented. + */ + private fun zoomPresentsEveryStep(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#576 maximize and restore zoom present every step in its own turn", + timeoutMillis = CASE_TIMEOUT_MILLIS, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + settle() + val minSteps = if (Platform.Current == Platform.MacOS) MIN_ANIM_SAMPLES else MIN_ZOOM_STEPS_INSTANT + val probe = PresentLagProbe(window, AtomicBoolean(true), minSteps) + window.onResized { w, h -> probe.onResized(w, h) } + window.setMaximized(true) + awaitUntil("maximized") { window.isMaximized } + settle(ZOOM_SETTLE_MILLIS) + window.setMaximized(false) + awaitUntil("restored") { !window.isMaximized } + settle(ZOOM_SETTLE_MILLIS) + probe.assertNone() + } + + /** + * The edge double-click zoom (`_zoomToScreenEdge:`) is AppKit's own + * `setFrame:display:animate:YES`: a blocking animator whose private + * run-loop mode services no tao observer, so every step's `Resized` waits + * in tao's queue until the animation has ended and the content snaps into + * the final bounds — the trailing of the title-bar zoom before #678, one + * path over. No Robot here, so the case takes that AppKit path + * programmatically: `set_maximized_async` on a non-resizable window is a + * plain `setFrame:display:NO animate:YES`. Every `Resized` must be + * dispatched while the native frame is at its size — outer minus inner + * height is then the chrome, a constant; a step dispatched after the + * animation reads the final outer height against its own inner one. + */ + private fun appKitAnimatorDispatchesEveryStepInTime(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#576 AppKit frame animation (edge double-click zoom) dispatches every step in time", + timeoutMillis = CASE_TIMEOUT_MILLIS, + skip = { "AppKit's setFrame:display:animate: is macOS only".takeIf { Platform.Current != Platform.MacOS } }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + settle() + window.setResizable(false) + val chromes = CopyOnWriteArrayList() + val probe = PresentLagProbe(window, AtomicBoolean(true)) + window.onResized { w, h -> + probe.onResized(w, h) + window.outerBoundsPx()?.let { chromes += it[3] - h } + } + window.setMaximized(true) + awaitUntil("maximized") { window.isMaximized } + settle(ZOOM_SETTLE_MILLIS) + window.setMaximized(false) + awaitUntil("restored") { !window.isMaximized } + settle(ZOOM_SETTLE_MILLIS) + probe.assertNone() + val spread = (chromes.max() - chromes.min()).toInt() + System.err.println("[#576] outer-minus-inner height spread over ${chromes.size} resize events: ${spread}px") + check(spread <= PX_TOLERANCE) { + "resize events were dispatched with the native frame ${spread}px away from their size — " + + "AppKit's animator ran to its end before tao delivered a step" + } + } + private fun animatedHeightDoesNotTremble(): TaoWindowTestCase { val windowState = WindowState( @@ -191,9 +267,11 @@ internal object AnimatedWindowSizeHeadfulCases { driver = { awaitUntil("window mapped") { bounds() != null } settle() + val presentLag = PresentLagProbe(window, recording) window.onResized { w, h -> innerW.set(w) innerH.set(h) + presentLag.onResized(w, h) } recording.set(true) settle(BASELINE_MILLIS) @@ -206,10 +284,64 @@ internal object AnimatedWindowSizeHeadfulCases { val dump = writeSamples(samples) System.err.println("[#576] wrote ${samples.size} samples to $dump") assertNoTremble(samples) + presentLag.assertNone() }, ) } + /** + * Counts resize events whose frame was not on its way by the time the + * next one arrived. The host presents a resize's frame at the end of the + * same run-loop turn (`MainEventsCleared`), after every listener has run — + * so this listener, at event N, checks that event N-1 has been presented, + * and [assertNone] that the last one has. Without the same-turn present + * the render loop trails by one to two steps and Core Animation shows the + * previous drawable stretched over the new bounds — the tremble itself + * (#576). The Metal and ANGLE hosts record presents; the gate covers + * macOS and Windows. + */ + private class PresentLagProbe( + private val window: TaoWindow, + private val recording: AtomicBoolean, + private val minChecked: Int = MIN_ANIM_SAMPLES, + ) { + private val checked = AtomicInteger(0) + private val lagging = AtomicInteger(0) + private val previous = AtomicReference(null) + + fun onResized( + w: Int, + h: Int, + ) { + if (!recording.get()) return + val size = IntSize(w, h) + // tao echoes a programmatic resize twice in one turn (its own + // dispatch and AppKit's `windowDidResize:`); only a size change + // closes the previous step. + if (previous.get() == size) return + val prev = previous.getAndSet(size) ?: return + checked.incrementAndGet() + // The host presents inside the resize dispatch, before this + // listener runs, so the last present is normally already this + // size; the previous one is the most that may still be pending. + val presented = TaoPresentDiagnostics.lastPresentedPx(window.handle) + if (presented != size && presented != prev) lagging.incrementAndGet() + } + + fun assertNone() { + val last = previous.get() + if (last != null && TaoPresentDiagnostics.lastPresentedPx(window.handle) != last) lagging.incrementAndGet() + System.err.println("[#576] presentLag=${lagging.get()} of ${checked.get()} resize events") + check(checked.get() >= minChecked) { + "only ${checked.get()} resize events reached the window during the animation" + } + check(lagging.get() == 0) { + "${lagging.get()} of ${checked.get()} resize events had no frame at their size presented before " + + "the next one arrived — Core Animation stretches the previous drawable over the new bounds" + } + } + } + private fun writeSamples(samples: List): File { val path = System.getProperty("nucleus.issue576.samples") @@ -325,7 +457,15 @@ internal object AnimatedWindowSizeHeadfulCases { if (m.maxSceneVsInner > PX_TOLERANCE) { failures += "Compose scene height drifted from native inner size by ${m.maxSceneVsInner}px" } - if (m.maxSceneVsOuter > PX_TOLERANCE) { + // The outer gate catches 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 lags the + // scene by 2-3px for a couple of consecutive samples while the inner + // gate stays at 0px. That is reporting latency, not tremble, and only + // the outer query can see it. So an outer-only drift is a failure only + // when the scene also lost the inner size; otherwise it is logged + // through the metric line above. + if (m.maxSceneVsOuter > PX_TOLERANCE && m.maxSceneVsInner > PX_TOLERANCE) { failures += "Compose scene height drifted from native outer size by " + "${m.maxSceneVsOuter}px (chrome $chrome)" @@ -428,6 +568,13 @@ internal object AnimatedWindowSizeHeadfulCases { private const val START_HEIGHT_DP = 360 private const val END_HEIGHT_DP = 560 private const val ANIM_MILLIS = 500 + + // Past `animationResizeTime:` (~250 ms for a screen-sized zoom) with margin. + private const val ZOOM_SETTLE_MILLIS = 800L + + // Windows maximizes without a zoom animation: the probe sees the restore + // step close the maximize one, and nothing more. + private const val MIN_ZOOM_STEPS_INSTANT = 1 private const val BASELINE_MILLIS = 200L private const val SETTLE_AFTER_ANIM_MILLIS = 250L private const val CASE_TIMEOUT_MILLIS = 20_000L diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ClipboardHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ClipboardHeadfulCases.kt index 65e300760..3aea1ed13 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ClipboardHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ClipboardHeadfulCases.kt @@ -68,7 +68,7 @@ internal object ClipboardHeadfulCases { private fun gtkClipboardReadsForeignSelection(): TaoWindowTestCase = clipboardCase( name = "#582 GTK clipboard reads a selection owned by another process", - skip = { linuxWithNativeClipboard() ?: requireTool("wl-copy") }, + skip = { clipboardSkipReason("wl-copy") }, ) { focusWindow -> val text = "nucleus-582-foreign$PROBE_SUFFIX" publishExternally(text.toByteArray(), "text/plain;charset=utf-8") @@ -83,7 +83,7 @@ internal object ClipboardHeadfulCases { private fun gtkClipboardPublishesToTheDesktop(): TaoWindowTestCase = clipboardCase( name = "#582 GTK clipboard publishes the app's selection to the desktop", - skip = { linuxWithNativeClipboard() ?: requireTool("wl-paste") }, + skip = { clipboardSkipReason("wl-paste") }, ) { focusWindow -> focusWindow() @@ -111,7 +111,7 @@ internal object ClipboardHeadfulCases { private fun gtkClipboardReadsForeignImage(): TaoWindowTestCase = clipboardCase( name = "#582 GTK clipboard reads an image published by another process", - skip = { linuxWithNativeClipboard() ?: requireTool("wl-copy") }, + skip = { clipboardSkipReason("wl-copy") }, ) { focusWindow -> publishExternally(probePng(), "image/png") focusWindow() @@ -126,7 +126,7 @@ internal object ClipboardHeadfulCases { private fun gtkClipboardPublishesAnImage(): TaoWindowTestCase = clipboardCase( name = "#582 GTK clipboard publishes an image to the desktop", - skip = { linuxWithNativeClipboard() ?: requireTool("wl-paste") }, + skip = { clipboardSkipReason("wl-paste") }, ) { focusWindow -> focusWindow() @@ -148,7 +148,7 @@ internal object ClipboardHeadfulCases { private fun gtkClipboardRoundTripsAFileList(): TaoWindowTestCase = clipboardCase( name = "#582 GTK clipboard round-trips a file list", - skip = { linuxWithNativeClipboard() ?: requireTool("wl-copy") }, + skip = { clipboardSkipReason("wl-copy") }, ) { focusWindow -> val file = withContext(Dispatchers.IO) { File.createTempFile("nucleus-582-", ".txt") } file.deleteOnExit() @@ -332,6 +332,27 @@ internal object ClipboardHeadfulCases { } }.getOrNull() + /** + * Why this case cannot run here: no GTK clipboard, no [tool], or a peer + * that would not share a selection with the app. + * + * `wl-copy` / `wl-paste` own the *Wayland* selection, so they only speak + * to the app when the app is on Wayland too. Forcing the window backend + * onto XWayland (`NUCLEUS_TAO_LINUX_RENDERER=x11`) inside a Wayland + * session — which is how the X11 leg is run on a developer machine — + * leaves the two on different selections, and the case can only time out. + * The environment is read rather than the window's own surface kind: the + * skip is evaluated before any window exists. + */ + private fun clipboardSkipReason(tool: String): String? { + linuxWithNativeClipboard()?.let { return it } + val forcedX11 = System.getenv("NUCLEUS_TAO_LINUX_RENDERER").orEmpty().equals("x11", ignoreCase = true) + if (forcedX11 && !System.getenv("WAYLAND_DISPLAY").isNullOrBlank()) { + return "app forced onto XWayland: $tool owns the Wayland selection" + } + return requireTool(tool) + } + /** Runs at case-selection time, outside any coroutine, so it stays blocking. */ private fun requireTool(name: String): String? = if (runProcess("which", name)?.isNotEmpty() != true) "$name not installed" else null diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt new file mode 100644 index 000000000..b1d5fb1fd --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DialogAppearanceHeadfulCases.kt @@ -0,0 +1,664 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.layer.drawLayer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import java.awt.Rectangle +import java.awt.Robot +import java.awt.image.BufferedImage +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Measures the appearance of a Compose `Dialog` as the user sees it — pixels + * grabbed from the screen while it opens — once drawn in the window's own + * scene and once as a native popup layer, and compares the two. + * + * `Dialog.skiko.kt` animates a dialog in over 200 ms: the scrim fades in, the + * content fades from 20 % alpha, scales up from 95 % and slides up 10 dp. + * Nothing in the layer API says so; a native layer only sees `scrimColor` + * writes and a `boundsInWindow`. The only way to know that a real OS surface + * reproduces the in-scene look is to film both and compare the curves: when + * the dialog first shows, how far it slides, how long the scrim and the + * content take to settle. + * + * The three cases run in order and share [Sample]s through [measured]; the + * first two film, the third compares and prints both curves side by side so a + * difference can be read off the log. + */ +internal object DialogAppearanceHeadfulCases { + fun all(): List = + listOf( + film(native = false), + film(native = true), + compare(), + film(native = false, material = true), + film(native = true, material = true), + compare(material = true), + translated(native = false), + translated(native = true), + compareTranslated(), + ) + + /** One screen grab: [tMs] after the dialog was shown. */ + internal class Sample( + val tMs: Long, + /** Red channel of the white background under the scrim (255 = no scrim). */ + val scrimRed: Int, + /** Top and bottom of the dialog's colour on the centre column, or null when not visible. */ + val dialogTop: Int?, + val dialogBottom: Int?, + /** Blue minus red at the dialog's centre; grows as the dialog fades in. */ + val blueness: Int, + ) + + internal class Curve( + val all: List, + /** When the dialog was asked to close; samples from here on film the disappearance. */ + val hideAtMs: Long, + ) { + /** The appearance: from the show request until the hide request. */ + val samples: List get() = all.filter { it.tMs < hideAtMs } + + /** The disappearance: from the hide request on. */ + val hiding: List get() = all.filter { it.tMs >= hideAtMs } + val visible: List get() = samples.filter { it.dialogTop != null } + + /** First moment after the hide request where the dialog started to change. */ + val hideStartMs: Long? + get() { + val rest = hiding.firstOrNull() ?: return null + return hiding + .firstOrNull { + it.dialogTop != rest.dialogTop || + it.blueness != rest.blueness || + it.scrimRed != rest.scrimRed + }?.tMs + ?.minus(hideAtMs) + } + + /** + * The smallest height the dialog's colour spanned while fading out, + * as a fraction of its resting height. `Dialog.skiko.kt` reports a + * zero-size `boundsInWindow` during the fade-out; a native surface that + * followed it shrank the dialog to a square of margin around a point. + * + * Read over *two consecutive frames*, not one. The collapse this guards + * against lasts the whole fade — it is where the surface now is — while + * a lone short frame is a drawable caught mid-present, which a separate + * OS surface can show and a scene drawing into the window canvas never + * can. Filming a fade-out on a real compositor turns up one such frame + * often enough (measured: heights of 1 px and of half the dialog, in + * runs whose neighbouring frames were both full height) that the strict + * minimum reports the compositor rather than the layer. + */ + val hideMinHeightRatio: Float? + get() { + val rest = visible.lastOrNull() ?: return null + val restHeight = (rest.dialogBottom!! - rest.dialogTop!!).coerceAtLeast(1) + val heights = + hiding + .filter { it.dialogTop != null && it.dialogBottom != null } + .map { it.dialogBottom!! - it.dialogTop!! } + if (heights.isEmpty()) return null + val sustained = + if (heights.size == 1) heights.first() else heights.zipWithNext(::maxOf).min() + return sustained.toFloat() / restHeight + } + + /** First moment after the hide request where the dialog was gone. */ + val hideGoneMs: Long? get() = hiding.firstOrNull { it.dialogTop == null }?.tMs?.minus(hideAtMs) + + /** + * Grabs during an animation that show exactly the frame before them. + * The screen is grabbed faster than the display refreshes, so a few + * repeats are normal; many more than the in-scene layer shows means + * frames were dropped. + */ + fun stalls(phase: List): Int = + phase + .zipWithNext() + .count { (a, b) -> + a.dialogTop == b.dialogTop && + a.dialogBottom == b.dialogBottom && + a.blueness == b.blueness && + a.scrimRed == b.scrimRed + } + + val showStalls: Int + get() { + val end = settledMs ?: return 0 + return stalls(visible.filter { it.tMs <= end }) + } + + val hideStalls: Int + get() { + val start = hideStartMs ?: return 0 + val end = hideGoneMs ?: return 0 + return stalls(hiding.filter { it.tMs - hideAtMs in start..end }) + } + + /** + * Frames from half-way through the fade-in on, which is where the + * appearance can be compared between the two layers. + * + * [visible] begins at the knife-edge of the colour probe: the dialog + * fades in over the scrim, so its first frames are detected or not + * depending on where the sampling clock lands against + * [DIALOG_DETECT_THRESHOLD]. Measured on both layers, that first frame + * is bimodal — 0 ms on the runs that caught the faint start, ~60 ms on + * the runs that did not — and every metric anchored on it inherits the + * split, so the two films disagree whenever they land in different + * modes. Half the settled blueness is far from that edge and names the + * same moment of the same animation on either layer. + */ + private val fadedIn: List get() = visible.filter { it.blueness * 2 >= finalBlueness } + + val firstVisibleMs: Long? get() = fadedIn.firstOrNull()?.tMs + val finalTop: Int? get() = visible.lastOrNull()?.dialogTop + val finalBlueness: Int get() = visible.lastOrNull()?.blueness ?: 0 + val finalScrimRed: Int get() = samples.lastOrNull()?.scrimRed ?: WHITE + + /** How far below its resting place the dialog was half-way in, in logical px. */ + val slideInPx: Int? + get() { + val first = fadedIn.firstOrNull()?.dialogTop ?: return null + val last = finalTop ?: return null + return first - last + } + + /** How long the appearance animated on screen, from its first frame to its last change. */ + val animationMs: Long? + get() { + val first = firstVisibleMs ?: return null + val end = settledMs ?: return null + return end - first + } + + /** First moment after which position, content alpha and scrim all stay at their final values. */ + val settledMs: Long? + get() { + val top = finalTop ?: return null + val settled = + visible.takeLastWhile { + abs(it.dialogTop!! - top) <= SETTLE_PX && + abs(it.blueness - finalBlueness) <= SETTLE_COLOR && + abs(it.scrimRed - finalScrimRed) <= SETTLE_COLOR + } + return settled.firstOrNull()?.tMs + } + + /** How much darker the scrim got between the dialog's first frame and the end. */ + val scrimRamp: Int + get() { + val first = visible.firstOrNull()?.scrimRed ?: return 0 + return first - finalScrimRed + } + + fun table(): String = + buildString { + appendLine(" t(ms) scrimR top bottom blueness (hide requested at ${hideAtMs}ms)") + for (s in all) { + appendLine( + " %5d %6d %4s %6s %8d".format( + s.tMs, + s.scrimRed, + s.dialogTop?.toString() ?: "-", + s.dialogBottom?.toString() ?: "-", + s.blueness, + ), + ) + } + } + + fun summary(): String = + "show: firstVisible=${firstVisibleMs}ms settled=${settledMs}ms animated=${animationMs}ms " + + "slideIn=${slideInPx}px " + + "scrimRamp=$scrimRamp finalScrimRed=$finalScrimRed finalBlueness=$finalBlueness " + + "stalls=$showStalls | hide: start=${hideStartMs}ms gone=${hideGoneMs}ms " + + "minHeight=${hideMinHeightRatio?.let { "%.2f".format(it) }} stalls=$hideStalls" + } + + /** Keyed by (material, native). */ + private val measured = HashMap, Curve>() + private val measuredTranslated = HashMap() + private val dialogShown = mutableStateOf(false) + private val translatedShown = mutableStateOf(false) + + @Composable + private fun Content() { + // Enough text under the dialog for the owner window's frame to cost + // something: a scrim fade re-presents the owner every frame, and a + // trivial scene would hide a cadence problem a real app shows. + androidx.compose.foundation.layout.Column(Modifier.fillMaxSize().background(Color.White)) { + repeat(HEAVY_ROWS) { row -> + androidx.compose.material.Text( + text = "Row $row - " + "lorem ipsum dolor sit amet ".repeat(HEAVY_REPEATS), + color = Color.DarkGray, + maxLines = 1, + ) + } + } + val shown by dialogShown + if (shown) { + Dialog(onDismissRequest = { }) { + Box(Modifier.size(DIALOG_W_DP.dp, DIALOG_H_DP.dp).background(DIALOG_COLOR)) + } + } + } + + /** + * The dialog nucleus-demo's Containment gallery opens: a Material 3 + * `AlertDialog` — `Surface` with shape, tonal and shadow elevation, title, + * body text and two text buttons — under a Material 3 theme. The container + * is painted [DIALOG_COLOR] so the sampler finds it the same way. + */ + @Composable + private fun MaterialContent() { + androidx.compose.material3.MaterialTheme { + androidx.compose.foundation.layout.Column(Modifier.fillMaxSize().background(Color.White)) { + repeat(HEAVY_ROWS) { row -> + androidx.compose.material3.Text( + text = "Row $row - " + "lorem ipsum dolor sit amet ".repeat(HEAVY_REPEATS), + color = Color.DarkGray, + maxLines = 1, + ) + } + } + val shown by dialogShown + if (shown) { + androidx.compose.material3.AlertDialog( + onDismissRequest = { }, + containerColor = DIALOG_COLOR, + titleContentColor = Color.White, + textContentColor = Color.White, + title = { androidx.compose.material3.Text("What is a dialog?") }, + text = { + androidx.compose.material3.Text( + "A dialog is a type of modal window that appears in front of app content " + + "to provide critical information, or prompt for a decision to be made.", + ) + }, + confirmButton = { + androidx.compose.material3.TextButton(onClick = { }) { androidx.compose.material3.Text("Okay") } + }, + dismissButton = { + androidx.compose.material3.TextButton( + onClick = { }, + ) { androidx.compose.material3.Text("Dismiss") } + }, + ) + } + } + } + + /** A popup whose content is moved by a plain graphicsLayer translation, no animation. */ + @Composable + private fun TranslatedContent() { + Box(Modifier.fillMaxSize().background(Color.White)) + val shown by translatedShown + // Exactly what Dialog.skiko.kt does: a GraphicsLayer created from the + // *owner window's* GraphicsContext, recorded and drawn inside the layer. + // Supported across contexts: a skiko RenderNode records a picture and + // replays it (alpha through saveLayer) on whatever canvas draws it — + // no GPU resource of the owner's DirectContext is touched inside the + // popup's. #658's "hang" in the native variant was the case's own + // screen capture, not this layer. + val graphicsContext = androidx.compose.ui.platform.LocalGraphicsContext.current + val layer = androidx.compose.runtime.remember { graphicsContext.createGraphicsLayer() } + if (shown) { + androidx.compose.ui.window.Popup(alignment = androidx.compose.ui.Alignment.Center) { + Box( + Modifier + .size(DIALOG_W_DP.dp, DIALOG_H_DP.dp) + .drawWithContent { + layer.record { this@drawWithContent.drawContent() } + layer.translationY = STATIC_TRANSLATION_PX + layer.scaleX = 0.95f + layer.scaleY = 0.95f + // Half-transparent like a dialog mid-appearance: alpha + // switches the GraphicsLayer to its saveLayer path. + layer.alpha = 0.5f + drawLayer(layer) + }.background(DIALOG_COLOR), + ) + } + } + } + + private fun translated(native: Boolean): TaoWindowTestCase = + TaoWindowTestCase( + name = "graphicsLayer translation filmed — ${if (native) "native popup layer" else "in-scene layer"}", + skip = ::skipReason, + nativePopupLayers = native, + content = { TranslatedContent() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + window.setAlwaysOnTop(true) + window.focus() + settle(SETTLE_BEFORE_MILLIS) + val rect = requireNotNull(bounds()) { "window not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val region = + Rectangle( + (rect[0] / scale).roundToInt(), + (rect[1] / scale).roundToInt(), + (rect[2] / scale).roundToInt(), + (rect[3] / scale).roundToInt(), + ) + translatedShown.value = true + try { + settle(SETTLE_BEFORE_MILLIS) + // Off the loop thread, like the film cases' grabber: on Linux a + // capture from the Tao thread deadlocks on GDK's global lock + // (#658, see HeadfulRobot) — the case then never returns and + // the global watchdog takes the whole suite down with it. + val img = + requireNotNull(HeadfulRobot.capture(region)) { + "screen capture unavailable: ${HeadfulRobot.unavailableReason}" + } + val s = sample(0, img) + measuredTranslated[native] = s + System.err.println( + "[dialog-appearance] translated ${if (native) "native" else "in-scene"}: " + + "top=${s.dialogTop} bottom=${s.dialogBottom} blueness=${s.blueness}", + ) + check(s.dialogTop != null) { "the translated popup never showed up on screen" } + } finally { + translatedShown.value = false + } + } + + private fun compareTranslated(): TaoWindowTestCase = + TaoWindowTestCase( + name = "graphicsLayer translation — native popup layer lands where the in-scene one does", + skip = { skipReason() ?: if (measuredTranslated.size < 2) "both filming cases must run first" else null }, + content = { TranslatedContent() }, + ) { + val a = requireNotNull(measuredTranslated[false]) + val b = requireNotNull(measuredTranslated[true]) + check( + abs(a.dialogTop!! - b.dialogTop!!) <= SLIDE_TOLERANCE_PX && + abs(a.dialogBottom!! - b.dialogBottom!!) <= SLIDE_TOLERANCE_PX, + ) { + "translated content lands elsewhere in a native layer: " + + "in-scene top=${a.dialogTop} bottom=${a.dialogBottom} " + + "native top=${b.dialogTop} bottom=${b.dialogBottom}" + } + } + + private fun film( + native: Boolean, + material: Boolean = false, + ): TaoWindowTestCase = + TaoWindowTestCase( + name = + "${if (material) "Material 3 AlertDialog" else "dialog"} appearance filmed — " + + "${if (native) "native popup layer" else "in-scene layer"}", + skip = ::skipReason, + nativePopupLayers = native, + paintDefaultBackground = false, + content = { if (material) MaterialContent() else Content() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + // The screen grab sees whatever is on top; the suite's window is not. + window.setAlwaysOnTop(true) + window.focus() + settle(SETTLE_BEFORE_MILLIS) + val rect = requireNotNull(bounds()) { "window not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + // Robot speaks logical screen points; the window reports physical px. + val region = + Rectangle( + (rect[0] / scale).roundToInt(), + (rect[1] / scale).roundToInt(), + (rect[2] / scale).roundToInt(), + (rect[3] / scale).roundToInt(), + ) + val robot = Robot() + val frames = java.util.Collections.synchronizedList(mutableListOf>()) + val capturing = + java.util.concurrent.atomic + .AtomicBoolean(true) + // Warm-up: the first composition of a dialog loads fonts and theme + // tokens; that would be filmed as a slow appearance. It runs BEFORE + // the grabber starts — the film is a fixed budget of frames, and a + // host that captures faster than the warm-up lasts would spend the + // whole budget on it and leave the curve with nothing after + // `shownNs`, which reads as "the dialog never showed up on screen". + dialogShown.value = true + settle(SETTLE_BEFORE_MILLIS) + dialogShown.value = false + settle(SETTLE_BEFORE_MILLIS) + settle(WARMUP_MILLIS) + // One capture session per half, each with its own frame budget. A + // single session would spend the whole budget on the appearance — + // grabbing is much faster than the film lasts — and leave the + // disappearance with no frames, which reads as "the dialog never + // went away" in the comparison. + var grabber: Thread? = null + + fun startFilm() { + capturing.set(true) + val from = frames.size + grabber = + kotlin.concurrent.thread(name = "dialog-appearance-capture") { + while (capturing.get() && frames.size - from < MAX_FRAMES_PER_HALF) { + frames += System.nanoTime() to robot.createScreenCapture(region) + } + } + } + + fun stopFilm() { + capturing.set(false) + grabber?.join() + grabber = null + } + startFilm() + val shownNs = System.nanoTime() + dialogShown.value = true + var hiddenNs = Long.MAX_VALUE + try { + settle(FILM_MILLIS) + stopFilm() + hiddenNs = System.nanoTime() + dialogShown.value = false + startFilm() + settle(HIDE_FILM_MILLIS) + } finally { + stopFilm() + dialogShown.value = false + } + settle(SETTLE_BEFORE_MILLIS) + val curve = + Curve( + frames + .filter { (ns, _) -> ns >= shownNs } + .map { (ns, img) -> sample((ns - shownNs) / 1_000_000, img) }, + hideAtMs = (hiddenNs - shownNs) / 1_000_000, + ) + measured[material to native] = curve + val mode = (if (material) "m3-" else "") + if (native) "native" else "in-scene" + // Keep the first and last grabbed frames on disk: when a curve reads + // wrong, the pictures say whether the region or the dialog is off. + val dir = java.io.File(System.getProperty("java.io.tmpdir"), "dialog-appearance").apply { mkdirs() } + frames.firstOrNull()?.let { + javax.imageio.ImageIO.write( + it.second, + "png", + java.io.File(dir, "$mode-first.png"), + ) + } + frames.lastOrNull()?.let { + javax.imageio.ImageIO.write( + it.second, + "png", + java.io.File(dir, "$mode-last.png"), + ) + } + if (System.getProperty("nucleus.dialog.appearance.dump") == "true") { + for ((ns, img) in frames) { + val t = (ns - shownNs) / 1_000_000 + if (t in + 0..DUMP_UNTIL_MS + ) { + javax.imageio.ImageIO.write(img, "png", java.io.File(dir, "$mode-t%03d.png".format(t))) + } + } + } + val screen = + java.awt.GraphicsEnvironment + .getLocalGraphicsEnvironment() + .defaultScreenDevice.defaultConfiguration + System.err.println( + "[dialog-appearance] $mode: window=${rect.toList()} scale=$scale region=$region " + + "awtScreen=${screen.bounds} awtTransform=${screen.defaultTransform.scaleX} " + + "frames=${frames.size} dump=$dir", + ) + System.err.println("[dialog-appearance] $mode: ${curve.summary()}") + System.err.print(curve.table()) + check(curve.firstVisibleMs != null) { "the dialog never showed up on screen; ${curve.summary()}" } + } + + private fun compare(material: Boolean = false): TaoWindowTestCase = + TaoWindowTestCase( + name = + "${if (material) "Material 3 AlertDialog" else "dialog"} appearance — " + + "native popup layer matches the in-scene layer", + skip = { + skipReason() + ?: if (measured[material to false] == null || measured[material to true] == null) { + "both filming cases must run first" + } else { + null + } + }, + content = { Content() }, + ) { + val inScene = requireNotNull(measured[material to false]) + val native = requireNotNull(measured[material to true]) + System.err.println("[dialog-appearance] in-scene: ${inScene.summary()}") + System.err.println("[dialog-appearance] native: ${native.summary()}") + val problems = mutableListOf() + + fun near( + what: String, + a: Number?, + b: Number?, + tolerance: Number, + ) { + if (a == null || b == null) { + problems += "$what: in-scene=$a native=$b" + } else if (abs(a.toDouble() - b.toDouble()) > tolerance.toDouble()) { + problems += "$what: in-scene=$a native=$b (tolerance $tolerance)" + } + } + // One-sided: the native layer shows its first frame sooner (its + // surface presents without waiting for the owner's frame); later + // than the in-scene layer would be a regression. + val inSceneFirst = inScene.firstVisibleMs + val nativeFirst = native.firstVisibleMs + if (inSceneFirst == null || + nativeFirst == null || + nativeFirst > inSceneFirst + FIRST_VISIBLE_TOLERANCE_MS + ) { + problems += + "first visible (ms): in-scene=$inSceneFirst native=$nativeFirst (tolerance $FIRST_VISIBLE_TOLERANCE_MS)" + } + near("appearance duration (ms)", inScene.animationMs, native.animationMs, SETTLE_TOLERANCE_MS) + near("slide-in (px)", inScene.slideInPx, native.slideInPx, SLIDE_TOLERANCE_PX) + near("scrim ramp", inScene.scrimRamp, native.scrimRamp, COLOR_TOLERANCE) + near("final scrim", inScene.finalScrimRed, native.finalScrimRed, COLOR_TOLERANCE) + near("final content", inScene.finalBlueness, native.finalBlueness, COLOR_TOLERANCE) + near("hide start (ms)", inScene.hideStartMs, native.hideStartMs, FIRST_VISIBLE_TOLERANCE_MS) + near("hide gone (ms)", inScene.hideGoneMs, native.hideGoneMs, SETTLE_TOLERANCE_MS) + near("hide min height ratio", inScene.hideMinHeightRatio, native.hideMinHeightRatio, HEIGHT_RATIO_TOLERANCE) + if (native.showStalls > inScene.showStalls + STALL_TOLERANCE) { + problems += + "appearance drops frames: in-scene stalls=${inScene.showStalls} native stalls=${native.showStalls}" + } + if (native.hideStalls > inScene.hideStalls + STALL_TOLERANCE) { + problems += + "disappearance drops frames: in-scene stalls=${inScene.hideStalls} native stalls=${native.hideStalls}" + } + check(problems.isEmpty()) { + "the native popup layer's dialog does not appear like the in-scene one:\n " + + problems.joinToString("\n ") + } + } + + /** Reads one grabbed frame; coordinates are logical px inside the window's outer rect. */ + private fun sample( + tMs: Long, + img: BufferedImage, + ): Sample { + val w = img.width + val h = img.height + val scrim = img.getRGB(SCRIM_PROBE_INSET, h - SCRIM_PROBE_INSET) + val x = w / 2 + var top: Int? = null + var bottom: Int? = null + for (y in 0 until h) { + if (isDialogColor(img.getRGB(x, y))) { + if (top == null) top = y + bottom = y + } + } + val blueness = + if (top != null && bottom != null) { + val c = img.getRGB(x, (top + bottom) / 2) + blue(c) - red(c) + } else { + 0 + } + return Sample(tMs, red(scrim), top, bottom, blueness) + } + + /** Anything the dialog's blue could look like while fading in over the scrimmed white. */ + private fun isDialogColor(argb: Int): Boolean = blue(argb) - red(argb) > DIALOG_DETECT_THRESHOLD + + private fun red(argb: Int): Int = (argb shr 16) and 0xFF + + private fun blue(argb: Int): Int = argb and 0xFF + + private fun skipReason(): String? = + if (java.awt.GraphicsEnvironment.isHeadless()) "no display for Robot capture" else null + + private val DIALOG_COLOR = Color(0xFF1030C0) + private const val DIALOG_W_DP = 320 + private const val DIALOG_H_DP = 220 + private const val STATIC_TRANSLATION_PX = 40f + private const val WHITE = 255 + private const val SCRIM_PROBE_INSET = 16 + private const val DIALOG_DETECT_THRESHOLD = 40 + private const val SETTLE_BEFORE_MILLIS = 600L + private const val WARMUP_MILLIS = 200L + private const val FILM_MILLIS = 700L + private const val HIDE_FILM_MILLIS = 500L + private const val HEAVY_ROWS = 40 + private const val HEAVY_REPEATS = 6 + private const val STALL_TOLERANCE = 3 + private const val HEIGHT_RATIO_TOLERANCE = 0.15f + private const val MAX_FRAMES = 200 + + /** Per-half budget; the two halves together stay within [MAX_FRAMES]. */ + private const val MAX_FRAMES_PER_HALF = MAX_FRAMES / 2 + + private const val DUMP_UNTIL_MS = 1_300L + private const val SETTLE_PX = 1 + private const val SETTLE_COLOR = 6 + private const val FIRST_VISIBLE_TOLERANCE_MS = 50L + private const val SETTLE_TOLERANCE_MS = 80L + private const val SLIDE_TOLERANCE_PX = 4 + private const val COLOR_TOLERANCE = 20 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt new file mode 100644 index 000000000..651661e6d --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutFixture.kt @@ -0,0 +1,340 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.DefaultDockSideOrder +import dev.nucleusframework.window.tao.DefaultDockSplitter +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockSplitterScope +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.Satellite +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteScope +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.math.abs + +/** One satellite the fixture declares: its id and where it starts. */ +internal class DockPanelSpec( + val id: String, + val placement: SatellitePlacement, + val open: Boolean = true, + val dockSides: Set = DockSide.entries.toSet(), + val floatable: Boolean = true, + val reorderable: Boolean = true, +) + +/** + * A `DockLayout` under observation: every panel body, every splitter and the + * content publish their window-px bounds, their layout direction and how many + * times they were built, so a case can assert on geometry the way a user sees + * it and on composition identity the way a `remember` experiences it. + * + * The layout's shape — side order, layered sides, direction — is state, so a + * case can change it mid-run and check what survived. + */ +internal class DockLayoutFixture( + val specs: List, + sideOrder: List = DefaultDockSideOrder, + layeredSides: Set = emptySet(), + direction: LayoutDirection = LayoutDirection.Ltr, + /** Draw the splitter as a 1 dp line whose grip is a wider, overflowing box. */ + val gripOverflow: Boolean = false, +) { + val workspace = SatelliteWorkspace() + val sideOrder = mutableStateOf(sideOrder) + val layeredSides = mutableStateOf(layeredSides) + val direction = mutableStateOf(direction) + + /** Bounds of each panel's `panel` slot (header and body), in host window px. */ + val panelBounds = mutableStateOf>(emptyMap()) + + /** Bounds of each docked panel's body, in host window px. */ + val bodyBounds = mutableStateOf>(emptyMap()) + + /** Bounds of each splitter grip, keyed by [splitterKey], in host window px. */ + val splitterBounds = mutableStateOf>(emptyMap()) + + /** Bounds of the layout's content slot, in host window px. */ + val contentBounds = mutableStateOf(null) + val contentDirection = mutableStateOf(null) + val bodyDirections = mutableStateOf>(emptyMap()) + + /** What each satellite's chrome was told about its window: `isCompositorPlaced`, per host kind. */ + val compositorPlacedDocked = mutableStateOf>(emptyMap()) + val compositorPlacedFloating = mutableStateOf>(emptyMap()) + + /** Bounds of each satellite's `floatingCaption` slot, in its own window px; absent while not composed. */ + val captionBounds = mutableStateOf>(emptyMap()) + + /** The floating window of each satellite while it floats. */ + val floatingWindows = mutableStateOf>(emptyMap()) + + /** How many times each satellite's body was built, and how many are live right now. */ + val incarnations = mutableStateOf>(emptyMap()) + val liveBodies = mutableStateOf>(emptyMap()) + val contentIncarnations = mutableIntStateOf(0) + + private var nextMarker = 0 + + fun incarnationsOf(id: String): Int = incarnations.value[id] ?: 0 + + fun liveBodiesOf(id: String): Int = liveBodies.value[id] ?: 0 + + /** The `splitterBounds` key of a splitter: the panel it resizes, or the side it drags. */ + fun splitterKey(scope: DockSplitterScope): String = scope.panel?.let { "panel:${it.id}" } ?: "side:${scope.side}" + + fun splitterOf(id: String): Rect? = splitterBounds.value["panel:$id"] + + fun sideSplitterOf(side: DockSide): Rect? = splitterBounds.value["side:$side"] + + /** Window content: join the workspace, host the dock around a plain body. */ + @Composable + fun Body() { + JoinSatelliteWorkspace(workspace) + CompositionLocalProvider(LocalLayoutDirection provides direction.value) { + DockLayout( + workspace = workspace, + modifier = Modifier.fillMaxSize(), + sideOrder = sideOrder.value, + layeredSides = layeredSides.value, + splitter = { Splitter(this) }, + panel = { body -> + val id = satellite.id + DisposableEffect(id) { + onDispose { panelBounds.value = panelBounds.value - id } + } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { + panelBounds.value = panelBounds.value + (id to it.boundsInWindow()) + }, + ) { body() } + }, + ) { + remember { contentIncarnations.value++ } + val here = LocalLayoutDirection.current + SideEffect { contentDirection.value = here } + Box( + Modifier + .fillMaxSize() + .background(Color.DarkGray) + .onGloballyPositioned { contentBounds.value = it.boundsInWindow() }, + ) + } + } + } + + @Composable + private fun Splitter(scope: DockSplitterScope) { + val key = splitterKey(scope) + DisposableEffect(key) { + onDispose { splitterBounds.value = splitterBounds.value - key } + } + val record = + Modifier.onGloballyPositioned { + splitterBounds.value = + splitterBounds.value + (key to it.boundsInWindow()) + } + with(scope) { + if (gripOverflow) { + val horizontal = orientation == Orientation.Horizontal + val line = + if (horizontal) { + Modifier.fillMaxHeight().width( + 1.dp, + ) + } else { + Modifier.fillMaxWidth().height(1.dp) + } + Box(line.background(Color.Red), contentAlignment = Alignment.Center) { + val grip = + if (horizontal) { + Modifier.requiredWidth(GRIP_OVERFLOW_DP.dp).fillMaxHeight() + } else { + Modifier.requiredHeight(GRIP_OVERFLOW_DP.dp).fillMaxWidth() + } + Box(grip.then(record).dockSplitterHandle()) + } + } else { + Box(record) { DefaultDockSplitter() } + } + } + } + + @Composable + fun ApplicationScope.Satellites() { + for (spec in specs) { + key(spec.id) { + Satellite( + workspace = workspace, + id = spec.id, + title = "Panel ${spec.id}", + initialPlacement = spec.placement, + initiallyOpen = spec.open, + floatingCaption = { + DisposableEffect(spec.id) { + onDispose { captionBounds.value = captionBounds.value - spec.id } + } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { + captionBounds.value = captionBounds.value + (spec.id to it.boundsInWindow()) + }, + ) + }, + dockSides = spec.dockSides, + floatable = spec.floatable, + reorderable = spec.reorderable, + ) { PanelBody(spec.id) } + } + } + } + + /** + * A body that tells the case whether it is the same one as before: the + * marker is a plain `remember`, so it survives exactly as long as the + * subtree does. + */ + @Composable + private fun SatelliteScope.PanelBody(id: String) { + val marker = remember { nextMarker++ } + val window = LocalTaoWindow.current + val docked = isDocked + val here = LocalLayoutDirection.current + val placed = isCompositorPlaced + SideEffect { + if (docked) { + compositorPlacedDocked.value = compositorPlacedDocked.value + (id to placed) + } else { + compositorPlacedFloating.value = compositorPlacedFloating.value + (id to placed) + } + bodyDirections.value = bodyDirections.value + (id to here) + if (!docked && window != null) floatingWindows.value = floatingWindows.value + (id to window) + } + DisposableEffect(marker) { + incarnations.value = incarnations.value + (id to (incarnations.value[id] ?: 0) + 1) + liveBodies.value = liveBodies.value + (id to (liveBodies.value[id] ?: 0) + 1) + onDispose { + liveBodies.value = liveBodies.value + (id to (liveBodies.value[id] ?: 0) - 1) + if (!docked && floatingWindows.value[id] === window) floatingWindows.value = floatingWindows.value - id + if (docked) bodyBounds.value = bodyBounds.value - id + } + } + Box( + Modifier + .fillMaxSize() + .background(PANEL_COLORS[abs(id.hashCode()) % PANEL_COLORS.size]) + .onGloballyPositioned { if (docked) bodyBounds.value = bodyBounds.value + (id to it.boundsInWindow()) }, + ) + } +} + +/** Waits until every satellite in [ids] has a docked body with a real size in the case window. */ +internal suspend fun TaoWindowTestScope.awaitDockedBodies( + fixture: DockLayoutFixture, + vararg ids: String, +) { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("panels ${ids.toList()} are docked with a size — have ${fixture.bodyBounds.value.keys}") { + ids.all { id -> + val rect = fixture.bodyBounds.value[id] + rect != null && rect.width > 0f && rect.height > 0f + } + } + awaitDockLayout(fixture.workspace, window) + settle() +} + +/** + * [awaitDockedBodies] without the screen half: waits for the bodies and for + * the layout's bounds *in the window*, which is all a native Wayland host can + * publish. + */ +internal suspend fun TaoWindowTestScope.awaitDockedBodiesInWindow( + fixture: DockLayoutFixture, + vararg ids: String, +) { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("panels ${ids.toList()} are docked with a size — have ${fixture.bodyBounds.value.keys}") { + ids.all { id -> + val rect = fixture.bodyBounds.value[id] + rect != null && rect.width > 0f && rect.height > 0f + } + } + awaitUntil("dock layout of the host is measured in its window") { + fixture.workspace + .dockHostGeometry(window) + ?.layoutBoundsInWindowPx + ?.isEmpty == false + } + settle() +} + +/** Screen position (physical px) of a point given in the case window's content coordinates. */ +internal fun TaoWindowTestScope.toScreen( + fixture: DockLayoutFixture, + inWindowPx: Offset, +): Offset { + val client = requireNotNull(fixture.workspace.dockHostGeometry(window)?.clientOriginPx()) { "no client origin" } + return client + inWindowPx +} + +/** `true` when [a] and [b] share any area beyond a rounding line. */ +internal fun overlaps( + a: Rect, + b: Rect, +): Boolean = + a.left < b.right - LAYOUT_TOLERANCE_PX && + b.left < a.right - LAYOUT_TOLERANCE_PX && + a.top < b.bottom - LAYOUT_TOLERANCE_PX && + b.top < a.bottom - LAYOUT_TOLERANCE_PX + +internal fun near( + a: Float, + b: Float, + tolerance: Float = LAYOUT_TOLERANCE_PX, +): Boolean = abs(a - b) <= tolerance + +/** The grip's width around the 1 dp line, in dp. */ +internal const val GRIP_OVERFLOW_DP = 7 + +private val PANEL_COLORS = + listOf( + Color(0xFF2D6CDF), + Color(0xFF7A5CD6), + Color(0xFF2E9E6B), + Color(0xFFD97B2B), + Color(0xFFC94C6A), + ) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt new file mode 100644 index 000000000..e04d336db --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutHeadfulCases.kt @@ -0,0 +1,1587 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DefaultDockSideOrder +import dev.nucleusframework.window.tao.DockPanelHeaderHeight +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.SatelliteDragOrigin +import dev.nucleusframework.window.tao.SatelliteDragSession +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.WorkspaceDragKind +import dev.nucleusframework.window.tao.hintedSides +import kotlin.math.abs + +/** + * Real-window coverage for the `DockLayout` arrangements: layered sides where + * every panel is a column of its own width, split sides where panels share a + * side by weight, the side order that decides who owns the corners, and the + * right-to-left layout that keeps its sides physical. + * + * 1. three panels on a layered right side sit side by side, each at its own + * width, with the default header strip sizing itself; + * 2. a layered panel's splitter, dragged with a real mouse, resizes that + * panel alone and the new extent lands in the snapshot; + * 3. two panels on a split side share it by weight, and the divider between + * them moves the weight from one to the other; + * 4. with the right side first in the order it runs the full height, the + * bottom panel stops at it and still runs under the left panel; + * 5. under a right-to-left direction the left side is the physical left, its + * splitter grows it rightwards, and the content and the panels see RTL; + * 6. no change of the layout — extents, weights, order, side, a restore, a + * new side order, a layered toggle, a direction flip — rebuilds a panel + * body or the content, and a floating satellite keeps its window through + * every restore; + * 7. a custom 1 dp splitter with a wider grip takes the drag aimed off the line; + * 8. a floating satellite dropped on a layered side becomes a layer of its + * window's width, next to the panel already there; + * 9. undocking a layer lifts the window off exactly where the layer was; + * 10. the drop preview follows the palette's own edge, not the pointer; + * 11. a layer floated and docked again without a rank comes back between the + * neighbours it left, on a layered and on a split side alike; + * 12. a layer dragged by its header over the outer half of the outermost + * layer previews the first rank and lands there, nothing rebuilt; + * 16. chrome is told how its window is placed, no caption strip is reserved + * where the app places its own windows, and a drag says how it is carried; + * 15. a fixed panel is never torn out — no ghost, no window, nothing + * rebuilt — nor displaced by a neighbour docking in front of it, while + * that neighbour is still torn out by the same gesture; + * 14. a palette declared for three sides is never offered the fourth: the + * top strip is neither hinted nor published, a release there leaves it + * floating, and a direct dock on that side is refused; + * 13. on a split side a panel dropped on its own rank stays, dropped on the + * first half of the first panel becomes the first, and the closed one in + * the middle keeps its rank. + * + * Every drag is a real mouse (AWT Robot) where the host can inject input, + * else the same change through the workspace — the geometry the layout then + * shows is asserted either way. Native Wayland is skipped as for every + * satellite case: no client-side screen placement to aim a pointer with. + */ +@Suppress("LargeClass") // one method per real-window case, by design +internal object DockLayoutHeadfulCases { + fun all(): List = + listOf( + layeredPanelsSitSideBySideWithTheirOwnWidths(), + aLayeredSplitterResizesItsPanelAlone(), + splitPanelsShareBySideWeightAndTheDividerMovesIt(), + theOuterSideOwnsTheCorners(), + rtlKeepsPhysicalSidesAndHandsTheDirectionBack(), + layoutChangesNeverRebuildAPanelOrTheContent(), + aOneDpSplitterWithAWiderGripTakesTheDrag(), + aDropOnALayeredSideAddsALayerOfTheWindowsWidth(), + undockingALayerLiftsTheWindowOffThePanel(), + thePaletteEdgeDecidesTheZoneNotThePointer(), + aPanelDockedAgainReturnsToTheRankItLeft(), + aLayerDraggedOverTheOutermostOneBecomesTheFirst(), + aSplitPanelDroppedOnItsStackTakesTheRankUnderThePointer(), + aPaletteIsNeverOfferedASideItWasNotDeclaredFor(), + aFixedPanelIsNeverTornOut(), + chromeIsToldHowTheWindowIsPlaced(), + ) + + // ── 16. what chrome is told about the two gestures ─────────────────── + + /** + * What chrome is told about the two gestures, where the app places its own + * windows: [SatelliteScope.isCompositorPlaced] is `false` for the panel and + * for the floating palette alike, the `floatingCaption` slot is not + * composed at all — the whole bar drags the satellite — and a pointer drag + * reports itself as [WorkspaceDragKind.Window] with a ghost to match. + * + * The other half of the contract, on a compositor-placed window, is + * `WaylandWorkspaceHeadfulCases`. + */ + private fun chromeIsToldHowTheWindowIsPlaced(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + ) + return TaoWindowTestCase( + name = "dock layout chrome is told the window places itself, and no caption strip is reserved", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE) + awaitUntil("the inspector floats") { + fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true + } + settle(SETTLE_AFTER_MAP_MILLIS) + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + + check(window.canPlaceOnScreen) { "the case window should place itself on this leg" } + check(floating.canPlaceOnScreen) { "the satellite window should place itself on this leg" } + check(fixture.compositorPlacedDocked.value[TREE] == false) { + "the panel was told the compositor places it: ${fixture.compositorPlacedDocked.value}" + } + check(fixture.compositorPlacedFloating.value[INSPECTOR] == false) { + "the palette was told the compositor places it: ${fixture.compositorPlacedFloating.value}" + } + check(fixture.captionBounds.value.isEmpty()) { + "a caption strip is reserved where nothing needs one: ${fixture.captionBounds.value}" + } + + // The drag reports how it is carried, and the ghost matches. + check(workspace.dragKind == null) { "a drag is reported before one starts" } + val outer = requireNotNull(floating.outerBoundsPx()) + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * floating.scaleFactor) + val palette = + requireNotNull(workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab)) + check(workspace.dragKind == WorkspaceDragKind.Window) { + "the palette's own window carries the drag, but the kind is ${workspace.dragKind}" + } + palette.cancel() + check(workspace.dragKind == null) { "the kind outlived the drag" } + + val treeBounds = panel(fixture, TREE) + val panelGrab = + toScreen( + fixture, + Offset( + treeBounds.center.x, + treeBounds.top + DockPanelHeaderHeight.value * window.scaleFactor / 2f, + ), + ) + val panelDrag = beginDockedDrag(workspace, TREE, panelGrab) + panelDrag.update(panelGrab + Offset(0f, PANEL_DRAG_STEP_PX)) + check(workspace.dragKind == WorkspaceDragKind.Window) { "the torn-out panel's ghost is a window" } + check(workspace.dragGhost?.satellite?.id == TREE) { "no ghost for a window-carried drag" } + panelDrag.cancel() + check(workspace.dragGhost == null && workspace.dragKind == null) { "feedback left behind" } + }, + ) + } + + // ── 15. a fixed panel ──────────────────────────────────────────────── + + /** + * A fixed panel ([floatable] `false`): dragged into the middle of the + * content and released, it is still the panel it was — no ghost followed + * the pointer, no window appeared, its subtree was never rebuilt — while + * the panel next to it, an ordinary one, is torn out by the same gesture. + */ + private fun aFixedPanelIsNeverTornOut(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec( + TREE, + SatellitePlacement.Docked(DockSide.Right, order = 0, extent = TREE_W_DP.dp), + dockSides = setOf(DockSide.Right), + floatable = false, + reorderable = false, + ), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Right, order = 1, extent = TOC_W_DP.dp)), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a fixed panel is never torn out nor displaced, its neighbour still is", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE, TOC) + val scale = window.scaleFactor + val layout = awaitDockLayout(workspace, window) + val client = requireNotNull(workspace.dockHostGeometry(window)?.clientOriginPx()) + val treeBefore = panel(fixture, TREE) + val tree = requireNotNull(workspace.satellite(TREE)) + check(!tree.isFloatable) { "the fixture did not declare the tree fixed" } + + // Into the middle of the content — a tear-out for any other panel. + val grab = + toScreen( + fixture, + Offset(treeBefore.center.x, treeBefore.top + DockPanelHeaderHeight.value * scale / 2f), + ) + // Deep in the content: clear of the left strip and well clear + // of the right side's ranks, which reach in behind its layers. + val middle = Offset(layout.left + CONTENT_AIM_DP * scale, layout.center.y) + // No wait for zones here: a pinned panel fixed to one side is + // offered none, which is the first thing to check. + val session = + requireNotNull(workspace.beginDrag(TREE, SatelliteDragOrigin.DockedPanel(window), grab)) + settle() + check(workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx.isNullOrEmpty()) { + "a zone is offered to a panel that can go nowhere: " + + "${workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx}" + } + session.update(middle) + check(workspace.dragGhost == null) { "a fixed panel published a tear-out ghost" } + check(workspace.dockPreview == null) { "the content previewed a zone: ${workspace.dockPreview}" } + session.end(middle) + settle(SETTLE_AFTER_MAP_MILLIS) + check( + tree.placement is SatellitePlacement.Docked, + ) { "the fixed panel left the dock: ${tree.placement}" } + check(fixture.floatingWindows.value[TREE] == null) { "the fixed panel opened a window of its own" } + check(fixture.incarnationsOf(TREE) == 1) { "the refused tear-out rebuilt the panel" } + check(near(panel(fixture, TREE).width, treeBefore.width)) { "the fixed panel changed width" } + + // A direct undock is refused as well. + workspace.undock(TREE) + settle() + check(tree.placement is SatellitePlacement.Docked) { "undock() tore out a fixed panel" } + + // Its rank is pinned: nothing offers it another one, and the + // panel next to it cannot be dropped in front of it. + check(hintedSides(tree, window, workspace.satellites).isEmpty()) { + "a pinned panel with one side is offered somewhere to go: " + + "${hintedSides(tree, window, workspace.satellites)}" + } + workspace.dock(TOC, DockSide.Right, order = 0) + settle() + check((workspace.satellite(TOC)?.placement as SatellitePlacement.Docked).order == 1) { + "the neighbour took the pinned panel's rank: ${workspace.satellite(TOC)?.placement}" + } + check((tree.placement as SatellitePlacement.Docked).order == 0) { + "the pinned panel lost its rank: ${tree.placement}" + } + awaitDockedBodies(fixture, TREE, TOC) + check(near(panel(fixture, TREE).right, layoutInWindowRight(layout, client), LAYOUT_TOLERANCE_PX * 2)) { + "the pinned panel is not still the outermost layer: ${panel(fixture, TREE)}" + } + + // The ordinary neighbour is torn out by the very same gesture. + val tocBefore = panel(fixture, TOC) + // Grabbed near its left edge, so its ghost hangs to the right + // of the pointer and stays clear of the left strip: the + // release is a tear-out, not a dock on the left. + val tocGrab = + toScreen( + fixture, + Offset( + tocBefore.left + GRAB_EDGE_INSET_DP * scale, + tocBefore.top + DockPanelHeaderHeight.value * scale / 2f, + ), + ) + val tocSession = beginDockedDrag(workspace, TOC, tocGrab) + tocSession.update(middle) + check(workspace.dragGhost?.satellite?.id == TOC) { "no ghost for the ordinary panel" } + check(workspace.dockPreview == null) { + "the ordinary panel is over a zone, so the release would not tear it out: ${workspace.dockPreview}" + } + tocSession.end(middle) + awaitUntil("the toc floats") { fixture.floatingWindows.value[TOC]?.hasRealFramePx() == true } + check(near(panel(fixture, TREE).right, layoutInWindowRight(layout, client), LAYOUT_TOLERANCE_PX * 2)) { + "the fixed panel is not still at the edge: ${panel(fixture, TREE)}" + } + }, + ) + } + + // ── 14. dockSides ──────────────────────────────────────────────────── + + /** + * A palette declared for three sides only: the top is neither hinted nor + * published as a zone, a direct `dock(Top)` is refused, a release with the + * palette's top edge in the top strip leaves it floating — and the left + * side, which it *was* declared for, still takes it. + * + * Nothing else is docked, so the only thing that could light up is an + * edge of the layout itself. + */ + private fun aPaletteIsNeverOfferedASideItWasNotDeclaredFor(): TaoWindowTestCase { + val notTop = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom) + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + dockSides = notTop, + ), + ), + ) + return TaoWindowTestCase( + name = "dock layout a palette is never offered a side it was not declared for", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("the inspector floats") { + fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true + } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val inspector = requireNotNull(workspace.satellite(INSPECTOR)) + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val layout = awaitDockLayout(workspace, window) + val zonePx = SatelliteWorkspace.DockZoneWidth.value * window.scaleFactor + check( + hintedSides(inspector, window, workspace.satellites) == + listOf(DockSide.Left, DockSide.Right, DockSide.Bottom), + ) { "the top is offered: ${hintedSides(inspector, window, workspace.satellites)}" } + + // A direct dock on the top is refused outright. + workspace.dock(INSPECTOR, DockSide.Top) + settle() + check(inspector.placement is SatellitePlacement.Floating) { + "dock(Top) was not refused: ${inspector.placement}" + } + + // Read live: the first release moves the window, so the second + // grab has to be taken where the palette is by then. + fun grabNow(): Pair { + val frame = requireNotNull(floating.outerBoundsPx()) + val inset = Offset(frame[2] / 2f, HEADER_GRAB_Y_DP * floating.scaleFactor) + return Offset(frame[0].toFloat(), frame[1].toFloat()) + inset to inset + } + val outer = requireNotNull(floating.outerBoundsPx()) + val paletteSize = Size(outer[2].toFloat(), outer[3].toFloat()) + val (grab, grabInset) = grabNow() + + // Top edge inside the top strip, the palette clear of the + // three sides it *may* dock on, so the top is the only edge it + // has reached and a preview could only come from there. + val paletteTopLeft = Offset(layout.center.x - paletteSize.width / 2f, layout.top + EDGE_INSET_PX) + val atTop = paletteTopLeft + grabInset + val session = + requireNotNull(workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab)) + awaitUntil("the layout published its drop zones") { + workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx?.isNotEmpty() == true + } + val zones = requireNotNull(workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx) + check(!zones.containsKey(DockSide.Top)) { "the top zone is published: $zones" } + check( + paletteTopLeft.x - layout.left > zonePx && + layout.right - (paletteTopLeft.x + paletteSize.width) > zonePx && + layout.bottom - (paletteTopLeft.y + paletteSize.height) > zonePx, + ) { "the palette also reaches a side it may dock on: layout=$layout palette=$paletteSize" } + session.update(atTop) + check(workspace.dockPreview == null) { + "a zone is previewed for a palette aimed at the top: ${workspace.dockPreview} — " + + "layout=$layout paletteTopLeft=$paletteTopLeft pointer=$atTop zones=$zones" + } + session.end(atTop) + settle() + check(inspector.placement is SatellitePlacement.Floating) { + "released on the top strip, the palette docked: ${inspector.placement}" + } + check(fixture.floatingWindows.value[INSPECTOR] != null) { "the floating window is gone" } + + // The left side, which it was declared for, still works. + val (grabAgain, insetAgain) = grabNow() + val atLeft = + Offset(layout.left + EDGE_INSET_PX, layout.center.y - paletteSize.height / 2f) + insetAgain + val second = + requireNotNull( + workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grabAgain), + ) + // A new session starts with the zones of the last one cleared. + awaitUntil("the layout published its drop zones again") { + workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx?.isNotEmpty() == true + } + second.update(atLeft) + check(workspace.dockPreview == DockTarget(window, DockSide.Left)) { + "the left zone is not previewed: ${workspace.dockPreview} — " + + "layout=$layout pointer=$atLeft grab=$grabAgain " + + "frame=${floating.outerBoundsPx()?.toList()}" + } + second.end(atLeft) + awaitDockedBodies(fixture, INSPECTOR) + check(near(panel(fixture, INSPECTOR).left, 0f, LAYOUT_TOLERANCE_PX * 2)) { + "not docked on the left: ${panel(fixture, INSPECTOR)}" + } + }, + ) + } + + // ── 12. reorder a layered side by dragging ─────────────────────────── + + /** + * The innermost of three layers is dragged by its header — a real mouse + * where the host injects one, else the drag session it drives — until the + * pointer is over the outer half of the outermost layer. The first rank + * is previewed; released, the layer is the outermost column, at its own + * width, and no panel was rebuilt on the way. + */ + private fun aLayerDraggedOverTheOutermostOneBecomesTheFirst(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a layer dragged over the outermost one becomes the first, nothing rebuilt", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val scale = window.scaleFactor + val layout = awaitDockLayout(workspace, window) + // The panels are in window px, the layout rect in screen px. + val client = requireNotNull(workspace.dockHostGeometry(window)?.clientOriginPx()) + val layoutInWindow = layout.translate(-client) + val tree = panel(fixture, TREE) + val notesBefore = panel(fixture, NOTES) + // The header strip is the grip; a docked panel of another + // rank is offered its own side. + check( + hintedSides( + requireNotNull(workspace.satellite(NOTES)), + window, + workspace.satellites, + ).contains(DockSide.Right), + ) { + "a layer with neighbours is not offered its own side" + } + val grab = + toScreen( + fixture, + Offset( + notesBefore.center.x, + notesBefore.top + DockPanelHeaderHeight.value * scale / 2f, + ), + ) + // The outer half of the outermost layer: rank 0. + val target = toScreen(fixture, Offset(tree.left + tree.width * OUTER_HALF, layoutInWindow.center.y)) + val expected = DockTarget(window, DockSide.Right, 0) + + if (robotPressAndDrag(grab, target, scale) != null) { + awaitUntil("the first rank previews under the pointer — ${robotAim()}") { + workspace.dockPreview == + expected + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, driving the drag session directly") + val session = beginDockedDrag(workspace, NOTES, grab) + session.update(target) + check( + workspace.dockPreview == expected, + ) { "expected $expected, previewed ${workspace.dockPreview}" } + session.end(target) + } + awaitUntil("the notes are the first rank") { + (workspace.satellite(NOTES)?.placement as? SatellitePlacement.Docked)?.order == 0 + } + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val notes = panel(fixture, NOTES) + val treeAfter = panel(fixture, TREE) + val toc = panel(fixture, TOC) + check( + near(notes.right, layoutInWindow.right, LAYOUT_TOLERANCE_PX * 2), + ) { "the notes are not at the edge: $notes vs $layoutInWindow" } + check( + near(treeAfter.right, notes.left, SPLITTER_TOLERANCE_PX) && + near(toc.right, treeAfter.left, SPLITTER_TOLERANCE_PX), + ) { + "the columns are not notes, tree, toc from the edge: notes=$notes tree=$treeAfter toc=$toc" + } + check( + near(notes.width, notesBefore.width), + ) { "the notes changed width: ${notesBefore.width} -> ${notes.width}" } + check( + fixture.incarnationsOf(TREE) == 1 && + fixture.incarnationsOf(TOC) == 1 && + fixture.incarnationsOf(NOTES) == 1, + ) { "a reorder rebuilt a panel: ${fixture.incarnations.value}" } + }, + ) + } + + // ── 13. reorder a split side, and stay on its own rank ────────────── + + private fun aSplitPanelDroppedOnItsStackTakesTheRankUnderThePointer(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Bottom, order = 0)), + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, order = 1)), + DockPanelSpec(INSPECTOR, SatellitePlacement.Docked(DockSide.Bottom, order = 2)), + ), + ) + return TaoWindowTestCase( + name = "dock layout a split panel dropped on its stack takes the rank under the pointer or stays put", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val scale = window.scaleFactor + val inspectorBefore = panel(fixture, INSPECTOR) + val targum = panel(fixture, TARGUM) + val grab = + toScreen( + fixture, + Offset( + inspectorBefore.center.x, + inspectorBefore.top + DockPanelHeaderHeight.value * scale / 2f, + ), + ) + + // Nudged within its own panel: its own rank is no target, and the release changes nothing. + var session = beginDockedDrag(workspace, INSPECTOR, grab) + val nudge = grab + Offset(OWN_NUDGE_PX, OWN_NUDGE_PX) + session.update(nudge) + check(workspace.dockPreview == null) { "its own rank is previewed: ${workspace.dockPreview}" } + session.end(nudge) + settle() + check((workspace.satellite(INSPECTOR)?.placement as SatellitePlacement.Docked).order == 2) { + "a release on its own rank moved the panel: ${workspace.satellite(INSPECTOR)?.placement}" + } + check( + fixture.floatingWindows.value[INSPECTOR] == null, + ) { "a release on its own rank undocked the panel" } + + // The left half of the first panel: the first rank. + val target = toScreen(fixture, Offset(targum.left + targum.width * (1f - OUTER_HALF), targum.center.y)) + session = beginDockedDrag(workspace, INSPECTOR, grab) + session.update(target) + check(workspace.dockPreview == DockTarget(window, DockSide.Bottom, 0)) { + "the first rank is not previewed: ${workspace.dockPreview}" + } + session.end(target) + awaitUntil("the inspector is the first rank") { + (workspace.satellite(INSPECTOR)?.placement as? SatellitePlacement.Docked)?.order == 0 + } + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val inspector = panel(fixture, INSPECTOR) + val targumAfter = panel(fixture, TARGUM) + val comments = panel(fixture, COMMENTS) + check( + inspector.right <= targumAfter.left + LAYOUT_TOLERANCE_PX && + targumAfter.right <= comments.left + LAYOUT_TOLERANCE_PX, + ) { + "the row is not inspector, targum, comments: " + + "inspector=$inspector targum=$targumAfter comments=$comments" + } + check( + fixture.incarnationsOf(TARGUM) == 1 && fixture.incarnationsOf(COMMENTS) == 1, + ) { "a reorder rebuilt a neighbour" } + + // With the middle one closed, a drop on the shown neighbour's far half goes behind the closed one too. + workspace.close(TARGUM) + awaitDockedBodies(fixture, INSPECTOR, COMMENTS) + val commentsShown = panel(fixture, COMMENTS) + val farHalf = + toScreen( + fixture, + Offset(commentsShown.left + commentsShown.width * OUTER_HALF, commentsShown.center.y), + ) + session = beginDockedDrag(workspace, INSPECTOR, grab) + session.update(farHalf) + check(workspace.dockPreview == DockTarget(window, DockSide.Bottom, 1)) { + "the rank after the comments is not previewed: ${workspace.dockPreview}" + } + session.end(farHalf) + awaitUntil("the inspector is last") { + (workspace.satellite(INSPECTOR)?.placement as? SatellitePlacement.Docked)?.order == 2 + } + check((workspace.satellite(TARGUM)?.placement as SatellitePlacement.Docked).order == 0) { + "the closed targum lost its rank: ${workspace.satellite(TARGUM)?.placement}" + } + workspace.open(TARGUM) + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val reopened = panel(fixture, TARGUM) + check(reopened.right <= panel(fixture, COMMENTS).left + LAYOUT_TOLERANCE_PX) { + "the reopened targum is not first: $reopened vs ${panel(fixture, COMMENTS)}" + } + }, + ) + } + + // ── 11. a re-dock returns to the rank ──────────────────────────────── + + /** + * The middle layer of three is floated, then docked again through the + * path a header button takes — a side and no rank. It comes back between + * the two it left, at its own width, and neither neighbour is rebuilt. + * The same on the bottom side, split: the panel that left the middle + * of the row is back in the middle of the row. + */ + private fun aPanelDockedAgainReturnsToTheRankItLeft(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + layeredRightSpecs() + + listOf( + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Bottom, order = 0)), + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, order = 1)), + DockPanelSpec(INSPECTOR, SatellitePlacement.Docked(DockSide.Bottom, order = 2)), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a panel docked again without a rank returns between the neighbours it left", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodies(fixture, TREE, TOC, NOTES, TARGUM, COMMENTS, INSPECTOR) + val tocBefore = panel(fixture, TOC) + val commentsBefore = panel(fixture, COMMENTS) + + // Layered right side: the toc is the middle column. + workspace.undock(TOC) + awaitUntil("the toc floats") { fixture.floatingWindows.value[TOC]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + check(panel(fixture, NOTES).right > tocBefore.left + LAYOUT_TOLERANCE_PX) { + "the inner layer did not slide out while the toc floated: ${panel(fixture, NOTES)}" + } + workspace.dock(TOC, DockSide.Right) + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val tree = panel(fixture, TREE) + val toc = panel(fixture, TOC) + val notes = panel(fixture, NOTES) + // Between its neighbours, a splitter's width from each. + check( + near(toc.right, tree.left, SPLITTER_TOLERANCE_PX) && + near(notes.right, toc.left, SPLITTER_TOLERANCE_PX), + ) { + "the toc is not back between the tree and the notes: tree=$tree toc=$toc notes=$notes" + } + check( + near(toc.width, tocBefore.width), + ) { "the toc came back at ${toc.width} px, was ${tocBefore.width}" } + check((workspace.satellite(TOC)?.placement as SatellitePlacement.Docked).order == 1) { + "the toc's rank is not 1: ${workspace.satellite(TOC)?.placement}" + } + check(fixture.incarnationsOf(TREE) == 1 && fixture.incarnationsOf(NOTES) == 1) { + "a neighbour was rebuilt by the toc leaving and returning" + } + + // Split bottom side: the comments are the middle of the row. + workspace.undock(COMMENTS) + awaitUntil( + "the comments float", + ) { fixture.floatingWindows.value[COMMENTS]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + workspace.dock(COMMENTS, DockSide.Bottom) + awaitDockedBodies(fixture, TARGUM, COMMENTS, INSPECTOR) + val targum = panel(fixture, TARGUM) + val comments = panel(fixture, COMMENTS) + val inspector = panel(fixture, INSPECTOR) + check( + targum.right <= comments.left + LAYOUT_TOLERANCE_PX && + comments.right <= inspector.left + LAYOUT_TOLERANCE_PX, + ) { + "the comments are not back in the middle of the row: " + + "targum=$targum comments=$comments inspector=$inspector" + } + check(near(comments.width, commentsBefore.width, SPLITTER_TOLERANCE_PX)) { + "the comments came back at ${comments.width} px, were ${commentsBefore.width}" + } + }, + ) + } + + // ── 10. the preview follows the palette, not the pointer ───────────── + + /** + * The zone lights up when the *palette* reaches it, with the pointer still + * in the middle of the palette and nowhere near the layout's edge — and + * the side the panel already occupies is never offered. + * + * Driven with a real mouse where the host allows it: the palette follows + * the pointer, so grabbing its centre keeps the pointer far from every + * edge for the whole gesture while the palette's own edge enters the zone. + */ + private fun thePaletteEdgeDecidesTheZoneNotThePointer(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Bottom, extent = BOTTOM_H_DP.dp)), + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + ) + return TaoWindowTestCase( + name = "dock layout the palette's own edge decides the zone, not the pointer", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE) + awaitUntil( + "the inspector floats", + ) { fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val layout = awaitDockLayout(workspace, window) + val scale = window.scaleFactor + val outer = requireNotNull(floating.outerBoundsPx()) + val paletteWidth = outer[2].toFloat() + + // The panel already on the bottom is not offered that side. + val tree = requireNotNull(workspace.satellite(TREE)) + check(!hintedSides(tree, window, workspace.satellites).contains(DockSide.Bottom)) { + "the bottom panel is offered the side it is already on: ${hintedSides( + tree, + window, + workspace.satellites, + )}" + } + check( + hintedSides(requireNotNull(workspace.satellite(INSPECTOR)), window, workspace.satellites).size == + DockSide.entries.size, + ) { + "a floating palette must be offered every side" + } + + // Aim so the palette's left edge lands just inside the left + // zone while the pointer stays at its centre — well past the + // zone, over the content — and the palette itself stays clear + // of the top and bottom zones, so the left one is the only + // edge in reach and the assertion is unambiguous. + val paletteHeight = outer[3].toFloat() + val grab = Offset(outer[0] + paletteWidth / 2f, outer[1] + HEADER_GRAB_Y_DP * floating.scaleFactor) + val grabInset = grab - Offset(outer[0].toFloat(), outer[1].toFloat()) + val target = Offset(layout.left + EDGE_INSET_PX, layout.center.y - paletteHeight / 2f) + grabInset + val zonePx = SatelliteWorkspace.DockZoneWidth.value * scale + check(target.x - layout.left > zonePx) { + "the pointer would land inside the left zone itself: this case would prove nothing" + } + val paletteTop = target.y - grabInset.y + check(paletteTop - layout.top > zonePx && layout.bottom - (paletteTop + paletteHeight) > zonePx) { + "the palette also reaches the top or bottom zone (layout=$layout palette height=$paletteHeight): " + + "the case would be ambiguous" + } + + val robot = robotPressAndDrag(grab, target, scale) != null + if (robot) { + awaitUntil("the left zone previews while the pointer is over the content — ${robotAim()}") { + workspace.dockPreview == DockTarget(window, DockSide.Left) + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, driving the drag session directly") + val session = + requireNotNull( + workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) + session.update(target) + check(workspace.dockPreview == DockTarget(window, DockSide.Left)) { + "the palette's edge reached the left zone but ${workspace.dockPreview} is previewed" + } + session.end(target) + } + awaitUntil("the palette docked on the left") { + (workspace.satellite(INSPECTOR)?.placement as? SatellitePlacement.Docked)?.side == DockSide.Left + } + awaitDockedBodies(fixture, TREE, INSPECTOR) + check(near(panel(fixture, INSPECTOR).left, 0f, LAYOUT_TOLERANCE_PX * 2)) { + "the new panel is not at the left edge: ${panel(fixture, INSPECTOR)}" + } + }, + ) + } + + // ── 1. layered geometry ────────────────────────────────────────────── + + private fun layeredPanelsSitSideBySideWithTheirOwnWidths(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout three layered panels on the right are three columns of their own width", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val scale = window.scaleFactor + val layout = requireNotNull(fixture.workspace.dockHostGeometry(window)).layoutBoundsInWindowPx + val tree = panel(fixture, TREE) + val toc = panel(fixture, TOC) + val notes = panel(fixture, NOTES) + val content = requireNotNull(fixture.contentBounds.value) + + // Order 0 is at the edge; each layer runs the full height. + check(near(tree.right, layout.right)) { "the first layer is not at the right edge: $tree in $layout" } + check(toc.right <= tree.left + LAYOUT_TOLERANCE_PX && notes.right <= toc.left + LAYOUT_TOLERANCE_PX) { + "layers are not side by side from the edge inwards: tree=$tree toc=$toc notes=$notes" + } + check( + content.right <= notes.left + LAYOUT_TOLERANCE_PX, + ) { "the content runs under a layer: $content vs $notes" } + for ((id, rect) in listOf(TREE to tree, TOC to toc, NOTES to notes)) { + check(near(rect.top, layout.top) && near(rect.bottom, layout.bottom)) { + "$id does not run the full height: $rect in $layout" + } + } + // Each at its own width. + check(near(tree.width, TREE_W_DP * scale)) { "tree width ${tree.width} != ${TREE_W_DP * scale}" } + check(near(toc.width, TOC_W_DP * scale)) { "toc width ${toc.width} != ${TOC_W_DP * scale}" } + check(near(notes.width, NOTES_W_DP * scale)) { "notes width ${notes.width} != ${NOTES_W_DP * scale}" } + // Nothing overlaps anything. + val all = listOf(tree, toc, notes, content) + for (i in all.indices) { + for (j in i + 1 until all.size) { + check(!overlaps(all[i], all[j])) { "panels overlap: ${all[i]} and ${all[j]}" } + } + } + // The default header strip sizes itself in the dock. + val body = requireNotNull(fixture.bodyBounds.value[TREE]) + check(near(body.top - tree.top, DockPanelHeaderHeight.value * scale)) { + "the header strip is ${body.top - tree.top} px, expected ${DockPanelHeaderHeight.value * scale}" + } + }, + ) + } + + // ── 2. layered splitter ────────────────────────────────────────────── + + private fun aLayeredSplitterResizesItsPanelAlone(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a layered panel's splitter resizes that panel alone", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val scale = window.scaleFactor + val treeBefore = panel(fixture, TREE) + val tocBefore = panel(fixture, TOC) + val notesBefore = panel(fixture, NOTES) + val contentBefore = requireNotNull(fixture.contentBounds.value) + val grip = requireNotNull(fixture.splitterOf(TOC)) { "no splitter published for $TOC" } + check( + grip.left <= tocBefore.left + LAYOUT_TOLERANCE_PX && + grip.right >= notesBefore.right - LAYOUT_TOLERANCE_PX, + ) { + "the toc splitter is not between toc and notes: grip=$grip toc=$tocBefore notes=$notesBefore" + } + + // On the right side, towards the content is leftwards. + val deltaPx = -(SPLITTER_DRAG_DP * scale) + val from = toScreen(fixture, grip.center) + val robot = robotPressAndDrag(from, from + Offset(deltaPx, 0f), scale) != null + if (robot) { + awaitUntil("the toc layer grew under the drag — ${robotAim()}") { + panelOrNull(fixture, TOC)?.let { it.width > tocBefore.width + abs(deltaPx) / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, resizing through the workspace") + fixture.workspace.setDockedExtent(TOC, (TOC_W_DP + SPLITTER_DRAG_DP).dp) + } + awaitUntil("the layout settled at the new width") { + panelOrNull( + fixture, + TOC, + )?.let { near(it.width, tocBefore.width + abs(deltaPx), SPLITTER_TOLERANCE_PX) } == + true + } + settle() + + val toc = panel(fixture, TOC) + check(near(panel(fixture, TREE).width, treeBefore.width)) { "the tree layer changed width" } + check(near(panel(fixture, NOTES).width, notesBefore.width)) { "the notes layer changed width" } + check(near(toc.right, tocBefore.right)) { "the toc layer moved instead of growing towards the content" } + val content = requireNotNull(fixture.contentBounds.value) + check(near(content.width, contentBefore.width - (toc.width - tocBefore.width), SPLITTER_TOLERANCE_PX)) { + "the content did not give up what the layer took: $contentBefore -> $content" + } + // The extent is the panel's own, and it is in the snapshot. + val saved = requireNotNull(fixture.workspace.snapshot().satellites[TOC]).placement + val docked = saved as SatellitePlacement.Docked + val extent = requireNotNull(docked.extent) + check(abs(extent.value * scale - toc.width) <= SPLITTER_TOLERANCE_PX) { + "the snapshot carries $extent, the layer is ${toc.width / scale} dp wide" + } + check( + ( + fixture.workspace + .snapshot() + .satellites[TREE] + ?.placement as SatellitePlacement.Docked + ).extent == + TREE_W_DP.dp, + ) { + "the tree's extent changed in the snapshot" + } + }, + ) + } + + // ── 3. split weights ───────────────────────────────────────────────── + + private fun splitPanelsShareBySideWeightAndTheDividerMovesIt(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Left, order = 0, weight = 1f)), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Left, order = 1, weight = 3f)), + ), + ) + return TaoWindowTestCase( + name = "dock layout split panels share the side by weight and the divider moves it", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC) + val scale = window.scaleFactor + val tree = panel(fixture, TREE) + val toc = panel(fixture, TOC) + check( + near(tree.left, toc.left) && near(tree.width, toc.width), + ) { "split panels do not share the side's width" } + check(tree.bottom <= toc.top + LAYOUT_TOLERANCE_PX) { "order 0 is not above order 1: $tree / $toc" } + // 1 : 3, minus the divider between them. + check(abs(toc.height - 3f * tree.height) <= SPLITTER_TOLERANCE_PX * 3) { + "heights are not 1:3 — tree ${tree.height}, toc ${toc.height}" + } + val extentPx = fixture.workspace.dockExtent(DockSide.Left).value * scale + check(near(tree.width, extentPx)) { "the stack is ${tree.width} px wide, extent says $extentPx" } + + val divider = requireNotNull(fixture.splitterOf(TREE)) { "no divider between the two panels" } + check( + divider.top >= tree.bottom - LAYOUT_TOLERANCE_PX && divider.bottom <= toc.top + LAYOUT_TOLERANCE_PX, + ) { + "the divider is not between the panels: $divider between $tree and $toc" + } + val deltaPx = SPLITTER_DRAG_DP * scale + val from = toScreen(fixture, divider.center) + val robot = robotPressAndDrag(from, from + Offset(0f, deltaPx), scale) != null + if (robot) { + awaitUntil("the tree panel grew under the drag — ${robotAim()}") { + panelOrNull(fixture, TREE)?.let { it.height > tree.height + deltaPx / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, moving weight through the workspace") + val total = tree.height + toc.height + val moved = deltaPx / total * 4f + fixture.workspace.setDockedWeight(TREE, 1f + moved) + fixture.workspace.setDockedWeight(TOC, 3f - moved) + } + awaitUntil("the divider settled where it was dropped") { + panelOrNull(fixture, TREE)?.let { near(it.height, tree.height + deltaPx, SPLITTER_TOLERANCE_PX) } == + true + } + settle() + val treeAfter = panel(fixture, TREE) + val tocAfter = panel(fixture, TOC) + check(near(tocAfter.height, toc.height - deltaPx, SPLITTER_TOLERANCE_PX)) { + "the toc panel did not shrink by what the tree took: ${toc.height} -> ${tocAfter.height}" + } + check(near(treeAfter.width, tree.width)) { "the side's width changed under a weight drag" } + val weights = + fixture.workspace.satellites.associate { + it.id to (it.placement as SatellitePlacement.Docked).weight + } + check(weights.getValue(TREE) > 1f && weights.getValue(TOC) < 3f) { "weights did not move: $weights" } + check(abs(weights.getValue(TREE) + weights.getValue(TOC) - 4f) < WEIGHT_SUM_TOLERANCE) { + "the divider changed the total weight: $weights" + } + // The side's own splitter still drags the shared width. + val sideGrip = requireNotNull(fixture.sideSplitterOf(DockSide.Left)) + check( + near(sideGrip.left, tree.right, LAYOUT_TOLERANCE_PX + 1f), + ) { "the side splitter is not at the stack's edge" } + }, + ) + } + + // ── 4. side order ──────────────────────────────────────────────────── + + private fun theOuterSideOwnsTheCorners(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Left, extent = TREE_W_DP.dp)), + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, extent = BOTTOM_H_DP.dp)), + ), + sideOrder = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout the first side in the order runs the full length and owns the corners", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TARGUM, COMMENTS) + val layout = requireNotNull(fixture.workspace.dockHostGeometry(window)).layoutBoundsInWindowPx + val tree = panel(fixture, TREE) + val targum = panel(fixture, TARGUM) + val comments = panel(fixture, COMMENTS) + val content = requireNotNull(fixture.contentBounds.value) + + check(near(tree.top, layout.top) && near(tree.bottom, layout.bottom)) { + "the right side does not run the full height: $tree" + } + check(comments.right <= tree.left + LAYOUT_TOLERANCE_PX) { + "the bottom panel runs under the right side: $comments vs $tree" + } + check(near(comments.left, layout.left)) { + "the bottom panel does not reach the left edge under the left panel: $comments" + } + check(targum.bottom <= comments.top + LAYOUT_TOLERANCE_PX) { + "the left panel runs beside the bottom one: $targum vs $comments" + } + check(near(targum.left, layout.left)) { "the left panel is not at the left edge: $targum" } + check( + content.left >= targum.right - LAYOUT_TOLERANCE_PX && + content.bottom <= comments.top + LAYOUT_TOLERANCE_PX, + ) { + "the content is not boxed in by left and bottom: $content" + } + check(near(comments.bottom, layout.bottom)) { "the bottom panel is not at the bottom edge" } + + // Now the classic order: bottom runs the full width under everything. + fixture.sideOrder.value = DefaultDockSideOrder + awaitUntil("the bottom panel took the full width — ${fixture.panelBounds.value}") { + panelOrNull( + fixture, + COMMENTS, + )?.let { near(it.right, layout.right) && near(it.left, layout.left) } == + true + } + settle() + val treeAfter = panel(fixture, TREE) + check(treeAfter.bottom <= panel(fixture, COMMENTS).top + LAYOUT_TOLERANCE_PX) { + "the right side still runs beside the bottom" + } + check( + fixture.incarnationsOf(TREE) == 1 && + fixture.incarnationsOf(COMMENTS) == 1 && + fixture.incarnationsOf(TARGUM) == 1, + ) { + "a side-order change rebuilt a panel: ${fixture.incarnations.value}" + } + check(fixture.contentIncarnations.value == 1) { "a side-order change rebuilt the content" } + }, + ) + } + + // ── 5. right-to-left ───────────────────────────────────────────────── + + private fun rtlKeepsPhysicalSidesAndHandsTheDirectionBack(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TARGUM, SatellitePlacement.Docked(DockSide.Left, extent = TREE_W_DP.dp)), + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + ), + layeredSides = setOf(DockSide.Left, DockSide.Right), + direction = LayoutDirection.Rtl, + ) + return TaoWindowTestCase( + name = "dock layout under RTL the left side is the physical left and its splitter grows it rightwards", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TARGUM, TREE) + val scale = window.scaleFactor + val layout = requireNotNull(fixture.workspace.dockHostGeometry(window)).layoutBoundsInWindowPx + val left = panel(fixture, TARGUM) + val right = panel(fixture, TREE) + check( + near(left.left, layout.left), + ) { "DockSide.Left is not at the physical left under RTL: $left in $layout" } + check( + near(right.right, layout.right), + ) { "DockSide.Right is not at the physical right under RTL: $right in $layout" } + check(fixture.contentDirection.value == LayoutDirection.Rtl) { "the content lost its RTL direction" } + check( + fixture.bodyDirections.value[TARGUM] == LayoutDirection.Rtl, + ) { "the panel body lost its RTL direction" } + + // Dragging the left panel's splitter to the right grows it. + val grip = requireNotNull(fixture.splitterOf(TARGUM)) + check(grip.left >= left.right - LAYOUT_TOLERANCE_PX) { + "the left panel's splitter is not on its content side: $grip vs $left" + } + val deltaPx = SPLITTER_DRAG_DP * scale + val from = toScreen(fixture, grip.center) + val robot = robotPressAndDrag(from, from + Offset(deltaPx, 0f), scale) != null + if (robot) { + awaitUntil("the left panel grew rightwards — ${robotAim()}") { + panelOrNull(fixture, TARGUM)?.let { it.width > left.width + deltaPx / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[dock-layout] robot unavailable, resizing through the workspace") + fixture.workspace.setDockedExtent(TARGUM, (TREE_W_DP + SPLITTER_DRAG_DP).dp) + } + awaitUntil("the left panel settled at its new width") { + panelOrNull(fixture, TARGUM)?.let { near(it.width, left.width + deltaPx, SPLITTER_TOLERANCE_PX) } == + true + } + check(near(panel(fixture, TARGUM).left, layout.left)) { "the left panel left the edge while growing" } + check(near(panel(fixture, TREE).width, right.width)) { "the right panel changed under a left drag" } + + // Flipping the direction changes nothing about where the sides are. + fixture.direction.value = LayoutDirection.Ltr + settle(SETTLE_AFTER_MAP_MILLIS) + check( + near(panel(fixture, TARGUM).left, layout.left) && near(panel(fixture, TREE).right, layout.right), + ) { + "a direction flip moved the sides" + } + check( + fixture.contentDirection.value == LayoutDirection.Ltr, + ) { "the content did not follow the direction flip" } + check(fixture.incarnationsOf(TARGUM) == 1 && fixture.contentIncarnations.value == 1) { + "a direction flip rebuilt the panel or the content" + } + }, + ) + } + + // ── 6. nothing is rebuilt ──────────────────────────────────────────── + + private fun layoutChangesNeverRebuildAPanelOrTheContent(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + layeredRightSpecs() + + DockPanelSpec(COMMENTS, SatellitePlacement.Docked(DockSide.Bottom, extent = BOTTOM_H_DP.dp)) + + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout no layout change rebuilds a panel or the content and restores keep the floating window", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES, COMMENTS) + awaitUntil( + "the inspector floats", + ) { fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val docked = listOf(TREE, TOC, NOTES, COMMENTS) + val initial = workspace.snapshot() + + suspend fun step(what: String) { + settle(SETTLE_AFTER_MAP_MILLIS) + for (id in docked) { + check( + fixture.incarnationsOf(id) == 1, + ) { "$what rebuilt $id: built ${fixture.incarnationsOf(id)} times" } + check( + fixture.liveBodiesOf(id) == 1, + ) { "$what left $id composed ${fixture.liveBodiesOf(id)} times" } + } + check(fixture.contentIncarnations.value == 1) { "$what rebuilt the content" } + check( + fixture.floatingWindows.value[INSPECTOR] === floating, + ) { "$what recreated the inspector's window" } + check(fixture.incarnationsOf(INSPECTOR) == 1) { "$what rebuilt the inspector's body" } + } + + workspace.setDockedExtent(TOC, (TOC_W_DP + SPLITTER_DRAG_DP).dp) + step("a layered extent change") + workspace.setDockExtent(DockSide.Bottom, (BOTTOM_H_DP + SPLITTER_DRAG_DP).dp) + step("a side extent change") + workspace.dock(TREE, DockSide.Right, order = 5) + step("a reorder on the same side") + awaitUntil("tree moved to the inner end") { + panelOrNull(fixture, TREE)?.let { + it.left < + panel(fixture, TOC).left + } == + true + } + workspace.dock(NOTES, DockSide.Left) + awaitUntil("notes moved to the left side") { + panelOrNull(fixture, NOTES)?.let { + near( + it.left, + 0f, + LAYOUT_TOLERANCE_PX * 2, + ) + } == + true + } + step("a move to another side") + workspace.dock(NOTES, DockSide.Bottom) + awaitUntil("notes shares the bottom") { + panelOrNull(fixture, NOTES)?.let { + it.top > + panel(fixture, TOC).top + } == + true + } + step("a move to a split side") + workspace.setDockedWeight(NOTES, 2f) + step("a weight change") + fixture.layeredSides.value = setOf(DockSide.Right, DockSide.Bottom) + step("a side turning layered") + fixture.layeredSides.value = setOf(DockSide.Right) + step("a side turning split again") + fixture.sideOrder.value = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top) + step("a new side order") + fixture.direction.value = LayoutDirection.Rtl + step("a direction flip") + repeat(RESTORE_ROUNDS) { + workspace.restore(initial) + step("a restore of the initial layout") + workspace.restore(workspace.snapshot()) + step("a restore of the current layout") + } + awaitUntil("the initial layout is back") { + panelOrNull(fixture, NOTES)?.let { near(it.width, NOTES_W_DP * window.scaleFactor) } == true + } + // A resize of the window re-lays everything out and rebuilds nothing. + window.setInnerSize(RESIZED_W_DP, RESIZED_H_DP) + awaitUntil("the window resized") { (bounds()?.get(2) ?: 0L) > PARENT_W_DP * window.scaleFactor + 1 } + step("a window resize") + }, + ) + } + + // ── 7. custom splitter ─────────────────────────────────────────────── + + private fun aOneDpSplitterWithAWiderGripTakesTheDrag(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = listOf(DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp))), + layeredSides = setOf(DockSide.Right), + gripOverflow = true, + ) + return TaoWindowTestCase( + name = "dock layout a 1 dp splitter with a wider grip takes a drag aimed off the line", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE) + val scale = window.scaleFactor + val before = panel(fixture, TREE) + val grip = requireNotNull(fixture.splitterOf(TREE)) + check(near(grip.width, GRIP_OVERFLOW_DP * scale, LAYOUT_TOLERANCE_PX)) { + "the grip is ${grip.width} px wide, expected ${GRIP_OVERFLOW_DP * scale}: " + + "requiredWidth did not overflow" + } + // The layout itself only gave the splitter one dp. + check( + near(before.left - requireNotNull(fixture.contentBounds.value).right, scale, LAYOUT_TOLERANCE_PX), + ) { + "the layout reserved more than 1 dp for the splitter" + } + // Aim two dp off the line, inside the grip but outside the 1 dp of layout. + val aim = Offset(grip.center.x - 2f * scale, grip.center.y) + val deltaPx = -(SPLITTER_DRAG_DP * scale) + val from = toScreen(fixture, aim) + if (robotPressAndDrag(from, from + Offset(deltaPx, 0f), scale) == null) { + System.err.println("[dock-layout] robot unavailable, the overflowing grip cannot be exercised") + return@TaoWindowTestCase + } + awaitUntil("the panel grew under a drag aimed beside the line — ${robotAim()}") { + panelOrNull(fixture, TREE)?.let { it.width > before.width + abs(deltaPx) / 2 } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + awaitUntil("the panel settled") { + panelOrNull( + fixture, + TREE, + )?.let { near(it.width, before.width + abs(deltaPx), SPLITTER_TOLERANCE_PX) } == + true + } + }, + ) + } + + // ── 8. drop on a layered side ──────────────────────────────────────── + + private fun aDropOnALayeredSideAddsALayerOfTheWindowsWidth(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = TREE_W_DP.dp)), + DockPanelSpec( + INSPECTOR, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout a floating satellite dropped on a layered side becomes a layer of its window's width", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE) + awaitUntil( + "the inspector floats", + ) { fixture.floatingWindows.value[INSPECTOR]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val workspace = fixture.workspace + val floating = requireNotNull(fixture.floatingWindows.value[INSPECTOR]) + val scale = window.scaleFactor + val layout = awaitDockLayout(workspace, window) + val treeBefore = panel(fixture, TREE) + val outer = requireNotNull(floating.outerBoundsPx()) + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * floating.scaleFactor) + val dropIn = Offset(layout.right - DROP_INSET_PX, layout.center.y) + + val session = + requireNotNull(workspace.beginDrag(INSPECTOR, SatelliteDragOrigin.FloatingWindow(floating), grab)) + session.update(dropIn) + check(workspace.dockPreview == DockTarget(window, DockSide.Right)) { + "the right zone is not previewed: ${workspace.dockPreview}" + } + session.end(dropIn) + awaitDockedBodies(fixture, TREE, INSPECTOR) + + val inspector = panel(fixture, INSPECTOR) + val tree = panel(fixture, TREE) + val placement = workspace.satellite(INSPECTOR)?.placement as SatellitePlacement.Docked + check( + placement.side == DockSide.Right && placement.order > 0, + ) { "not appended on the right: $placement" } + check( + placement.extent == workspaceSatelliteSize().width, + ) { "the layer's extent is not the window's width: $placement" } + check(near(inspector.width, SATELLITE_W_DP * scale)) { + "the layer is ${inspector.width} px, the window was ${SATELLITE_W_DP * scale}" + } + check(inspector.right <= tree.left + LAYOUT_TOLERANCE_PX) { + "the new layer is not inside the existing one: $inspector vs $tree" + } + check(near(tree.width, treeBefore.width) && near(tree.right, treeBefore.right)) { + "the existing layer moved or resized: $treeBefore -> $tree" + } + }, + ) + } + + // ── 9. lift-off from a layer ───────────────────────────────────────── + + private fun undockingALayerLiftsTheWindowOffThePanel(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = layeredRightSpecs(), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "dock layout undocking a middle layer lifts its window off where the layer was", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitDockedBodies(fixture, TREE, TOC, NOTES) + val client = requireNotNull(fixture.workspace.dockHostGeometry(window)?.clientOriginPx()) + val tocBefore = panel(fixture, TOC) + val expected = tocBefore.translate(client) + val treeBefore = panel(fixture, TREE) + val notesBefore = panel(fixture, NOTES) + + fixture.workspace.undock(TOC) + awaitUntil( + "the toc floats with a frame", + ) { fixture.floatingWindows.value[TOC]?.hasRealFramePx() == true } + settle(SETTLE_AFTER_MAP_MILLIS) + val outer = requireNotNull(requireNotNull(fixture.floatingWindows.value[TOC]).outerBoundsPx()) + check( + abs(outer[0] - expected.left) <= LIFT_OFF_TOLERANCE_PX && + abs(outer[1] - expected.top) <= LIFT_OFF_TOLERANCE_PX, + ) { + "the window lifted off at (${outer[0]}, ${outer[1]}), " + + "the layer was at (${expected.left}, ${expected.top})" + } + check(abs(outer[2] - expected.width) <= LIFT_OFF_TOLERANCE_PX) { + "the window is ${outer[2]} px wide, the layer was ${expected.width}" + } + // The neighbours close the gap: the tree stays at the edge, the notes slide out to meet it. + val tree = panel(fixture, TREE) + val notes = panel(fixture, NOTES) + check( + near(tree.right, treeBefore.right) && near(tree.width, treeBefore.width), + ) { "the outer layer moved" } + check(near(notes.width, notesBefore.width) && notes.right > notesBefore.right + tocBefore.width / 2) { + "the inner layer did not slide out to fill the gap: $notesBefore -> $notes" + } + check( + fixture.incarnationsOf(TREE) == 1 && fixture.incarnationsOf(NOTES) == 1, + ) { "undocking one layer rebuilt another" } + }, + ) + } + + // ── helpers ────────────────────────────────────────────────────────── + + /** + * Starts a drag of the docked panel [id] and waits for the layout to + * publish the zones the drop is resolved against. A pointer gesture gives + * the hints a frame to compose before the slop is passed; a session driven + * by hand has to wait for it, or the first sample is resolved against the + * bare edges. + */ + private suspend fun TaoWindowTestScope.beginDockedDrag( + workspace: SatelliteWorkspace, + id: String, + grab: Offset, + ): SatelliteDragSession { + val session = requireNotNull(workspace.beginDrag(id, SatelliteDragOrigin.DockedPanel(window), grab)) + awaitUntil("the layout published its drop zones") { + workspace.dockHostGeometry(window)?.zoneBoundsInWindowPx?.isNotEmpty() == true + } + return session + } + + private fun layeredRightSpecs(): List = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, order = 0, extent = TREE_W_DP.dp)), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Right, order = 1, extent = TOC_W_DP.dp)), + DockPanelSpec(NOTES, SatellitePlacement.Docked(DockSide.Right, order = 2, extent = NOTES_W_DP.dp)), + ) + + private fun panel( + fixture: DockLayoutFixture, + id: String, + ): Rect = + requireNotNull(fixture.panelBounds.value[id]) { "no panel bounds for $id: ${fixture.panelBounds.value.keys}" } + + /** The layout's right edge in window px: the panels are measured there, the layout rect on screen. */ + private fun layoutInWindowRight( + layoutScreenPx: Rect, + clientOriginPx: Offset, + ): Float = layoutScreenPx.right - clientOriginPx.x + + private fun panelOrNull( + fixture: DockLayoutFixture, + id: String, + ): Rect? = fixture.panelBounds.value[id] + + private const val TREE = "tree" + private const val TOC = "toc" + private const val NOTES = "notes" + private const val TARGUM = "targum" + private const val COMMENTS = "comments" + private const val INSPECTOR = "inspector" + + private const val TREE_W_DP = 100f + private const val TOC_W_DP = 120f + private const val NOTES_W_DP = 90f + private const val BOTTOM_H_DP = 90f + private const val SPLITTER_DRAG_DP = 40f + private const val SPLITTER_TOLERANCE_PX = 6f + private const val WEIGHT_SUM_TOLERANCE = 0.01f + private const val RESTORE_ROUNDS = 3 + + /** How far inside the layout's edge the dragged palette's own edge is aimed. */ + private const val EDGE_INSET_PX = 8f + + /** Where in a neighbour a drop aims to land ahead of it: well inside its outer half. */ + private const val OUTER_HALF = 0.8f + + /** A drag that stays on the panel it started from. */ + private const val OWN_NUDGE_PX = 6f + + /** Into the content, in dp from the layout's left edge: past the strip, short of the right ranks. */ + private const val CONTENT_AIM_DP = 120f + + /** Enough to pass the touch slop and publish a ghost. */ + private const val PANEL_DRAG_STEP_PX = 24f + + /** How far inside a panel's leading edge a grab is taken. */ + private const val GRAB_EDGE_INSET_DP = 8f +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutMonkeyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutMonkeyHeadfulCases.kt new file mode 100644 index 000000000..d13d65733 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/DockLayoutMonkeyHeadfulCases.kt @@ -0,0 +1,582 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DefaultDockSideOrder +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteLayoutSnapshot +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventCode +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * The dock-layout monkeys: random layout mutations on one `DockLayout`, one + * case per (layout profile, seed). + * + * Where [SatelliteWorkspaceMonkeyHeadfulCases] shakes the *workspace* — hosts + * coming and going, drags across windows — these shake the *layout*: layered + * and split sides, per-panel extents and weights, splitters dragged with a real + * mouse, side orders shuffled, sides flipping between layered and split, the + * direction flipping between LTR and RTL, and snapshots restored on top of + * whatever the previous steps left. Each profile is a layout an app would + * actually declare — the reader layout of a right-to-left book app among them — + * and each is run under several seeds, because the interleavings are the point. + * + * What a run asserts, after every action and at checkpoints: + * + * - **geometry**: no two visible panels overlap, none overlaps the content, + * and every one is inside the layout — whatever the extents, weights, order + * and direction happen to be; + * - **identity**: a panel body is built once per *hosting change* (docked to + * floating, closed to open, hidden to shown) and never by a change of the + * layout alone — a splitter, a reorder, a side change, a restore, a new + * side order or direction must move a subtree, not rebuild it. The content + * is never rebuilt at all; + * - **composition**: no panel composes in two hosts once a step has settled; + * - **liveness**: `Dispatchers.Main` keeps answering ([MainLoopWatchdog]), + * no action wedges, and native windows do not accumulate; + * - **convergence**: the closing phase docks everything back into one + * layered configuration and it has to lay out cleanly. + * + * Every failure carries the profile, the seed and the last actions; + * `-Dnucleus.tao.headful.monkeySeed=` replays the action sequence and + * `-Dnucleus.tao.headful.monkeyScript=A,B,C` replays a journal verbatim. + */ +internal object DockLayoutMonkeyHeadfulCases { + fun all(): List = + PROFILES.flatMap { profile -> + SEEDS.map { seed -> randomLayoutChangesLeaveACleanLayout(profile, seed, MONKEY_ACTIONS) } + } + randomLayoutChangesLeaveACleanLayout(PROFILES[READER_PROFILE], LONG_RUN_SEED, LONG_RUN_ACTIONS) + + private fun randomLayoutChangesLeaveACleanLayout( + profile: LayoutProfile, + seed: Long, + actions: Int, + ): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = profile.specs, + sideOrder = profile.sideOrder, + layeredSides = profile.layeredSides, + direction = profile.direction, + ) + return TaoWindowTestCase( + name = "dock layout monkey ${profile.name} seed $seed: $actions random layout changes leave a clean layout", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + awaitUntil("the case window is mapped") { bounds() != null } + awaitUntil("the layout published its geometry") { + fixture.workspace.dockHostGeometry(window)?.layoutScreenRectPx() != null + } + awaitUntil("every satellite is declared") { + profile.specs.all { + fixture.workspace.satellite(it.id) != + null + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + val monkey = DockMonkey(this, fixture, profile, monkeySeedOr(seed), actions) + monkey.run() + monkey.quiesceAndAssert() + }, + ) + } + + /** The seed property overrides every case's own seed, so a red one replays. */ + private fun monkeySeedOr(default: Long): Long = System.getProperty(MONKEY_SEED_PROPERTY)?.toLongOrNull() ?: default + + private val SEEDS = longArrayOf(20_260_907L, 42L, 7L) + private const val READER_PROFILE = 1 + private const val LONG_RUN_SEED = 1_000_003L +} + +/** A layout an app would declare, with the satellites that start in it. */ +private class LayoutProfile( + val name: String, + val sideOrder: List, + val layeredSides: Set, + val direction: LayoutDirection, + val specs: List, +) + +private val FLOATING = + SatellitePlacement.Floating(positioner = workspaceRightEdgePositioner(), size = workspaceSatelliteSize()) + +private val PROFILES = + listOf( + LayoutProfile( + name = "border", + sideOrder = DefaultDockSideOrder, + layeredSides = emptySet(), + direction = LayoutDirection.Ltr, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Left)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Left, order = 1)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Bottom)), + DockPanelSpec("targum", FLOATING), + DockPanelSpec("comments", FLOATING), + ), + ), + // The reader: a right-to-left book app with its navigation layered on the + // right, the translation on the left and the commentaries under both. + LayoutProfile( + name = "reader", + sideOrder = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top), + layeredSides = setOf(DockSide.Right), + direction = LayoutDirection.Rtl, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Right, order = 0, extent = 90.dp)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 80.dp)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Right, order = 2, extent = 80.dp)), + DockPanelSpec("targum", SatellitePlacement.Docked(DockSide.Left, extent = 90.dp)), + DockPanelSpec("comments", SatellitePlacement.Docked(DockSide.Bottom, extent = 80.dp)), + ), + ), + LayoutProfile( + name = "all layered", + sideOrder = listOf(DockSide.Left, DockSide.Right, DockSide.Top, DockSide.Bottom), + layeredSides = DockSide.entries.toSet(), + direction = LayoutDirection.Ltr, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Left, extent = 90.dp)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Top, extent = 80.dp)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Right, extent = 90.dp)), + DockPanelSpec("targum", SatellitePlacement.Docked(DockSide.Bottom, extent = 80.dp)), + DockPanelSpec("comments", FLOATING), + ), + ), + LayoutProfile( + name = "rows rtl", + sideOrder = listOf(DockSide.Top, DockSide.Bottom, DockSide.Right, DockSide.Left), + layeredSides = setOf(DockSide.Top, DockSide.Bottom), + direction = LayoutDirection.Rtl, + specs = + listOf( + DockPanelSpec("tree", SatellitePlacement.Docked(DockSide.Top, extent = 80.dp)), + DockPanelSpec("toc", SatellitePlacement.Docked(DockSide.Top, order = 1, extent = 80.dp)), + DockPanelSpec("notes", SatellitePlacement.Docked(DockSide.Right, weight = 2f)), + DockPanelSpec("targum", SatellitePlacement.Docked(DockSide.Right, order = 1)), + DockPanelSpec("comments", SatellitePlacement.Docked(DockSide.Bottom, extent = 80.dp)), + ), + ), + ) + +/** One atomic layout change the monkey can make. Drawn uniformly. */ +private enum class DockAction { + /** Docks a satellite on a random side, at a random or appended order. */ + Dock, + + /** Lifts a docked satellite into a floating window. */ + Undock, + + /** Shows a closed satellite. */ + Open, + + /** Hides a satellite, keeping its placement. */ + Close, + + /** Sets a layered panel's own extent to a random value, tiny to huge. */ + SetExtent, + + /** Sets a split panel's weight to a random value, including a degenerate one. */ + SetWeight, + + /** Drags a random splitter with the real mouse, a random distance along its axis. */ + DragSplitter, + + /** Records the current layout for a later restore. */ + Snapshot, + + /** Restores a recorded layout — or the current one — on top of what is there. */ + Restore, + + /** Shuffles the side order. */ + ShuffleSides, + + /** Flips one side between layered and split. */ + ToggleLayered, + + /** Flips the layout direction. */ + FlipDirection, + + /** Resizes the window to a random inner size. */ + Resize, + + /** Flips the workspace-wide visibility sweep. */ + ToggleVisible, + + /** Injects a scale-factor change. */ + ChangeDpi, +} + +/** How a satellite is hosted at a given instant, the thing whose changes justify a rebuild. */ +private enum class Hosting { Docked, Floating, None } + +private class DockMonkey( + private val scope: TaoWindowTestScope, + private val fixture: DockLayoutFixture, + private val profile: LayoutProfile, + seed: Long, + private val actions: Int, +) { + private val random = Random(seed) + private val journal = MonkeyJournal("dock-monkey[${profile.name}]", seed) + private val script = monkeyScript() + private val workspace get() = fixture.workspace + private val ids = profile.specs.map { it.id } + private val snapshots = ArrayList() + private var worstStallMillis = 0L + + /** Hosting changes seen per satellite: the only thing that may rebuild a body. */ + private val hostingChanges = HashMap() + private var lastHosting: Map = emptyMap() + + suspend fun run() { + System.err.println("[dock-monkey] profile=${profile.name} seed=${journal.seed} actions=$actions") + lastHosting = currentHosting() + val watchdog = MainLoopWatchdog("dock-monkey", journal::report).start() + try { + while (journal.step < actions) { + val action = nextAction() ?: break + journal.record(action) + monkeyAction({ journal.failure("$action never returned", describe()) }) { apply(action) } + scope.settle(STEP_SETTLE_MILLIS) + noteHosting() + checkStepInvariants() + if ((journal.step + 1) % CHECKPOINT_EVERY == 0) checkpoint() + journal.step++ + } + } finally { + worstStallMillis = watchdog.stop() + } + } + + private fun nextAction(): DockAction? { + val scripted = script ?: return DockAction.entries[random.nextInt(DockAction.entries.size)] + val name = scripted.getOrNull(journal.step) ?: return null + return DockAction.valueOf(name) + } + + /** + * Docks everything back into the profile's own layout and requires a clean + * result: one body per panel, no overlap, no leftover window. + */ + suspend fun quiesceAndAssert() { + workspace.visible = true + scope.window.dispatch( + TaoEventCode.SCALE_FACTOR_CHANGED, + (scope.window.scaleFactor * SCALE_MILLI).roundToInt(), + 0, + ) + scope.window.setInnerSize(PARENT_W_DP.toDouble(), PARENT_H_DP.toDouble()) + fixture.sideOrder.value = profile.sideOrder + fixture.layeredSides.value = profile.layeredSides + fixture.direction.value = profile.direction + for ((index, id) in ids.withIndex()) { + workspace.open(id) + workspace.dock(id, DockSide.entries[index % DockSide.entries.size], order = index) + workspace.setDockedExtent(id, QUIESCE_EXTENT_DP.dp) + workspace.setDockedWeight(id, 1f) + } + scope.settle(SETTLE_AFTER_MAP_MILLIS) + + awaitConverges("every panel is docked with exactly one live body") { + ids.all { fixture.liveBodiesOf(it) == 1 && fixture.bodyBounds.value[it] != null } + } + awaitConverges("the docked layout is clean") { geometryProblem() == null } + awaitConverges("no floating window is left") { fixture.floatingWindows.value.isEmpty() } + awaitConverges("the run leaked no window") { TaoApplication.liveWindowCount() <= 1 + TEARDOWN_SLACK } + check(fixture.contentIncarnations.value == 1) { journal.failure("the content was rebuilt", describe()) } + + System.err.println( + "[dock-monkey] profile=${profile.name} seed=${journal.seed} survived $actions actions; " + + "worst main-dispatcher round trip ${worstStallMillis}ms; reached ${journal.reachedSummary()}", + ) + check(worstStallMillis <= MONKEY_MAX_STALL_MILLIS) { + journal.failure("the main dispatcher took ${worstStallMillis}ms to answer a heartbeat", describe()) + } + if (script == null) { + check(journal.reachedCount("splitterDragged") + journal.reachedCount("splitterSet") > 0) { + journal.failure("no splitter was ever moved", describe()) + } + check(journal.reachedCount("restored") > 0) { journal.failure("no snapshot was ever restored", describe()) } + } + } + + // ── applying one action ────────────────────────────────────────────── + + private suspend fun apply(action: DockAction) { + when (action) { + DockAction.Dock -> { + val order = if (random.nextBoolean()) null else random.nextInt(MAX_ORDER) + workspace.dock(randomId(), randomSide(), order = order) + } + DockAction.Undock -> workspace.undock(randomId()) + DockAction.Open -> workspace.open(randomId()) + DockAction.Close -> workspace.close(randomId()) + DockAction.SetExtent -> { + workspace.setDockedExtent(randomId(), (random.nextFloat() * EXTENT_SPAN_DP).dp) + journal.reach("splitterSet") + } + DockAction.SetWeight -> workspace.setDockedWeight(randomId(), random.nextFloat() * WEIGHT_SPAN - 1f) + DockAction.DragSplitter -> dragSplitter() + DockAction.Snapshot -> { + snapshots += workspace.snapshot() + if (snapshots.size > MAX_SNAPSHOTS) snapshots.removeAt(0) + } + DockAction.Restore -> { + val snapshot = snapshots.randomOrNull(random) ?: workspace.snapshot() + workspace.restore(snapshot) + journal.reach("restored") + } + DockAction.ShuffleSides, + DockAction.ToggleLayered, + DockAction.FlipDirection, + DockAction.Resize, + DockAction.ToggleVisible, + DockAction.ChangeDpi, + -> applyToTheLayout(action) + } + } + + /** The actions that change the layout's shape or its window rather than a satellite. */ + private fun applyToTheLayout(action: DockAction) { + when (action) { + DockAction.ShuffleSides -> fixture.sideOrder.value = DockSide.entries.shuffled(random) + DockAction.ToggleLayered -> { + val side = randomSide() + val current = fixture.layeredSides.value + fixture.layeredSides.value = if (side in current) current - side else current + side + } + DockAction.FlipDirection -> + fixture.direction.value = + if (fixture.direction.value == LayoutDirection.Ltr) LayoutDirection.Rtl else LayoutDirection.Ltr + DockAction.Resize -> + scope.window.setInnerSize( + MIN_INNER_W_DP + random.nextDouble(INNER_W_SPAN_DP), + MIN_INNER_H_DP + random.nextDouble(INNER_H_SPAN_DP), + ) + DockAction.ToggleVisible -> workspace.visible = !workspace.visible + DockAction.ChangeDpi -> { + val scale = SCALE_HOPS[random.nextInt(SCALE_HOPS.size)] + scope.window.dispatch(TaoEventCode.SCALE_FACTOR_CHANGED, (scale * SCALE_MILLI).roundToInt(), 0) + } + else -> error("not a layout action: $action") + } + } + + /** + * A real mouse drag on a random splitter: a press on its grip and a move + * along its axis, a flick or a deliberate drag. Falls back to the + * workspace call when the host cannot inject input. + */ + private suspend fun dragSplitter() { + val (key, grip) = + fixture.splitterBounds.value.entries + .randomOrNull(random) + ?: return journal.reach("noSplitter") + if (grip.width <= 0f || grip.height <= 0f) return journal.reach("emptySplitter") + val horizontal = grip.height > grip.width + val deltaPx = (random.nextFloat() * 2f - 1f) * DRAG_SPAN_PX + val delta = if (horizontal) Offset(deltaPx, 0f) else Offset(0f, deltaPx) + val client = + workspace.dockHostGeometry(scope.window)?.clientOriginPx() ?: return journal.reach("noClientOrigin") + val from = client + grip.center + val steps = if (random.nextBoolean()) FLICK_STEPS else ROBOT_DRAG_STEPS + val pressed = + robotPressAndDrag(from, from + delta, scope.window.scaleFactor, steps = steps, stepDelayMillis = 0L) + if (pressed == null) { + // Same change, no mouse: the panel the splitter would have moved. + val id = key.removePrefix("panel:") + if (key.startsWith("panel:")) workspace.setDockedExtent(id, (random.nextFloat() * EXTENT_SPAN_DP).dp) + journal.reach("splitterSet") + return + } + robotRelease() + journal.reach("splitterDragged") + } + + // ── invariants ─────────────────────────────────────────────────────── + + private fun currentHosting(): Map = + ids.associateWith { id -> + val entry = workspace.satellite(id) + when { + entry == null || !entry.isOpen || !workspace.visible -> Hosting.None + entry.isDocked -> Hosting.Docked + else -> Hosting.Floating + } + } + + private fun noteHosting() { + val now = currentHosting() + for (id in ids) { + if (now[id] != lastHosting[id]) hostingChanges[id] = (hostingChanges[id] ?: 0) + 1 + } + lastHosting = now + } + + /** Holds at every instant, whatever is in flight. */ + private fun checkStepInvariants() { + for (id in ids) { + val live = fixture.liveBodiesOf(id) + check(live in 0..MAX_LIVE_BODIES) { journal.failure("$id has $live live bodies", describe()) } + // One build for the first hosting plus one per hosting change; a + // layout change on its own is never one of them. + val allowed = 1 + (hostingChanges[id] ?: 0) + REBUILD_SLACK + check(fixture.incarnationsOf(id) <= allowed) { + journal.failure( + "$id was built ${fixture.incarnationsOf(id)} times for ${hostingChanges[id] ?: 0} hosting changes", + describe(), + ) + } + } + check(fixture.contentIncarnations.value == 1) { journal.failure("the content was rebuilt", describe()) } + val live = TaoApplication.liveWindowCount() + check(live <= 1 + ids.size + TEARDOWN_SLACK) { journal.failure("$live native windows are alive", describe()) } + } + + /** Holds once the dust of a step has settled. */ + private suspend fun checkpoint() { + awaitConverges("every open satellite has exactly one body") { + ids.all { id -> + val entry = workspace.satellite(id) + val expected = if (entry != null && entry.isOpen && workspace.visible) 1 else 0 + fixture.liveBodiesOf(id) == expected + } + } + awaitConverges("the layout is clean: ${geometryProblem()}") { geometryProblem() == null } + } + + /** + * What is wrong with the visible geometry, or `null`: a panel outside the + * layout, two panels overlapping, or one overlapping the content. Panels + * whose bounds have not been published yet are not judged. + */ + private fun geometryProblem(): String? { + val layout = workspace.dockHostGeometry(scope.window)?.layoutBoundsInWindowPx ?: return "no layout geometry" + val visible = + ids.filter { id -> + val entry = workspace.satellite(id) + entry != null && entry.isOpen && workspace.visible && entry.isDocked + } + val rects = visible.mapNotNull { id -> fixture.panelBounds.value[id]?.let { id to it } } + val outer = layout.inflate(LAYOUT_TOLERANCE_PX) + for ((id, rect) in rects) { + if (rect.left < outer.left || + rect.top < outer.top || + rect.right > outer.right || + rect.bottom > outer.bottom + ) { + return "$id at $rect is outside the layout $layout" + } + } + for (i in rects.indices) { + for (j in i + 1 until rects.size) { + if (overlaps(rects[i].second, rects[j].second)) { + return "${rects[i].first} ${rects[i].second} overlaps ${rects[j].first} ${rects[j].second}" + } + } + } + val content = fixture.contentBounds.value + if (content != null && content.width > 0f && content.height > 0f) { + for ((id, rect) in rects) { + if (overlaps(rect, content)) return "$id $rect overlaps the content $content" + } + } + return null + } + + private suspend fun awaitConverges( + description: String, + predicate: () -> Boolean, + ) { + val deadline = System.currentTimeMillis() + CONVERGE_MILLIS + while (!predicate()) { + check(System.currentTimeMillis() < deadline) { + journal.failure("$description did not hold within ${CONVERGE_MILLIS}ms", describe()) + } + scope.settle(CONVERGE_POLL_MILLIS) + } + } + + private fun randomId(): String = ids[random.nextInt(ids.size)] + + private fun randomSide(): DockSide = DockSide.entries[random.nextInt(DockSide.entries.size)] + + private fun describe(): String = + "profile=${profile.name} sides=${fixture.sideOrder.value} layered=${fixture.layeredSides.value} " + + "direction=${fixture.direction.value} visible=${workspace.visible} " + + "live=${TaoApplication.liveWindowCount()} content=${fixture.contentBounds.value} " + + workspace.satellites.joinToString(prefix = "satellites=[", postfix = "]") { entry -> + val placement = entry.placement + val where = + if (placement is SatellitePlacement.Docked) { + "docked(${placement.side}#${placement.order} " + + "extent=${placement.extent} weight=${placement.weight})" + } else { + "floating" + } + "${entry.id}:${if (entry.isOpen) "open" else "closed"}/$where" + + "/bounds=${fixture.panelBounds.value[entry.id]?.let(::short)}" + + "/bodies=${fixture.liveBodiesOf(entry.id)}/built=${fixture.incarnationsOf(entry.id)}" + } + + private fun short(rect: Rect): String = + "(${rect.left.roundToInt()},${rect.top.roundToInt()} ${rect.width.roundToInt()}x${rect.height.roundToInt()})" +} + +/** Enough to interleave every pair of actions a few times, short enough to run a dozen profiles. */ +private const val MONKEY_ACTIONS = 120 + +/** The reader profile once more, for longer: the layout SeforimApp would declare. */ +private const val LONG_RUN_ACTIONS = 400 + +private const val MONKEY_CASE_TIMEOUT_MILLIS = 300_000L +private const val STEP_SETTLE_MILLIS = 25L +private const val CHECKPOINT_EVERY = 10 +private const val CONVERGE_MILLIS = 5_000L +private const val CONVERGE_POLL_MILLIS = 50L + +/** Two bodies overlap for the frame in which a dock or an undock hands a panel over. */ +private const val MAX_LIVE_BODIES = 2 + +/** + * A hosting change is counted after the step settled; a panel that went + * docked → floating → docked inside one restore shows as no change and two + * builds. One step of slack absorbs that without hiding a layout rebuild, + * which happens on every splitter drag and would run away at once. + */ +private const val REBUILD_SLACK = 2 + +/** Windows dropped from composition are counted until the platform confirms the destroy. */ +private const val TEARDOWN_SLACK = 3 + +private const val MAX_ORDER = 6 +private const val MAX_SNAPSHOTS = 6 +private const val EXTENT_SPAN_DP = 500f +private const val WEIGHT_SPAN = 6f +private const val DRAG_SPAN_PX = 240f +private const val QUIESCE_EXTENT_DP = 70f + +private const val MIN_INNER_W_DP = 300.0 +private const val INNER_W_SPAN_DP = 400.0 +private const val MIN_INNER_H_DP = 220.0 +private const val INNER_H_SPAN_DP = 300.0 + +private val SCALE_HOPS = floatArrayOf(1f, 1.25f, 1.5f, 2f) +private const val SCALE_MILLI = 1000 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogAnimationHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogAnimationHeadfulCases.kt new file mode 100644 index 000000000..341556eaa --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogAnimationHeadfulCases.kt @@ -0,0 +1,266 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventLoopWatchdog +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger +import kotlin.random.Random + +/** + * The watchdog monkey against a **running app** (#643). + * + * `EventLoopWatchdogMonkeyHeadfulCases` freezes a real but idle window. An idle + * loop is the easy case: nothing is in flight when the pump stops. This one + * freezes an app that is *working* — an infinite Compose transition driving a + * frame every vsync, a second real window (`DecoratedDialog`) opening and + * closing under the storm, and the freezes landing inside that traffic, which + * is the shape #640 actually had. + * + * Two things only this can check: + * + * - **the animation survives**: after every freeze, `withFrameNanos` must tick + * again. A watchdog that perturbed the loop — a probe that posted, a callback + * that ran on the wrong thread — would show up as frames that never resume, + * and nothing in a unit test can see that. + * - **windows coming and going are tracked**: the dialog is a real second + * window, so the storm exercises `WINDOW_READY` / `DESTROYED` registration + * against a live watchdog rather than a hand-called `registerWindow`. + */ +internal object EventLoopWatchdogAnimationHeadfulCases { + fun all(): List { + val frames = AtomicInteger() + val dialogVisible = mutableStateOf(true) + return listOf( + TaoWindowTestCase( + "watchdog monkey: animating app, real dialog, real freezes (#643)", + timeoutMillis = CASE_TIMEOUT_MS, + skip = { + if (Platform.Current != Platform.Windows) { + "IsHungAppWindow is Windows-only — no non-perturbing probe elsewhere yet" + } else { + null + } + }, + dialogSize = DpSize(DIALOG_DP.dp, DIALOG_DP.dp), + dialogVisible = dialogVisible, + dialogContent = { Spinner(Color(DIALOG_ARGB)) }, + content = { + Spinner(Color(WINDOW_ARGB)) + LaunchedEffect(Unit) { + // The app's own frame pulse. It is what a wedged loop + // stops producing, and what must come back afterwards. + while (true) { + withFrameNanos { frames.incrementAndGet() } + } + } + }, + driver = { storm(frames, dialogVisible) }, + ), + ) + } + + @Suppress("LongMethod") // one flat case: setup, storm, invariants + private suspend fun TaoWindowTestScope.storm( + frames: AtomicInteger, + dialogVisible: androidx.compose.runtime.MutableState, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + awaitUntil("the app is animating") { frames.get() > FRAMES_BEFORE_START } + settle() + + val records = ConcurrentLinkedDeque() + val logger = Logger.getLogger(TaoEventLoopWatchdog::class.java.name) + val collector = + object : Handler() { + override fun publish(record: LogRecord) { + records += record + } + + override fun flush() = Unit + + override fun close() = Unit + } + logger.addHandler(collector) + + val unresponsive = AtomicInteger() + val responsive = AtomicInteger() + TaoApplication.onUnresponsive { unresponsive.incrementAndGet() } + TaoApplication.onResponsive { responsive.incrementAndGet() } + + val previousGrace = System.getProperty(GRACE_PROPERTY) + System.setProperty(GRACE_PROPERTY, "0") + TaoEventLoopWatchdog.stop() + TaoEventLoopWatchdog.start() + TaoEventLoopWatchdog.registerWindow(window.handle) + + val random = Random(monkeySeed()) + val journal = mutableListOf() + var expectedReports = 0 + try { + repeat(MOVES) { move -> + val action = AnimatedMove.entries[random.nextInt(AnimatedMove.entries.size)] + journal += "#$move $action" + val framesBefore = frames.get() + val reportsBefore = records.count { it.level == Level.SEVERE } + when (action) { + AnimatedMove.ShortFreeze -> Thread.sleep(random.nextLong(300, SHORT_FREEZE_MAX_MS)) + AnimatedMove.LongFreeze -> { + Thread.sleep(random.nextLong(LONG_FREEZE_MIN_MS, LONG_FREEZE_MAX_MS)) + expectedReports++ + // Waited for here, not counted at the end: a report that + // lands during the *next* move would otherwise read as + // "a short freeze was reported". + awaitUntil("the long freeze was reported", timeoutMillis = REPORT_TIMEOUT_MS) { + records.count { it.level == Level.SEVERE } > reportsBefore + } + } + AnimatedMove.ExpectedLongFreeze -> + TaoApplication.expectUnresponsive { + Thread.sleep(random.nextLong(LONG_FREEZE_MIN_MS, LONG_FREEZE_MAX_MS)) + } + AnimatedMove.ToggleDialog -> dialogVisible.value = !dialogVisible.value + AnimatedMove.RestartWatchdog -> { + TaoEventLoopWatchdog.stop() + TaoEventLoopWatchdog.start() + TaoEventLoopWatchdog.registerWindow(window.handle) + } + } + settle(SETTLE_MS) + + // The app must be animating again, whatever just happened to it. + val target = framesBefore + FRAMES_AFTER_MOVE + if (!awaitUntilOrTimeout(FRAME_RESUME_TIMEOUT_MS) { frames.get() > target }) { + // Stuck. Ask the window for one frame: if that unsticks it, + // the animation's own invalidation was lost rather than the + // clock being dead — a host bug, not a watchdog one, and the + // distinction is the whole value of this failure. + window.requestRedraw() + val nudged = awaitUntilOrTimeout(FRAME_RESUME_TIMEOUT_MS) { frames.get() > target } + val verdict = + if (nudged) { + "frames only resumed after an explicit requestRedraw" + } else { + "frames never resumed" + } + error("$verdict after $action; stuck at ${frames.get()} (was $framesBefore), journal=$journal") + } + + val reports = records.count { it.level == Level.SEVERE } + if (action == AnimatedMove.ShortFreeze && reports != reportsBefore) { + error("a freeze under the OS threshold was reported; journal=$journal") + } + if (action == AnimatedMove.ExpectedLongFreeze && reports != reportsBefore) { + error("a freeze inside expectUnresponsive was reported; journal=$journal") + } + } + + val reports = records.count { it.level == Level.SEVERE } + check(reports >= expectedReports) { + "only $reports report(s) for $expectedReports long freeze(s); journal=$journal" + } + awaitUntil( + "every unresponsive paired with a responsive", + timeoutMillis = PAIRING_TIMEOUT_MS, + detail = { "unresponsive=${unresponsive.get()} responsive=${responsive.get()}" }, + ) { + unresponsive.get() == responsive.get() && unresponsive.get() >= expectedReports + } + } finally { + logger.removeHandler(collector) + if (previousGrace == null) { + System.clearProperty(GRACE_PROPERTY) + } else { + System.setProperty(GRACE_PROPERTY, previousGrace) + } + dialogVisible.value = true + TaoEventLoopWatchdog.stop() + TaoEventLoopWatchdog.start() + } + + // The app is still an app: it animates, and it still holds a real frame. + val settled = frames.get() + awaitUntil("the app is still animating after the storm") { frames.get() > settled + FRAMES_AFTER_MOVE } + awaitUntil("the window still reports a real frame") { window.hasRealFramePx() } + } + + /** A cheap always-moving thing: real recomposition, real frames, real GPU work. */ + @Composable + private fun Spinner(color: Color) { + val transition = rememberInfiniteTransition(label = "watchdog-monkey") + val angle by transition.animateFloat( + initialValue = 0f, + targetValue = FULL_TURN, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = SPIN_MS, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "angle", + ) + Box(Modifier.fillMaxSize().background(Color(BACKDROP_ARGB))) { + Box( + Modifier + .size(SPINNER_DP.dp) + .rotate(angle) + .graphicsLayer { alpha = lerp(HALF_ALPHA, 1f, angle / FULL_TURN) } + .background(color), + ) + } + } + + private enum class AnimatedMove { + ShortFreeze, + LongFreeze, + ExpectedLongFreeze, + ToggleDialog, + RestartWatchdog, + } + + private const val GRACE_PROPERTY = "nucleus.tao.watchdogGraceMs" + private const val MOVES = 10 + private const val SHORT_FREEZE_MAX_MS = 2_500L + private const val LONG_FREEZE_MIN_MS = 8_000L + private const val LONG_FREEZE_MAX_MS = 11_000L + private const val SETTLE_MS = 3_000L + private const val PAIRING_TIMEOUT_MS = 20_000L + private const val REPORT_TIMEOUT_MS = 20_000L + private const val FRAME_RESUME_TIMEOUT_MS = 15_000L + private const val CASE_TIMEOUT_MS = 300_000L + private const val FRAMES_BEFORE_START = 5 + private const val FRAMES_AFTER_MOVE = 3 + private const val DIALOG_DP = 240 + private const val SPINNER_DP = 120 + private const val SPIN_MS = 1_200 + private const val FULL_TURN = 360f + private const val HALF_ALPHA = 0.4f + private const val WINDOW_ARGB = 0xFF3D7EFF + private const val DIALOG_ARGB = 0xFFFF9F0A + private const val BACKDROP_ARGB = 0xFF1E1F22 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogHeadfulCases.kt new file mode 100644 index 000000000..5edf271f7 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogHeadfulCases.kt @@ -0,0 +1,142 @@ +package dev.nucleusframework.window.tao.headful + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventLoopWatchdog +import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger +import kotlin.concurrent.thread + +/** + * End-to-end coverage for the hang watchdog (#643), in a real app process with + * a real window and the default configuration — no lowered thresholds. + * + * The case driver runs on the composition dispatcher, i.e. the event-loop + * thread itself, so a plain [Thread.sleep] there stops the message pump for + * real: the same observable state as #640's deadlock, which is all + * `IsHungAppWindow` measures. Two independent things are asserted while it is + * frozen — that the OS actually flags the window (a second thread polls the + * native probe throughout), and that the watchdog turns that into one `SEVERE` + * report carrying a thread dump that names the event-loop thread sitting in + * `nativeRunBlocking`. That log line is exactly what #640 never produced. + */ +internal object EventLoopWatchdogHeadfulCases { + fun all(): List = + listOf( + TaoWindowTestCase( + "watchdog reports a frozen event loop with a thread dump (#643)", + // The freeze alone outlasts the default case timeout. + timeoutMillis = CASE_TIMEOUT_MS, + skip = { + if (Platform.Current != Platform.Windows) { + "IsHungAppWindow is Windows-only — no non-perturbing probe elsewhere yet" + } else { + null + } + }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + settle() + + // Resolved here, while the loop still runs: the native window + // map is behind a mutex the frozen loop can be holding. + val hwnd = NativeTaoBridge.nativeHwndHandle(window.handle) + check(hwnd != 0L) { "no HWND for the case window" } + + val records = CopyOnWriteArrayList() + val logger = Logger.getLogger(TaoEventLoopWatchdog::class.java.name) + val collector = + object : Handler() { + override fun publish(record: LogRecord) { + records += record + } + + override fun flush() = Unit + + override fun close() = Unit + } + logger.addHandler(collector) + + // Electron parity: the app hears about the stall and its end. + val unresponsive = AtomicInteger() + val responsive = AtomicInteger() + TaoApplication.onUnresponsive { unresponsive.incrementAndGet() } + TaoApplication.onResponsive { responsive.incrementAndGet() } + + // Independent witness: the OS's own verdict, sampled from a + // thread the freeze does not touch. + val osFlaggedHung = AtomicBoolean(false) + val stop = AtomicBoolean(false) + val observer = + thread(isDaemon = true, name = "watchdog-case-observer") { + try { + while (!stop.get()) { + if (NativeTaoBridge.nativeIsWindowHung(hwnd)) osFlaggedHung.set(true) + Thread.sleep(OBSERVE_INTERVAL_MS) + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() // stopped by the case + } + } + + try { + // The freeze. Blocking, on the event-loop thread, on + // purpose — the window really stops responding and Windows + // really ghosts it. + Thread.sleep(FREEZE_MS) + + // Back on our feet: give the watchdog a sample to see it. + // Both, not just the log: the watchdog logs the recovery + // before handing it to the app, so asserting the callback + // right after the log record would race the event thread. + awaitUntil( + "watchdog reported the recovery and told the app", + timeoutMillis = RECOVERY_TIMEOUT_MS, + detail = { records.joinToString { "${it.level}: ${it.message.lineSequence().first()}" } }, + ) { + records.any { it.level == Level.INFO && "responded again" in it.message } && + responsive.get() == 1 + } + } finally { + stop.set(true) + observer.interrupt() + logger.removeHandler(collector) + } + + check(osFlaggedHung.get()) { + "IsHungAppWindow never flagged the window during a ${FREEZE_MS}ms freeze — " + + "the probe, not the watchdog, is what failed" + } + + val stalls = records.filter { it.level == Level.SEVERE } + check(stalls.size == 1) { + "expected exactly one SEVERE stall report, got ${stalls.size}: " + + stalls.joinToString { it.message.lineSequence().first() } + } + val report = stalls.single().message + check("has not pumped messages" in report) { "unexpected report: ${report.lineSequence().first()}" } + check("(Tao event loop)" in report) { "the report does not mark the event-loop thread:\n$report" } + check("nativeRunBlocking" in report) { + "the dump does not show the loop thread inside nativeRunBlocking:\n$report" + } + check(unresponsive.get() == 1) { + "onUnresponsive fired ${unresponsive.get()} times, expected once" + } + check(responsive.get() == 1) { + "onResponsive fired ${responsive.get()} times, expected once" + } + }, + ) + + /** Well past Windows' ~5 s hung threshold plus the watchdog's default grace. */ + private const val FREEZE_MS = 20_000L + private const val OBSERVE_INTERVAL_MS = 500L + private const val RECOVERY_TIMEOUT_MS = 15_000L + private const val CASE_TIMEOUT_MS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogMonkeyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogMonkeyHeadfulCases.kt new file mode 100644 index 000000000..1f265db80 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/EventLoopWatchdogMonkeyHeadfulCases.kt @@ -0,0 +1,233 @@ +package dev.nucleusframework.window.tao.headful + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventLoopWatchdog +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger +import kotlin.random.Random + +/** + * The watchdog monkey against a **real window** (#643). + * + * `TaoEventLoopWatchdogMonkeyTest` hammers the lifecycle with a fake probe: it + * finds races, and it found three, but every sample it takes is a lie. This one + * takes none: a real `DecoratedWindow`, the real Tao event loop, the real + * `IsHungAppWindow`, and freezes made the only way a freeze can be made — by + * blocking the thread that pumps messages, which is the thread this driver runs + * on. + * + * The moves are the ones an app really makes, in a random order: + * + * - a **short** freeze, below Windows' own ~5 s threshold: must produce nothing, + * - a **long** freeze: must produce exactly one report, paired with its recovery, + * - a long freeze inside `expectUnresponsive { }`: must produce nothing, + * - a listener that throws, and one that calls back into the watchdog, + * - the watchdog stopped and started under the app's feet. + * + * What it asserts is what an app can rely on: no report without a real freeze, + * no freeze past the threshold without a report, every `unresponsive` paired, + * and — the part only a real window can check — the window still lives, paints + * and reports a frame once the storm is over. + */ +internal object EventLoopWatchdogMonkeyHeadfulCases { + @Suppress("LongMethod") // one flat case: setup, storm, invariants + fun all(): List = + listOf( + TaoWindowTestCase( + "watchdog monkey: real window, real freezes (#643)", + timeoutMillis = CASE_TIMEOUT_MS, + skip = { + if (Platform.Current != Platform.Windows) { + "IsHungAppWindow is Windows-only — no non-perturbing probe elsewhere yet" + } else { + null + } + }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + settle() + + val records = ConcurrentLinkedDeque() + val logger = Logger.getLogger(TaoEventLoopWatchdog::class.java.name) + val collector = + object : Handler() { + override fun publish(record: LogRecord) { + records += record + } + + override fun flush() = Unit + + override fun close() = Unit + } + logger.addHandler(collector) + + val unresponsive = AtomicInteger() + val responsive = AtomicInteger() + + fun countingHandlers() { + TaoApplication.onUnresponsive { unresponsive.incrementAndGet() } + TaoApplication.onResponsive { responsive.incrementAndGet() } + } + countingHandlers() + + // Shorten the grace so a storm of real freezes fits in a case: + // the OS's own ~5 s threshold stays, which is what keeps the + // freezes honest. Applied by restarting the watchdog, since the + // grace is read when a run starts. + val previousGrace = System.getProperty(GRACE_PROPERTY) + System.setProperty(GRACE_PROPERTY, "0") + TaoEventLoopWatchdog.stop() + TaoEventLoopWatchdog.start() + TaoEventLoopWatchdog.registerWindow(window.handle) + + val random = Random(monkeySeed()) + val journal = mutableListOf() + var expectedReports = 0 + try { + repeat(MOVES) { move -> + val action = MonkeyMove.entries[random.nextInt(MonkeyMove.entries.size)] + journal += "#$move $action" + val before = records.count { it.level == Level.SEVERE } + when (action) { + MonkeyMove.ShortFreeze -> Thread.sleep(random.nextLong(300, SHORT_FREEZE_MAX_MS)) + MonkeyMove.LongFreeze -> { + Thread.sleep(random.nextLong(LONG_FREEZE_MIN_MS, LONG_FREEZE_MAX_MS)) + expectedReports++ + // Waited for here, not counted at the end: a + // report that lands during the *next* move would + // otherwise read as "a short freeze was + // reported". Each long freeze answers for itself. + awaitUntil( + "the long freeze was reported", + timeoutMillis = REPORT_TIMEOUT_MS, + ) { + records.count { it.level == Level.SEVERE } > before + } + } + MonkeyMove.ExpectedLongFreeze -> + TaoApplication.expectUnresponsive { + Thread.sleep(random.nextLong(LONG_FREEZE_MIN_MS, LONG_FREEZE_MAX_MS)) + } + MonkeyMove.HostileListener -> + TaoApplication.onUnresponsive { + unresponsive.incrementAndGet() + error("hostile listener") + } + MonkeyMove.ReentrantListener -> + TaoApplication.onUnresponsive { + unresponsive.incrementAndGet() + TaoEventLoopWatchdog.registerWindow(window.handle) + } + MonkeyMove.CleanListener -> countingHandlers() + MonkeyMove.RestartWatchdog -> { + TaoEventLoopWatchdog.stop() + TaoEventLoopWatchdog.start() + TaoEventLoopWatchdog.registerWindow(window.handle) + } + } + // Let the watchdog take its samples with the loop alive: + // suspending keeps the pump running, which is what makes + // the window healthy again. + settle(SETTLE_MS) + val after = records.count { it.level == Level.SEVERE } + if (action == MonkeyMove.ShortFreeze && after != before) { + fail(journal, "a ${SHORT_FREEZE_MAX_MS}ms freeze was reported", records) + } + if (action == MonkeyMove.ExpectedLongFreeze && after != before) { + fail(journal, "a freeze inside expectUnresponsive was reported", records) + } + } + + // Every unguarded long freeze must have been reported. The + // OS flag is the floor, not the ceiling: a report may also + // land one sample late, so this is a lower bound. + val reports = records.count { it.level == Level.SEVERE } + if (reports < expectedReports) { + fail(journal, "only $reports report(s) for $expectedReports long freeze(s)", records) + } + + countingHandlers() + awaitUntil( + "every unresponsive paired with a responsive", + timeoutMillis = PAIRING_TIMEOUT_MS, + detail = { "unresponsive=${unresponsive.get()} responsive=${responsive.get()}" }, + ) { + unresponsive.get() == responsive.get() && unresponsive.get() >= expectedReports + } + } finally { + logger.removeHandler(collector) + if (previousGrace == null) { + System.clearProperty(GRACE_PROPERTY) + } else { + System.setProperty(GRACE_PROPERTY, previousGrace) + } + TaoEventLoopWatchdog.stop() + TaoEventLoopWatchdog.start() + } + + // The part only a real window can answer: the storm left the app + // alive. The loop pumps, the window paints, and the OS agrees. + window.requestRedraw() + awaitUntil("the window still reports a real frame") { window.hasRealFramePx() } + // Polled, not sampled once: `stop()` does not join, so a thread + // already inside a sample outlives it by up to one poll interval + // (2 s in a real app). What must not happen is threads piling up. + + fun liveWatchdogs() = + Thread + .getAllStackTraces() + .keys + .count { it.isAlive && it.name == "nucleus-tao-watchdog" } + awaitUntil( + "the storm left at most one watchdog thread", + timeoutMillis = THREAD_SETTLE_MS, + detail = { "${liveWatchdogs()} alive" }, + ) { + liveWatchdogs() <= 1 + } + }, + ) + + private fun fail( + journal: List, + reason: String, + records: Collection, + ): Nothing = + error( + buildString { + appendLine(reason) + appendLine(" seed: ${monkeySeed()} (replay with -D$MONKEY_SEED_PROPERTY=${monkeySeed()})") + appendLine(" moves:") + journal.forEach { appendLine(" $it") } + records + .filter { it.level == Level.SEVERE } + .forEach { appendLine(" report: ${it.message.lineSequence().first()}") } + }, + ) + + private enum class MonkeyMove { + ShortFreeze, + LongFreeze, + ExpectedLongFreeze, + HostileListener, + ReentrantListener, + CleanListener, + RestartWatchdog, + } + + private const val GRACE_PROPERTY = "nucleus.tao.watchdogGraceMs" + private const val MOVES = 12 + private const val SHORT_FREEZE_MAX_MS = 2_500L + private const val LONG_FREEZE_MIN_MS = 8_000L + private const val LONG_FREEZE_MAX_MS = 11_000L + private const val SETTLE_MS = 3_000L + private const val PAIRING_TIMEOUT_MS = 20_000L + private const val REPORT_TIMEOUT_MS = 20_000L + private const val THREAD_SETTLE_MS = 10_000L + private const val CASE_TIMEOUT_MS = 300_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/FrameResumeAfterFreezeHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/FrameResumeAfterFreezeHeadfulCases.kt new file mode 100644 index 000000000..6be6f212e --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/FrameResumeAfterFreezeHeadfulCases.kt @@ -0,0 +1,145 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform +import java.util.concurrent.atomic.AtomicInteger + +/** + * A window that stops painting after a long freeze, even though its loop is + * pumping again (host bug, found by the #643 watchdog monkey). + * + * `TaoWindow.requestRedraw` latches [TaoWindow] `redrawPending` to coalesce, and + * only the matching `REDRAW_REQUESTED` clears it. The code already knows two + * ways the OS can swallow that event and leave the latch armed forever — a + * nested modal pump (`resetRedrawLatch`) and an occluding modal child (the + * `FOCUSED` branch) — and both carry the same symptom in their comments: + * "frozen until I click on it again". + * + * This is the third way. A freeze past ~5 s makes Windows ghost the window; the + * redraw that was in flight when the thread blocked never comes back, so an app + * that recovers from a long synchronous operation keeps a live event loop and a + * dead picture — which is strictly worse than the freeze, because nothing + * suggests the app is still there. + * + * The case is deliberately watchdog-free: it freezes, waits, and if frames have + * not resumed it tries the two repairs in order — a plain `requestRedraw` + * (a no-op while the latch is armed, so it proves nothing on its own) and then + * `resetRedrawLatch`. Which one revives the animation names the culprit. + */ +internal object FrameResumeAfterFreezeHeadfulCases { + fun all(): List { + val frames = AtomicInteger() + val dialogVisible = mutableStateOf(true) + return listOf( + TaoWindowTestCase( + "frames resume after a long freeze (#643 host lead)", + timeoutMillis = CASE_TIMEOUT_MS, + skip = { + // The ghosting that swallows the redraw is Windows'. + if (Platform.Current != Platform.Windows) "window ghosting is a Windows behaviour" else null + }, + dialogSize = DpSize(DIALOG_DP.dp, DIALOG_DP.dp), + dialogVisible = dialogVisible, + dialogContent = { Box(Modifier.fillMaxSize().background(Color(DIALOG_ARGB))) }, + content = { + val transition = rememberInfiniteTransition(label = "frame-resume") + val angle by transition.animateFloat( + initialValue = 0f, + targetValue = FULL_TURN, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = SPIN_MS, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "angle", + ) + Box(Modifier.fillMaxSize().background(Color(BACKDROP_ARGB))) { + Box(Modifier.size(SPINNER_DP.dp).rotate(angle).background(Color(SPINNER_ARGB))) + } + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { frames.incrementAndGet() } + } + } + }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + awaitUntil("the app is animating") { frames.get() > FRAMES_BEFORE } + settle() + + repeat(FREEZES) { round -> + // The dialog is what makes the main window an occluded one, + // which is the state whose dropped redraws the host already + // repairs on FOCUSED. Freezing across that transition is the + // variant nothing covers. + when (round % DIALOG_PHASES) { + 1 -> dialogVisible.value = false + 2 -> dialogVisible.value = true + else -> Unit + } + settle(DIALOG_SETTLE_MS) + val before = frames.get() + // Long enough for Windows to ghost the window, which is what + // swallows the redraw that was in flight. + Thread.sleep(FREEZE_MS) + + if (awaitUntilOrTimeout(RESUME_TIMEOUT_MS) { frames.get() > before + FRAMES_AFTER }) { + return@repeat + } + // Stalled. A plain request first: it is a no-op while the + // latch is armed, so if this revives the window the latch was + // not the problem. + window.requestRedraw() + val plainWorked = awaitUntilOrTimeout(RESUME_TIMEOUT_MS) { frames.get() > before + FRAMES_AFTER } + if (plainWorked) { + error("round $round: frames only resumed after a plain requestRedraw (invalidation dropped)") + } + window.resetRedrawLatch() + val latchWorked = awaitUntilOrTimeout(RESUME_TIMEOUT_MS) { frames.get() > before + FRAMES_AFTER } + error( + if (latchWorked) { + "round $round: the redraw latch was stuck — resetRedrawLatch revived the window, " + + "so a ${FREEZE_MS}ms freeze leaves an app with a live loop and a dead picture" + } else { + "round $round: frames never resumed, and clearing the redraw latch did not help" + }, + ) + } + }, + ) + } + + private const val FREEZES = 6 + private const val FREEZE_MS = 7_000L + private const val RESUME_TIMEOUT_MS = 4_000L + private const val FRAMES_BEFORE = 5 + private const val FRAMES_AFTER = 3 + private const val CASE_TIMEOUT_MS = 180_000L + private const val DIALOG_DP = 220 + private const val DIALOG_PHASES = 3 + private const val DIALOG_SETTLE_MS = 700L + private const val DIALOG_ARGB = 0xFFFF9F0A + private const val SPINNER_DP = 120 + private const val SPIN_MS = 1_200 + private const val FULL_TURN = 360f + private const val SPINNER_ARGB = 0xFF3D7EFF + private const val BACKDROP_ARGB = 0xFF1E1F22 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulRobot.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulRobot.kt index e537d416d..4152d3b1d 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulRobot.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/HeadfulRobot.kt @@ -2,7 +2,12 @@ package dev.nucleusframework.window.tao.headful import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.awt.MouseInfo +import java.awt.Point +import java.awt.Rectangle import java.awt.Robot +import java.awt.event.InputEvent +import java.awt.image.BufferedImage import java.util.concurrent.CompletableFuture import java.util.concurrent.ExecutionException import java.util.concurrent.TimeUnit @@ -22,10 +27,19 @@ import java.util.concurrent.TimeoutException * (`sun.awt.screencast.ScreencastHelper`). When the compositor refuses the * session ("Session is not allowed to call NotifyPointer methods"), * `mousePress` blocks forever inside the native call. + * - Linux/X11: `createScreenCapture` deadlocks on the Tao thread (#658). The + * JDK 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 that same non-recursive mutex via + * `gdk_threads_enter()`, so a driver capturing from inside an event + * dispatch parks its own thread for good (`futex` wait, no Java frames). + * Whether a case is inside a dispatch depends on what resumed it, which is + * why the deadlock looked like a property of the case's content. * - * So every gesture runs on [Dispatchers.IO] under a timeout, and the first - * timeout latches [unavailableReason] — later calls fail fast instead of - * parking another thread on the same wedged native lock. + * So every gesture and capture runs on [Dispatchers.IO] under a timeout, and + * the first timeout latches [unavailableReason] — later calls fail fast + * instead of parking another thread on the same wedged native lock. */ internal object HeadfulRobot { @Volatile @@ -34,10 +48,60 @@ internal object HeadfulRobot { @Volatile private var cached: Robot? = null + @Volatile + private var lastAim: String? = null + + /** + * Where the last gesture aimed and where the pointer actually ended up, or + * `null` before any gesture. + * + * A headful pointer case that times out says nothing on its own — "the + * drag started" never held — and the two ways it gets there look the same + * from the outside: the point was computed wrong (a window frame read + * before the platform had one), or the point was right and the press + * never reached the window. Reporting both the requested and the observed + * position tells them apart from a CI log. + */ + val lastAimReport: String + get() = lastAim ?: "no gesture yet" + + /** Where the last gesture aimed, in logical screen points, or `null`. */ + @Volatile + var lastAimPoint: Point? = null + private set + + /** Whether a press has been injected since the last release — see [releaseEveryButton]. */ + @Volatile + private var buttonMayBeHeld = false + + /** Records that a press is about to be injected. */ + fun notePress() { + buttonMayBeHeld = true + } + + /** Records where [x] / [y] was aimed and where the pointer landed. */ + fun noteAim( + x: Int, + y: Int, + ) { + val landed = runCatching { MouseInfo.getPointerInfo()?.location }.getOrNull() + lastAimPoint = Point(x, y) + lastAim = "aimed ($x, $y), pointer at ${landed?.let { "(${it.x}, ${it.y})" } ?: "unknown"}" + } + /** Why input injection is unusable on this host, or null while it works. */ val unavailableReason: String? get() = unavailable + /** + * Grabs [region] (logical screen points) off the event loop, or null when + * the host cannot capture — see [inject] for the failure modes and + * [unavailableReason] for the latched cause. Never call + * `Robot.createScreenCapture` on the Tao thread directly: see the Linux + * bullet above. + */ + suspend fun capture(region: Rectangle): BufferedImage? = inject { it.createScreenCapture(region) } + /** * Runs [gesture] with a shared [Robot] off the event loop, giving up after * [timeoutMillis]. Returns null when the host cannot inject input — the @@ -46,6 +110,7 @@ internal object HeadfulRobot { * [gesture] runs on an IO thread, so blocking `Thread.sleep` pauses between * synthetic events are fine (and are what Robot's own autoDelay does). */ + @Suppress("SwallowedException") suspend fun inject( timeoutMillis: Long = INJECT_TIMEOUT_MILLIS, gesture: (Robot) -> T, @@ -57,23 +122,56 @@ internal object HeadfulRobot { val future = CompletableFuture.supplyAsync { gesture(robot()) } try { future.get(timeoutMillis, TimeUnit.MILLISECONDS) - } catch (_: TimeoutException) { + } catch (t: TimeoutException) { unavailable = "AWT Robot injection blocked for ${timeoutMillis}ms (see HeadfulRobot)" + System.err.println("[HeadfulRobot] unavailable: $unavailable") null } catch (e: ExecutionException) { unavailable = "AWT Robot injection failed: ${e.cause ?: e}" + System.err.println("[HeadfulRobot] unavailable: $unavailable") null } } } + /** + * Lets go of every mouse button, whatever the case that held one did. + * + * A case that fails between its press and its release leaves the button + * down *at the X server*, and a `mousePress` on an already-pressed button + * is a no-op: every later robot case then aims correctly, moves the + * pointer correctly, and receives nothing. One red case turns the whole + * rest of the robot suite red with it, and the log gives no hint that the + * first one is the only real failure. Run after every case. + * + * Only after a press, though: `CRobot.mouseEvent` segfaults the JVM on + * macOS when it is asked to release a button that was never pressed, and + * that would take down a suite where most cases never touch the robot at + * all. + */ + suspend fun releaseEveryButton() { + if (unavailable != null || !buttonMayBeHeld) return + buttonMayBeHeld = false + inject { robot -> + for (mask in BUTTON_MASKS) robot.mouseRelease(mask) + true + } + } + private fun robot(): Robot = cached ?: Robot() .apply { autoDelay = AUTO_DELAY_MILLIS - isAutoWaitForIdle = true + isAutoWaitForIdle = false }.also { cached = it } + private val BUTTON_MASKS = + intArrayOf( + InputEvent.BUTTON1_DOWN_MASK, + InputEvent.BUTTON2_DOWN_MASK, + InputEvent.BUTTON3_DOWN_MASK, + ) + private const val INJECT_TIMEOUT_MILLIS = 5_000L private const val AUTO_DELAY_MILLIS = 30 } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ImeHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ImeHeadfulCases.kt index 1be597433..a49be5305 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ImeHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/ImeHeadfulCases.kt @@ -1,6 +1,11 @@ package dev.nucleusframework.window.tao.headful +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.text.BasicTextField import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -34,8 +39,151 @@ internal object ImeHeadfulCases { listOf( kotoeriNihongoCommitsWithoutNewline(), textInputClientAnswersAndEmptyCorporateCommit(), + caretRectDiesWithTheFocusedField(), + noCaretRectBeforeAnyField(), ) + /** + * Before any field is focused the client must already answer + * `NSZeroRect`. Tao's own `firstRectForCharacterRange:` hands back the + * window corner with a *top-down* y read as a Cocoa (bottom-up) + * coordinate, which parks the input-source indicator in the bottom-left + * corner of an app that has never shown a text field — so the overrides + * are installed with the window, not with the first session. + */ + private fun noCaretRectBeforeAnyField(): TaoWindowTestCase = + TaoWindowTestCase( + name = "macOS publishes no caret rect before any text field", + timeoutMillis = CASE_TIMEOUT_MILLIS, + skip = { macOsOnly() }, + paintDefaultBackground = false, + size = DpSize(480.dp, 360.dp), + content = { Box(Modifier.fillMaxSize()) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle(FOCUS_SETTLE_MILLIS) + check(MacOsTextInputClientProbe.imeRect(window.handle) == null) { + "a window that never showed a text field must answer NSZeroRect, got " + + "${MacOsTextInputClientProbe.imeRect(window.handle)}" + } + } + + /** + * A destroyed text field must take its insertion point with it. macOS + * anchors the input-source indicator — the badge raised by a Caps Lock + * bound to keyboard-layout switching, and the one this machine's + * US/Hebrew pair shows — to `firstRectForCharacterRange:`, so a caret + * rect that outlives its field leaves the badge floating over the spot + * the field used to occupy. + * + * The session teardown both deactivates the input context and drops the + * rect. Dropping the rect is the half this case locks: `interpretKeyEvents:` + * re-activates the context on the next keystroke whatever we do, so the + * rect is what has to be gone. + */ + @Suppress("LongMethod") // one field lifecycle, walked end to end + private fun caretRectDiesWithTheFocusedField(): TaoWindowTestCase { + val fieldsVisible = mutableStateOf(true) + val secondField = FocusRequester() + val focused = AtomicBoolean(false) + return TaoWindowTestCase( + name = "macOS caret rect is dropped with the focused field", + timeoutMillis = CASE_TIMEOUT_MILLIS, + skip = { macOsOnly() }, + paintDefaultBackground = false, + size = DpSize(480.dp, 360.dp), + content = { + if (fieldsVisible.value) { + twoImeFields(secondField, focused) + } else { + Box(Modifier.fillMaxSize()) + } + }, + ) { + val handle = window.handle + awaitUntil("window mapped") { bounds() != null } + awaitUntil("first field focused") { focused.get() } + awaitUntil("caret rect published") { MacOsTextInputClientProbe.imeRect(handle) != null } + val firstRect = MacOsTextInputClientProbe.imeRect(handle) + + // Focus moves field-to-field: the incoming session activates + // before the outgoing one is torn down, so the teardown must not + // take the caret the new field just published with it. + secondField.requestFocus() + awaitUntil("caret rect follows the newly focused field") { + val rect = MacOsTextInputClientProbe.imeRect(handle) + rect != null && rect != firstRect + } + + fieldsVisible.value = false + awaitUntil("caret rect dropped with the fields") { + MacOsTextInputClientProbe.imeRect(handle) == null + } + + // The keystroke that re-activates the input context must not + // bring the dead caret back with it. + check(MacOsKotoeriProbe.postKey(handle, MacOsKotoeriProbe.KEY_N, "n", down = true)) { + "keyDown was not delivered" + } + check(MacOsKotoeriProbe.postKey(handle, MacOsKotoeriProbe.KEY_N, "n", down = false)) { + "keyUp was not delivered" + } + settle(POST_TYPE_SETTLE_MILLIS) + check(MacOsTextInputClientProbe.imeRect(handle) == null) { + "a keystroke after the fields are gone republished a caret rect: " + + "${MacOsTextInputClientProbe.imeRect(handle)}" + } + + // …and a field composed again gets its caret published back. + focused.set(false) + fieldsVisible.value = true + awaitUntil("field focused again") { focused.get() } + awaitUntil("caret rect published again") { + MacOsTextInputClientProbe.imeRect(handle) != null + } + } + } + + /** + * Two stacked fields, the first focused on composition. Stacked (not + * side by side) so the caret rects differ on the axis + * `firstRectForCharacterRange:` reports in screen coordinates. + */ + @Composable + private fun twoImeFields( + secondField: FocusRequester, + focused: AtomicBoolean, + ) { + val firstField = remember { FocusRequester() } + var top by remember { mutableStateOf(TextFieldValue("top")) } + var bottom by remember { mutableStateOf(TextFieldValue("bottom")) } + LaunchedEffect(Unit) { + firstField.requestFocus() + focused.set(true) + } + Column(Modifier.fillMaxSize()) { + BasicTextField( + value = top, + onValueChange = { top = it }, + modifier = + Modifier + .fillMaxWidth() + .height(FIELD_HEIGHT_DP.dp) + .focusRequester(firstField), + ) + Spacer(Modifier.height(FIELD_GAP_DP.dp)) + BasicTextField( + value = bottom, + onValueChange = { bottom = it }, + modifier = + Modifier + .fillMaxWidth() + .height(FIELD_HEIGHT_DP.dp) + .focusRequester(secondField), + ) + } + } + private fun kotoeriNihongoCommitsWithoutNewline(): TaoWindowTestCase { val value = AtomicReference("") val composition = AtomicReference(null) @@ -299,6 +447,8 @@ internal object ImeHeadfulCases { private fun Char.isJapanese(): Boolean = isKana() || this in '\u4E00'..'\u9FFF' || this in '\uFF66'..'\uFF9D' + private const val FIELD_HEIGHT_DP = 40 + private const val FIELD_GAP_DP = 80 private const val CASE_TIMEOUT_MILLIS = 45_000L private const val FOCUS_SETTLE_MILLIS = 200L private const val IME_SWITCH_SETTLE_MILLIS = 400L diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/Issue444HeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/Issue444HeadfulCases.kt new file mode 100644 index 000000000..6bf71f569 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/Issue444HeadfulCases.kt @@ -0,0 +1,182 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.scene.TaoWaylandFrameDiagnostics +import kotlinx.coroutines.delay + +/** + * #444 — on native Wayland the content detaches from the window while an edge + * is dragged. + * + * The defect these cases gate is the geometric one: Skia is handed a render + * target wrapping the default framebuffer (`fbId = 0`) at a size of *our* + * choosing, while the buffer behind that framebuffer is only reallocated + * inside `eglSwapBuffers`. Under `SurfaceOrigin.BOTTOM_LEFT` a paint that + * overstates the height by N lands N rows off the top of the real drawable, + * leaving a band of clear colour along one edge — the flicker the issue's + * frame-by-frame analysis measured on ~41 % of frames. + * + * The measurement does not depend on the eye, on the compositor's frame clock + * or on a GPU: [TaoWaylandFrameDiagnostics] records, per render pass, the size + * Skia was told against `wl_egl_window_get_attached_size` — libwayland-egl's + * own record of the buffer the compositor holds. Any frame where the two + * disagree is the defect. + * + * [resizeStormKeepsPaintOnTheBuffer] drives the size from the client rather + * than through a pointer grab: the configure / ack / reallocate pipeline is the + * same one a dragged edge exercises, only the cadence differs, so the defect + * shows without depending on input injection reaching the compositor. + */ +internal object Issue444HeadfulCases { + fun all(): List = listOf(resizeStormKeepsPaintOnTheBuffer()) + + private const val BASE_W = 900.0 + private const val BASE_H = 700.0 + + private const val TOGGLES = 16 + private const val TOGGLE_MILLIS = 180L + private const val MIN_FRAMES = 8 + + private fun worstReport(worst: TaoWaylandFrameDiagnostics.Frame?): String = + worst?.let { + " (worst dw=${it.widthDelta} dh=${it.heightDelta}, paint=${it.paintPx}, " + + "attached=${it.attachedPx}, window=${it.windowPx})" + } ?: "" + + private fun skipUnlessNativeWayland(): String? = + when { + Platform.Current != Platform.Linux -> "#444 is a Wayland defect" + System.getenv("WAYLAND_DISPLAY").isNullOrBlank() -> + "needs a native Wayland session (WAYLAND_DISPLAY unset)" + System.getenv("NUCLEUS_TAO_LINUX_RENDERER") == "x11" -> + "renderer forced to XWayland, where the defect does not exist" + System.getenv("GDK_BACKEND") == "x11" -> "GDK forced to x11" + else -> null + } + + private fun resizeStormKeepsPaintOnTheBuffer() = + TaoWindowTestCase( + name = "#444 a resize storm paints every frame at its own buffer size", + size = DpSize(BASE_W.dp, BASE_H.dp), + timeoutMillis = 90_000, + skip = ::skipUnlessNativeWayland, + ) { + awaitUntil("window mapped") { bounds() != null } + // A Wayland surface the compositor considers occluded stops getting + // frame callbacks, the swap never completes and every render pass is + // skipped — the case would then measure nothing while looking like a + // pass. Keep the window in front for the duration of the gesture. + window.setAlwaysOnTop(true) + window.focus() + settle() + + // Compositor-driven size changes rather than `setInnerSize`. Not + // because a client resize never works — it does on Mutter 50.1, + // where #576 drives 40 distinct sizes through it — but because it + // is advisory: it is a request the compositor is free to drop, and + // a session that drops it would leave this case measuring frames + // from a window that never changed size. A maximize is the + // compositor's own state change, so the configure always follows, + // and a dragged edge is compositor-driven too, so this is also the + // closer shape to the gesture the issue is about. + val sizesSeen = linkedSetOf>() + window.onResized { w, h -> sizesSeen += listOf(w.toLong(), h.toLong()) } + + TaoWaylandFrameDiagnostics.start() + repeat(TOGGLES) { i -> + window.setMaximized(i % 2 == 0) + delay(TOGGLE_MILLIS) + } + window.setMaximized(false) + settle() + window.setAlwaysOnTop(false) + val skipped = TaoWaylandFrameDiagnostics.skipped + val frames = TaoWaylandFrameDiagnostics.stop() + // A run that resized nothing measured nothing, and every "no frame + // was painted at the wrong size" check below would hold trivially. + check(sizesSeen.size >= 2) { + "the window never changed size (${sizesSeen.size} distinct sizes seen) — " + + "nothing was measured, so the result says nothing about #444" + } + assertPaintMatchedBuffer(frames, "maximize/restore storm", sizesSeen.size, skipped) + } + + /** + * Fails when any recorded frame was painted at a size the buffer behind the + * framebuffer did not have. Prints the distribution either way — a run that + * passes because nothing resized is a run that measured nothing, which the + * frame-count floor catches. + */ + private fun assertPaintMatchedBuffer( + frames: List, + gesture: String, + distinctSizes: Int, + skippedPasses: Int, + ) { + // `attachedPx` is the buffer already committed, so it lags by design; + // the size that matters is `queriedPx`, the back buffer this frame's GL + // commands land in. + val measurable = frames.filter { it.queriedPx.height > 0 } + val mismatched = measurable.filter { it.heightDelta != 0 || it.widthDelta != 0 } + val worst = mismatched.maxByOrNull { maxOf(kotlin.math.abs(it.heightDelta), kotlin.math.abs(it.widthDelta)) } + // Defect (1) of the issue, reported but not gated here: the buffer the + // compositor actually holds while it shows the window at its new size. + // Painting at the right size does not make the buffer arrive with the + // frame — that is a commit-ordering problem between GTK's toplevel and + // our sub-surface, not a render-target one. + val behindTheWindow = frames.count { it.attachedPx.height > 0 && it.attachedPx != it.windowPx } + System.err.println( + "[#444] $gesture: $distinctSizes distinct window sizes, ${frames.size} frames, " + + "${measurable.size} with a known buffer, " + + "$behindTheWindow with a committed buffer that did not match the window (defect 1), " + + "${mismatched.size} painted at the wrong size, " + + "${measurable.count { it.reallocatedMidFrame }} reallocated mid-frame" + + worstReport(worst), + ) + mismatched.take(MIN_FRAMES).forEach { + System.err.println( + "[#444] window=${it.windowPx} paint=${it.paintPx} attached=${it.attachedPx} " + + "queried=${it.queriedPx}->${it.queriedAfterPx} requested=${it.requestedPx} " + + "dw=${it.widthDelta} dh=${it.heightDelta}", + ) + } + // #444 on non-Mesa drivers: the drawable can be reallocated *during* the + // frame, which makes the size queried up front a stale basis for the + // render target — the very premise the fix rests on. Dump those frames: + // if `queriedAfter` equals `requested`, the buffer reached the size we + // asked for mid-frame, and painting at the pre-frame size was wrong by + // exactly one step, in the opposite direction to the original defect. + val realloc = measurable.filter { it.reallocatedMidFrame } + if (realloc.isNotEmpty()) { + System.err.println("[#444] ${realloc.size} frames reallocated mid-frame:") + realloc.take(MIN_FRAMES).forEach { + System.err.println( + "[#444] REALLOC window=${it.windowPx} paint=${it.paintPx} " + + "queried=${it.queriedPx}->${it.queriedAfterPx} " + + "requested=${it.requestedPx} attached=${it.attachedPx}", + ) + } + } + check(measurable.size >= MIN_FRAMES) { + "only ${measurable.size} frames with a known buffer size were recorded during the $gesture — " + + "nothing was measured (frames=${frames.size}, $skippedPasses passes skipped on a swap still " + + "in flight). A window the compositor treats as occluded never gets its frame callbacks, so it " + + "renders nothing at all; run the suite against a nested compositor, e.g. " + + "`mutter --headless --virtual-monitor 1920x1080 --wayland-display=nested` with WAYLAND_DISPLAY set" + } + check(mismatched.isEmpty()) { + "${mismatched.size} of ${measurable.size} frames were painted at a size the buffer did not have; " + + "under SurfaceOrigin.BOTTOM_LEFT each one lands off the real drawable by that difference" + } + // Painting at the buffer's size is only right if the buffer catches the + // window up: a render that followed a drawable that never converged + // would satisfy the check above with content permanently a step small. + val last = measurable.last() + check(last.paintPx == last.windowPx) { + "the gesture settled with the frame still painted at ${last.paintPx} for a ${last.windowPx} window — " + + "the drawable never caught the window up" + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/LinuxTrackpadPinchHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/LinuxTrackpadPinchHeadfulCases.kt new file mode 100644 index 000000000..1e95789c4 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/LinuxTrackpadPinchHeadfulCases.kt @@ -0,0 +1,541 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.TitleBar +import dev.nucleusframework.window.tao.TaoEventCode +import dev.nucleusframework.window.tao.TaoMouseButton +import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs + +/** + * #660 end-to-end on Linux: a GDK touchpad pinch must reach Compose as + * `ScaleStart` / `ScaleChange` / `ScaleEnd` at the cursor. Every case emits + * `GdkEventTouchpadPinch` through the GtkWindow's `event` signal + * ([NativeTaoBridge.nativeLinuxInjectGdkTouchpadPinch]), so `touch.rs`'s + * absolute-scale / radian conversion, the JNI callback and + * `TaoComposeSceneHostLinux.onTrackpadGesture` all run as for a real pinch. + * + * Unlike AppKit, GDK reports pinch and rotation as **one** gesture: every + * event carries a scale and an angle, so `touch.rs` forwards a magnify and a + * rotate step for each. A real pinch always carries some angle noise; the + * cases below guard that it stays a Scale gesture, that a deliberate + * rotation still reaches `detectTransformGestures`, and that the rotation + * contacts never coexist with a mouse-only event. + */ +internal object LinuxTrackpadPinchHeadfulCases { + fun all(): List = + listOf( + pinchArrivesAsScaleEventsAtTheCursor(), + onePercentPinchZoomsTransformable(), + cancelledPinchClosesTheScaleGesture(), + pinchWithAngleNoiseStaysScaleOnly(), + rotationTakesOverAPinchThatDoesNotZoom(), + clickDuringRotationCancelsItWithoutATap(), + rotationInTheTitleBarNeverDragsTheWindow(), + ) + + /** Begin / Update… / End at a fixed angle: one ScaleStart, one ScaleChange per step, one ScaleEnd. */ + private fun pinchArrivesAsScaleEventsAtTheCursor(): TaoWindowTestCase { + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 Linux GDK pinch arrives as Compose Scale events at the cursor", + skip = { linuxOnly() }, + // The suite's default chrome is a fillMaxSize sibling stacked above + // [content]; leaving it on gives the recorder 0 height. + paintDefaultBackground = false, + content = { Box(Modifier.fillMaxSize().record(recorder)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + recorder.reset() + + pinch(PHASE_BEGIN, 1.0) + SCALES.forEach { pinch(PHASE_UPDATE, it) } + pinch(PHASE_END, SCALES.last()) + awaitUntil("ScaleEnd recorded") { recorder.count(PointerEventType.ScaleEnd) >= 1 } + settle() + + val events = recorder.snapshot() + val scale = events.filter { it.type.isScale() } + check(scale.map { it.type } == expectedScaleTypes(SCALES.size)) { + "one ScaleStart, one ScaleChange per update, one ScaleEnd; recorded=${recorder.describe()}" + } + // GDK's scale is absolute; each ScaleChange must be the ratio to the previous one. + val ratios = (listOf(1.0) + SCALES).zipWithNext { a, b -> (b / a).toFloat() } + scale.filter { it.type == PointerEventType.ScaleChange }.zip(ratios).forEach { (event, ratio) -> + check(abs(event.scaleFactor - ratio) <= FACTOR_TOLERANCE) { + "ScaleChange must carry GDK's per-event ratio ($ratio); recorded=${recorder.describe()}" + } + } + val cursor = Offset(TARGET_X * window.scaleFactor, TARGET_Y * window.scaleFactor) + scale.forEach { + check((it.position - cursor).getDistance() <= POSITION_TOLERANCE_PX) { + "Scale events must sit at the cursor $cursor (got ${it.position}); recorded=${recorder.describe()}" + } + } + check(events.none { it.pointerType == PointerType.Touch }) { + "a pinch must not synthesise Touch contacts; recorded=${recorder.describe()}" + } + check(events.none { it.type == PointerEventType.Press || it.type == PointerEventType.Scroll }) { + "a pinch must produce no Press and no Scroll; recorded=${recorder.describe()}" + } + } + } + + /** A 1 % pinch zooms `Modifier.transformable` on its first update — no touch slop. */ + private fun onePercentPinchZoomsTransformable(): TaoWindowTestCase { + val transform = Transform() + return TaoWindowTestCase( + name = "#660 Linux GDK 1% pinch zooms Modifier.transformable with no slop", + skip = { linuxOnly() }, + paintDefaultBackground = false, + content = { Transformable(transform) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + transform.reset() + + pinch(PHASE_BEGIN, 1.0) + pinch(PHASE_UPDATE, 1.01) + awaitUntilOrTimeout(REACTION_MILLIS) { transform.zoom != 1f } + check(abs(transform.zoom - 1.01f) <= FACTOR_TOLERANCE) { + "the first 1% update must zoom the transformable at once (zoom=${transform.zoom})" + } + pinch(PHASE_UPDATE, 0.99) + pinch(PHASE_END, 0.99) + awaitUntilOrTimeout(REACTION_MILLIS) { transform.zoom < 1f } + check(abs(transform.zoom - 0.99f) <= FACTOR_TOLERANCE) { + "the pinch-out must land on GDK's absolute 0.99 (zoom=${transform.zoom})" + } + } + } + + /** A pinch the compositor cancels still closes with exactly one ScaleEnd. */ + private fun cancelledPinchClosesTheScaleGesture(): TaoWindowTestCase { + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 Linux GDK cancelled pinch closes the Scale gesture", + skip = { linuxOnly() }, + paintDefaultBackground = false, + content = { Box(Modifier.fillMaxSize().record(recorder)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + recorder.reset() + + pinch(PHASE_BEGIN, 1.0) + pinch(PHASE_UPDATE, 1.02) + pinch(PHASE_CANCEL, 1.02) + awaitUntil("ScaleEnd recorded") { recorder.count(PointerEventType.ScaleEnd) >= 1 } + settle() + check(recorder.snapshot().filter { it.type.isScale() }.map { it.type } == expectedScaleTypes(1)) { + "a cancelled pinch must close with one ScaleEnd; recorded=${recorder.describe()}" + } + check(recorder.snapshot().none { it.pointerType == PointerType.Touch }) { + "a cancelled pinch must press no touch contact; recorded=${recorder.describe()}" + } + } + } + + /** + * The shape of a real pinch: every update zooms and carries a degree or so + * of rotation. It must stay a pure Scale gesture — no touch contact ever + * pressed (a Scale event lists only the mouse pointer, so contacts pressed + * alongside read as released on every scale step and re-pressed on every + * rotate step: a touch tap per update) and every update zooms + * `Modifier.transformable` exactly once. + */ + private fun pinchWithAngleNoiseStaysScaleOnly(): TaoWindowTestCase { + val transform = Transform() + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 Linux GDK pinch with angle noise stays Scale-only", + skip = { linuxOnly() }, + paintDefaultBackground = false, + content = { Transformable(transform, Modifier.record(recorder)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + transform.reset() + recorder.reset() + + var scale = 1.0 + pinch(PHASE_BEGIN, scale, NOISE_RADIANS) + repeat(NOISY_STEPS) { step -> + scale *= NOISY_STEP_RATIO + pinch(PHASE_UPDATE, scale, if (step % 2 == 0) NOISE_RADIANS else -NOISE_RADIANS / 2) + } + pinch(PHASE_END, scale) + awaitUntil("ScaleEnd recorded") { recorder.count(PointerEventType.ScaleEnd) >= 1 } + settle() + + check(recorder.snapshot().none { it.pointerType == PointerType.Touch }) { + "angle noise inside a pinch must press no touch contact; recorded=${recorder.describe()}" + } + check(recorder.snapshot().filter { it.type.isScale() }.map { it.type } == expectedScaleTypes(NOISY_STEPS)) { + "every update must be one ScaleChange; recorded=${recorder.describe()}" + } + check(abs(transform.zoom - scale.toFloat()) <= FACTOR_TOLERANCE) { + "every update must zoom transformable exactly once (zoom=${transform.zoom}, expected $scale)" + } + check(transform.rotation == 0f) { "a pinch must not rotate (rotation=${transform.rotation})" } + } + } + + /** + * A two-finger twist that barely zooms: the pinch opens as Scale (no delay + * for the common case), and once the rotation clearly dominates it takes + * the gesture over — the Scale gesture closes, the contacts go down once + * and up once, and `detectTransformGestures` rotates clockwise for GDK's + * clockwise (positive) `angle_delta`. + */ + private fun rotationTakesOverAPinchThatDoesNotZoom(): TaoWindowTestCase { + val transform = Transform() + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 Linux GDK rotation takes over a pinch that does not zoom", + skip = { linuxOnly() }, + paintDefaultBackground = false, + content = { Box(Modifier.fillMaxSize().record(recorder).detectTransform(transform)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + transform.reset() + recorder.reset() + + twist() + awaitUntil("rotation reached detectTransformGestures") { transform.rotation != 0f } + settle() + + val events = recorder.snapshot() + check(events.count { it.down } == 2 && events.count { it.up } == 2) { + "the two contacts must go down once and up once; recorded=${recorder.describe()}" + } + val starts = events.count { it.type == PointerEventType.ScaleStart } + check(starts <= 1 && events.count { it.type == PointerEventType.ScaleEnd } == starts) { + "the pinch's Scale gesture opens at most once and closes at the takeover; " + + "recorded=${recorder.describe()}" + } + val firstDown = events.indexOfFirst { it.down } + check(events.drop(firstDown).none { it.type.isScale() }) { + "no Scale event may reach the scene while the contacts are down; recorded=${recorder.describe()}" + } + check(transform.rotation > 0f) { + "GDK's positive angle_delta is clockwise: Compose must rotate clockwise " + + "(rotation=${transform.rotation})" + } + check(abs(transform.rotation - TWIST_TOTAL_DEGREES) <= ROTATION_TOLERANCE_DEGREES) { + "the rotation must reach detectTransformGestures in full, the takeover's own " + + "degrees included (rotation=${transform.rotation}, twisted $TWIST_TOTAL_DEGREES°)" + } + } + } + + /** + * A real click during a rotation would reach the scene as a mouse-only + * event, i.e. the contacts' release — a touch tap. It cancels the + * rotation instead, and the rest of that gesture is ignored. + */ + private fun clickDuringRotationCancelsItWithoutATap(): TaoWindowTestCase { + val taps = AtomicInteger() + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 Linux GDK click during a rotation cancels it without a tap", + skip = { linuxOnly() }, + paintDefaultBackground = false, + content = { + Box( + Modifier + .fillMaxSize() + .record(recorder) + .pointerInput(taps) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + event.changes.forEach { + val touchUp = it.type == PointerType.Touch && it.changedToUpIgnoreConsumed() + if (touchUp && !it.isConsumed) taps.incrementAndGet() + } + } + } + }, + ) + }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + // The click lands where the cursor is: put it on the gesture first, + // so the press is neither a move (itself an interruption) nor a + // press in the resize band at (0, 0). + moveCursor(TARGET_X, TARGET_Y) + settle() + recorder.reset() + taps.set(0) + + pinch(PHASE_BEGIN, 1.0) + repeat(TWIST_STEPS / 2) { pinch(PHASE_UPDATE, 1.0, TWIST_STEP_RADIANS) } + awaitUntil("the rotation took the gesture over") { recorder.snapshot().any { it.down } } + window.dispatch(TaoEventCode.MOUSE_DOWN, TaoMouseButton.LEFT, 0) + window.dispatch(TaoEventCode.MOUSE_UP, TaoMouseButton.LEFT, 0) + repeat(TWIST_STEPS / 2) { pinch(PHASE_UPDATE, 1.0, TWIST_STEP_RADIANS) } + pinch(PHASE_END, 1.0) + settle() + + val events = recorder.snapshot() + check(events.count { it.down && it.pointerType == PointerType.Touch } == 2) { + "the contacts must go down once — the steps after the click are ignored; " + + "recorded=${recorder.describe()}" + } + check(events.any { it.type == PointerEventType.Press && it.pointerType == PointerType.Mouse }) { + "the click must reach the scene; recorded=${recorder.describe()}" + } + check(taps.get() == 0) { + "the click must cancel the rotation, not release its contacts as a tap (${taps.get()} taps); " + + "recorded=${recorder.describe()}" + } + } + } + + /** + * The contacts of a rotation twisted over the title bar are Touch + * pointers: they must never arm the title bar's window drag. + */ + private fun rotationInTheTitleBarNeverDragsTheWindow(): TaoWindowTestCase { + val recorder = EventRecorder() + val drags = AtomicInteger() + return TaoWindowTestCase( + name = "#660 Linux GDK rotation over the title bar never drags the window", + skip = { linuxOnly() }, + paintDefaultBackground = false, + content = { + val scope = this + Column(Modifier.fillMaxSize().record(recorder)) { + with(scope) { TitleBar { _ -> } } + Box(Modifier.weight(1f).fillMaxWidth().background(Color.DarkGray)) + } + }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + window.onDragWindow { drags.incrementAndGet() } + recorder.reset() + + twist(y = TITLE_BAR_Y) + settle() + + check(recorder.snapshot().any { it.down && it.pointerType == PointerType.Touch }) { + "the rotation must have pressed its contacts over the bar; recorded=${recorder.describe()}" + } + check(drags.get() == 0) { "the rotation contacts started ${drags.get()} window drag(s)" } + } + } + + // ── Injection ─────────────────────────────────────────────────────────── + + /** A twist that zooms by under a percent and turns [TWIST_TOTAL_DEGREES] clockwise. */ + private suspend fun TaoWindowTestScope.twist(y: Int = TARGET_Y) { + pinch(PHASE_BEGIN, 1.0, y = y) + repeat(TWIST_STEPS) { step -> + pinch(PHASE_UPDATE, if (step % 2 == 0) 1.004 else 0.998, TWIST_STEP_RADIANS, y = y) + } + pinch(PHASE_END, 0.998, y = y) + } + + /** Compose's pointer, through the CURSOR_MOVED wire (content px, 1/1024 fixed point). */ + private fun TaoWindowTestScope.moveCursor( + x: Int, + y: Int, + ) { + val fixed = window.scaleFactor * CURSOR_FIXED_SCALE + window.dispatch(TaoEventCode.CURSOR_MOVED, (x * fixed).toInt(), (y * fixed).toInt()) + } + + private suspend fun TaoWindowTestScope.pinch( + phase: Int, + scale: Double, + angleDeltaRadians: Double = 0.0, + x: Int = TARGET_X, + y: Int = TARGET_Y, + ) { + // GDK reports pinch coordinates in the toplevel's GdkWindow, i.e. + // including the CSD shadow ring `touch.rs` subtracts again. + val origin = NativeTaoBridge.nativeLinuxContentOrigin(window.handle) + val delivered = + NativeTaoBridge.nativeLinuxInjectGdkTouchpadPinch( + window.handle, + phase, + x + (origin shr 32).toInt(), + y + origin.toInt(), + (scale * MICRO).toInt(), + (angleDeltaRadians * MICRO).toInt(), + ) + check(delivered) { "nativeLinuxInjectGdkTouchpadPinch returned false (window not realized?)" } + settle(STEP_MILLIS) + } + + // ── Compose content ───────────────────────────────────────────────────── + + private class Recorded( + val type: PointerEventType, + val pointerType: PointerType, + val position: Offset, + val scaleFactor: Float, + val down: Boolean, + val up: Boolean, + ) { + override fun toString(): String = + when { + type == PointerEventType.ScaleChange -> "$type($scaleFactor)" + pointerType == PointerType.Touch -> "$type(touch)" + else -> type.toString() + } + } + + /** Every pointer event seen on the Initial pass, in order (one entry per change). */ + private class EventRecorder { + private val events = Collections.synchronizedList(mutableListOf()) + + fun add(event: PointerEvent) { + event.changes.forEach { + events += + Recorded( + type = event.type, + pointerType = it.type, + position = it.position, + scaleFactor = it.scaleFactor, + down = it.changedToDownIgnoreConsumed(), + up = it.changedToUpIgnoreConsumed(), + ) + } + } + + fun snapshot(): List = synchronized(events) { events.toList() } + + /** Cases share their recorder with the registry; start each run clean. */ + fun reset() = events.clear() + + fun count(type: PointerEventType): Int = snapshot().count { it.type == type } + + fun describe(): String = snapshot().joinToString(prefix = "[", postfix = "]") + } + + private fun Modifier.record(recorder: EventRecorder): Modifier = + pointerInput(recorder) { + awaitPointerEventScope { + while (true) { + recorder.add(awaitPointerEvent(PointerEventPass.Initial)) + } + } + } + + private fun Modifier.detectTransform(transform: Transform): Modifier = + pointerInput(transform) { + detectTransformGestures { _, pan, zoom, rotation -> transform.apply(pan, zoom, rotation) } + } + + private class Transform { + @Volatile var zoom: Float = 1f + + @Volatile var rotation: Float = 0f + + fun apply( + @Suppress("UNUSED_PARAMETER") pan: Offset, + zoomChange: Float, + rotationChange: Float, + ) { + zoom *= zoomChange + rotation += rotationChange + } + + fun reset() { + zoom = 1f + rotation = 0f + } + } + + @Composable + private fun Transformable( + transform: Transform, + modifier: Modifier = Modifier, + ) { + val state = rememberTransformableState { zoom, pan, rotation -> transform.apply(pan, zoom, rotation) } + Box(Modifier.fillMaxSize().then(modifier).transformable(state)) + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private fun PointerEventType.isScale(): Boolean = + this == PointerEventType.ScaleStart || + this == PointerEventType.ScaleChange || + this == PointerEventType.ScaleEnd + + private fun expectedScaleTypes(changes: Int): List = + listOf(PointerEventType.ScaleStart) + + List(changes) { PointerEventType.ScaleChange } + + PointerEventType.ScaleEnd + + private fun linuxOnly(): String? = + if (Platform.Current != Platform.Linux) "Linux only — GdkEventTouchpadPinch injection" else null + + /** `GdkTouchpadGesturePhase`. */ + private const val PHASE_BEGIN = 0 + private const val PHASE_UPDATE = 1 + private const val PHASE_END = 2 + private const val PHASE_CANCEL = 3 + + private const val MICRO = 1_000_000.0 + + /** Must match `events.rs::CURSOR_FIXED_SCALE`. */ + private const val CURSOR_FIXED_SCALE = 1024f + + /** Widget-local logical px, well inside the 800×600 default window. */ + private const val TARGET_X = 400 + private const val TARGET_Y = 300 + + /** Inside the title bar's 40 dp band, clear of its controls. */ + private const val TITLE_BAR_Y = 18 + + /** GDK's absolute scale after each update. */ + private val SCALES = listOf(1.01, 1.03, 1.04, 1.02) + + private const val NOISY_STEPS = 12 + private const val NOISY_STEP_RATIO = 1.02 + + /** About a degree per update — what a real pinch carries. */ + private const val NOISE_RADIANS = 0.017 + + private const val TWIST_STEPS = 16 + + /** 3° per update, clockwise. */ + private const val TWIST_STEP_RADIANS = 0.05235987755982988 + private const val TWIST_TOTAL_DEGREES = 48f + private const val ROTATION_TOLERANCE_DEGREES = 12f + + private const val FACTOR_TOLERANCE = 2e-3f + private const val POSITION_TOLERANCE_PX = 1.5f + private const val STEP_MILLIS = 16L + + /** How long a transformable gets to react before the (soft) wait gives up. */ + private const val REACTION_MILLIS = 2_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacDisplayModeTool.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacDisplayModeTool.kt index 96cb57d6c..dcafd4642 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacDisplayModeTool.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacDisplayModeTool.kt @@ -33,6 +33,12 @@ internal object MacDisplayModeTool { /** `mode` is `1x`, `2x` or `query`; returns the helper's one-line report. */ fun run(mode: String): String { + // Two cases in one process can flip the display back to back — one + // restoring its original mode, the next asking for the other one. The + // WindowServer is still reconfiguring from the first flip and the + // second is applied without the JVM ever seeing a scale change, so the + // waiting case times out. Space the flips out; a query never waits. + if (mode != QUERY_MODE) awaitModeCooldown() val process = ProcessBuilder(binary.absolutePath, mode) .redirectErrorStream(true) @@ -43,9 +49,24 @@ internal object MacDisplayModeTool { .readText() .trim() val code = process.waitFor() + if (mode != QUERY_MODE) lastModeChangeNanos = System.nanoTime() return if (code == 0) out else "exit $code: $out" } + private var lastModeChangeNanos = 0L + + private fun awaitModeCooldown() { + if (lastModeChangeNanos == 0L) return + val sinceMillis = (System.nanoTime() - lastModeChangeNanos) / NANOS_PER_MILLI + if (sinceMillis < MODE_COOLDOWN_MILLIS) Thread.sleep(MODE_COOLDOWN_MILLIS - sinceMillis) + } + + private const val QUERY_MODE = "query" + private const val NANOS_PER_MILLI = 1_000_000L + + /** Long enough for the WindowServer to finish one reconfiguration before the next. */ + private const val MODE_COOLDOWN_MILLIS = 2_500L + private fun compile(): Boolean { val source = File(binary.parentFile, "${binary.name}.swift") source.writeText(SOURCE) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt index 81045e46c..1f958b144 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTextInputClientProbe.kt @@ -26,6 +26,19 @@ internal object MacOsTextInputClientProbe { ) } + /** + * The caret rect TaoView publishes to AppKit, in Cocoa screen + * coordinates. `null` when the view has no insertion point — an all-zero + * rect, which is what keeps the input-source indicator off a field that + * no longer exists. + */ + fun imeRect(handle: Long): ImeRect? { + val rect = DoubleArray(4) + if (!NativeTaoBridge.nativeMacOsQueryImeRect(handle, rect)) return null + if (rect.all { it == 0.0 }) return null + return ImeRect(rect[0], rect[1], rect[2], rect[3]) + } + fun setMarkedText( handle: Long, text: String, @@ -57,6 +70,13 @@ internal object MacOsTextInputClientProbe { replacementLength, ) + data class ImeRect( + val x: Double, + val y: Double, + val width: Double, + val height: Double, + ) + data class Snapshot( val markedLocation: Long, val markedLength: Long, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadGestureMonkeyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadGestureMonkeyHeadfulCases.kt new file mode 100644 index 000000000..e2f40c316 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadGestureMonkeyHeadfulCases.kt @@ -0,0 +1,1417 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.TitleBar +import dev.nucleusframework.window.tao.TaoDecoratedWindowScope +import dev.nucleusframework.window.tao.headful.MacTrackpadGestureProbe.Kind +import java.awt.MouseInfo +import java.time.LocalTime +import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.abs +import kotlin.random.Random + +/** + * #660 monkeys: random streams of real AppKit gesture NSEvents (magnify, + * rotate, smart-magnify) interleaved with trackpad scroll gestures, one case + * per (profile, seed), each checked against an exact model of the host. + * + * Unlike a layout monkey there *is* a right answer here: [GestureOracle] + * replays every injected event through a reference copy of the host's rules — + * the IOHID → `NSEventPhase` → wire phase mapping, the 1/10 000 fixed-point + * value, `TaoTrackpadScaleSession`, and "the gesture that begins first owns + * it" — and predicts the exact Scale stream Compose must see and how many + * times the synthetic rotation contacts go down. The invariants: + * + * - **Scale stream**: the ScaleStart / ScaleChange / ScaleEnd sequence at the + * root equals the oracle's, factor by factor; + * - **one pointer per Scale event**: Compose stamps the factor on every + * pointer and foundation multiplies it per pointer, so a second pointer is + * a double-counted zoom; + * - **no overlap**: no Scale event while a synthetic contact is pressed (a + * Scale event without them reads as their release — a touch tap); + * - **contacts**: exactly the oracle's number of touch downs (a re-press is + * a tap on whatever is under the finger), never more than two pressed; + * - **quiescence**: once every gesture is closed nothing stays pressed or + * open, and a canonical pinch zooms `Modifier.transformable` by exactly its + * factor; + * - **liveness**: `Dispatchers.Main` keeps answering ([MainLoopWatchdog]). + * + * Profiles: `trackpad` (well-formed gestures a real trackpad produces, mixed + * in either order, with swipes and momentum), `chaos` (single events with + * arbitrary phases — orphans, double Began, NSEventPhaseNone — extreme values, + * no pause at all between some of them) and `burst` (well-formed gestures + * posted back to back, hundreds deep, before the loop sees any). Every failure + * carries the profile, the seed and the last actions; + * `-Dnucleus.tao.headful.monkeySeed=` replays one. + */ +internal object MacOsTrackpadGestureMonkeyHeadfulCases { + fun all(): List = + GestureMonkeyProfile.entries.flatMap { profile -> + SEEDS.map { seed -> randomGesturesMatchTheModel(profile, seed, profile.steps) } + } + randomGesturesMatchTheModel(GestureMonkeyProfile.CHAOS, LONG_RUN_SEED, LONG_RUN_STEPS) + + DEGENERATE_ROTATIONS.map { (label, magnification) -> degenerateRotation(label, magnification) } + + listOf( + offscreenGestures(), + windowClosesWithGesturesInFlight(), + pinchWorksAfterAWindowClosedMidGesture(), + rotationOnTheTitleBarNeverDragsTheWindow(), + ) + + nightWindows() + + /** + * A rotation centred in the title bar puts its synthetic contacts on the + * window-drag area, which arms a drag on any touch press — and the + * macOS drag replays the last real mouseDown AppKit saw. After a real + * click on the bar (which leaves that mouseDown saved), rotations there + * must neither move nor maximize the window. + */ + private fun rotationOnTheTitleBarNeverDragsTheWindow(): TaoWindowTestCase { + val trace = GestureTrace() + val zoom = ZoomProbe() + val frames = AtomicLong() + return TaoWindowTestCase( + name = "#660 macOS gesture monkey degenerate: rotations on the title bar never drag the window", + skip = { macOnly() ?: robotDriverSkipReason() }, + paintDefaultBackground = false, + content = { NightContent(this, trace, zoom, frames) }, + ) { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("the animation renders") { frames.get() > NIGHT_WARMUP_FRAMES } + settle() + val scale = window.scaleFactor + // No host listens on macOS: the hook only counts the drags the bar starts. + val drags = AtomicLong() + window.onDragWindow { drags.incrementAndGet() } + val driver = + RobotPointerDriver(window) { + IntSize((WINDOW_W * scale).toInt(), (WINDOW_H * scale).toInt()) + } + var round = 0 + var attempts = 0 + while (round < TITLE_BAR_ROUNDS) { + check(++attempts <= TITLE_BAR_ROUNDS * TITLE_BAR_ATTEMPTS_PER_ROUND) { + "the machine never stayed idle long enough for a round (someone is using the mouse)" + } + // A real click on the bar, no drag: AppKit keeps that mouseDown. + driver.click(Offset(TITLE_BAR_X * scale, TITLE_BAR_Y * scale)) + settle() + // Only the rotation is measured: the click itself may drag if a + // real mouse moved between its press and release. + drags.set(0) + val cursorBefore = cursorOnScreen() + val before = checkNotNull(bounds()).copyOf() + val maximizedBefore = window.isMaximized + gesture(Kind.ROTATE, 1, TITLE_BAR_X, TITLE_BAR_Y, 0.0) + repeat(TITLE_BAR_ROTATE_STEPS) { gesture(Kind.ROTATE, 2, TITLE_BAR_X, TITLE_BAR_Y, 4.0) } + gesture(Kind.ROTATE, 4, TITLE_BAR_X, TITLE_BAR_Y, 0.0) + settle(TITLE_BAR_SETTLE_MILLIS) + if (cursorOnScreen() != cursorBefore) { + System.err.println("[gesture-monkey] title bar round $round disturbed by the real cursor; again") + continue + } + check( + drags.get() == 0L, + ) { "round $round: the rotation's contacts started ${drags.get()} window drag(s)" } + val after = bounds() + check(after != null && after.contentEquals(before)) { + "round $round: a rotation on the title bar moved the window: " + + "${before.toList()} → ${after?.toList()}" + } + check( + window.isMaximized == maximizedBefore, + ) { "round $round: a rotation on the title bar toggled maximize" } + val touches = trace.snapshot().flatMap { e -> e.changes.filter { it.type == PointerType.Touch } } + check(touches.isNotEmpty()) { "round $round: the rotation never reached the scene" } + round++ + } + } + } + + /** + * The overnight run: the three profiles back to back, seed after seed, + * in a real `DecoratedWindow` with a `TitleBar` and content animating + * every frame, for `-Dnucleus.tao.headful.monkeyNightMinutes=` (one + * short window without it). A fresh window every [NIGHT_WINDOW_MINUTES]. + * A quarter of the well-formed gestures are centred in the title bar, so + * the synthetic contacts land on the window-drag area. On top of the + * model: the window never moves, resizes, maximizes or goes fullscreen, + * and the animation keeps producing frames between checkpoints. + */ + private fun nightWindows(): List { + val minutes = System.getProperty(NIGHT_MINUTES_PROPERTY)?.toLongOrNull() + if (minutes == null || minutes <= 0) return listOf(nightWindow(0, 1, NIGHT_SMOKE_MILLIS)) + val count = ((minutes + NIGHT_WINDOW_MINUTES - 1) / NIGHT_WINDOW_MINUTES).toInt() + return List(count) { index -> + val left = minutes - index * NIGHT_WINDOW_MINUTES + nightWindow(index, count, minOf(left, NIGHT_WINDOW_MINUTES) * MILLIS_PER_MINUTE) + } + } + + private fun nightWindow( + index: Int, + count: Int, + durationMillis: Long, + ): TaoWindowTestCase { + val trace = GestureTrace() + val zoom = ZoomProbe() + val frames = AtomicLong() + val active = AtomicBoolean(true) + val minimized = AtomicBoolean(false) + return TaoWindowTestCase( + name = + "#660 macOS gesture monkey night window ${index + 1}/$count: ${durationMillis / 1000}s " + + "in a real decorated window with title bar and animation", + timeoutMillis = durationMillis + NIGHT_SLACK_MILLIS, + skip = { macOnly() }, + paintDefaultBackground = false, + content = { NightContent(this, trace, zoom, frames, active, minimized) }, + ) { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("the animation renders") { frames.get() > NIGHT_WARMUP_FRAMES } + settle() + var baseline = checkNotNull(bounds()).copyOf() + var lastFrames = frames.get() + var lastCursor = cursorOnScreen() + val drags = AtomicLong() + window.onDragWindow { drags.incrementAndGet() } + val chrome: () -> String? = { + val cursor = cursorOnScreen() + val b = bounds() + val f = frames.get() + when { + // Ours whatever else happened: the synthetic contacts started a move. + drags.get() != 0L -> "synthetic contacts started ${drags.get()} window drag(s)" + // Someone else is at the machine: nothing below is ours to judge. + cursor != lastCursor -> interference("the real cursor moved: $lastCursor → $cursor") + minimized.get() -> interference("the window was minimized") + b == null -> "the window is gone" + !b.contentEquals(baseline) -> "the window moved or resized: ${baseline.toList()} → ${b.toList()}" + window.isMaximized -> "the window maximized" + window.isFullscreen -> "the window went fullscreen" + // A background window may be covered: macOS stops its frames. + f <= lastFrames && !active.get() -> + interference("no frame while the window is in the background (covered?)") + f <= lastFrames -> "no frame rendered since the last checkpoint ($f)" + else -> { + lastFrames = f + null + } + } + } + val deadline = System.currentTimeMillis() + durationMillis + val base = monkeySeedOr(NIGHT_SEED) + index * NIGHT_SEEDS_PER_WINDOW + var session = 0 + var disturbed = 0 + val runtime = Runtime.getRuntime() + while (System.currentTimeMillis() < deadline) { + val profile = GestureMonkeyProfile.entries[session % GestureMonkeyProfile.entries.size] + try { + GestureMonkey( + scope = this, + trace = trace, + zoom = zoom, + profile = profile, + seed = base + session, + steps = profile.steps, + titleBarBand = NIGHT_TITLE_BAR_BAND_DP, + extraCheck = chrome, + echo = false, + canonicalAt = NIGHT_BODY_POINT, + ).run() + } catch (interference: MonkeyInterference) { + disturbed++ + System.err.println( + "[gesture-monkey-night] ${now()} window ${index + 1}/$count session $session disturbed: " + + "${interference.message} — waiting for the machine to be idle", + ) + // Re-baseline wherever the window was left. The focus is not + // taken back: whoever is at the machine may be typing elsewhere. + awaitIdleMachine(deadline) + baseline = checkNotNull(bounds()).copyOf() + lastFrames = frames.get() + drags.set(0) + } + lastCursor = cursorOnScreen() + session++ + if (session % NIGHT_REPORT_EVERY == 0) { + System.gc() + System.err.println( + "[gesture-monkey-night] ${now()} window ${index + 1}/$count: $session sessions " + + "($disturbed disturbed), frames=${frames.get()}, " + + "heap=${(runtime.totalMemory() - runtime.freeMemory()) shr 20} MB", + ) + } + } + System.err.println( + "[gesture-monkey-night] ${now()} window ${index + 1}/$count survived $session sessions " + + "($disturbed disturbed)", + ) + } + } + + @Composable + private fun NightContent( + scope: TaoDecoratedWindowScope, + trace: GestureTrace, + zoom: ZoomProbe, + frames: AtomicLong, + active: AtomicBoolean? = null, + minimized: AtomicBoolean? = null, + ) { + val windowState = scope.state + SideEffect { + active?.set(windowState.isActive) + minimized?.set(windowState.isMinimized) + } + val transition = rememberInfiniteTransition(label = "night") + val angle by transition.animateFloat( + initialValue = 0f, + targetValue = FULL_TURN, + animationSpec = infiniteRepeatable(tween(SPIN_MILLIS, easing = LinearEasing)), + label = "spin", + ) + val pulse by transition.animateFloat( + initialValue = PULSE_MIN, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(PULSE_MILLIS), RepeatMode.Reverse), + label = "pulse", + ) + LaunchedEffect(Unit) { while (true) withFrameNanos { frames.incrementAndGet() } } + val state = + rememberTransformableState { + _, + zoomChange, + _, + rotationChange, + -> + zoom.apply(zoomChange, rotationChange) + } + Box( + Modifier.fillMaxSize().pointerInput(trace) { + awaitPointerEventScope { + while (true) trace.add(awaitPointerEvent(PointerEventPass.Initial)) + } + }, + ) { + Column(Modifier.fillMaxSize()) { + with(scope) { + TitleBar { _ -> + Box(Modifier.width(TITLE_BAR_PULSE_DP.dp * pulse).height(10.dp).background(Color.Cyan)) + } + } + Box( + Modifier + .weight(1f) + .fillMaxWidth() + .background(Color(0xFF15181D)) + .transformable(state), + contentAlignment = Alignment.Center, + ) { + Box( + Modifier + .size(SPINNER_DP.dp) + .graphicsLayer { + rotationZ = angle + alpha = pulse + }.background(Color.Magenta), + ) + } + } + } + } + + /** + * Gestures centred far outside the window (and absurd rotations): the + * contacts land nowhere the scene can hit-test. Nothing may throw, no + * non-finite transform may reach the content, and an in-window pinch + * afterwards must be exact. + */ + private fun offscreenGestures(): TaoWindowTestCase { + val trace = GestureTrace() + val zoom = ZoomProbe() + return TaoWindowTestCase( + name = "#660 macOS gesture monkey degenerate: gestures centred far outside the window", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Target(trace, zoom) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + val random = Random(monkeySeedOr(OFFSCREEN_SEED)) + repeat(OFFSCREEN_STEPS) { + val (x, y) = OFFSCREEN_POINTS[random.nextInt(OFFSCREEN_POINTS.size)] + val kind = random.nextInt(3) + val phase = intArrayOf(0, 1, 2, 2, 4, 8)[random.nextInt(6)] + val value = + if (kind == + Kind.ROTATE + ) { + (random.nextDouble() - 0.5) * 2e6 + } else { + random.nextInt(-256, 768) / 256.0 + } + gesture(kind, phase, x, y, value) + if (it % DEGENERATE_FLUSH_EVERY == 0) settle(DEGENERATE_FLUSH_MILLIS) + } + gesture(Kind.MAGNIFY, 4, TARGET_X, TARGET_Y, 0.0) + gesture(Kind.ROTATE, 4, TARGET_X, TARGET_Y, 0.0) + settle() + zoom.badChange?.let { error("transformable received a non-finite transform: $it") } + canonicalPinch(zoom) + } + } + + /** + * The window closes while a rotation owns a pinch and hundreds of gesture + * events are still queued for it: the queued NSEvents name a window that + * is gone. Nothing may crash (a native use-after-free kills the suite + * here) — [pinchWorksAfterAWindowClosedMidGesture] runs right after. + */ + private fun windowClosesWithGesturesInFlight(): TaoWindowTestCase { + val trace = GestureTrace() + val zoom = ZoomProbe() + return TaoWindowTestCase( + name = "#660 macOS gesture monkey degenerate: the window closes with gestures in flight", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Target(trace, zoom) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + gesture(Kind.ROTATE, 1, TARGET_X, TARGET_Y, 0.0) + gesture(Kind.MAGNIFY, 1, TARGET_X, TARGET_Y, 0.0) + settle(DEGENERATE_FLUSH_MILLIS) + // Queued, never flushed: the case returns and the window closes under them. + repeat(IN_FLIGHT_EVENTS) { + gesture(if (it % 2 == 0) Kind.MAGNIFY else Kind.ROTATE, 2, TARGET_X, TARGET_Y, 0.01) + } + } + } + + private fun pinchWorksAfterAWindowClosedMidGesture(): TaoWindowTestCase { + val trace = GestureTrace() + val zoom = ZoomProbe() + return TaoWindowTestCase( + name = "#660 macOS gesture monkey degenerate: a new window pinches after one closed mid-gesture", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Target(trace, zoom) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + check(trace.snapshot().none { e -> e.isScale || e.changes.any { it.type == PointerType.Touch } }) { + "the closed window's queued gestures leaked into this one: ${trace.snapshot()}" + } + canonicalPinch(zoom) + } + } + + private suspend fun TaoWindowTestScope.canonicalPinch(zoom: ZoomProbe) { + val before = zoom.logZoom + gesture(Kind.MAGNIFY, 1, TARGET_X, TARGET_Y, 0.0) + gesture(Kind.MAGNIFY, 2, TARGET_X, TARGET_Y, CANONICAL_PINCH) + gesture(Kind.MAGNIFY, 4, TARGET_X, TARGET_Y, 0.0) + settle() + val ratio = kotlin.math.exp(zoom.logZoom - before) + check(abs(ratio - (1 + CANONICAL_PINCH)) <= CANONICAL_TOLERANCE) { + "an in-window pinch zoomed by $ratio instead of ${1 + CANONICAL_PINCH}" + } + } + + /** + * A rotation that owns the fingers folds every magnify into the contacts' + * spacing. Hundreds of floored collapses (or ×4 expansions) drive that + * spacing to 0 or past Float range: the contacts must stay finite points, + * `transformable` must never see a non-finite transform, and the pipeline + * must still pinch afterwards. + */ + private fun degenerateRotation( + label: String, + magnification: Double, + ): TaoWindowTestCase { + val trace = GestureTrace() + val zoom = ZoomProbe() + return TaoWindowTestCase( + name = "#660 macOS gesture monkey degenerate rotation: $DEGENERATE_STEPS magnifies $label the contacts", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Target(trace, zoom) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + val x = TARGET_X + val y = TARGET_Y + gesture(Kind.ROTATE, 1, x, y, 0.0) + gesture(Kind.ROTATE, 2, x, y, DEGENERATE_ROTATE_DEGREES) + repeat(DEGENERATE_STEPS) { + gesture(Kind.MAGNIFY, 2, x, y, magnification) + gesture(Kind.ROTATE, 2, x, y, DEGENERATE_ROTATE_DEGREES) + if (it % DEGENERATE_FLUSH_EVERY == 0) settle(DEGENERATE_FLUSH_MILLIS) + } + gesture(Kind.ROTATE, 4, x, y, 0.0) + settle() + zoom.badChange?.let { error("transformable received a non-finite transform: $it") } + val events = trace.snapshot() + val touches = events.flatMap { e -> e.changes.filter { it.type == PointerType.Touch } } + check(touches.isNotEmpty()) { "the rotation never reached the scene" } + check(!touches.last().pressed) { "the contacts are still pressed: ${events.takeLast(4)}" } + check( + events.none { it.isScale }, + ) { "a magnify inside a rotation must not scale: ${events.filter { it.isScale }}" } + canonicalPinch(zoom) + } + } + + /** Someone at the machine: abort the session without judging it. */ + private fun interference(reason: String): Nothing = throw MonkeyInterference(reason) + + /** Returns once the system cursor has stayed still for [NIGHT_IDLE_MILLIS] (or at [deadline]). */ + private suspend fun TaoWindowTestScope.awaitIdleMachine(deadline: Long) { + var idleSince = System.currentTimeMillis() + var cursor = cursorOnScreen() + while (System.currentTimeMillis() - idleSince < NIGHT_IDLE_MILLIS && System.currentTimeMillis() < deadline) { + settle(NIGHT_IDLE_POLL_MILLIS) + val now = cursorOnScreen() + if (now != cursor) { + cursor = now + idleSince = System.currentTimeMillis() + } + } + settle() + } + + /** The system cursor, screen points — moves only when someone at the machine moves it. */ + private fun cursorOnScreen(): Pair? = + runCatching { MouseInfo.getPointerInfo()?.location }.getOrNull()?.let { it.x to it.y } + + private fun now(): String = LocalTime.now().withNano(0).toString() + + private fun TaoWindowTestScope.gesture( + kind: Int, + phase: Int, + x: Float, + y: Float, + value: Double, + ) { + check(MacTrackpadGestureProbe.inject(window, kind, phase, x, y, value)) { "the gesture injector refused" } + } + + private fun randomGesturesMatchTheModel( + profile: GestureMonkeyProfile, + seed: Long, + steps: Int, + ): TaoWindowTestCase { + val trace = GestureTrace() + val zoom = ZoomProbe() + return TaoWindowTestCase( + name = "#660 macOS gesture monkey ${profile.label} seed $seed: $steps random gesture steps match the model", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Target(trace, zoom) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + val monkey = GestureMonkey(this, trace, zoom, profile, monkeySeedOr(seed), steps) + monkey.run() + } + } + + @Composable + private fun Target( + trace: GestureTrace, + zoom: ZoomProbe, + ) { + val state = + rememberTransformableState { + _, + zoomChange, + _, + rotationChange, + -> + zoom.apply(zoomChange, rotationChange) + } + Box( + Modifier + .fillMaxSize() + .pointerInput(trace) { + awaitPointerEventScope { + while (true) trace.add(awaitPointerEvent(PointerEventPass.Initial)) + } + }.transformable(state), + ) + } + + private fun monkeySeedOr(default: Long): Long = System.getProperty(MONKEY_SEED_PROPERTY)?.toLongOrNull() ?: default + + private fun macOnly(): String? = + when { + Platform.Current != Platform.MacOS -> "macOS only — AppKit gesture NSEvent injection" + !MacTrackpadGestureProbe.available -> "nucleus_tao_metal not loaded" + else -> null + } + + private val SEEDS = longArrayOf(MONKEY_DEFAULT_SEED, 42L, 7L) + + private const val WINDOW_W = 800f + private const val WINDOW_H = 600f + private const val TITLE_BAR_X = 600f + private const val TITLE_BAR_Y = 20f + private const val TITLE_BAR_ROUNDS = 3 + private const val TITLE_BAR_ATTEMPTS_PER_ROUND = 5 + private const val TITLE_BAR_ROTATE_STEPS = 6 + private const val TITLE_BAR_SETTLE_MILLIS = 700L + + private const val NIGHT_MINUTES_PROPERTY = "nucleus.tao.headful.monkeyNightMinutes" + private const val NIGHT_WINDOW_MINUTES = 15L + private const val MILLIS_PER_MINUTE = 60_000L + private const val NIGHT_SMOKE_MILLIS = 60_000L + private const val NIGHT_SLACK_MILLIS = 600_000L + private const val NIGHT_SEED = 20_260_924L + private const val NIGHT_SEEDS_PER_WINDOW = 100_000L + private const val NIGHT_WARMUP_FRAMES = 10L + private const val NIGHT_REPORT_EVERY = 10 + + /** How long the cursor must stay still before a disturbed night resumes. */ + private const val NIGHT_IDLE_MILLIS = 30_000L + private const val NIGHT_IDLE_POLL_MILLIS = 500L + + /** Inside the macOS title bar (the bar is ~40 dp): contacts land on the window-drag area. */ + private val NIGHT_TITLE_BAR_BAND_DP = 14f..30f + private val NIGHT_BODY_POINT = 400f to 360f + private const val FULL_TURN = 360f + private const val SPIN_MILLIS = 2_000 + private const val PULSE_MILLIS = 700 + private const val PULSE_MIN = 0.2f + private const val TITLE_BAR_PULSE_DP = 120 + private const val SPINNER_DP = 160 + private val DEGENERATE_ROTATIONS = listOf("collapse" to -1.5, "explode" to 3.0) + private const val DEGENERATE_STEPS = 300 + private const val OFFSCREEN_SEED = 660L + private const val OFFSCREEN_STEPS = 400 + private const val IN_FLIGHT_EVENTS = 200 + private val OFFSCREEN_POINTS = + listOf(-5_000f to 300f, 400f to -5_000f, 1e6f to 1e6f, -1e6f to 1e6f, 799f to 599f, 0f to 0f, 1e7f to -1e7f) + private const val DEGENERATE_ROTATE_DEGREES = 3.0 + private const val DEGENERATE_FLUSH_EVERY = 20 + private const val DEGENERATE_FLUSH_MILLIS = 16L + private const val TARGET_X = 400f + private const val TARGET_Y = 300f + private const val CANONICAL_PINCH = 0.125 + private const val CANONICAL_TOLERANCE = 1e-3 + private const val LONG_RUN_SEED = 1_000_003L + private const val LONG_RUN_STEPS = 2_000 +} + +/** Someone at the machine touched the window or the cursor: the session proves nothing either way. */ +private class MonkeyInterference( + message: String, +) : RuntimeException(message) + +private enum class GestureMonkeyProfile( + val label: String, + val steps: Int, +) { + /** Well-formed gestures, one gesture per step. */ + TRACKPAD("trackpad", 60), + + /** One arbitrary event per step. */ + CHAOS("chaos", 500), + + /** Well-formed pinch / rotate gestures posted back to back, one gesture per step. */ + BURST("burst", 80), +} + +/** + * What `transformable` applied: the zoom in log space (hundreds of extreme + * factors overflow a Float product), and the first change that was not a + * finite positive ratio. + */ +private class ZoomProbe { + @Volatile var logZoom: Double = 0.0 + + @Volatile var badChange: String? = null + + fun apply( + zoomChange: Float, + rotationChange: Float, + ) { + if (!zoomChange.isFinite() || zoomChange <= 0f || !rotationChange.isFinite()) { + if (badChange == null) badChange = "zoomChange=$zoomChange rotationChange=$rotationChange" + return + } + logZoom += kotlin.math.ln(zoomChange.toDouble()) + } +} + +/** One pointer change as the root saw it. */ +private class TracedChange( + val id: Long, + val type: PointerType, + val pressed: Boolean, + val down: Boolean, + val scaleFactor: Float, +) + +private class TracedEvent( + val type: PointerEventType, + val changes: List, +) { + val isScale: Boolean + get() = + type == PointerEventType.ScaleStart || + type == PointerEventType.ScaleChange || + type == PointerEventType.ScaleEnd + + override fun toString(): String = + when (type) { + PointerEventType.ScaleChange -> "ScaleChange(${changes.firstOrNull()?.scaleFactor})" + else -> + "$type" + + changes.filter { it.type == PointerType.Touch }.joinToString("", prefix = "") { + "[t${it.id and 0xF}${if (it.pressed) "↓" else "↑"}]" + } + } +} + +/** Every pointer event the root saw on the Initial pass, in order. */ +private class GestureTrace { + private val events = Collections.synchronizedList(mutableListOf()) + + fun add(event: PointerEvent) { + events += + TracedEvent( + event.type, + event.changes.map { + TracedChange(it.id.value, it.type, it.pressed, it.changedToDownIgnoreConsumed(), it.scaleFactor) + }, + ) + } + + fun snapshot(): List = synchronized(events) { events.toList() } + + fun reset() = events.clear() +} + +// ── Actions ───────────────────────────────────────────────────────────────── + +/** IOHID phase encodings the injectors take. */ +private object IoPhase { + const val NONE = 0 + const val BEGAN = 1 + const val CHANGED = 2 + const val ENDED = 4 + const val CANCELLED = 8 +} + +private const val SCROLL_BEGAN = 1 +private const val SCROLL_CHANGED = 2 +private const val SCROLL_ENDED = 4 +private const val MOMENTUM_BEGAN = 1 +private const val MOMENTUM_CHANGED = 2 +private const val MOMENTUM_ENDED = 3 + +private sealed class GestureAction { + /** Content-local injection point, dp. */ + abstract val x: Float + abstract val y: Float + + data class Magnify( + val phase: Int, + val value: Double, + override val x: Float, + override val y: Float, + ) : GestureAction() + + data class Rotate( + val phase: Int, + val degrees: Double, + override val x: Float, + override val y: Float, + ) : GestureAction() + + data class Smart( + override val x: Float, + override val y: Float, + ) : GestureAction() + + /** A precise (trackpad) scroll step: scroll-phase / momentum-phase encodings of [MacScrollWheelProbe]. */ + data class Scroll( + val phase: Int, + val momentum: Int, + val dx: Float, + val dy: Float, + override val x: Float, + override val y: Float, + ) : GestureAction() + + /** No pause before the next action. */ + var immediate: Boolean = false +} + +// ── Oracle ────────────────────────────────────────────────────────────────── + +/** + * Reference model of `TaoComposeSceneHost.onTrackpadGesture` + the + * `touchpad_gestures.m` / Rust wire. Kept deliberately independent of the + * production classes: it restates the rules, so a change to either side that + * the other does not follow turns a monkey red. + */ +private class GestureOracle { + /** Expected Scale events: type and, for a change, the factor. */ + val scale = mutableListOf>() + + /** Expected touch-down transitions of the synthetic contacts. */ + var downs = 0 + private set + + var scaleOpen = false + private set + var rotateActive = false + private set + + /** An interrupted rotation ignores its remaining steps until it ends. */ + private var rotateInterrupted = false + + /** A phased trackpad scroll opened a pan the router has not closed yet (see [panSettled]). */ + var panOpen = false + private set + + /** Last cursor position the host dispatched, dp (its 1 dp deadband). */ + private var cursor: Pair? = null + + fun apply(action: GestureAction) { + when (action) { + is GestureAction.Magnify -> magnify(wirePhase(action.phase), action.value) + is GestureAction.Rotate -> rotate(wirePhase(action.phase)) + is GestureAction.Smart -> smart() + is GestureAction.Scroll -> scroll(action) + } + } + + /** The router's grace ran out: the generator waited long enough for the PanEnd. */ + fun panSettled() { + panOpen = false + } + + private fun scroll(action: GestureAction.Scroll) { + // tao moves the cursor before it delivers the scroll; a move past the + // deadband reaches the scene, and a mouse-only event interrupts a rotation. + val last = cursor + val dx = last?.let { action.x - it.first } ?: Float.MAX_VALUE + val dy = last?.let { action.y - it.second } ?: 0f + if (last == null || dx * dx + dy * dy >= 1f) { + cursor = action.x to action.y + if (rotateActive) { + rotateActive = false + rotateInterrupted = true + } + } + // A rotation owns the fingers: its scroll is dropped. + if (rotateActive) return + when (action.phase) { + SCROLL_BEGAN, SCROLL_CHANGED -> panOpen = true + 0 -> if (action.momentum == 0) panOpen = false // a phase-less scroll closes the pan now + } + } + + private fun magnify( + phase: WirePhase, + value: Double, + ) { + if (rotateActive) return // folded into the contacts: a touch Move, no Scale + when (phase) { + WirePhase.BEGAN -> { + open() + change(factor(value)) + } + WirePhase.CHANGED -> change(factor(value)) + WirePhase.ENDED, WirePhase.CANCELLED -> close() + } + } + + private fun rotate(phase: WirePhase) { + if (phase == WirePhase.ENDED || phase == WirePhase.CANCELLED) { + rotateInterrupted = false + rotateActive = false + return + } + if (scaleOpen || panOpen) return + if (phase == WirePhase.BEGAN) { + rotateInterrupted = false + } else if (rotateInterrupted) { + return + } + // A second Began re-presses already pressed contacts: filtered as no change. + if (!rotateActive) downs += 2 + rotateActive = true + } + + private fun smart() { + if (rotateActive || scaleOpen) return + open() + change(SMART_MAGNIFY_FACTOR) + close() + } + + private fun open() { + if (scaleOpen) return + scaleOpen = true + scale += PointerEventType.ScaleStart to 1f + } + + private fun change(factor: Float) { + if (factor == 1f) return + open() + scale += PointerEventType.ScaleChange to factor + } + + private fun close() { + if (!scaleOpen) return + scaleOpen = false + scale += PointerEventType.ScaleEnd to 1f + } + + private enum class WirePhase { BEGAN, CHANGED, ENDED, CANCELLED } + + /** IOHID → NSEventPhase → `touchpad_gestures.m`'s `phase_from_event` (None → Changed). */ + private fun wirePhase(ioPhase: Int): WirePhase = + when (ioPhase) { + IoPhase.BEGAN -> WirePhase.BEGAN + IoPhase.ENDED -> WirePhase.ENDED + IoPhase.CANCELLED -> WirePhase.CANCELLED + else -> WirePhase.CHANGED + } + + /** Rust truncates `value × 10 000` to an int; the host divides back in Float. */ + private fun factor(value: Double): Float { + val fixed = (value * VALUE_FIXED_SCALE).toInt() + val delta = fixed / VALUE_FIXED_SCALE.toFloat() + return (1f + delta).coerceAtLeast(MIN_GESTURE_SCALE) + } + + private companion object { + const val VALUE_FIXED_SCALE = 10_000.0 + const val MIN_GESTURE_SCALE = 0.05f + const val SMART_MAGNIFY_FACTOR = 1.5f + } +} + +// ── Driver ────────────────────────────────────────────────────────────────── + +@Suppress("LongParameterList") +private class GestureMonkey( + private val scope: TaoWindowTestScope, + private val trace: GestureTrace, + private val zoom: ZoomProbe, + private val profile: GestureMonkeyProfile, + seed: Long, + private val steps: Int, + /** When set, a quarter of the well-formed gestures are centred at a y in this band (dp). */ + private val titleBarBand: ClosedFloatingPointRange? = null, + /** Extra invariant run at every checkpoint: a failure reason, or null. */ + private val extraCheck: (() -> String?)? = null, + echo: Boolean = true, + /** Where the closing canonical pinch lands (a `transformable` must be under it); random by default. */ + private val canonicalAt: Pair? = null, +) { + private val random = Random(seed) + private val journal = MonkeyJournal("gesture-monkey[${profile.label}]", seed, echo = echo) + private val oracle = GestureOracle() + private var scrollOpen = false + private var lastScroll: Pair? = null + + suspend fun run() { + System.err.println("[gesture-monkey] profile=${profile.label} seed=${journal.seed} steps=$steps") + trace.reset() + val watchdog = MainLoopWatchdog("gesture-monkey") { journal.report() }.start() + try { + for (step in 0 until steps) { + journal.step = step + val actions = + when (profile) { + GestureMonkeyProfile.TRACKPAD -> wellFormedGesture(withScroll = true) + GestureMonkeyProfile.BURST -> + wellFormedGesture( + withScroll = false, + ).onEach { it.immediate = true } + GestureMonkeyProfile.CHAOS -> listOf(chaosEvent()) + } + monkeyAction({ "step $step (${actions.size} events)" }) { perform(actions) } + if (actions.any { it is GestureAction.Scroll && it.phase != 0 }) { + // Let the router's grace close the pan before the next gesture can rotate. + scope.settle(PAN_GRACE_MILLIS) + oracle.panSettled() + } + if (step % CHECKPOINT_EVERY == CHECKPOINT_EVERY - 1) checkpoint() + } + quiesce() + } finally { + val worst = watchdog.stop() + check(worst < MONKEY_MAX_STALL_MILLIS) { + journal.failure("Dispatchers.Main stalled for ${worst}ms", state()) + } + } + System.err.println( + "[gesture-monkey] profile=${profile.label} seed=${journal.seed} survived $steps steps; " + + "reached ${journal.reachedSummary()}", + ) + } + + // ── Generators ────────────────────────────────────────────────────────── + + private fun wellFormedGesture(withScroll: Boolean): List { + val (cx, cy) = center() + val kinds = if (withScroll) GESTURES_WITH_SCROLL else GESTURES + val kind = kinds[random.nextInt(kinds.size)] + journal.reach(kind) + val script = GestureScript(random, cx, cy, steps = 1 + random.nextInt(MAX_GESTURE_STEPS)) + if (withScroll) script.out += cursorTo(cx, cy) + with(script) { + when (kind) { + "pinch" -> single(::mag) + "rotate" -> single(::rot) + "pinch+rotate" -> interleaved(::mag, ::rot) + "rotate+pinch" -> interleaved(::rot, ::mag) + "smart" -> out += GestureAction.Smart(cx, cy) + "swipe" -> swipeAlone() + "pinch+swipe" -> withSwipe(::mag, swipeFirst = false) + "rotate+swipe" -> withSwipe(::rot, swipeFirst = false) + "swipe+rotate" -> withSwipe(::rot, swipeFirst = true) + } + } + return script.out + } + + /** + * Builds one well-formed gesture around ([cx], [cy]). Gesture events + * jitter a few dp; scrolls stay on the cursor — it does not move while + * fingers gesture. + */ + private class GestureScript( + private val random: Random, + private val cx: Float, + private val cy: Float, + private val steps: Int, + ) { + val out = mutableListOf() + + private fun jx() = cx + random.nextInt(-JITTER_DP, JITTER_DP + 1) + + private fun jy() = cy + random.nextInt(-JITTER_DP, JITTER_DP + 1) + + fun mag(phase: Int): GestureAction = + GestureAction.Magnify(phase, if (phase == IoPhase.CHANGED) pinchStep() else 0.0, jx(), jy()) + + fun rot(phase: Int): GestureAction = + GestureAction.Rotate(phase, if (phase == IoPhase.CHANGED) rotateStep() else 0.0, jx(), jy()) + + private fun swipe(phase: Int): GestureAction { + fun delta() = if (phase == SCROLL_CHANGED) random.nextInt(-SWIPE_PT, SWIPE_PT + 1).toFloat() else 0f + return GestureAction.Scroll(phase = phase, momentum = 0, dx = delta(), dy = delta(), x = cx, y = cy) + } + + private fun end(): Int = if (random.nextInt(CANCEL_ONE_IN) == 0) IoPhase.CANCELLED else IoPhase.ENDED + + private fun pinchStep(): Double = random.nextInt(-PINCH_STEP, PINCH_STEP + 1) / DYADIC.toDouble() + + private fun rotateStep(): Double = random.nextInt(-ROTATE_STEP, ROTATE_STEP + 1).toDouble() + + fun single(g: (Int) -> GestureAction) { + out += g(IoPhase.BEGAN) + repeat(steps) { out += g(IoPhase.CHANGED) } + out += g(end()) + } + + /** Both recognizers: [a] begins first, [b] possibly a few steps late; either may end first. */ + fun interleaved( + a: (Int) -> GestureAction, + b: (Int) -> GestureAction, + ) { + out += a(IoPhase.BEGAN) + repeat(random.nextInt(LATE_START_MAX)) { out += a(IoPhase.CHANGED) } + out += b(IoPhase.BEGAN) + repeat(steps) { out += if (random.nextBoolean()) a(IoPhase.CHANGED) else b(IoPhase.CHANGED) } + val (first, second) = if (random.nextBoolean()) a to b else b to a + out += first(end()) + out += second(end()) + } + + fun swipeAlone() { + single(::swipe) + out.removeAt(out.lastIndex) + out += swipe(SCROLL_ENDED) + if (random.nextBoolean()) { + out += GestureAction.Scroll(0, MOMENTUM_BEGAN, 0f, SWIPE_PT.toFloat(), cx, cy) + out += GestureAction.Scroll(0, MOMENTUM_CHANGED, 0f, 2f, cx, cy) + out += GestureAction.Scroll(0, MOMENTUM_ENDED, 0f, 0f, cx, cy) + } + } + + /** + * Fingers that travel while pinching / rotating: AppKit sends both + * streams. With [swipeFirst] the pan owns the fingers and a rotation + * must not press. + */ + fun withSwipe( + g: (Int) -> GestureAction, + swipeFirst: Boolean, + ) { + if (swipeFirst) { + out += swipe(SCROLL_BEGAN) + out += g(IoPhase.BEGAN) + } else { + out += g(IoPhase.BEGAN) + out += swipe(SCROLL_BEGAN) + } + repeat(steps) { out += if (random.nextBoolean()) g(IoPhase.CHANGED) else swipe(SCROLL_CHANGED) } + out += swipe(SCROLL_ENDED) + out += g(end()) + } + } + + /** A zero, phase-less scroll: tao moves the cursor there first, nothing scrolls. */ + private fun cursorTo( + x: Float, + y: Float, + ): GestureAction = GestureAction.Scroll(0, 0, 0f, 0f, x, y) + + private fun chaosEvent(): GestureAction { + val (x, y) = center() + val action = + when (random.nextInt(CHAOS_KINDS)) { + 0, 1, 2 -> GestureAction.Magnify(chaosPhase(), chaosMagnification(), x, y) + 3, 4 -> GestureAction.Rotate(chaosPhase(), (random.nextDouble() - 0.5) * CHAOS_MAX_DEGREES, x, y) + 5 -> GestureAction.Smart(x, y) + else -> chaosScroll(x, y) + } + action.immediate = random.nextInt(IMMEDIATE_ONE_IN) != 0 + journal.reach(action::class.simpleName ?: "?") + return action + } + + private fun chaosPhase(): Int = CHAOS_PHASES[random.nextInt(CHAOS_PHASES.size)] + + /** Dyadic, so `value × 10 000` is exact whatever precision the CGEvent field keeps. */ + private fun chaosMagnification(): Double = + when (random.nextInt(4)) { + 0 -> 0.0 + 1 -> random.nextInt(-DYADIC, DYADIC + 1) / DYADIC.toDouble() / 8 // small: ±1/8 + 2 -> random.nextInt(-DYADIC, DYADIC * 3) / DYADIC.toDouble() // wild: -1 … 3 + else -> -random.nextInt(DYADIC, DYADIC * 2) / DYADIC.toDouble() // collapses: ≤ -1, floored + } + + /** + * Phase-less only: a phased pan's end is a timer the model cannot place + * in a burst of events, and a phase-less scroll still moves the cursor — + * which is what interrupts a rotation. Phased pans are the trackpad + * profile's. + */ + private fun chaosScroll( + x: Float, + y: Float, + ): GestureAction { + // Half of them on the cursor's last position: a scroll that does not move it. + val here = lastScroll?.takeIf { random.nextBoolean() } + return GestureAction.Scroll( + 0, + 0, + random.nextInt(-CHAOS_SCROLL_PT, CHAOS_SCROLL_PT + 1).toFloat(), + random.nextInt(-CHAOS_SCROLL_PT, CHAOS_SCROLL_PT + 1).toFloat(), + here?.first ?: x, + here?.second ?: y, + ) + } + + /** + * A gesture centre far enough inside the window that the synthetic + * contacts (120 px either side, 60 dp on a 2× display) press inside it. + */ + private fun center(): Pair { + val x = MARGIN_DP + random.nextFloat() * (WINDOW_W_DP - 2 * MARGIN_DP) + val band = titleBarBand + if (band != null && profile != GestureMonkeyProfile.CHAOS && random.nextInt(TITLE_BAR_ONE_IN) == 0) { + journal.reach("title bar") + return x to band.start + random.nextFloat() * (band.endInclusive - band.start) + } + return x to (MARGIN_DP + random.nextFloat() * (WINDOW_H_DP - 2 * MARGIN_DP)) + } + + // ── Execution ─────────────────────────────────────────────────────────── + + private suspend fun perform(actions: List) { + for (action in actions) { + journal.record(action) + when (action) { + is GestureAction.Magnify -> post(Kind.MAGNIFY, action.phase, action.x, action.y, action.value) + is GestureAction.Rotate -> post(Kind.ROTATE, action.phase, action.x, action.y, action.degrees) + is GestureAction.Smart -> post(Kind.SMART_MAGNIFY, IoPhase.NONE, action.x, action.y, 0.0) + is GestureAction.Scroll -> scroll(action) + } + oracle.apply(action) + if (!action.immediate) scope.settle(STEP_MILLIS) + } + } + + private fun post( + kind: Int, + phase: Int, + x: Float, + y: Float, + value: Double, + ) { + check(MacTrackpadGestureProbe.inject(scope.window, kind, phase, x, y, value)) { + journal.failure("nativeDiagInjectTrackpadGesture refused the event", state()) + } + } + + /** + * The scroll injector is synchronous while gesture events are posted, and + * tao buffers the events it raises from inside a loop callback (the + * cursor move, the wheel) until that callback returns — while a gesture + * posted meanwhile reaches the host straight from the monitor. Flush + * both ways so the two streams arrive in the order the model applies + * them; real events are dispatched one by one and never race like this. + */ + private suspend fun scroll(action: GestureAction.Scroll) { + scope.settle(FLUSH_MILLIS) + val delivered = + MacScrollWheelProbe.inject( + window = scope.window, + x = action.x, + y = action.y, + dx = action.dx, + dy = action.dy, + precise = true, + phase = action.phase, + momentum = action.momentum, + ) + check(delivered) { journal.failure("nativeDiagInjectScrollWheel refused the event", state()) } + scope.settle(FLUSH_MILLIS) + scrollOpen = action.phase == SCROLL_BEGAN || action.phase == SCROLL_CHANGED + lastScroll = action.x to action.y + } + + // ── Invariants ────────────────────────────────────────────────────────── + + private suspend fun checkpoint() { + scope.settle(FLUSH_MILLIS) + // First: an interference aborts the session before the model judges + // what a real mouse did to it. + extraCheck?.invoke()?.let { fail("checkpoint", it) } + verify("checkpoint") + } + + /** Closes whatever the walk left open, lets every timer run out, then checks the rest state. */ + private suspend fun quiesce() { + journal.step = steps + val (x, y) = center() + perform( + listOf( + GestureAction.Magnify(IoPhase.ENDED, 0.0, x, y), + GestureAction.Rotate(IoPhase.ENDED, 0.0, x, y), + ), + ) + if (scrollOpen) { + val ended = GestureAction.Scroll(SCROLL_ENDED, 0, 0f, 0f, lastScroll?.first ?: x, lastScroll?.second ?: y) + journal.record(ended) + scroll(ended) + oracle.apply(ended) + } + scope.settle(QUIESCE_MILLIS) + oracle.panSettled() + extraCheck?.invoke()?.let { fail("quiescence", it) } + verify("quiescence") + val events = trace.snapshot() + check(!oracle.scaleOpen && !oracle.rotateActive) { journal.failure("the model left a gesture open", state()) } + check(pressedTouches(events).isEmpty()) { + journal.failure("synthetic contacts still pressed at rest: ${pressedTouches(events)}", state()) + } + val panStarts = events.count { it.type == PointerEventType.PanStart } + val panEnds = events.count { it.type == PointerEventType.PanEnd } + check(panStarts == panEnds) { + journal.failure("unbalanced pan: $panStarts PanStart vs $panEnds PanEnd", state()) + } + + // The pipeline still works: a canonical pinch zooms by exactly its factor. + val (cx, cy) = canonicalAt ?: (x to y) + val before = zoom.logZoom + perform( + listOf( + GestureAction.Magnify(IoPhase.BEGAN, 0.0, cx, cy), + GestureAction.Magnify(IoPhase.CHANGED, CANONICAL_PINCH, cx, cy), + GestureAction.Magnify(IoPhase.ENDED, 0.0, cx, cy), + ), + ) + scope.settle(FLUSH_MILLIS) + extraCheck?.invoke()?.let { fail("canonical pinch", it) } + verify("canonical pinch") + val ratio = kotlin.math.exp(zoom.logZoom - before).toFloat() + check(abs(ratio - (1f + CANONICAL_PINCH.toFloat())) <= FACTOR_TOLERANCE) { + journal.failure("a canonical pinch after the walk zoomed transformable by $ratio", state()) + } + } + + private fun verify(where: String) { + zoom.badChange?.let { fail(where, "transformable received a non-finite or non-positive transform: $it") } + val events = trace.snapshot() + val scale = events.filter { it.isScale } + + scale.firstOrNull { it.changes.size != 1 || it.changes[0].type != PointerType.Mouse }?.let { + fail(where, "a Scale event must carry exactly one mouse pointer, got ${it.changes.map { c -> c.type }}") + } + val actual = scale.map { it.type to (it.changes.firstOrNull()?.scaleFactor ?: 1f) } + val expected = oracle.scale + val firstDiff = + (0 until maxOf(actual.size, expected.size)).firstOrNull { i -> + val a = actual.getOrNull(i) + val e = expected.getOrNull(i) + a == null || + e == null || + a.first != e.first || + (a.first == PointerEventType.ScaleChange && abs(a.second - e.second) > FACTOR_TOLERANCE) + } + if (firstDiff != null) { + fail( + where, + "Scale stream diverges from the model at #$firstDiff: " + + "got ${actual.window(firstDiff)} expected ${expected.window(firstDiff)} " + + "(${actual.size} vs ${expected.size} events)", + ) + } + + // No Scale event while a contact is pressed; never more than two contacts. + val pressed = mutableSetOf() + var downs = 0 + for ((index, event) in events.withIndex()) { + // Every event lists the active pointers: one without the pressed + // contacts is their (synthetic) release — a touch tap. + if (pressed.isNotEmpty() && event.changes.none { it.type == PointerType.Touch }) { + fail(where, "$index: ${event.type} without the pressed contacts $pressed: ${events.around(index)}") + } + for (change in event.changes) { + if (change.type != PointerType.Touch) continue + if (change.down) downs++ + if (change.pressed) pressed += change.id else pressed -= change.id + } + if (pressed.size > MAX_CONTACTS) fail(where, "${pressed.size} contacts pressed at #$index") + } + if (downs != oracle.downs) { + val firstDown = events.indexOfFirst { e -> e.changes.any { it.down } } + fail( + where, + "$downs touch downs, the model expects ${oracle.downs} (a re-press is a tap); " + + "trace around the first: ${events.around(firstDown)}", + ) + } + } + + private fun pressedTouches(events: List): Set { + val pressed = mutableSetOf() + for (event in events) { + for (change in event.changes) { + if (change.type != PointerType.Touch) continue + if (change.pressed) pressed += change.id else pressed -= change.id + } + } + return pressed + } + + private fun fail( + where: String, + reason: String, + ): Nothing = throw IllegalStateException(journal.failure("$where: $reason", state())) + + private fun state(): String = + "at ${java.time.LocalTime.now().withNano( + 0, + )} scaleOpen=${oracle.scaleOpen} rotateActive=${oracle.rotateActive} expectedDowns=${oracle.downs} " + + "expectedScale=${oracle.scale.size} logZoom=${zoom.logZoom} scale=${scope.window.scaleFactor}" + + private fun List.window(at: Int): List = subList(maxOf(0, at - 3), minOf(size, at + 4)) + + private fun List.around(at: Int): String = + if (at < 0) "[]" else subList(maxOf(0, at - 6), minOf(size, at + 6)).joinToString(prefix = "[", postfix = "]") + + private companion object { + val GESTURES = listOf("pinch", "rotate", "pinch+rotate", "rotate+pinch", "smart") + val GESTURES_WITH_SCROLL = GESTURES + listOf("swipe", "pinch+swipe", "rotate+swipe", "swipe+rotate") + + val CHAOS_PHASES = + intArrayOf(IoPhase.NONE, IoPhase.BEGAN, IoPhase.CHANGED, IoPhase.CHANGED, IoPhase.ENDED, IoPhase.CANCELLED) + + const val CHAOS_KINDS = 8 + const val CHAOS_MAX_DEGREES = 720.0 + const val CHAOS_SCROLL_PT = 40 + const val IMMEDIATE_ONE_IN = 3 + + const val DYADIC = 256 + const val PINCH_STEP = 16 // ±1/16 per step + const val ROTATE_STEP = 8 // ±8° per step + const val SWIPE_PT = 12 + const val MAX_GESTURE_STEPS = 10 + const val LATE_START_MAX = 4 + const val CANCEL_ONE_IN = 8 + const val JITTER_DP = 3 + + const val WINDOW_W_DP = 800f + const val WINDOW_H_DP = 600f + const val MARGIN_DP = 140f + + const val MAX_CONTACTS = 2 + const val TITLE_BAR_ONE_IN = 4 + + /** The pan router's 150 ms momentum grace plus delivery. */ + const val PAN_GRACE_MILLIS = 300L + const val CHECKPOINT_EVERY = 10 + const val STEP_MILLIS = 4L + const val FLUSH_MILLIS = 60L + + /** Past the pan router's 150 ms grace and its 1 s stall watchdog. */ + const val QUIESCE_MILLIS = 1_600L + + const val CANONICAL_PINCH = 0.125 + const val FACTOR_TOLERANCE = 1e-4f + } +} + +private const val MONKEY_CASE_TIMEOUT_MILLIS = 600_000L diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScaleHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScaleHeadfulCases.kt new file mode 100644 index 000000000..06f2de262 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacOsTrackpadScaleHeadfulCases.kt @@ -0,0 +1,532 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.headful.MacTrackpadGestureProbe.Kind +import dev.nucleusframework.window.tao.headful.MacTrackpadGestureProbe.Phase +import java.util.Collections +import kotlin.math.abs + +/** + * #660 end-to-end on macOS: an AppKit magnify gesture must reach Compose as + * `ScaleStart` / `ScaleChange` / `ScaleEnd` at the cursor, never as two + * synthetic Touch contacts. Every case queues real gesture NSEvents on + * `NSApp` ([MacTrackpadGestureProbe]), so the whole chain runs: + * the `touchpad_gestures.m` local monitor → Rust loop → `TaoWindow` → + * `TaoComposeSceneHost.onTrackpadGesture` → `ComposeScene`. + * + * Rotation still synthesises two Touch pointers (Compose has no rotation + * event); the rotate cases guard that half and its interplay with a pinch — + * a real trackpad pinch interleaves magnify and rotate events. + */ +internal object MacOsTrackpadScaleHeadfulCases { + fun all(): List = + listOf( + pinchArrivesAsScaleEventsAtTheCursor(), + onePercentPinchZoomsTransformable(), + pinchAtMapEdgeReachesOnlyTheMap(), + smartMagnifyIsOneDiscreteScaleStep(), + cancelledPinchClosesTheScaleGesture(), + rotateStillRotatesDetectTransformGestures(), + pinchFirstOwnsAnInterleavedGesture(), + rotateFirstOwnsAnInterleavedGesture(), + ) + + /** + * Began / Changed… / Ended arrives as exactly one ScaleStart, one + * ScaleChange per non-zero magnification carrying `1 + magnification`, + * and one ScaleEnd — all at the cursor, with no press, no Touch pointer + * and no Scroll. + */ + private fun pinchArrivesAsScaleEventsAtTheCursor(): TaoWindowTestCase { + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 macOS pinch arrives as Compose Scale events at the cursor", + skip = { macOnly() }, + // The suite's default chrome is a fillMaxSize sibling stacked above + // [content]; leaving it on gives the recorder 0 height. + paintDefaultBackground = false, + content = { Box(Modifier.fillMaxSize().record(recorder)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + recorder.reset() + + magnify(Phase.BEGAN, 0.0) + MAGNIFICATIONS.forEach { magnify(Phase.CHANGED, it) } + magnify(Phase.ENDED, 0.0) + awaitUntil("ScaleEnd recorded") { recorder.count(PointerEventType.ScaleEnd) >= 1 } + settle() + + val events = recorder.snapshot() + val scale = events.filter { it.type.isScale() } + check(scale.map { it.type } == expectedScaleTypes(MAGNIFICATIONS.size)) { + "one ScaleStart, one ScaleChange per magnification, one ScaleEnd; recorded=${recorder.describe()}" + } + val factors = scale.filter { it.type == PointerEventType.ScaleChange }.map { it.scaleFactor } + MAGNIFICATIONS.zip(factors).forEach { (magnification, factor) -> + check(abs(factor - (1f + magnification.toFloat())) <= FACTOR_TOLERANCE) { + "ScaleChange must carry 1 + magnification ($magnification → $factor); " + + "recorded=${recorder.describe()}" + } + } + val cursor = Offset(TARGET_X * window.scaleFactor, TARGET_Y * window.scaleFactor) + scale.forEach { + check((it.position - cursor).getDistance() <= POSITION_TOLERANCE_PX) { + "Scale events must sit at the cursor $cursor (got ${it.position}); recorded=${recorder.describe()}" + } + check(it.pointerType == PointerType.Mouse) { + "Scale events must come from the mouse pointer (got ${it.pointerType})" + } + } + check(events.none { it.pointerType == PointerType.Touch }) { + "a pinch must not synthesise Touch contacts any more; recorded=${recorder.describe()}" + } + check(events.none { it.type == PointerEventType.Press || it.type == PointerEventType.Scroll }) { + "a pinch must produce no Press and no Scroll; recorded=${recorder.describe()}" + } + } + } + + /** + * Through foundation: a 1 % pinch zooms `Modifier.transformable` on its + * first step — under the two-touch synthesis it took ~13 such steps to + * clear the touch slop — and a pinch-out zooms it back. + */ + private fun onePercentPinchZoomsTransformable(): TaoWindowTestCase { + val zoom = Transform() + return TaoWindowTestCase( + name = "#660 macOS 1% pinch zooms Modifier.transformable with no slop", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Transformable(zoom) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + zoom.reset() + + magnify(Phase.BEGAN, 0.0) + magnify(Phase.CHANGED, ONE_PERCENT) + awaitUntilOrTimeout(REACTION_MILLIS) { zoom.zoom != 1f } + check(abs(zoom.zoom - (1f + ONE_PERCENT.toFloat())) <= FACTOR_TOLERANCE) { + "the first 1% step must zoom the transformable at once (zoom=${zoom.zoom})" + } + magnify(Phase.CHANGED, -ONE_PERCENT * 2) + awaitUntilOrTimeout(REACTION_MILLIS) { zoom.zoom < 1f } + magnify(Phase.ENDED, 0.0) + check(zoom.zoom < 1f) { "a pinch-out must zoom back out (zoom=${zoom.zoom})" } + check(zoom.rotation == 0f && zoom.pan == Offset.Zero) { + "a pure pinch must neither rotate nor pan (rotation=${zoom.rotation} pan=${zoom.pan})" + } + } + } + + /** + * The MapLibre report: the cursor 10 dp inside the map's left edge. The + * two-touch synthesis planted a contact 120 px left of the cursor, in the + * neighbouring chrome; the Scale events must hit the map only. + */ + private fun pinchAtMapEdgeReachesOnlyTheMap(): TaoWindowTestCase { + val chrome = EventRecorder() + val map = EventRecorder() + return TaoWindowTestCase( + name = "#660 macOS pinch at a map edge reaches only the map", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { + Row(Modifier.fillMaxSize()) { + Box(Modifier.width((TARGET_X - EDGE_INSET_DP).dp).fillMaxHeight().record(chrome)) + Box(Modifier.weight(1f).fillMaxHeight().record(map)) + } + }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + chrome.reset() + map.reset() + + magnify(Phase.BEGAN, 0.0) + repeat(EDGE_STEPS) { magnify(Phase.CHANGED, ONE_PERCENT) } + magnify(Phase.ENDED, 0.0) + awaitUntil("map got ScaleEnd") { map.count(PointerEventType.ScaleEnd) >= 1 } + settle() + + check(map.count(PointerEventType.ScaleChange) == EDGE_STEPS) { + "every step must reach the map under the cursor; map=${map.describe()}" + } + check(chrome.snapshot().none { it.type.isScale() || it.type == PointerEventType.Press }) { + "the neighbouring chrome must see no part of the pinch; chrome=${chrome.describe()}" + } + } + } + + /** A smart-magnify (two-finger double tap) is one discrete 1.5× Scale step. */ + private fun smartMagnifyIsOneDiscreteScaleStep(): TaoWindowTestCase { + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 macOS smart-magnify is one discrete Scale step", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Box(Modifier.fillMaxSize().record(recorder)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + recorder.reset() + + inject(Kind.SMART_MAGNIFY, Phase.NONE, 0.0) + awaitUntil("ScaleEnd recorded") { recorder.count(PointerEventType.ScaleEnd) >= 1 } + settle() + val scale = recorder.snapshot().filter { it.type.isScale() } + check(scale.map { it.type } == expectedScaleTypes(1)) { + "smart-magnify must be ScaleStart, one ScaleChange, ScaleEnd; recorded=${recorder.describe()}" + } + check(abs(scale[1].scaleFactor - SMART_MAGNIFY_FACTOR) <= FACTOR_TOLERANCE) { + "smart-magnify must carry the 1.5× step; recorded=${recorder.describe()}" + } + } + } + + /** A pinch the system cancels still closes with exactly one ScaleEnd. */ + private fun cancelledPinchClosesTheScaleGesture(): TaoWindowTestCase { + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 macOS cancelled pinch closes the Scale gesture", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Box(Modifier.fillMaxSize().record(recorder)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + recorder.reset() + + magnify(Phase.BEGAN, 0.0) + magnify(Phase.CHANGED, ONE_PERCENT) + magnify(Phase.CANCELLED, 0.0) + awaitUntil("ScaleEnd recorded") { recorder.count(PointerEventType.ScaleEnd) >= 1 } + settle() + check(recorder.snapshot().filter { it.type.isScale() }.map { it.type } == expectedScaleTypes(1)) { + "a cancelled pinch must close with one ScaleEnd; recorded=${recorder.describe()}" + } + } + } + + /** + * Rotation keeps the two-touch synthesis: `detectTransformGestures` sees + * the angle change (clockwise on screen for AppKit's counter-clockwise + * `rotation`, flipped into Compose's y-down space) and no zoom, and no + * Scale event is emitted. + */ + private fun rotateStillRotatesDetectTransformGestures(): TaoWindowTestCase { + val transform = Transform() + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 macOS two-finger rotate still rotates detectTransformGestures", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { + Box( + Modifier + .fillMaxSize() + .record(recorder) + .pointerInput(transform) { + detectTransformGestures { _, pan, zoom, rotation -> transform.apply(pan, zoom, rotation) } + }, + ) + }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + transform.reset() + recorder.reset() + + rotate(Phase.BEGAN, 0.0) + repeat(ROTATE_STEPS) { rotate(Phase.CHANGED, ROTATE_STEP_DEGREES) } + rotate(Phase.ENDED, 0.0) + awaitUntil("rotation reached detectTransformGestures") { transform.rotation != 0f } + settle() + + check(transform.rotation < 0f) { + "a counter-clockwise AppKit rotation must rotate Compose content counter-clockwise " + + "(negative rotationZ); rotation=${transform.rotation}" + } + check(abs(transform.zoom - 1f) <= FACTOR_TOLERANCE) { + "a pure rotation must not zoom (zoom=${transform.zoom})" + } + check(recorder.snapshot().none { it.type.isScale() }) { + "a rotation must emit no Scale event; recorded=${recorder.describe()}" + } + } + } + + /** + * A real trackpad interleaves magnify and rotate. When the pinch begins + * first it owns the gesture: the rotate steps are dropped, so no touch + * contact is ever pressed (a Scale event lists every active pointer; one + * without the contacts read as their release, and each rotate step + * re-pressed them — a touch tap per step), and every magnification zooms + * `Modifier.transformable` exactly once. + */ + private fun pinchFirstOwnsAnInterleavedGesture(): TaoWindowTestCase { + val transform = Transform() + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 macOS pinch-first interleaved gesture stays Scale-only", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { Transformable(transform, Modifier.record(recorder)) }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + transform.reset() + recorder.reset() + + magnify(Phase.BEGAN, 0.0) + rotate(Phase.BEGAN, 0.0) + repeat(INTERLEAVED_STEPS) { + magnify(Phase.CHANGED, INTERLEAVED_MAGNIFICATION) + rotate(Phase.CHANGED, ROTATE_STEP_DEGREES) + } + rotate(Phase.ENDED, 0.0) + magnify(Phase.ENDED, 0.0) + awaitUntil("ScaleEnd recorded") { recorder.count(PointerEventType.ScaleEnd) >= 1 } + settle() + + check(recorder.snapshot().none { it.pointerType == PointerType.Touch }) { + "a rotation inside a pinch must press no touch contact; recorded=${recorder.describe()}" + } + val scaleTypes = recorder.snapshot().filter { it.type.isScale() }.map { it.type } + check(scaleTypes == expectedScaleTypes(INTERLEAVED_STEPS)) { + "every magnification must be one ScaleChange; recorded=${recorder.describe()}" + } + check(abs(transform.zoom - interleavedZoom()) <= FACTOR_TOLERANCE) { + "every magnification must zoom transformable exactly once (zoom=${transform.zoom}, " + + "expected ${interleavedZoom()})" + } + } + } + + /** + * When the rotation begins first it owns the gesture: the contacts go + * down once and up once, the magnify steps widen them (as before #660) so + * `detectTransformGestures` both rotates and zooms, and no Scale event is + * emitted. + */ + private fun rotateFirstOwnsAnInterleavedGesture(): TaoWindowTestCase { + val transform = Transform() + val recorder = EventRecorder() + return TaoWindowTestCase( + name = "#660 macOS rotate-first interleaved gesture keeps its contacts down and zooms them", + skip = { macOnly() }, + paintDefaultBackground = false, + content = { + Box( + Modifier + .fillMaxSize() + .record(recorder) + .pointerInput(transform) { + detectTransformGestures { _, pan, zoom, rotation -> transform.apply(pan, zoom, rotation) } + }, + ) + }, + ) { + awaitUntil("window mapped") { bounds() != null } + settle() + transform.reset() + recorder.reset() + + rotate(Phase.BEGAN, 0.0) + magnify(Phase.BEGAN, 0.0) + repeat(INTERLEAVED_STEPS) { + rotate(Phase.CHANGED, ROTATE_STEP_DEGREES) + magnify(Phase.CHANGED, INTERLEAVED_MAGNIFICATION) + } + magnify(Phase.ENDED, 0.0) + rotate(Phase.ENDED, 0.0) + settle() + + val events = recorder.snapshot() + check(events.count { it.down } == 2 && events.count { it.up } == 2) { + "the two contacts must go down once and up once; recorded=${recorder.describe()}" + } + check(events.none { it.type.isScale() }) { + "a magnify inside a rotation must emit no Scale event; recorded=${recorder.describe()}" + } + check(transform.rotation < 0f) { "the rotation must reach detectTransformGestures (${transform.rotation})" } + check(transform.zoom > 1f) { "the magnify steps must widen the contacts (zoom=${transform.zoom})" } + } + } + + private fun interleavedZoom(): Float = + Math.pow(1.0 + INTERLEAVED_MAGNIFICATION, INTERLEAVED_STEPS.toDouble()).toFloat() + + // ── Injection ─────────────────────────────────────────────────────────── + + private suspend fun TaoWindowTestScope.magnify( + phase: Int, + magnification: Double, + ) = inject(Kind.MAGNIFY, phase, magnification) + + private suspend fun TaoWindowTestScope.rotate( + phase: Int, + degrees: Double, + ) = inject(Kind.ROTATE, phase, degrees) + + private suspend fun TaoWindowTestScope.inject( + kind: Int, + phase: Int, + value: Double, + ) { + val delivered = MacTrackpadGestureProbe.inject(window, kind, phase, TARGET_X, TARGET_Y, value) + check(delivered) { "nativeDiagInjectTrackpadGesture returned false (injection disabled or window gone?)" } + settle(STEP_MILLIS) + } + + // ── Compose content ───────────────────────────────────────────────────── + + private class Recorded( + val type: PointerEventType, + val pointerType: PointerType, + val position: Offset, + val scaleFactor: Float, + val down: Boolean, + val up: Boolean, + ) { + override fun toString(): String = + when { + type == PointerEventType.ScaleChange -> "$type($scaleFactor)" + pointerType == PointerType.Touch -> "$type(touch)" + else -> type.toString() + } + } + + /** Every pointer event seen on the Initial pass, in order (one entry per change). */ + private class EventRecorder { + private val events = Collections.synchronizedList(mutableListOf()) + + fun add(event: PointerEvent) { + event.changes.forEach { + events += + Recorded( + type = event.type, + pointerType = it.type, + position = it.position, + scaleFactor = it.scaleFactor, + down = it.changedToDownIgnoreConsumed(), + up = it.changedToUpIgnoreConsumed(), + ) + } + } + + fun snapshot(): List = synchronized(events) { events.toList() } + + /** Cases share their recorder with the registry; start each run clean. */ + fun reset() = events.clear() + + fun count(type: PointerEventType): Int = snapshot().count { it.type == type } + + fun describe(): String = snapshot().joinToString(prefix = "[", postfix = "]") + } + + private fun Modifier.record(recorder: EventRecorder): Modifier = + pointerInput(recorder) { + awaitPointerEventScope { + while (true) { + recorder.add(awaitPointerEvent(PointerEventPass.Initial)) + } + } + } + + private class Transform { + @Volatile var zoom: Float = 1f + + @Volatile var rotation: Float = 0f + + @Volatile var pan: Offset = Offset.Zero + + fun apply( + panChange: Offset, + zoomChange: Float, + rotationChange: Float, + ) { + zoom *= zoomChange + rotation += rotationChange + pan += panChange + } + + fun reset() { + zoom = 1f + rotation = 0f + pan = Offset.Zero + } + } + + @Composable + private fun Transformable( + transform: Transform, + modifier: Modifier = Modifier, + ) { + val state = rememberTransformableState { _, zoom, pan, rotation -> transform.apply(pan, zoom, rotation) } + Box(Modifier.fillMaxSize().then(modifier).transformable(state)) + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private fun PointerEventType.isScale(): Boolean = + this == PointerEventType.ScaleStart || + this == PointerEventType.ScaleChange || + this == PointerEventType.ScaleEnd + + private fun expectedScaleTypes(changes: Int): List = + listOf(PointerEventType.ScaleStart) + + List(changes) { PointerEventType.ScaleChange } + + PointerEventType.ScaleEnd + + private fun macOnly(): String? = + when { + Platform.Current != Platform.MacOS -> "macOS only — AppKit gesture NSEvent injection" + !MacTrackpadGestureProbe.available -> "nucleus_tao_metal not loaded" + else -> null + } + + /** Content-local injection point (points, top-left origin), well inside the 800×600 default window. */ + private const val TARGET_X = 400f + private const val TARGET_Y = 300f + + private val MAGNIFICATIONS = listOf(0.01, 0.02, 0.01, -0.02) + private const val ONE_PERCENT = 0.01 + private const val SMART_MAGNIFY_FACTOR = 1.5f + + /** The map's left edge sits this far left of the cursor. */ + private const val EDGE_INSET_DP = 10f + private const val EDGE_STEPS = 3 + + private const val ROTATE_STEPS = 4 + private const val ROTATE_STEP_DEGREES = 5.0 + private const val INTERLEAVED_STEPS = 5 + private const val INTERLEAVED_MAGNIFICATION = 0.02 + + private const val FACTOR_TOLERANCE = 1e-3f + private const val POSITION_TOLERANCE_PX = 1.5f + private const val STEP_MILLIS = 16L + + /** How long a transformable gets to react before the (soft) wait gives up. */ + private const val REACTION_MILLIS = 2_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacTrackpadGestureProbe.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacTrackpadGestureProbe.kt new file mode 100644 index 000000000..41c1a3899 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MacTrackpadGestureProbe.kt @@ -0,0 +1,55 @@ +package dev.nucleusframework.window.tao.headful + +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.ffi.NativeMetalBridge + +/** + * macOS headful helper (#660): delivers a synthetic magnify / rotate / + * smart-magnify NSEvent through [NativeMetalBridge.nativeDiagInjectTrackpadGesture]. + * The event is queued with `NSApp.postEvent` (delivered once the current + * loop callback returns, in posting order), so the local monitor in + * `touchpad_gestures.m`, the Rust loop, `TaoWindow` and the scene host all + * run exactly as for a real trackpad pinch. + * + * [Phase] values are the IOHID encodings `+[NSEvent eventWithCGEvent:]` maps + * onto `NSEventPhase` — NOT the `NSEventPhase` bits themselves. + */ +internal object MacTrackpadGestureProbe { + /** The `touchpad_gestures.m` wire. */ + object Kind { + const val MAGNIFY: Int = 0 + const val ROTATE: Int = 1 + const val SMART_MAGNIFY: Int = 2 + } + + /** Gesture phase field encodings → `NSEvent.phase`. */ + object Phase { + const val NONE: Int = 0 + const val BEGAN: Int = 1 + const val CHANGED: Int = 2 + const val ENDED: Int = 4 + const val CANCELLED: Int = 8 + } + + val available: Boolean get() = NativeMetalBridge.isLoaded + + /** + * [x] / [y] are content-local points, top-left origin. [value] is the + * magnification delta (`NSEvent.magnification`) or the rotation in degrees + * (`NSEvent.rotation`, positive = counter-clockwise). Returns `false` when + * injection is disabled or the window is gone. + */ + @Suppress("LongParameterList") + fun inject( + window: TaoWindow, + kind: Int, + phase: Int, + x: Float, + y: Float, + value: Double = 0.0, + ): Boolean { + val nsView = window.nativeHandle + if (nsView == 0L) return false + return NativeMetalBridge.nativeDiagInjectTrackpadGesture(nsView, kind, phase, x, y, value) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MonitorAndScaleHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MonitorAndScaleHeadfulCases.kt new file mode 100644 index 000000000..59c423359 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MonitorAndScaleHeadfulCases.kt @@ -0,0 +1,629 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TaoMonitor +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.WindowAnchor +import dev.nucleusframework.window.tao.WindowConstraintAdjustment +import dev.nucleusframework.window.tao.WindowPositioner +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Monitors and scale, on real windows. + * + * Two coordinate spaces run through every one of these APIs: windows are + * *placed* in logical pixels and *measured* in physical ones, and the ratio + * between them belongs to a monitor rather than to the application. Every + * mix-up in that conversion looks correct at 100% and is wrong by a factor of + * two on a HiDPI desktop — or wrong only on the second display, which is worse, + * because it looks like a rendering glitch rather than a bug. + * + * 1. **enumeration** — what a window says about the monitor it is on has to + * agree with what the monitor says about the window, and stay true while + * windows open and close; + * 2. **scale** — the size a window is asked for in dp is the size it gets in + * px, and everything the workspaces hit-test with is in the same space; + * 3. **hops** — a window moved from one display to another, repeatedly and + * fast, with its satellites and its strips following it there. + * + * The hop cases need a second display, so they report a skip on a + * single-monitor machine rather than pretending. The scale cases run + * everywhere, and are the ones that catch a logical/physical mix-up on the + * HiDPI desktop most developers are actually using. + */ +internal object MonitorAndScaleHeadfulCases { + fun all(): List = + listOf( + everyMonitorReportsACoherentFrame(), + aWindowResolvesToTheMonitorThatContainsIt(), + enumerationSurvivesAWindowStorm(), + aRequestedSizeInDpArrivesAsPixelsAtTheMonitorScale(), + stripSlotsAreInTheSameSpaceAsTheHitTest(), + theDockZoneIsScaledWithTheDisplay(), + aTearOffRectInPixelsBecomesAWindowOfTheRightLogicalSize(), + theDragGhostIsMeasuredInTheSameSpaceAsThePointer(), + aSatelliteIsKeptInsideTheWorkArea(), + aWindowPlacedFarOffEveryMonitorStillResolvesOne(), + aWindowHoppedBetweenMonitorsReportsEachOne(), + rapidHopsBetweenMonitorsConvergeOnTheLast(), + aSatelliteFollowsItsOwnerToAnotherMonitor(), + aStripStillTakesDropsAfterItsWindowChangesMonitor(), + ) + + // ── 1. enumeration ─────────────────────────────────────────────────── + + /** + * The frames the platform reports have to make sense before anything can be + * anchored against them: a work area inside its monitor, a positive scale, + * a unique id per monitor and exactly one primary. + */ + private fun everyMonitorReportsACoherentFrame(): TaoWindowTestCase = + TaoWindowTestCase( + name = "monitors every monitor reports a coherent frame", + size = DpSize(CASE_W_DP.dp, CASE_H_DP.dp), + driver = { + awaitUntil("window mapped") { bounds() != null } + settle() + val monitors = TaoMonitors.all(window) + check(monitors.isNotEmpty()) { "no monitor was reported at all" } + check(monitors.map { it.id }.toSet().size == monitors.size) { + "duplicate monitor ids: ${monitors.map { it.id }}" + } + check(monitors.count { it.isPrimary } == 1) { + "${monitors.count { it.isPrimary }} primary monitors" + } + for (monitor in monitors) { + check(monitor.boundsPx.width > 0 && monitor.boundsPx.height > 0) { + "${monitor.name} has no size: ${monitor.boundsPx}" + } + check(monitor.scaleFactor > 0f) { "${monitor.name} reports scale ${monitor.scaleFactor}" } + val work = monitor.workAreaPx + check(work.width in 1..monitor.boundsPx.width && work.height in 1..monitor.boundsPx.height) { + "${monitor.name}'s work area $work is not inside its bounds ${monitor.boundsPx}" + } + check( + work.left >= monitor.boundsPx.left && work.top >= monitor.boundsPx.top, + ) { "${monitor.name}'s work area starts outside its bounds" } + check(TaoMonitors.byId(monitor.id, window)?.id == monitor.id) { + "${monitor.name} cannot be looked up by its own id" + } + } + }, + ) + + /** + * The two directions have to agree: the monitor a window resolves to is + * one that actually contains it. A window is placed in logical pixels and + * a monitor is measured in physical ones, so this is the smallest possible + * test of that conversion. + */ + private fun aWindowResolvesToTheMonitorThatContainsIt(): TaoWindowTestCase = + TaoWindowTestCase( + name = "monitors a window resolves to a monitor that contains it", + skip = ::workspaceSkipReason, + size = DpSize(CASE_W_DP.dp, CASE_H_DP.dp), + driver = { + awaitUntil("window mapped") { bounds() != null } + settle(SETTLE_AFTER_MAP_MILLIS) + val rect = requireNotNull(bounds()) + val centre = + Offset(rect[0] + rect[RECT_W] / 2f, rect[1] + rect[RECT_H] / 2f) + val resolved = TaoMonitors.forWindow(window) + check(resolved.containsPx(centre.x.roundToInt(), centre.y.roundToInt())) { + "the window's centre $centre is not on ${resolved.name} ${resolved.boundsPx}" + } + check(abs(window.scaleFactor - resolved.scaleFactor) < SCALE_TOLERANCE) { + "the window reports scale ${window.scaleFactor}, its monitor ${resolved.scaleFactor}" + } + }, + ) + + /** + * Enumeration must not depend on what the application happens to have open: + * opening and closing windows is not a display change, and a list that + * shifts under one would move every anchored satellite with it. + */ + private fun enumerationSurvivesAWindowStorm(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "monitors enumeration is unchanged by a storm of windows opening and closing", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val before = TaoMonitors.all(window).map { it.id to it.boundsPx } + + repeat(STORM_ROUNDS) { round -> + val title = titles[1 + round % (titles.size - 1)] + val id = fixture.tabId(title) + val from = fixture.groupOf(title)?.window ?: first + fixture.workspace.tearOff(id, tearOffRectPx(from), from.scaleFactor) + fixture.workspace.move(id, requireNotNull(fixture.groupOf("Alpha"))) + } + awaitUntil("the storm settled") { fixture.workspace.groups.size == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + + val after = TaoMonitors.all(window).map { it.id to it.boundsPx } + check(after == before) { "the monitor list changed under a window storm: $before → $after" } + }, + ) + } + + // ── 2. scale ───────────────────────────────────────────────────────── + + /** + * The conversion every window API rests on: a size asked for in dp arrives + * as that many dp worth of physical pixels. On a 200% desktop a factor-of- + * two mix-up is invisible in the code and unmistakable on screen. + */ + private fun aRequestedSizeInDpArrivesAsPixelsAtTheMonitorScale(): TaoWindowTestCase { + val scene = mutableStateOf(IntSize.Zero) + return TaoWindowTestCase( + name = "monitors a size requested in dp arrives as pixels at the monitor's scale", + size = DpSize(CASE_W_DP.dp, CASE_H_DP.dp), + paintDefaultBackground = false, + content = { + val container = LocalWindowInfo.current.containerSize + SideEffect { scene.value = container } + Box(Modifier.fillMaxSize().background(Color.DarkGray)) + }, + driver = { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("the scene has a size") { scene.value.width > 0 } + settle(SETTLE_AFTER_MAP_MILLIS) + val scale = window.scaleFactor + check(scale > 0f) { "the window reports scale $scale" } + for (wDp in listOf(REQUEST_A_DP, REQUEST_B_DP)) { + window.setInnerSize(wDp, REQUEST_H_DP) + // The *scene* is the inner size in physical pixels, which + // is what `setInnerSize` asks for. The outer frame carries + // the chrome and, on a CSD desktop, a shadow margin the WM + // owns — measuring it would measure the decoration. + awaitUntil("the scene is ${wDp}dp wide at scale $scale") { + abs(scene.value.width - (wDp * scale).toInt()) <= SIZE_TOLERANCE_PX + } + } + }, + ) + } + + /** + * The strip publishes its slots in physical window pixels and the workspace + * hit-tests drops in physical screen pixels. If either side used logical + * ones, every drop on a HiDPI display would resolve half a strip away — so + * each tab's own centre has to resolve to its own index. + */ + private fun stripSlotsAreInTheSameSpaceAsTheHitTest(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "monitors strip slots are hit-tested in the space they are published in", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabWindows(fixture, *titles.toTypedArray()) + val group = requireNotNull(fixture.groupOf("Alpha")) + val scale = tabWindow.scaleFactor + val strip = requireNotNull(fixture.stripRectPx(group)) + + // The strip spans the window it is in, in the same units. + val outer = requireNotNull(tabWindow.outerBoundsPx()) + check(strip.width <= outer[RECT_W] + STRIP_SLOP_PX) { + "the strip (${strip.width}px) is wider than its window (${outer[RECT_W]}px) at scale $scale" + } + for ((index, id) in group.ids.withIndex()) { + val title = titles.first { fixture.tabId(it) == id } + val centre = requireNotNull(fixture.tabCenterPx(title)) { "$title has no slot" } + val entry = requireNotNull(fixture.workspace.tab(id)) + val resolved = + requireNotNull(fixture.workspace.dropTargetAt(centre, exclude = entry)) { + "$title's own centre resolves to no strip at scale $scale" + } + check(resolved.group === group && resolved.index == index) { + "$title sits at $index but resolves to ${resolved.index} at scale $scale" + } + } + }, + ) + } + + /** + * The dock zone is a dp band, so its width in pixels has to follow the + * display. A fixed pixel band is half as deep as it should be at 200% — + * and on a mixed-DPI desktop it is right on one display and wrong on the + * other. + */ + private fun theDockZoneIsScaledWithTheDisplay(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "monitors the dock zone band is scaled with the display", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + awaitFloating(fixture) + val workspace = fixture.workspace + awaitUntil("the layout published its geometry") { + workspace.dockHostGeometry(window)?.layoutScreenRectPx() != null + } + val layout = awaitDockLayout(workspace, window) + val scale = window.scaleFactor + val bandPx = SatelliteWorkspace.DockZoneWidth.value * scale + + // Just inside the band on the right edge is a zone… + val inside = Offset(layout.right - bandPx / 2f, layout.center.y) + check(workspace.dockTargetAt(inside)?.side == DockSide.Right) { + "a point ${bandPx / 2f}px inside the right edge is not the right zone at scale $scale" + } + // …and well past it is content, not a zone. + val outside = Offset(layout.right - bandPx * BEYOND_BAND, layout.center.y) + check(workspace.dockTargetAt(outside) == null) { + "a point ${bandPx * BEYOND_BAND}px inside the right edge is still a zone at scale $scale" + } + }, + ) + } + + /** + * `tearOff` takes a rect in physical pixels and the scale it was measured + * at, because the window it creates is placed in logical ones. Getting that + * conversion wrong gives a window half or twice the size the user dragged. + */ + private fun aTearOffRectInPixelsBecomesAWindowOfTheRightLogicalSize(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "monitors a tear-off rect in pixels becomes a window of the right logical size", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val scale = first.scaleFactor + val source = requireNotNull(first.outerBoundsPx()) + val torn = + requireNotNull( + fixture.workspace.tearOff(fixture.tabId("Beta"), tearOffRectPx(first), scale), + ) + val tornWindow = awaitMappedStrip(fixture, torn) + settle(SETTLE_AFTER_MAP_MILLIS) + val rect = requireNotNull(tornWindow.outerBoundsPx()) + + val sourceLogicalW = source[RECT_W] / scale + val tornLogicalW = rect[RECT_W] / tornWindow.scaleFactor + check(abs(tornLogicalW - sourceLogicalW) <= LOGICAL_TOLERANCE_DP) { + "torn-off window is ${tornLogicalW}dp wide, the source is ${sourceLogicalW}dp " + + "(scales ${tornWindow.scaleFactor} vs $scale)" + } + }, + ) + } + + /** + * The ghost follows the pointer, and both are physical screen pixels. A + * ghost sized or placed in logical ones drifts away from the cursor by the + * scale factor — which on a 200% display means it is nowhere near the + * pointer by the time it crosses the screen. + */ + private fun theDragGhostIsMeasuredInTheSameSpaceAsThePointer(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "monitors the drag ghost is measured in the same space as the pointer", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, *titles.toTypedArray()) + val group = requireNotNull(fixture.groupOf("Beta")) + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val away = requireNotNull(fixture.farFromStripPx(group)) + val session = + requireNotNull(fixture.workspace.beginDrag(fixture.tabId("Beta"), stripOrigin(first), grab)) + session.update(grab) + session.update(away) + settle() + + val ghost = requireNotNull(fixture.workspace.dragGhost) { "no ghost while dragging out" } + check(abs(ghost.scaleFactor - first.scaleFactor) < SCALE_TOLERANCE) { + "the ghost reports scale ${ghost.scaleFactor}, the window ${first.scaleFactor}" + } + check(ghost.screenRectPx.contains(away)) { + "the ghost ${ghost.screenRectPx} does not cover the pointer at $away" + } + val slot = requireNotNull(fixture.tabRectPx("Beta")) + check(abs(ghost.screenRectPx.width - slot.width) <= GHOST_SIZE_TOLERANCE_PX) { + "the ghost is ${ghost.screenRectPx.width}px wide, the tab ${slot.width}px" + } + session.cancel() + }, + ) + } + + /** + * A satellite anchored past the edge of the display: the positioner is + * asked to keep it inside the work area, and the work area is a monitor + * fact in physical pixels. A conversion slip here parks the palette + * off-screen, where the user cannot reach it at all. + */ + private fun aSatelliteIsKeptInsideTheWorkArea(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "monitors a satellite anchored past the edge is slid back into the work area", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val satellite = awaitFloating(fixture) + val monitor = TaoMonitors.forWindow(window) + val scale = window.scaleFactor.toDouble() + + // The owner pushed against the right edge of the work area, so + // the satellite's anchor lands beyond it. + val edgeX = (monitor.workAreaPx.right - EDGE_MARGIN_PX) / scale + window.setOuterPosition(edgeX, monitor.workAreaPx.top / scale + EDGE_MARGIN_PX) + awaitUntil("the owner moved to the edge") { + val rect = bounds() ?: return@awaitUntil false + rect[0] > monitor.workAreaPx.right - monitor.boundsPx.width / 2 + } + // Re-anchor with a rule that is allowed to slide it back on. + val entry = requireNotNull(fixture.workspace.satellite(SATELLITE_ID)) + entry.windowState.positioner = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + offset = DpOffset(GAP_DP.dp, 0.dp), + constraintAdjustment = WindowConstraintAdjustment.Slide, + ) + entry.windowState.reanchor() + + awaitUntil("the satellite is inside the work area") { + val rect = satellite.outerBoundsPx() ?: return@awaitUntil false + rect[0] + rect[RECT_W] <= monitor.workAreaPx.right + WORK_AREA_SLOP_PX && + rect[0] >= monitor.workAreaPx.left - WORK_AREA_SLOP_PX + } + }, + ) + } + + /** + * A window dropped far outside every display — a restored layout from a + * monitor that is no longer plugged in. Resolving a monitor for it has to + * answer *something* usable rather than fail, or every anchor computed + * from it is null and the palettes never appear. + */ + private fun aWindowPlacedFarOffEveryMonitorStillResolvesOne(): TaoWindowTestCase = + TaoWindowTestCase( + name = "monitors a window placed far off every display still resolves one", + skip = ::workspaceSkipReason, + size = DpSize(CASE_W_DP.dp, CASE_H_DP.dp), + driver = { + awaitUntil("window mapped") { bounds() != null } + settle(SETTLE_AFTER_MAP_MILLIS) + val monitors = TaoMonitors.all(window) + val farRight = monitors.maxOf { it.boundsPx.right } + OFF_SCREEN_PX + val scale = window.scaleFactor.toDouble() + + window.setOuterPosition(farRight / scale, OFF_SCREEN_PX / scale) + settle(SETTLE_AFTER_MAP_MILLIS) + val resolved = TaoMonitors.forWindow(window) + check(resolved.boundsPx.width > 0) { "resolved a monitor with no bounds for an off-screen window" } + check(resolved.scaleFactor > 0f) { "resolved a monitor with no scale" } + check(TaoMonitors.all(window).any { it.id == resolved.id }) { + "resolved a monitor that is not in the list" + } + check(bounds() != null) { "the window was lost off-screen" } + }, + ) + + // ── 3. hops between displays ───────────────────────────────────────── + + /** A window moved onto each display in turn reports the one it is on. */ + private fun aWindowHoppedBetweenMonitorsReportsEachOne(): TaoWindowTestCase = + TaoWindowTestCase( + name = "monitors a window hopped between displays reports each one", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::twoMonitorsSkipReason, + size = DpSize(CASE_W_DP.dp, CASE_H_DP.dp), + driver = { + awaitUntil("window mapped") { bounds() != null } + settle(SETTLE_AFTER_MAP_MILLIS) + for (monitor in TaoMonitors.all(window)) { + moveOnto(window, monitor) + awaitUntil("the window reports ${monitor.name}") { + TaoMonitors.forWindow(window).id == monitor.id + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(abs(window.scaleFactor - monitor.scaleFactor) < SCALE_TOLERANCE) { + "on ${monitor.name} the window reports scale ${window.scaleFactor}, " + + "the monitor ${monitor.scaleFactor}" + } + } + }, + ) + + /** + * Hops fired faster than the platform answers. Each one may change the + * backing scale, which rebuilds the surface — so this is where a window + * ends up reporting one display while drawing at another's scale. + */ + private fun rapidHopsBetweenMonitorsConvergeOnTheLast(): TaoWindowTestCase = + TaoWindowTestCase( + name = "monitors rapid hops between displays converge on the last one", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::twoMonitorsSkipReason, + size = DpSize(CASE_W_DP.dp, CASE_H_DP.dp), + driver = { + awaitUntil("window mapped") { bounds() != null } + settle(SETTLE_AFTER_MAP_MILLIS) + val monitors = TaoMonitors.all(window) + repeat(HOP_ROUNDS) { round -> moveOnto(window, monitors[round % monitors.size]) } + val last = monitors[(HOP_ROUNDS - 1) % monitors.size] + moveOnto(window, last) + + awaitUntil("the window settled on ${last.name}") { + TaoMonitors.forWindow(window).id == last.id + } + awaitUntil("and reports that display's scale") { + abs(window.scaleFactor - last.scaleFactor) < SCALE_TOLERANCE + } + settle(SETTLE_AFTER_MAP_MILLIS) + val rect = requireNotNull(bounds()) + check(rect[RECT_W] > 0 && rect[RECT_H] > 0) { "the window lost its size hopping" } + }, + ) + + /** The satellite goes where its owner goes, including onto another display. */ + private fun aSatelliteFollowsItsOwnerToAnotherMonitor(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "monitors a satellite follows its owner onto another display", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::twoMonitorsSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val satellite = awaitFloating(fixture) + val monitors = TaoMonitors.all(window) + val target = monitors.first { it.id != TaoMonitors.forWindow(window).id } + + moveOnto(window, target) + awaitUntil("the owner is on ${target.name}") { TaoMonitors.forWindow(window).id == target.id } + awaitUntil("the satellite came along") { + val rect = satellite.outerBoundsPx() ?: return@awaitUntil false + target.containsPx( + (rect[0] + rect[RECT_W] / 2).toInt(), + (rect[1] + rect[RECT_H] / 2).toInt(), + ) + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(requireNotNull(satellite.outerBoundsPx())[RECT_W] > 0L) { + "the satellite lost its size on the way over" + } + }, + ) + } + + /** + * A strip whose window changed display: the geometry it published was in + * the old display's pixels, and a drop resolved against it would land in + * the wrong place — or nowhere. + */ + private fun aStripStillTakesDropsAfterItsWindowChangesMonitor(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "monitors a strip still takes drops after its window changes display", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::twoMonitorsSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabWindows(fixture, *titles.toTypedArray()) + val group = requireNotNull(fixture.groupOf("Alpha")) + val target = TaoMonitors.all(tabWindow).first { it.id != TaoMonitors.forWindow(tabWindow).id } + + moveOnto(tabWindow, target) + awaitUntil("the tab window is on ${target.name}") { + TaoMonitors.forWindow(tabWindow).id == target.id + } + awaitUntil("its strip republished on the new display") { + val strip = fixture.stripRectPx(group) ?: return@awaitUntil false + target.containsPx(strip.center.x.roundToInt(), strip.center.y.roundToInt()) + } + settle(SETTLE_AFTER_MAP_MILLIS) + val strip = requireNotNull(fixture.stripRectPx(group)) + check(fixture.workspace.dropTargetAt(strip.center)?.group === group) { + "the strip does not answer a drop after the hop" + } + for ((index, id) in group.ids.withIndex()) { + val title = titles.first { fixture.tabId(it) == id } + val centre = requireNotNull(fixture.tabCenterPx(title)) + val entry = requireNotNull(fixture.workspace.tab(id)) + check(fixture.workspace.dropTargetAt(centre, exclude = entry)?.index == index) { + "$title resolves to the wrong index after the hop" + } + } + }, + ) + } + + // ── helpers ────────────────────────────────────────────────────────── + + /** Puts [window] near the top-left of [monitor]'s work area, in logical pixels. */ + private fun moveOnto( + window: TaoWindow, + monitor: TaoMonitor, + ) { + val scale = (monitor.scaleFactor.takeIf { it > 0f } ?: 1f).toDouble() + window.setOuterPosition( + (monitor.workAreaPx.left + EDGE_MARGIN_PX) / scale, + (monitor.workAreaPx.top + EDGE_MARGIN_PX) / scale, + ) + } + + /** Why the hop cases cannot run here, or `null` when a second display exists. */ + private fun twoMonitorsSkipReason(): String? = + workspaceSkipReason() ?: if (TaoMonitors.all().size < 2) "needs a second display" else null + + private const val CASE_W_DP = 420 + private const val CASE_H_DP = 300 + private const val REQUEST_A_DP = 380.0 + private const val REQUEST_B_DP = 520.0 + private const val REQUEST_H_DP = 300.0 + private const val EDGE_MARGIN_PX = 40 + private const val OFF_SCREEN_PX = 4_000 + private const val HOP_ROUNDS = 12 + private const val STORM_ROUNDS = 6 + + /** Where the "outside the band" probe sits, as a multiple of the band's own depth. */ + private const val BEYOND_BAND = 3f + + private const val SCALE_TOLERANCE = 0.01f + private const val SIZE_TOLERANCE_PX = 12 + private const val STRIP_SLOP_PX = 8f + private const val GHOST_SIZE_TOLERANCE_PX = 24f + private const val LOGICAL_TOLERANCE_DP = 12f + private const val WORK_AREA_SLOP_PX = 48L + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MonkeySupport.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MonkeySupport.kt new file mode 100644 index 000000000..777517ff0 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/MonkeySupport.kt @@ -0,0 +1,225 @@ +package dev.nucleusframework.window.tao.headful + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentLinkedDeque +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.thread +import kotlin.math.max + +// What every monkey case shares: the seed, the journal that makes a random +// failure readable, and the watchdog that measures `Dispatchers.Main` from +// a thread that is not on it. +// +// A monkey does not assert that "the right thing happened" — for a random +// sequence there is none. It asserts that nothing wedges and nothing is left +// behind, and it has to be *diagnosable* when it fails: the seed replays the +// action sequence, the journal names the last actions, and the watchdog dumps +// every stack the moment the loop stops answering — which the driver cannot +// do for itself, because it runs on the very dispatcher that is stuck. + +/** System property that replays a red run's action sequence. */ +internal const val MONKEY_SEED_PROPERTY = "nucleus.tao.headful.monkeySeed" + +/** Fixed so a green run stays green; override the property to explore. */ +internal const val MONKEY_DEFAULT_SEED = 20_260_903L + +/** The seed of a monkey run; overridable so a red run replays exactly. */ +internal fun monkeySeed(): Long = System.getProperty(MONKEY_SEED_PROPERTY)?.toLongOrNull() ?: MONKEY_DEFAULT_SEED + +/** + * System property that replaces the random walk with a fixed action list — + * the journal of a red run pasted back, comma-separated, to turn a sequence + * into a repro and then bisect it by deleting entries. + */ +internal const val MONKEY_SCRIPT_PROPERTY = "nucleus.tao.headful.monkeyScript" + +/** The scripted actions, by enum name, or null for a random walk. */ +internal fun monkeyScript(): List? = + System + .getProperty(MONKEY_SCRIPT_PROPERTY) + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + +/** + * The last [depth] actions of a run plus what it reached, newest last. + * + * Concurrent because [MainLoopWatchdog] prints it from its own thread, + * precisely when the main thread is not answering. [reached] counts what the + * run actually did: a monkey whose every guard refuses early still passes + * every invariant, so a green run has to say what it exercised. + */ +internal class MonkeyJournal( + private val tag: String, + val seed: Long, + private val depth: Int = JOURNAL_DEPTH, + /** Echo each action to stderr; hours-long runs keep only the in-memory tail. */ + private val echo: Boolean = true, +) { + private val entries = ConcurrentLinkedDeque() + private val reached = mutableMapOf() + + /** Index of the action being applied, for reports. */ + @Volatile + var step: Int = 0 + + /** + * Also echoed to stderr as it happens: a native abort (a Rust panic, a + * SIGSEGV in a bridge) leaves no Kotlin frame to print the journal from, + * and the last echoed line is then the only record of what was running. + */ + fun record(action: Any) { + if (entries.size >= depth) entries.pollFirst() + entries.addLast("$step $action") + if (echo) System.err.println("[$tag] $step $action") + } + + fun reach(what: String) { + reached[what] = (reached[what] ?: 0) + 1 + } + + fun reachedCount(what: String): Int = reached[what] ?: 0 + + fun reachedSummary(): String = reached.toSortedMap().toString() + + /** Only the journal, the seed and the step: safe to read from another thread. */ + fun report(): String = + buildString { + appendLine(" $tag seed $seed, at step $step, last ${entries.size} actions:") + for (entry in entries) appendLine(" $entry") + } + + fun failure( + reason: String, + state: String, + ): String = + buildString { + appendLine("$tag failed at step $step: $reason") + appendLine(" seed: $seed (replay with -D$MONKEY_SEED_PROPERTY=$seed)") + appendLine(" state: $state") + append(report()) + } + + private companion object { + const val JOURNAL_DEPTH = 40 + } +} + +/** + * Runs [block] under the short per-action budget. An action is a handful of + * calls and a settle, so [budgetMillis] is orders of magnitude of slack — + * anything that exceeds it is stuck, not slow, and saying *which* action + * wedged is worth far more than the case's own deadline firing later. + */ +internal suspend fun monkeyAction( + describe: () -> String, + budgetMillis: Long = MONKEY_ACTION_BUDGET_MILLIS, + block: suspend () -> T, +): T = + try { + withTimeout(budgetMillis) { block() } + } catch (timeout: TimeoutCancellationException) { + throw IllegalStateException("${describe()} never returned (budget ${budgetMillis}ms)", timeout) + } + +internal const val MONKEY_ACTION_BUDGET_MILLIS = 5_000L + +/** + * Measures `Dispatchers.Main` from a thread that is not on it. + * + * Every mutation, every frame and the driver itself run on the Tao event-loop + * thread, which is also the main dispatcher. That makes the one failure a + * monkey is hunting invisible from the inside: if the loop and the dispatcher + * ever wait on each other, the driver is not running either, so it cannot fail + * its own case — the suite would just hit its deadline with no clue why. + * + * So the heartbeat is posted from outside. A round trip unanswered for + * [MONKEY_STALL_DUMP_MILLIS] dumps every thread's stack next to the journal, + * which names both halves of the deadlock; one that comes back late is + * reported as the worst stall and fails the case at the end. If it never comes + * back the suite's own watchdog halts the process — with the dump already on + * stderr. + */ +internal class MainLoopWatchdog( + private val name: String, + private val journal: () -> String, +) { + private val worst = AtomicLong(0) + private val stopped = AtomicBoolean(false) + private val dumped = AtomicBoolean(false) + private val main = CoroutineScope(Dispatchers.Main) + private var watcher: Thread? = null + + fun start(): MainLoopWatchdog { + watcher = thread(isDaemon = true, name = "$name-watchdog") { watch() } + return this + } + + /** Stops watching and answers the worst round trip it measured, in ms. */ + fun stop(): Long { + stopped.set(true) + watcher?.interrupt() + main.cancel() + return worst.get() + } + + private fun watch() { + try { + while (!stopped.get()) { + val posted = System.nanoTime() + val beat = CountDownLatch(1) + main.launch { beat.countDown() } + if (!beat.await(MONKEY_STALL_DUMP_MILLIS, TimeUnit.MILLISECONDS)) { + dumpEveryThread() + // Gone for good: the suite watchdog owns the process from + // here, and the dump above is what it will be diagnosed on. + if (!beat.await(STALL_GIVE_UP_MILLIS, TimeUnit.MILLISECONDS)) return + } + val roundTrip = (System.nanoTime() - posted) / NANOS_PER_MILLI + worst.accumulateAndGet(roundTrip) { a, b -> max(a, b) } + Thread.sleep(BEAT_INTERVAL_MILLIS) + } + } catch (_: InterruptedException) { + // stop() interrupted the wait; nothing left to measure. + } + } + + private fun dumpEveryThread() { + if (!dumped.compareAndSet(false, true)) return + val dump = + buildString { + appendLine( + "[$name] Dispatchers.Main has not answered in ${MONKEY_STALL_DUMP_MILLIS}ms — " + + "the Tao loop and the dispatcher may be deadlocked", + ) + append(journal()) + for ((thread, frames) in Thread.getAllStackTraces()) { + appendLine(" \"${thread.name}\" ${thread.state}") + for (frame in frames) appendLine(" at $frame") + } + } + System.err.println(dump) + System.err.flush() + } + + private companion object { + const val BEAT_INTERVAL_MILLIS = 250L + const val STALL_GIVE_UP_MILLIS = 30_000L + const val NANOS_PER_MILLI = 1_000_000L + } +} + +/** A heartbeat unanswered this long is a stall worth every thread's stack. */ +internal const val MONKEY_STALL_DUMP_MILLIS = 8_000L + +/** Same threshold: a stall that recovered still fails the case, with the dump already printed. */ +internal const val MONKEY_MAX_STALL_MILLIS = MONKEY_STALL_DUMP_MILLIS diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupMarginInputHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupMarginInputHeadfulCases.kt new file mode 100644 index 000000000..46a3b9578 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupMarginInputHeadfulCases.kt @@ -0,0 +1,383 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import dev.nucleusframework.window.tao.popup.PopupFrameRecord +import dev.nucleusframework.window.tao.popup.TaoPopupDiagnostics +import kotlinx.coroutines.delay +import java.awt.event.InputEvent +import kotlin.math.abs +import kotlin.math.roundToInt + +/** + * Headful battery for the **draw margin's input contract** on native popup + * layers. + * + * A native popup layer's surface extends 32 dp past `boundsInWindow` so shadows + * and the dialog appearance animation are not clipped (`popupDrawBounds`). That + * margin is transparent, and every backend has to make it transparent to input + * as well, or an open popup grows an invisible dead ring: a click on a button + * 20 px beside a menu would dismiss the menu without ever pressing the button, + * and hovering past the menu's edge would freeze the owner window's hover state. + * + * Windows and macOS get this from the OS — the layer hands the *content* rect to + * `nativeSetFrameInWindow` / `nativeSetInteractiveRegions`, so the display + * server routes a margin click to the parent. GTK's input shaping does not take + * on a popup toplevel (the region reaches GDK and the X window keeps its full + * input shape), so the Linux layer routes the event to the owner itself — + * `TaoPopupHostLinux.forwardMarginPointer`. + * + * The cases drive a real pointer with [Robot] against a real popup and assert on + * what the **owner window's scene** received, which is the only thing that tells + * a pass-through apart from a swallow. + */ +internal object NativePopupMarginInputHeadfulCases { + fun all(): List = + listOf( + ownerWindowReceivesAPlainPress(), + marginPressReachesTheOwnerWindow(), + marginMoveReachesTheOwnerWindow(), + contentPressDoesNotReachTheOwnerWindow(), + compositorPlacedPopupReanchorsWhenItGrows(), + ) + + /** + * Native Wayland only. A compositor-placed popup is an `xdg_popup`, and GDK + * builds its positioner once, at map. A popup that re-measures afterwards + * — a menu whose items size late — cannot apply the new size in place: + * resizing the EGL buffer alone leaves the `xdg_surface` geometry at the + * anchored size, the buffer/geometry disagreement of #502. The layer has to + * re-map, which shows up as a second anchor. + */ + private fun compositorPlacedPopupReanchorsWhenItGrows(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a compositor-placed popup that grows after it is mapped re-anchors", + skip = { + when { + !isNativeWayland -> "compositor placement is a native-Wayland path" + else -> null + } + }, + nativePopupLayers = true, + paintDefaultBackground = false, + content = { Content() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + settle(POINTER_SETTLE_MILLIS) + TaoPopupDiagnostics.reset() + popupHeightDp.value = POPUP_H_DP + popupShown.value = true + try { + awaitUntil("the popup took the compositor-placed path") { + TaoPopupDiagnostics.lastCompositorPlaced == true + } + awaitUntil("the popup anchored once") { TaoPopupDiagnostics.compositorAnchorCount >= 1 } + settle(REANCHOR_SETTLE_MILLIS) + val before = TaoPopupDiagnostics.compositorAnchorCount + popupHeightDp.value = POPUP_H_DP + POPUP_GROWTH_DP + awaitUntil( + "the popup re-anchored at its new size", + detail = { "anchors before=$before now=${TaoPopupDiagnostics.compositorAnchorCount}" }, + ) { TaoPopupDiagnostics.compositorAnchorCount > before } + } finally { + popupShown.value = false + popupHeightDp.value = POPUP_H_DP + } + } + + /** + * The guard the other cases lean on: without it, "the owner saw nothing" + * is as consistent with a broken driver as with a swallowed press. + */ + private fun ownerWindowReceivesAPlainPress(): TaoWindowTestCase = + marginCase("#569 the owner window receives a press with no popup open") { + val rect = requireNotNull(bounds()) { "window not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val x = ((rect[0] + rect[2] / 2) / scale).roundToInt() + val y = ((rect[1] + rect[3] / 2) / scale).roundToInt() + ownerPresses.value = 0 + clickAt(x, y) + awaitUntil("the owner window received the press at ($x,$y)") { ownerPresses.value > 0 } + } + + /** + * The reported failure: a press in the margin is the popup window's by + * accident of geometry, and the owner never sees it. + */ + private fun marginPressReachesTheOwnerWindow(): TaoWindowTestCase = + marginCase("#569 a press in a popup's draw margin reaches the owner window") { + val record = openPopupAndSettle() + val (x, y) = marginPointOf(record) + ownerPresses.value = 0 + clickAt(x, y) + awaitUntil( + "the owner window received the margin press", + detail = { "point=($x,$y) ${describe(record)}" }, + ) { ownerPresses.value > 0 } + } + + /** + * The same ring, in its quieter form: pointer moves over the margin belong + * to the owner too, or its hover state freezes within 32 dp of any open + * popup. + */ + private fun marginMoveReachesTheOwnerWindow(): TaoWindowTestCase = + marginCase("#569 a pointer move over a popup's draw margin reaches the owner window") { + val record = openPopupAndSettle() + val (x, y) = marginPointOf(record) + // Park the pointer well away first, so the move under test is a real + // transition rather than a repeat of wherever the last case left it. + val rect = requireNotNull(bounds()) { "window not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + moveTo( + (rect[0] / scale).roundToInt() + PARK_INSET_PX, + (rect[1] / scale).roundToInt() + PARK_INSET_PX, + ) + ownerMoves.value = 0 + moveTo(x, y) + awaitUntil( + "the owner window received the margin move", + detail = { "point=($x,$y) ${describe(record)}" }, + ) { ownerMoves.value > 0 } + } + + /** + * The other half of the contract: the *content* still belongs to the popup. + * A fix that opened the whole surface to the parent would pass the two cases + * above and break every menu. + */ + private fun contentPressDoesNotReachTheOwnerWindow(): TaoWindowTestCase = + marginCase("#569 a press on a popup's content does not reach the owner window") { + val record = openPopupAndSettle() + val content = record.contentOnScreenPx + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val x = ((content.left + content.right) / 2 / scale).roundToInt() + val y = ((content.top + content.bottom) / 2 / scale).roundToInt() + ownerPresses.value = 0 + popupPresses.value = 0 + clickAt(x, y) + awaitUntil("the popup received the press on its content") { popupPresses.value > 0 } + settle(POINTER_SETTLE_MILLIS) + check(ownerPresses.value == 0) { + "a press on the popup's content must not also reach the owner window; ${describe(record)}" + } + } + + // ── Case scaffolding ────────────────────────────────────────────────── + + private val popupShown = mutableStateOf(false) + private val ownerPresses = mutableStateOf(0) + private val ownerMoves = mutableStateOf(0) + private val popupPresses = mutableStateOf(0) + private val popupHeightDp = mutableStateOf(POPUP_H_DP) + + /** + * Owner content that counts what the window's own scene receives, and a + * popup parked in the middle of it so its whole draw margin still lands on + * the owner window — the margin has to be over the parent for a + * pass-through to be observable at all. + */ + @Composable + private fun Content() { + val shown by popupShown + Box( + Modifier + .fillMaxSize() + .background(Color(0xFF203040)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + when (awaitPointerEvent().type) { + PointerEventType.Press -> ownerPresses.value++ + PointerEventType.Move -> ownerMoves.value++ + else -> Unit + } + } + } + }, + ) + if (shown) { + Popup(alignment = Alignment.Center) { + val height by popupHeightDp + Box( + Modifier + .size(POPUP_W_DP.dp, height.dp) + .background(Color.Magenta) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + if (awaitPointerEvent().type == PointerEventType.Press) { + popupPresses.value++ + } + } + } + }, + ) + } + } + } + + private fun marginCase( + name: String, + driver: suspend TaoWindowTestScope.() -> Unit, + ): TaoWindowTestCase = + TaoWindowTestCase( + name = name, + skip = ::skipReason, + nativePopupLayers = true, + // The scope is a ColumnScope and the harness's default background + // is a `fillMaxSize` sibling: leaving it on would take the whole + // height and lay this case's content out at zero, where nothing + // hit-tests and every assertion here would hold for the wrong + // reason. + paintDefaultBackground = false, + content = { Content() }, + driver = { + awaitUntil("window mapped") { window.hasRealFramePx() } + window.setAlwaysOnTop(true) + window.focus() + centerWindow() + settle(POINTER_SETTLE_MILLIS) + try { + driver() + } finally { + popupShown.value = false + window.setAlwaysOnTop(false) + } + }, + ) + + private suspend fun TaoWindowTestScope.openPopupAndSettle(): PopupFrameRecord { + TaoPopupDiagnostics.reset() + popupShown.value = true + awaitUntil("popup layer pushed a frame") { TaoPopupDiagnostics.lastFrame != null } + var previous: IntRect? = null + var stable = 0 + val deadline = System.currentTimeMillis() + SETTLE_TIMEOUT_MILLIS + while (stable < STABLE_FRAMES) { + delay(POLL_MILLIS) + val frame = TaoPopupDiagnostics.lastFrame?.frameOnScreenPx + stable = if (frame != null && frame == previous) stable + 1 else 0 + previous = frame + check(System.currentTimeMillis() < deadline) { "popup frame never settled (last=$frame)" } + } + val record = requireNotNull(TaoPopupDiagnostics.lastFrame) + check(record.frameOnScreenPx.right > record.contentOnScreenPx.right) { + "this case needs a real draw margin to aim at; ${describe(record)}" + } + return record + } + + /** + * A screen point (logical, as [Robot] speaks) inside the popup's surface but + * outside its content: halfway into the right-hand margin, level with the + * content's vertical centre. + */ + private fun TaoWindowTestScope.marginPointOf(record: PopupFrameRecord): Pair { + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val content = record.contentOnScreenPx + val frame = record.frameOnScreenPx + val xPx = (content.right + frame.right) / 2 + val yPx = (content.top + content.bottom) / 2 + return (xPx / scale).roundToInt() to (yPx / scale).roundToInt() + } + + private suspend fun TaoWindowTestScope.clickAt( + x: Int, + y: Int, + ) { + moveTo(x, y) + HeadfulRobot.notePress() + HeadfulRobot.inject { robot -> + robot.mousePress(InputEvent.BUTTON1_DOWN_MASK) + Thread.sleep(CLICK_HOLD_MILLIS) + robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK) + } + settle(POINTER_SETTLE_MILLIS) + } + + /** + * Parks the pointer at a logical screen point and lets the scene catch up. + * + * Two hops, the second a couple of pixels: a warp into a *different* window + * arrives there as an enter, not as motion, and a layer that only forwards + * motion would see nothing. The short second hop happens inside the window + * the first one landed in, so a real move is always delivered. + */ + private suspend fun TaoWindowTestScope.moveTo( + x: Int, + y: Int, + ) { + HeadfulRobot.inject { robot -> + robot.mouseMove(x - NUDGE_PX, y - NUDGE_PX) + Thread.sleep(NUDGE_PAUSE_MILLIS) + robot.mouseMove(x, y) + } + HeadfulRobot.noteAim(x, y) + settle(POINTER_SETTLE_MILLIS) + } + + private suspend fun TaoWindowTestScope.centerWindow() { + val work = + dev.nucleusframework.window.tao.TaoMonitors + .forWindow(window) + .workAreaPx + val rect = requireNotNull(bounds()) { "window not mapped" } + val x = work.left + (work.width - rect[2].toInt()) / 2 + val y = work.top + (work.height - rect[3].toInt()) / 2 + window.setOuterPositionPx(x, y) + awaitUntil("window settled at ${x}x$y", detail = { "bounds=${bounds()?.toList()}" }) { + val b = bounds() ?: return@awaitUntil false + abs(b[0] - x) <= MOVE_TOLERANCE_PX && abs(b[1] - y) <= MOVE_TOLERANCE_PX + } + settle(POINTER_SETTLE_MILLIS) + } + + private fun TaoWindowTestScope.describe(record: PopupFrameRecord): String = + "frame=${record.frameOnScreenPx} content=${record.contentOnScreenPx} " + + "ownerPresses=${ownerPresses.value} ownerMoves=${ownerMoves.value} " + + "popupPresses=${popupPresses.value} " + + "window=${bounds()?.toList()} scale=${window.scaleFactor}" + + private val isNativeWayland: Boolean + get() { + val forcedX11 = + System.getenv("GDK_BACKEND")?.split(',')?.firstOrNull() == "x11" || + System.getenv("NUCLEUS_TAO_LINUX_RENDERER").orEmpty().equals("x11", ignoreCase = true) + return System.getenv("WAYLAND_DISPLAY") != null && !forcedX11 + } + + private fun skipReason(): String? = + when { + java.awt.GraphicsEnvironment.isHeadless() -> "no display for Robot input" + HeadfulRobot.unavailableReason != null -> HeadfulRobot.unavailableReason + else -> null + } + + private const val POPUP_W_DP = 220 + private const val POPUP_H_DP = 160 + private const val POPUP_GROWTH_DP = 90 + private const val PARK_INSET_PX = 12 + private const val NUDGE_PX = 3 + private const val NUDGE_PAUSE_MILLIS = 40L + private const val POINTER_SETTLE_MILLIS = 400L + private const val REANCHOR_SETTLE_MILLIS = 700L + private const val CLICK_HOLD_MILLIS = 60L + private const val POLL_MILLIS = 50L + private const val SETTLE_TIMEOUT_MILLIS = 10_000L + private const val STABLE_FRAMES = 4 + private const val MOVE_TOLERANCE_PX = 8L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt new file mode 100644 index 000000000..01852aabb --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativePopupPlacementHeadfulCases.kt @@ -0,0 +1,768 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material.DropdownMenu +import androidx.compose.material.DropdownMenuItem +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.Popup +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsDecoBridge +import dev.nucleusframework.window.tao.ffi.PopupNativeBridgeWindows +import dev.nucleusframework.window.tao.popup.PopupFrameRecord +import dev.nucleusframework.window.tao.popup.TaoPopupDiagnostics +import kotlinx.coroutines.delay +import kotlin.math.abs + +/** + * Headful battery for issue #569 — "nativePopupLayers: popups position against + * the window, not the screen". + * + * With `nativePopupLayers = true` a Compose `Popup` becomes a real OS window + * that escapes the owner. Native placement was already correct; the *decision* + * was not, in two compounding ways: + * + * 1. The layers built a work-area-sized `WindowInfo` so popups could lay out + * and flip against the display — but then `setContent` replayed the parent + * window's composition locals *over* it, so `Popup.skiko.kt` read the owner + * window's `containerSize` and clipped every popup back into the window. + * 2. Even with the work area in force, that box is rooted at the window's + * content top-left, not at the work-area origin. A `DropdownMenu` in a + * window near the bottom of the display did not flip up — Compose believed + * a whole work area of room was left below the anchor — and walked off the + * screen. + * + * A `Dialog` goes through the same layer machinery but must *not* follow the + * display: `Dialog.skiko.kt` centres it in `containerSize`, so it keeps the + * owner window as its box (cases 12-13). + * + * Each case places a real window at a real position, opens a popup, and asserts + * against [TaoPopupDiagnostics] — the frame the layer actually pushed, in global + * screen pixels. `boundsInWindow` cannot answer "did the popup land on screen": + * it is deliberately left unclamped, because Compose's own hit-testing is + * expressed in it. + * + * The two halves matter equally. A clamp is only correct if it also *doesn't* + * fire — cases 1, 5, 9 and 12 fail if the fix over-reaches and starts treating + * the owner window (or the display) as everyone's reference rect. + * + * Skipped on native Wayland: a popup there is a `wl_subsurface` positioned + * relative to the parent surface, with no global position to clamp against, so + * the Linux host reports no screen geometry at all. + */ +internal object NativePopupPlacementHeadfulCases { + fun all(): List = + listOf( + popupInsideIsNotMoved(), + popupAtBottomEdgeIsClamped(), + popupAtRightEdgeIsClamped(), + popupAtBottomRightCornerIsClamped(), + popupEscapesTheOwnerWindowWhenTheScreenHasRoom(), + popupAboveScreenTopIsClamped(), + oversizedPopupKeepsItsTopLeft(), + dropdownMenuAtBottomEdgeStaysOnScreen(), + popupLargerThanItsOwnerWindowStaysOnScreen(), + ownerMoveReclampsAnOpenPopup(), + nativeWindowRectMatchesTheClampedFrame(), + dialogStaysCentredInItsWindow(), + dialogNearTheScreenEdgeIsStillClamped(), + dialogSurfaceCoversItsShadow(), + ) + + // ── 1. no gratuitous shifting ───────────────────────────────────────── + + private fun popupInsideIsNotMoved(): TaoWindowTestCase = + popupCase("#569 a popup with room around it is placed exactly where Compose asked") { + centerWindow() + val record = openPopup(offset = IntOffset(POPUP_INSET_PX, POPUP_INSET_PX)) + checkOnWorkArea(record) + check(record.clampOffsetPx == IntOffset.Zero) { + "a popup with room on every side must not be moved, got ${record.clampOffsetPx}" + } + } + + // ── 2-4. the reported failure ───────────────────────────────────────── + // + // The offsets matter. `Popup(alignment)` aligns inside the *parent* scene + // (the owner window's content), so an alignment alone never leaves the + // window and never reproduces #569. The extra offset is what pushes the + // popup past the window edge — where `Popup.skiko.kt` happily allows it, + // because its clip box is the work-area-sized virtual screen rooted at the + // window, and only there does the missing screen origin show up. + + private fun popupAtBottomEdgeIsClamped(): TaoWindowTestCase = + popupCase("#569 a popup anchored past the bottom of the work area slides back in") { + moveWindow(fromBottomPx = edgeMarginPx()) + val record = openPopup(alignment = Alignment.BottomStart, offset = IntOffset(0, POPUP_H_DP)) + checkOnWorkArea(record) + check(record.clampOffsetPx.y < 0) { + "expected an upward clamp at the bottom edge; ${describe(record)}" + } + } + + private fun popupAtRightEdgeIsClamped(): TaoWindowTestCase = + popupCase("#569 a popup anchored past the right of the work area slides back in") { + moveWindow(fromRightPx = edgeMarginPx()) + val record = openPopup(alignment = Alignment.TopEnd, offset = IntOffset(POPUP_W_DP, 0)) + checkOnWorkArea(record) + check(record.clampOffsetPx.x < 0) { + "expected a leftward clamp at the right edge; ${describe(record)}" + } + } + + private fun popupAtBottomRightCornerIsClamped(): TaoWindowTestCase = + popupCase("#569 a popup in the bottom-right corner clamps on both axes") { + moveWindow(fromBottomPx = edgeMarginPx(), fromRightPx = edgeMarginPx()) + val record = + openPopup( + alignment = Alignment.BottomEnd, + offset = IntOffset(POPUP_W_DP, POPUP_H_DP), + ) + checkOnWorkArea(record) + check(record.clampOffsetPx.x < 0 && record.clampOffsetPx.y < 0) { + "expected both axes to clamp in the corner; ${describe(record)}" + } + } + + // ── 5-6. the window edge is not a screen edge ───────────────────────── + + private fun popupEscapesTheOwnerWindowWhenTheScreenHasRoom(): TaoWindowTestCase = + // The case needs the popup to land *outside the window* and *inside the + // work area* at once, so the window has to leave room to its right for + // one. The default 800 dp window centred on a 1024 px display — the + // macOS CI runner — leaves 112 px, and the popup was clamped back in on + // a case whose whole point is that nothing clamps. A small window + // against the left edge has room on any display we run on. + popupCase( + "#569 a popup outside the owner window is left alone while the screen has room", + size = DpSize(ESCAPE_WINDOW_DP.dp, ESCAPE_WINDOW_DP.dp), + ) { + moveWindow(fromLeftPx = edgeMarginPx()) + val windowRight = windowRightPx() + // Offset past the window's own right edge. The whole point of + // native popup layers is that a popup may leave the window; a + // clamp that used the window as its reference rect (the pre-#569 + // behaviour, only from the other side) would drag it back in. + val record = openPopup(offset = IntOffset(windowWidthDp() + POPUP_ESCAPE_DP, 0)) + checkOnWorkArea(record) + check(record.contentOnScreenPx.left > windowRight) { + "popup must be allowed outside the owner window: " + + "content=${record.contentOnScreenPx} windowRight=$windowRight" + } + check(record.clampOffsetPx == IntOffset.Zero) { + "nothing to clamp here — the popup is off the window, not off the screen; " + + describe(record) + } + } + + private fun popupAboveScreenTopIsClamped(): TaoWindowTestCase = + popupCase( + "#569 a popup above the top of the work area slides down", + skip = ::aboveWorkAreaSkipReason, + ) { + // Compose clips popup positions at 0 in *window* coordinates, so a + // popup can only end up above the work area when the window itself + // does. Drag the window's top off the top of the screen — the + // everyday way a user gets there. + moveWindow(abovePx = ABOVE_SCREEN_PX) + val record = openPopup() + checkOnWorkArea(record) + check(record.clampOffsetPx.y > 0) { + "expected a downward clamp above the work area; ${describe(record)}" + } + } + + // ── 7. oversized ────────────────────────────────────────────────────── + + private fun oversizedPopupKeepsItsTopLeft(): TaoWindowTestCase = + popupCase("#569 a popup taller than the work area is aligned to the work-area top") { + centerWindow() + val work = workArea() + val tallDp = (work.height / scale()).toInt() + OVERSIZE_SLACK_DP + val record = openPopup(heightDp = tallDp) + val frame = record.contentOnScreenPx + // It cannot fit; the contract is that the *top* stays visible (a + // menu's first items, a tooltip's first line). + check(frame.top == work.top) { + "an oversized popup must align to the work-area top: frame=$frame work=$work" + } + check(frame.left >= work.left) { + "left edge escaped the work area: frame=$frame work=$work" + } + } + + // ── 8. the component the issue names ────────────────────────────────── + + private fun dropdownMenuAtBottomEdgeStaysOnScreen(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a DropdownMenu near the bottom of the display stays on screen", + skip = ::skipReason, + nativePopupLayers = true, + content = { DropdownSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + moveWindow(fromBottomPx = edgeMarginPx()) + TaoPopupDiagnostics.reset() + dropdownExpanded.value = true + try { + checkOnWorkArea(awaitSettledRecord()) + } finally { + dropdownExpanded.value = false + } + } + + // ── 9. the tray-anchor pattern ──────────────────────────────────────── + + private fun popupLargerThanItsOwnerWindowStaysOnScreen(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a popup far larger than its owner window is placed against the display", + skip = ::skipReason, + nativePopupLayers = true, + size = DpSize(TINY_WINDOW_DP.dp, TINY_WINDOW_DP.dp), + content = { PopupSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + moveWindow(fromBottomPx = edgeMarginPx(), fromRightPx = edgeMarginPx()) + val record = + openPopup( + widthDp = POPUP_W_DP * 2, + heightDp = POPUP_H_DP * 2, + ) + checkOnWorkArea(record) + // The owner is TINY_WINDOW_DP square and the popup many times that + // — the shape #569 broke worst, since the clamp reference used to + // be a work-area-sized box rooted at this tiny window. + val minWidthPx = (POPUP_W_DP * scale()).toInt() + check(record.contentOnScreenPx.width >= minWidthPx) { + "popup collapsed toward the owner window size: ${record.contentOnScreenPx}" + } + } + + // ── 10. re-clamp on owner move ──────────────────────────────────────── + + private fun ownerMoveReclampsAnOpenPopup(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 moving the owner window re-clamps an already-open popup", + skip = { + // macOS panels are AppKit child windows that ride along with + // the owner; there is no owner-move re-clamp there by design + // (documented on TaoPopupSceneLayer). + skipReason() ?: "no owner-move re-clamp on macOS".takeIf { Platform.Current == Platform.MacOS } + }, + nativePopupLayers = true, + content = { PopupSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + centerWindow() + val opened = + openPopup( + alignment = Alignment.BottomStart, + offset = IntOffset(0, POPUP_H_DP), + closeAfter = false, + ) + try { + check(opened.clampOffsetPx == IntOffset.Zero) { + "popup should open unclamped in the middle of the screen, got ${opened.clampOffsetPx}" + } + // Move the window into the bottom-right corner with the popup + // still open: the owner-move listener must re-issue the frame. + TaoPopupDiagnostics.reset() + moveWindow(fromBottomPx = edgeMarginPx(), fromRightPx = edgeMarginPx()) + awaitUntil( + "popup re-clamped after the owner moved", + detail = { "last=${TaoPopupDiagnostics.lastFrame?.frameOnScreenPx}" }, + ) { + TaoPopupDiagnostics.lastFrame?.clampOffsetPx?.let { it != IntOffset.Zero } == true + } + checkOnWorkArea(requireNotNull(TaoPopupDiagnostics.lastFrame)) + } finally { + popupRequest.value = null + } + } + + // ── 11. the OS agrees ───────────────────────────────────────────────── + + private fun nativeWindowRectMatchesTheClampedFrame(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 the popup window's real screen rect is the clamped one", + // Reads the popup's own HWND back through Win32. The equivalent + // introspection has no counterpart for a bare NSPanel handle or a + // Tao popup window here, so the round-trip is Windows-only; the + // other platforms are covered by the frame assertions above. + skip = { skipReason() ?: "Windows only".takeIf { Platform.Current != Platform.Windows } }, + nativePopupLayers = true, + content = { PopupSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + moveWindow(fromBottomPx = edgeMarginPx()) + val record = + openPopup( + alignment = Alignment.BottomStart, + offset = IntOffset(0, POPUP_H_DP), + closeAfter = false, + ) + try { + checkOnWorkArea(record) + val popupHwnd = PopupNativeBridgeWindows.nativeContentHwnd(record.panelHandle) + check(popupHwnd != 0L) { "popup HWND not resolvable from panel=${record.panelHandle}" } + val rect = + requireNotNull(NativeTaoWindowsDecoBridge.nativeGetWindowRect(popupHwnd)) { + "GetWindowRect failed for the popup HWND" + } + val actual = + IntRect( + left = rect[0].toInt(), + top = rect[1].toInt(), + right = (rect[0] + rect[2]).toInt(), + bottom = (rect[1] + rect[3]).toInt(), + ) + // To the pixel: this is what proves the Kotlin-side clamp and + // the native ClientToScreen path neither double-apply nor + // cancel the offset. + check(actual == record.frameOnScreenPx) { + "OS rect $actual disagrees with the reported frame ${record.frameOnScreenPx}" + } + // The surface carries the draw margin past the content, so the + // OS rect may hang off the work area — the content must not. + val work = workArea() + val content = + record.contentOnScreenPx.translate( + IntOffset(actual.left - record.frameOnScreenPx.left, actual.top - record.frameOnScreenPx.top), + ) + check(content.top >= work.top && content.bottom <= work.bottom) { + "the OS placed the popup outside the work area: $content vs $work" + } + } finally { + popupRequest.value = null + } + } + + // ── 12-13. dialogs belong to the window, not the display ────────────── + + private fun dialogStaysCentredInItsWindow(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a Dialog stays centred in its window, not on the display", + skip = ::skipReason, + nativePopupLayers = true, + content = { DialogSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + // Deliberately *not* centred and not maximized: a layer that used + // the work area as every layer's container would centre the dialog + // on the display, which only coincides with the window centre for + // a maximized window on the primary display. + moveWindow(fromRightPx = edgeMarginPx() * DIALOG_WINDOW_INSET_FACTOR) + TaoPopupDiagnostics.reset() + dialogShown.value = true + try { + val record = awaitSettledRecord() + // The content, not the surface: the dialog's appearance animation + // inflates the surface below the layout bounds. + val frame = record.contentOnScreenPx + val rect = requireNotNull(bounds()) { "window not mapped" } + val windowCentreX = (rect[0] + rect[2] / 2).toInt() + val windowCentreY = (rect[1] + rect[3] / 2).toInt() + val dx = abs(frame.left + frame.width / 2 - windowCentreX) + val dy = abs(frame.top + frame.height / 2 - windowCentreY) + // Tolerance covers the decoration inset between the window's + // outer rect (what `bounds()` reports) and its content rect + // (what the dialog centres in). + check(dx <= DIALOG_CENTRE_TOLERANCE_PX && dy <= DIALOG_CENTRE_TOLERANCE_PX) { + "dialog is not centred in its window: frame=$frame " + + "windowCentre=($windowCentreX, $windowCentreY) off by ($dx, $dy)" + } + check(record.clampOffsetPx == IntOffset.Zero) { + "a dialog inside its window needs no clamp; ${describe(record)}" + } + } finally { + dialogShown.value = false + } + } + + private fun dialogSurfaceCoversItsShadow(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a Dialog's surface is inflated around the shadow it draws", + skip = ::skipReason, + nativePopupLayers = true, + content = { DialogSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + centerWindow() + TaoPopupDiagnostics.reset() + dialogShadow.value = true + dialogShown.value = true + try { + val record = awaitSettledRecord() + val frame = record.frameOnScreenPx + val content = record.contentOnScreenPx + // The layout bounds are the content; an in-scene layer draws its + // elevation shadow past them into the window canvas, and a + // separate OS surface must grow to hold it or clip it away. + val coversEverySide = + frame.left < content.left && + frame.top < content.top && + frame.right > content.right && + frame.bottom > content.bottom + check(coversEverySide) { + "the surface must extend past the content on every side to hold the shadow: " + + "frame=$frame content=$content" + } + check(record.boundsInWindowPx.size == content.size) { + "the content frame must keep Compose's layout size; ${describe(record)}" + } + } finally { + dialogShown.value = false + dialogShadow.value = false + } + } + + private fun dialogNearTheScreenEdgeIsStillClamped(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#569 a Dialog whose window hangs off the display is clamped back on", + skip = ::aboveWorkAreaSkipReason, + nativePopupLayers = true, + content = { DialogSlot() }, + ) { + awaitUntil("window mapped") { window.hasRealFramePx() } + // Window dragged off the top of the screen: centring in the window + // is the right rule, but a dialog nobody can see is not — the same + // clamp that saves popups applies. + moveWindow(abovePx = ABOVE_SCREEN_PX * DIALOG_ABOVE_FACTOR) + TaoPopupDiagnostics.reset() + dialogShown.value = true + try { + val record = awaitSettledRecord() + checkOnWorkArea(record) + check(record.clampOffsetPx.y > 0) { + "expected the dialog to be pushed back onto the display; ${describe(record)}" + } + } finally { + dialogShown.value = false + } + } + + // ── Case scaffolding ────────────────────────────────────────────────── + + /** + * Popup geometry the *driver* chooses, after the window has been placed. + * + * Deliberately not a `LaunchedEffect(delay)`: #569 is about the position + * decided at open time, so a case that opens the popup on a timer while the + * window is still moving would be racing its own setup. One shared slot + * across cases is safe — the harness runs them sequentially in a fresh + * window each time. + */ + private class PopupRequest( + val widthDp: Int, + val heightDp: Int, + val offset: IntOffset, + val alignment: Alignment, + ) + + private val popupRequest = mutableStateOf(null) + private val dropdownExpanded = mutableStateOf(false) + private val dialogShown = mutableStateOf(false) + private val dialogShadow = mutableStateOf(false) + + @Composable + private fun PopupSlot() { + val request by popupRequest + val current = request ?: return + Popup(alignment = current.alignment, offset = current.offset) { + Box(Modifier.size(current.widthDp.dp, current.heightDp.dp).background(Color.Magenta)) + } + } + + /** + * A real `DropdownMenu` anchored at the **bottom** of the window content — + * the everyday shape of #569. Compose opens a dropdown below its anchor and + * only flips when the anchor is near the bottom of what it thinks the + * screen is; anchored here, in a window sitting at the bottom of the + * display, its window-rooted view of the screen sends the menu off it. + */ + @Composable + private fun DropdownSlot() { + val expanded by dropdownExpanded + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) { + Box(Modifier.size(DROPDOWN_ANCHOR_DP.dp)) { + DropdownMenu(expanded = expanded, onDismissRequest = { }) { + repeat(DROPDOWN_ITEMS) { index -> + DropdownMenuItem(onClick = { }) { Text("item $index") } + } + } + } + } + } + + /** + * A `Dialog` — the other thing that lands in a scene layer, and the one + * that must *not* be placed against the display. `Dialog.skiko.kt` puts it + * at `containerSize.center`, so a layer reporting the work area as its + * container would centre a window-owned dialog on the screen instead of on + * its window. + */ + @Composable + private fun DialogSlot() { + val shown by dialogShown + val shadow by dialogShadow + if (shown) { + Dialog(onDismissRequest = { }) { + Box( + Modifier + .size(DIALOG_W_DP.dp, DIALOG_H_DP.dp) + .then(if (shadow) Modifier.shadow(DIALOG_SHADOW_DP.dp) else Modifier) + .background(Color.Cyan), + ) + } + } + } + + private fun popupCase( + name: String, + size: DpSize? = null, + skip: () -> String? = ::skipReason, + driver: suspend TaoWindowTestScope.() -> Unit, + ): TaoWindowTestCase = + TaoWindowTestCase( + name = name, + skip = skip, + nativePopupLayers = true, + size = size, + content = { PopupSlot() }, + driver = { + awaitUntil("window mapped") { window.hasRealFramePx() } + driver() + }, + ) + + /** Opens the shared [PopupSlot] popup and returns its settled frame. */ + private suspend fun TaoWindowTestScope.openPopup( + widthDp: Int = POPUP_W_DP, + heightDp: Int = POPUP_H_DP, + offset: IntOffset = IntOffset.Zero, + alignment: Alignment = Alignment.TopStart, + closeAfter: Boolean = true, + ): PopupFrameRecord { + TaoPopupDiagnostics.reset() + popupRequest.value = PopupRequest(widthDp, heightDp, offset, alignment) + val record = awaitSettledRecord() + if (closeAfter) popupRequest.value = null + return record + } + + /** + * Waits until the popup layer's pushed frame stops changing. + * + * The layers push a frame from their bootstrap measure pass too (the inner + * scene has to render once before Compose can write `boundsInWindow` at + * all), so the first record can predate the measured size. Settling is what + * makes the assertions about the final position meaningful. + */ + private suspend fun TaoWindowTestScope.awaitSettledRecord(): PopupFrameRecord { + awaitUntil("popup layer pushed a frame") { TaoPopupDiagnostics.lastFrame != null } + var previous: IntRect? = null + var stable = 0 + val deadline = System.currentTimeMillis() + RECORD_SETTLE_TIMEOUT_MILLIS + while (stable < STABLE_FRAMES) { + delay(RECORD_POLL_MILLIS) + val frame = TaoPopupDiagnostics.lastFrame?.frameOnScreenPx + stable = if (frame != null && frame == previous) stable + 1 else 0 + previous = frame + check(System.currentTimeMillis() < deadline) { "popup frame never settled (last=$frame)" } + } + return requireNotNull(TaoPopupDiagnostics.lastFrame) + } + + // ── Assertions ──────────────────────────────────────────────────────── + + /** + * The #569 contract: the popup is fully inside its display's work area. + * Judged on the content — the surface may carry a shadow margin past the + * edge, exactly as an in-scene layer's shadow would. + */ + private fun TaoWindowTestScope.checkOnWorkArea(record: PopupFrameRecord) { + val frame = record.contentOnScreenPx + val areas = TaoMonitors.all(window).map { it.workAreaPx } + check(areas.any { frame.fitsIn(it) }) { + "popup landed outside every work area: content=$frame areas=$areas " + + "clamp=${record.clampOffsetPx} composeBounds=${record.boundsInWindowPx}" + } + } + + /** + * Guards the edge cases against passing for the wrong reason: if the clamp + * agreed with Compose's own decision, the window was not actually placed + * somewhere that reproduces #569 and the case proves nothing. + */ + private fun TaoWindowTestScope.checkClampDiverged(record: PopupFrameRecord) { + check(record.clampOffsetPx != IntOffset.Zero) { + "the clamp never fired — the window is not at an edge, so this case " + + "is not exercising #569 (frame=${record.frameOnScreenPx} " + + "composeBounds=${record.boundsInWindowPx} window=${bounds()?.toList()} " + + "work=${workArea()} scale=${scale()})" + } + } + + private fun TaoWindowTestScope.describe(record: PopupFrameRecord): String = + "frame=${record.frameOnScreenPx} content=${record.contentOnScreenPx} clamp=${record.clampOffsetPx} " + + "composeBounds=${record.boundsInWindowPx} window=${bounds()?.toList()} " + + "work=${workArea()} scale=${scale()}" + + private fun IntRect.fitsIn(other: IntRect): Boolean = + left >= other.left && top >= other.top && right <= other.right && bottom <= other.bottom + + // ── Geometry helpers ────────────────────────────────────────────────── + + private fun TaoWindowTestScope.workArea(): IntRect = TaoMonitors.forWindow(window).workAreaPx + + private fun TaoWindowTestScope.scale(): Float = window.scaleFactor.takeIf { it > 0f } ?: 1f + + /** Margin the edge cases leave between the window and the work-area edge. */ + private fun TaoWindowTestScope.edgeMarginPx(): Int = (EDGE_MARGIN_DP * scale()).toInt() + + private fun TaoWindowTestScope.windowRightPx(): Int { + val rect = requireNotNull(bounds()) { "window not mapped" } + return (rect[0] + rect[2]).toInt() + } + + /** The owner window's width in dp — the unit `Popup(offset =)` takes. */ + private fun TaoWindowTestScope.windowWidthDp(): Int { + val rect = requireNotNull(bounds()) { "window not mapped" } + return (rect[2] / scale()).toInt() + } + + private suspend fun TaoWindowTestScope.centerWindow() { + val work = workArea() + val rect = requireNotNull(bounds()) { "window not mapped" } + moveTo( + work.left + (work.width - rect[2].toInt()) / 2, + work.top + (work.height - rect[3].toInt()) / 2, + ) + } + + /** + * Places the window against a work-area edge — the geometry that makes + * Compose's window-rooted flip decision wrong. Unconstrained axes are + * centred. + */ + private suspend fun TaoWindowTestScope.moveWindow( + fromBottomPx: Int? = null, + fromTopPx: Int? = null, + fromRightPx: Int? = null, + fromLeftPx: Int? = null, + abovePx: Int? = null, + ) { + val work = workArea() + val rect = requireNotNull(bounds()) { "window not mapped" } + val w = rect[2].toInt() + val h = rect[3].toInt() + val x = + when { + fromRightPx != null -> work.right - w - fromRightPx + fromLeftPx != null -> work.left + fromLeftPx + else -> work.left + (work.width - w) / 2 + } + val y = + when { + fromBottomPx != null -> work.bottom - h - fromBottomPx + fromTopPx != null -> work.top + fromTopPx + abovePx != null -> work.top - abovePx + else -> work.top + (work.height - h) / 2 + } + moveTo(x, y) + } + + private suspend fun TaoWindowTestScope.moveTo( + xPx: Int, + yPx: Int, + ) { + window.setOuterPositionPx(xPx, yPx) + awaitUntil( + "window settled at ${xPx}x$yPx", + detail = { "bounds=${bounds()?.toList()}" }, + ) { + val rect = bounds() ?: return@awaitUntil false + abs(rect[0] - xPx) <= MOVE_TOLERANCE_PX && abs(rect[1] - yPx) <= MOVE_TOLERANCE_PX + } + // The owner-move listener runs on the Tao loop; give the layers a frame + // to react before anything reads the popup's frame back. + settle(SETTLE_MILLIS) + } + + /** + * The two cases that need a window *above* the work area to exist. + * + * macOS pulls every window back into it: measured on 26.5, both + * `setFrameOrigin:` and `setFrame:display:` clamp a frame whose top would + * go under the menu bar — titled and borderless alike — and only an + * override of `constrainFrameRect:toScreen:` escapes, which is not a trade + * Nucleus makes (AppKit runs that constraint on display changes too, and a + * window it no longer keeps on screen is a window the user cannot reach). + * A user cannot drag a window off the top of the screen there either, so + * the state under test is one the platform does not have. It stays covered + * on Windows and Linux, where that drag is an everyday gesture. + */ + private fun aboveWorkAreaSkipReason(): String? = + skipReason() + ?: "macOS clamps every window into the work area — nothing can sit above it" + .takeIf { Platform.Current == Platform.MacOS } + + private fun skipReason(): String? = + if (Platform.Current == Platform.Linux && isNativeWayland) { + "Wayland popups are parent-relative subsurfaces — no global position to clamp" + } else { + null + } + + private val isNativeWayland: Boolean + get() { + val forcedX11 = + System.getenv("GDK_BACKEND")?.split(',')?.firstOrNull() == "x11" || + System.getenv("NUCLEUS_TAO_LINUX_RENDERER").orEmpty().equals("x11", ignoreCase = true) + return System.getenv("WAYLAND_DISPLAY") != null && !forcedX11 + } + + private const val POPUP_W_DP = 240 + private const val POPUP_H_DP = 200 + private const val POPUP_INSET_PX = 20 + private const val TINY_WINDOW_DP = 120 + private const val EDGE_MARGIN_DP = 40 + private const val OVERSIZE_SLACK_DP = 200 + private const val POPUP_ESCAPE_DP = 24 + + /** + * Owner window for the escape case: small enough that it, the escape + * offset and the popup all fit side by side on the narrowest display the + * suite runs on (1024 px on the macOS runner). + */ + private const val ESCAPE_WINDOW_DP = 320 + private const val ABOVE_SCREEN_PX = 260 + private const val DIALOG_W_DP = 320 + private const val DIALOG_H_DP = 220 + private const val DIALOG_SHADOW_DP = 16 + private const val DIALOG_CENTRE_TOLERANCE_PX = 24 + private const val DIALOG_WINDOW_INSET_FACTOR = 2 + private const val DIALOG_ABOVE_FACTOR = 2 + private const val DROPDOWN_ANCHOR_DP = 60 + private const val DROPDOWN_ITEMS = 12 + private const val SETTLE_MILLIS = 600L + private const val RECORD_POLL_MILLIS = 50L + private const val RECORD_SETTLE_TIMEOUT_MILLIS = 10_000L + private const val STABLE_FRAMES = 4 + private const val MOVE_TOLERANCE_PX = 8L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativeProbe.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativeProbe.kt new file mode 100644 index 000000000..417e0b0d0 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativeProbe.kt @@ -0,0 +1,203 @@ +package dev.nucleusframework.window.tao.headful + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.NucleusPlatformView +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoLinuxWidgetBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoMacOsNativeViewBridge +import dev.nucleusframework.window.tao.ffi.NativeTaoWindowsNativeViewBridge +import java.util.concurrent.atomic.AtomicInteger + +/** + * A real, focusable native text widget for a headful case to embed through + * `NativeView`: a `GtkEntry`, an `NSTextField` or an `EDIT` control. + * + * The focus and cursor races between Compose and an embed only happen + * against a widget that *takes* keyboard focus on click and *shows* an + * I-beam — an empty overlay view has neither, so it cannot lose a keystroke + * or leave a stale cursor behind. The bridges hand these out for the suite + * (`nativeDiag*`); nothing in `NativeView` itself uses them. + * + * [platformView] is what `NativeView`'s factory returns. Its `dispose()` — the + * one `NativeView` calls when it leaves composition — destroys the widget and + * marks the probe [isDisposed], so a fixture can count probes created against + * probes disposed and know whether an unmount leaked one. + */ +internal class NativeProbe private constructor( + /** The widget as a handle, for reports. */ + val handle: Long, + private val focusQuery: () -> Boolean, + private val textQuery: () -> String?, + private val frameQuery: () -> IntArray?, + private val destroy: () -> Unit, +) { + @Volatile + var isDisposed: Boolean = false + private set + + /** Whether the OS routes keystrokes to the widget right now. */ + fun hasNativeFocus(): Boolean = !isDisposed && focusQuery() + + /** What has been typed into the widget so far. */ + fun text(): String = if (isDisposed) "" else textQuery().orEmpty() + + /** + * Where the platform actually put the widget, in the window's content + * space and physical px as `[x, y, w, h]` — null while it is not mapped. + * Compared against the Compose slot to see how far the embed trails the + * layout through a resize. + */ + fun framePx(): IntArray? = if (isDisposed) null else frameQuery() + + val platformView: NucleusPlatformView = + when (Platform.Current) { + Platform.Linux -> + object : NucleusPlatformView.GtkWidget { + override val gtkWidgetHandle: Long get() = handle + + override fun dispose() = disposeOnce() + } + Platform.MacOS -> + object : NucleusPlatformView.NsView { + override val nsViewHandle: Long get() = handle + + override fun dispose() = disposeOnce() + } + else -> + object : NucleusPlatformView.HWnd { + override val hwndHandle: Long get() = handle + + override fun dispose() = disposeOnce() + } + } + + private fun disposeOnce() { + if (isDisposed) return + isDisposed = true + disposedCount.incrementAndGet() + destroy() + } + + companion object { + /** Probes created so far in this process. */ + val createdCount = AtomicInteger() + + /** Probes whose `dispose()` ran so far in this process. */ + val disposedCount = AtomicInteger() + + /** Why no probe can be made on this host, or null when one can. */ + fun skipReason(): String? = + when (Platform.Current) { + Platform.Linux -> + if (!NativeTaoLinuxWidgetBridge.isLoaded) { + "libnucleus_tao_linux_widget is not loaded" + } else if (NativeTaoLinuxWidgetBridge.nativeGtkVersion() == null) { + "GTK 3 is not available" + } else { + null + } + Platform.MacOS -> + if (NativeTaoMacOsNativeViewBridge.isLoaded) { + null + } else { + "libnucleus_tao_macos_native_view is not loaded" + } + Platform.Windows -> + if (NativeTaoWindowsNativeViewBridge.isLoaded) { + null + } else { + "nucleus_tao_windows_native_view is not loaded" + } + else -> "no native view backend on ${Platform.Current}" + } + + /** + * Makes a probe for [window]. Runs on the loop thread (GTK / AppKit + * demand it), typically from a `NativeView` factory. Null when the + * platform refused — see [skipReason] for the reasons known upfront. + */ + fun create(window: TaoWindow): NativeProbe? { + val probe = + when (Platform.Current) { + Platform.Linux -> createGtkEntry(window) + Platform.MacOS -> createNsTextField() + Platform.Windows -> createWin32Edit() + else -> null + } ?: return null + createdCount.incrementAndGet() + return probe.also { probes[window.handle] = it } + } + + /** Whether Compose — not an embed — owns the keyboard in [window], as far as the OS can tell. */ + fun composeOwnsNativeFocus(window: TaoWindow): Boolean? = + when (Platform.Current) { + Platform.Linux -> { + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + if (gtkWindow == 0L) { + null + } else { + // Tao's own key handler sits on the toplevel: focus on + // nothing, or on one of the suite's input boxes, is what + // "Compose has the keyboard" looks like. Only the embed + // itself steals it. + val focus = NativeTaoLinuxWidgetBridge.nativeDiagFocusWidget(gtkWindow) + probes[window.handle]?.handle != focus + } + } + Platform.MacOS -> { + val content = NativeTaoBridge.nativeNsViewHandle(window.handle) + if (content == 0L) null else NativeTaoMacOsNativeViewBridge.nativeDiagViewIsFirstResponder(content) + } + Platform.Windows -> NativeTaoWindowsNativeViewBridge.nativeDiagFocusedHwnd() == window.nativeHandle + else -> null + } + + /** The last probe created per window, for [composeOwnsNativeFocus]. */ + private val probes = java.util.concurrent.ConcurrentHashMap() + + private fun createGtkEntry(window: TaoWindow): NativeProbe? { + val entry = NativeTaoLinuxWidgetBridge.nativeDiagCreateEntry() + if (entry == 0L) return null + return NativeProbe( + handle = entry, + focusQuery = { NativeTaoLinuxWidgetBridge.nativeDiagWidgetHasFocus(entry) }, + textQuery = { NativeTaoLinuxWidgetBridge.nativeDiagEntryText(entry) }, + frameQuery = { + // GTK lays out in logical px; Compose measures in physical. + val gtkWindow = NativeTaoBridge.nativeLinuxGtkWindow(window.handle) + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + NativeTaoLinuxWidgetBridge + .nativeDiagWidgetFrame(gtkWindow, entry) + ?.map { (it * scale).toInt() } + ?.toIntArray() + }, + destroy = { NativeTaoLinuxWidgetBridge.nativeDiagDestroyWidget(entry) }, + ) + } + + private fun createNsTextField(): NativeProbe? { + val field = NativeTaoMacOsNativeViewBridge.nativeDiagCreateTextField() + if (field == 0L) return null + return NativeProbe( + handle = field, + focusQuery = { NativeTaoMacOsNativeViewBridge.nativeDiagViewIsEditing(field) }, + textQuery = { NativeTaoMacOsNativeViewBridge.nativeDiagTextFieldString(field) }, + frameQuery = { NativeTaoMacOsNativeViewBridge.nativeDiagViewFrame(field) }, + destroy = { NativeTaoMacOsNativeViewBridge.nativeDiagReleaseView(field) }, + ) + } + + private fun createWin32Edit(): NativeProbe? { + val edit = NativeTaoWindowsNativeViewBridge.nativeDiagCreateEdit() + if (edit == 0L) return null + return NativeProbe( + handle = edit, + focusQuery = { NativeTaoWindowsNativeViewBridge.nativeDiagFocusedHwnd() == edit }, + textQuery = { NativeTaoWindowsNativeViewBridge.nativeDiagWindowText(edit) }, + frameQuery = { NativeTaoWindowsNativeViewBridge.nativeDiagWindowFrame(edit) }, + destroy = { NativeTaoWindowsNativeViewBridge.nativeDiagDestroyWindow(edit) }, + ) + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativeViewMonkeyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativeViewMonkeyHeadfulCases.kt new file mode 100644 index 000000000..5795d2e24 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/NativeViewMonkeyHeadfulCases.kt @@ -0,0 +1,1115 @@ +@file:OptIn(ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.NativeView +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoCursorIcon +import dev.nucleusframework.window.tao.TaoEventCode +import dev.nucleusframework.window.tao.TaoMouseButton +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.ffi.NativeTaoBridge +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * Compose and an embedded native widget under one pointer, hit faster than a + * human can and in every order a random walk finds. + * + * The failures this is after are the ones a user reports as "it went dead": + * after a few quick clicks between a Compose control and a native view, the + * Compose side stops taking clicks, the I-beam never comes back over the text + * field, or keystrokes go to whichever side had focus last but the caret + * shows on the other. None of those is a crash; each is a state two input + * routers — Compose's hit-testing and the platform's own (GtkEventBox capture, + * AppKit's responder chain, Win32 focus) — disagree about, reached through an + * interleaving nobody wrote a case for. + * + * The fixture is the smallest desktop that has both routers: a `BasicTextField` + * (I-beam, Compose focus), a Compose button, a [NativeView] embedding a real + * text widget ([NativeProbe]: it *takes* native focus and *shows* an I-beam of + * its own), and a Compose button drawn *over* the native view through the + * `content` slot — the blending path. + * + * What is asserted is not "the right thing happened" but that both routers + * still agree and still answer, re-checked after every burst: + * + * - **responsiveness** — a click on either Compose button is counted, a + * click on the field focuses it; + * - **one keyboard owner** — Compose focus and native focus are never both + * held, and a typed letter lands on exactly the side that holds it; + * - **the cursor** — a still pointer over the field leaves `TEXT` as the + * last requested cursor and keeps it (no flicker from a stray move); + * - **no leak, no wedge** — every probe an unmount disposed is disposed, + * and the main dispatcher keeps answering ([MainLoopWatchdog]). + * + * Every case runs twice: with the [SyntheticPointerDriver] (everywhere, native + * Wayland included) and with the [RobotPointerDriver] (a real X server or a + * real desktop), which is the only one that reaches the platform half. + */ +internal object NativeViewMonkeyHeadfulCases { + fun all(): List = + listOf( + alternatingClicksKeepComposeResponsive(synthetic = true), + alternatingClicksKeepComposeResponsive(synthetic = false), + aRightClickOnTheEmbedDoesNotSwallowLaterClicks(synthetic = true), + aRightClickOnTheEmbedDoesNotSwallowLaterClicks(synthetic = false), + resizeStormKeepsTheEmbedOnItsSlot(), + randomActionsLeaveBothRoutersAgreeing(synthetic = true), + randomActionsLeaveBothRoutersAgreeing(synthetic = false), + ) + + /** + * Pinned from the robot monkey's journal: a right click on the embed is + * forwarded to the widget, whose own context menu takes a grab and eats + * the button *release*. Compose then holds a button that was never let go + * of, and every later click on Compose is dead — no down transition. The + * plain left click that follows has to be counted. + */ + private fun aRightClickOnTheEmbedDoesNotSwallowLaterClicks(synthetic: Boolean): TaoWindowTestCase { + val fixture = NativeViewFixture() + return TaoWindowTestCase( + name = "native view ${driverName(synthetic)} a right click on the embed does not swallow later clicks", + skip = { skipReason(synthetic) }, + windowState = caseWindowState(), + size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Content() }, + driver = { + fixture.awaitReady(this) + val driver = newDriver(synthetic, window, fixture) + val probe = ResponsivenessProbe(this, fixture, driver) + probe.expectResponsive("before the right click") + driver.click(fixture.center(Region.Native), TaoMouseButton.RIGHT) + settle() + // Whatever menu the embed opened, a click on plain ground + // dismisses it — GTK gives that click to the menu, which is the + // platform's contract, not the bug. The bug is everything after. + driver.click(fixture.backdropPoint()) + settle() + probe.expectResponsive("after a right click on the embed") + driver.exit() + }, + ) + } + + /** + * The embed has to *follow* its slot through a resize: sizes asked for one + * after another with no pause, then a smooth animated resize. After each + * step the platform widget's own frame is compared with the Compose rect + * of the slot, the lag between the two is measured, and at the end they + * have to agree. Purely programmatic — no pointer, so it runs everywhere. + */ + private fun resizeStormKeepsTheEmbedOnItsSlot(): TaoWindowTestCase { + val fixture = NativeViewFixture() + return TaoWindowTestCase( + name = "native view resize storm keeps the embed on its slot", + timeoutMillis = STORM_CASE_TIMEOUT_MILLIS, + skip = { NativeProbe.skipReason() }, + windowState = caseWindowState(), + size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Content() }, + driver = { + fixture.awaitReady(this) + val geometry = EmbedGeometryProbe(this, fixture) + geometry.expectOnSlot("before the storm") + + // 1. Discrete steps, each awaited: how long does the embed trail the layout? + var worstLagMillis = 0L + for (round in 0 until RESIZE_ROUNDS) { + // Never the current size: a step that changes nothing has no lag to measure. + val w = WINDOW_W_DP - (round % RESIZE_SPAN + 1) * RESIZE_STEP_DP + val h = WINDOW_H_DP - (round % RESIZE_SPAN + 1) * RESIZE_STEP_DP + worstLagMillis = maxOf(worstLagMillis, geometry.resizeAndMeasureLag(w, h)) + } + + // 2. A burst with no waiting at all, then a smooth animation. + for (round in 0 until RESIZE_BURST) { + window.setInnerSize((WINDOW_W_DP - round * RESIZE_STEP_DP).toDouble(), WINDOW_H_DP.toDouble()) + } + geometry.expectOnSlot("after a burst of resizes") + for (step in 0..ANIMATION_STEPS) { + val t = step / ANIMATION_STEPS.toFloat() + window.setInnerSize( + (MIN_INNER_W_DP + (WINDOW_W_DP - MIN_INNER_W_DP) * t), + (MIN_INNER_H_DP + (WINDOW_H_DP - MIN_INNER_H_DP) * t), + ) + settle(ANIMATION_FRAME_MILLIS) + geometry.sample() + } + geometry.expectOnSlot("after an animated resize") + System.err.println( + "[native-view-resize] worst lag ${worstLagMillis}ms over $RESIZE_ROUNDS steps; " + + "animated: ${geometry.offSlotSamples} of ${geometry.samples} samples off the slot, " + + "worst ${geometry.worstDistancePx}px behind", + ) + + // 3. The user's gesture: a real pointer dragging the corner of + // the frame, so the sizes flow in from the window manager at + // its cadence instead of from setInnerSize. Robot hosts only. + if (robotDriverSkipReason() == null) { + val interactive = EmbedGeometryProbe(this, fixture) + val sizeBefore = window.outerBoundsPx()?.drop(2) + dragBottomRightCorner(interactive) + interactive.expectOnSlot("after an interactive edge drag") + val sizeAfter = window.outerBoundsPx()?.drop(2) + System.err.println( + "[native-view-resize] interactive: ${interactive.offSlotSamples} of ${interactive.samples} " + + "samples off the slot, worst ${interactive.worstDistancePx}px behind; frame " + + if (sizeBefore == sizeAfter) { + "$sizeBefore unchanged (the press started no resize on this host)" + } else { + "$sizeBefore -> $sizeAfter" + }, + ) + check(interactive.worstDistancePx <= ANIMATION_LAG_FRAMES * EDGE_DRAG_STEP_PX) { + "the embed fell ${interactive.worstDistancePx}px behind its slot during an interactive " + + "resize " + + "(${EDGE_DRAG_STEP_PX}px per step, budget $ANIMATION_LAG_FRAMES steps)" + } + } + check(worstLagMillis <= EMBED_LAG_BUDGET_MILLIS) { + "the embed trailed its slot by ${worstLagMillis}ms after a resize (budget $EMBED_LAG_BUDGET_MILLIS)" + } + // One frame behind the layout is the pipeline (Compose places, + // then the platform allocates); several frames is the embed + // visibly peeling away from the window edge as it is dragged. + val perFramePx = ((WINDOW_W_DP - MIN_INNER_W_DP) / ANIMATION_STEPS * window.scaleFactor).roundToInt() + check(geometry.worstDistancePx <= ANIMATION_LAG_FRAMES * perFramePx) { + "the embed fell ${geometry.worstDistancePx}px behind its slot during an animated resize " + + "(${perFramePx}px per frame, budget $ANIMATION_LAG_FRAMES frames)" + } + }, + ) + } + + /** + * The bug report, verbatim: click Compose, click native, click Compose + * over native, click the field, again, as fast as possible. Every click + * must have been counted at the end, and the desktop must still answer. + */ + private fun alternatingClicksKeepComposeResponsive(synthetic: Boolean): TaoWindowTestCase { + val fixture = NativeViewFixture() + return TaoWindowTestCase( + name = "native view ${driverName(synthetic)} alternating clicks keep compose responsive", + timeoutMillis = STORM_CASE_TIMEOUT_MILLIS, + skip = { skipReason(synthetic) }, + windowState = caseWindowState(), + size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Content() }, + driver = { + fixture.awaitReady(this) + val driver = newDriver(synthetic, window, fixture) + val probe = ResponsivenessProbe(this, fixture, driver) + probe.expectResponsive("before the storm") + + val headerBefore = fixture.headerClicks + val overlayBefore = fixture.overlayClicks + for (round in 0 until STORM_ROUNDS) { + driver.click(fixture.center(Region.HeaderButton)) + driver.click(fixture.center(Region.Native)) + driver.click(fixture.center(Region.OverlayButton)) + driver.click(fixture.center(Region.Field)) + } + settle(SETTLE_AFTER_MAP_MILLIS) + + // Every click must have landed: a lost one is the report. + awaitUntil( + "every header click of the storm was counted", + detail = { "counted ${fixture.headerClicks - headerBefore} of $STORM_ROUNDS; ${robotAim()}" }, + ) { fixture.headerClicks - headerBefore == STORM_ROUNDS } + awaitUntil( + "every overlay click of the storm was counted", + detail = { "counted ${fixture.overlayClicks - overlayBefore} of $STORM_ROUNDS; ${robotAim()}" }, + ) { fixture.overlayClicks - overlayBefore == STORM_ROUNDS } + probe.expectResponsive("after the storm") + probe.expectKeyboardAgrees("after the storm") + driver.exit() + }, + ) + } + + /** A seeded random walk over every gesture the fixture knows, checked every few steps. */ + private fun randomActionsLeaveBothRoutersAgreeing(synthetic: Boolean): TaoWindowTestCase { + val fixture = NativeViewFixture() + return TaoWindowTestCase( + name = "native view ${driverName( + synthetic, + )} monkey $MONKEY_ACTIONS random actions leave both routers agreeing", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + skip = { skipReason(synthetic) }, + windowState = caseWindowState(), + size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Content() }, + driver = { + fixture.awaitReady(this) + val driver = newDriver(synthetic, window, fixture) + val monkey = NativeViewMonkey(this, fixture, driver, monkeySeed()) + monkey.run() + monkey.quiesceAndAssert() + }, + ) + } + + private fun skipReason(synthetic: Boolean): String? = + NativeProbe.skipReason() ?: if (synthetic) null else robotDriverSkipReason() + + /** + * Presses the resize band at the bottom-right corner of the frame with the + * real pointer and drags it inwards, sampling the embed against its slot + * after every step. Whether the platform turns the press into a resize is + * its business (Tao's own band on X11 and Win32, AppKit's edges on macOS); + * a press that resizes nothing simply leaves nothing to trail. + */ + private suspend fun TaoWindowTestScope.dragBottomRightCorner(geometry: EmbedGeometryProbe) { + val outer = requireNotNull(window.outerBoundsPx()) { "the case window is not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val startX = outer[0] + outer[OUTER_W] - EDGE_PRESS_INSET_PX + val startY = outer[1] + outer[OUTER_H] - EDGE_PRESS_INSET_PX + val moved = + HeadfulRobot.inject { robot -> + robot.mouseMove((startX / scale).roundToInt(), (startY / scale).roundToInt()) + HeadfulRobot.noteAim((startX / scale).roundToInt(), (startY / scale).roundToInt()) + Thread.sleep(ROBOT_PRESS_SETTLE_MILLIS) + HeadfulRobot.notePress() + robot.mousePress(java.awt.event.InputEvent.BUTTON1_DOWN_MASK) + true + } + checkNotNull(moved) { "the AWT Robot became unavailable: ${HeadfulRobot.unavailableReason}" } + for (step in 1..EDGE_DRAG_STEPS) { + val x = startX - step * EDGE_DRAG_STEP_PX + val y = startY - step * EDGE_DRAG_STEP_PX + HeadfulRobot.inject { robot -> + robot.mouseMove((x / scale).roundToInt(), (y / scale).roundToInt()) + true + } + settle(EDGE_DRAG_STEP_MILLIS) + geometry.sample() + } + HeadfulRobot.inject { robot -> + robot.mouseRelease(java.awt.event.InputEvent.BUTTON1_DOWN_MASK) + true + } + } + + private fun driverName(synthetic: Boolean) = if (synthetic) "synthetic" else "robot" + + private fun newDriver( + synthetic: Boolean, + window: TaoWindow, + fixture: NativeViewFixture, + ): PointerDriver = + if (synthetic) SyntheticPointerDriver(window) else RobotPointerDriver(window) { fixture.sceneSize } + + private fun caseWindowState() = + WindowState( + position = WindowPosition.Absolute(WINDOW_X_DP.dp, WINDOW_Y_DP.dp), + size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + ) +} + +/** The hit targets the fixture lays out, each with a rect in content px. */ +private enum class Region { + /** The `BasicTextField` in the header row. */ + Field, + + /** The Compose button beside it — plain Compose ground, no embed underneath. */ + HeaderButton, + + /** The embedded native widget's slot (its centre is clear of the overlay button). */ + Native, + + /** The Compose button drawn over the native view through `NativeView`'s content slot. */ + OverlayButton, +} + +/** + * The desktop described in [NativeViewMonkeyHeadfulCases], publishing its + * rects, its counters and its focus state for the driver to read. + */ +private class NativeViewFixture { + /** + * The field's whole value, caret included: the caret is what a `KeyDown` + * for an arrow moves, and a plain `String` would hide it. + */ + var fieldValue by mutableStateOf(TextFieldValue("")) + + val fieldText: String get() = fieldValue.text + + /** Where the caret sits, or the start of the selection. */ + val caret: Int get() = fieldValue.selection.start + + var fieldFocused by mutableStateOf(false) + var headerClicks by mutableIntStateOf(0) + var overlayClicks by mutableIntStateOf(0) + + /** Whether the native view is in composition; flipped by the monkey. */ + var nativeMounted by mutableStateOf(true) + + /** The probe currently embedded, or the last one when unmounted. */ + var probe: NativeProbe? = null + private set + + var sceneSize: IntSize = IntSize.Zero + private set + + /** The case window, once composed. */ + var window: TaoWindow? = null + private set + + private val rects = java.util.concurrent.ConcurrentHashMap() + + /** + * The last presses and releases the Compose scene received, as seen from + * the root in the initial pass — so a lost click can be told apart from a + * click that arrived at the wrong place, or never arrived at all. + */ + private val recentPointerEvents = java.util.concurrent.ConcurrentLinkedDeque() + + fun recentPointerEvents(): List = recentPointerEvents.toList() + + /** Interleaves a driver-side marker with the scene's events, so intent and reception read together. */ + fun note(marker: String) { + if (recentPointerEvents.size >= POINTER_LOG_DEPTH) recentPointerEvents.pollFirst() + recentPointerEvents.addLast(marker) + } + + fun rect(region: Region): Rect? = rects[region] + + fun center(region: Region): Offset = requireNotNull(rect(region)) { "$region has no rect yet" }.center + + /** + * A point on plain Compose ground: the gap between the header row and the + * native slot, well clear of the resize band. Where a context menu the + * embed opened gets dismissed — that click is the menu's, not Compose's. + */ + fun backdropPoint(): Offset { + val native = requireNotNull(rect(Region.Native)) { "the native slot has no rect yet" } + return Offset(native.center.x, native.top - (native.top - requireNotNull(rect(Region.Field)).bottom) / 2f) + } + + @Composable + fun Content() { + val window = LocalTaoWindow.current + this.window = window + Box( + Modifier + .fillMaxSize() + .background(Color(BACKDROP_ARGB)) + .onGloballyPositioned { sceneSize = it.size } + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type == PointerEventType.Press || event.type == PointerEventType.Release) { + val position = event.changes.firstOrNull()?.position + if (recentPointerEvents.size >= POINTER_LOG_DEPTH) recentPointerEvents.pollFirst() + recentPointerEvents.addLast( + "${event.type}(${event.button})@${position?.x?.toInt()},${position?.y?.toInt()}", + ) + } + } + } + }, + ) { + Column(Modifier.fillMaxSize()) { + Row(Modifier.fillMaxWidth().height(HEADER_H_DP.dp).padding(PAD_DP.dp)) { + Box( + Modifier + .weight(1f) + .fillMaxHeight() + .background(Color.White) + .recordRect(Region.Field), + ) { + BasicTextField( + value = fieldValue, + onValueChange = { fieldValue = it }, + modifier = + Modifier + .fillMaxSize() + .padding(PAD_DP.dp) + .onFocusChanged { fieldFocused = it.isFocused } + .onPreviewKeyEvent { event -> + // Logged, never consumed: which keys reach the field. + note("key ${event.type} ${event.key}") + false + }, + textStyle = TextStyle(color = Color.Black, fontSize = FONT_SP.sp), + ) + } + Spacer(Modifier.width(PAD_DP.dp)) + Box( + Modifier + .width(BUTTON_W_DP.dp) + .fillMaxHeight() + .background(Color(HEADER_BUTTON_ARGB)) + .clickable { headerClicks++ } + .recordRect(Region.HeaderButton), + ) + } + Box(Modifier.fillMaxSize().padding(PAD_DP.dp)) { + if (nativeMounted && window != null) { + NativeView( + factory = { + requireNotNull(NativeProbe.create(window)) { "the platform refused a probe widget" } + .also { probe = it } + .platformView + }, + modifier = Modifier.fillMaxSize().recordRect(Region.Native), + ) { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .align(Alignment.BottomEnd) + .size(BUTTON_W_DP.dp, OVERLAY_H_DP.dp) + .background(Color(OVERLAY_BUTTON_ARGB)) + .clickable { overlayClicks++ } + .recordRect(Region.OverlayButton), + ) + } + } + } else { + // The slot without its embed: the same rect, plain Compose. + Box(Modifier.fillMaxSize().background(Color(EMPTY_SLOT_ARGB)).recordRect(Region.Native)) + } + } + } + } + } + + private fun Modifier.recordRect(region: Region): Modifier = + onGloballyPositioned { coords -> + val origin = coords.positionInRoot() + rects[region] = + Rect( + origin, + androidx.compose.ui.geometry + .Size(coords.size.width.toFloat(), coords.size.height.toFloat()), + ) + } + + suspend fun awaitReady(scope: TaoWindowTestScope) { + with(scope) { + awaitUntil("the case window is mapped with a real frame") { window.hasRealFramePx() } + // On top, please: a window an earlier case leaked may sit where the + // real pointer is about to click, and the WM places this one + // wherever it finds room. `focus()` is an activation request the WM + // may refuse; always-on-top is a stacking order it honours. + window.setAlwaysOnTop(true) + window.focus() + awaitUntil("every region has a rect") { Region.entries.all { rect(it) != null } } + awaitUntil("the overlay button sits inside the native slot") { + val native = rect(Region.Native) ?: return@awaitUntil false + val overlay = rect(Region.OverlayButton) ?: return@awaitUntil false + native.contains(overlay.center) && !overlay.contains(native.center) + } + awaitUntil("a probe widget was created") { probe != null } + // The platform must have mapped the widget where Compose put the + // slot: this is the "the native view never shows up" check, the + // first setFrame routinely beats the attach effect and is what + // mounts the widget. + awaitUntil("the embed is mapped on its slot", detail = { describeGeometry() }) { + val slot = rect(Region.Native) ?: return@awaitUntil false + val frame = probe?.framePx() ?: return@awaitUntil false + kotlin.math.abs(frame[2] - slot.width.roundToInt()) <= GEOMETRY_TOLERANCE_PX && + kotlin.math.abs(frame[3] - slot.height.roundToInt()) <= GEOMETRY_TOLERANCE_PX + } + settle(SETTLE_AFTER_MAP_MILLIS) + } + } +} + +/** + * The checks every case ends on and the monkey repeats at each checkpoint — + * each one a gesture followed by a converging assertion, because the point + * is not the state the desktop is in but whether it still *answers*. + */ +private class ResponsivenessProbe( + private val scope: TaoWindowTestScope, + private val fixture: NativeViewFixture, + private val driver: PointerDriver, +) { + private var typed = 'a' + + /** Compose still takes clicks and focus, and still asks for the I-beam. */ + suspend fun expectResponsive(moment: String) { + val header = fixture.headerClicks + driver.click(fixture.center(Region.HeaderButton)) + converge("$moment: a click on the header button is counted") { fixture.headerClicks == header + 1 } + + if (fixture.nativeMounted) { + val overlay = fixture.overlayClicks + driver.click(fixture.center(Region.OverlayButton)) + converge("$moment: a click on the button over the native view is counted") { + fixture.overlayClicks == overlay + 1 + } + } + + driver.click(fixture.center(Region.Field)) + converge("$moment: a click on the text field focuses it") { fixture.fieldFocused } + + expectTextCursor(moment) + } + + /** + * A still pointer over the field must have left `TEXT` as the last cursor + * request and must keep it: a change while nothing moves is the flicker + * a stray, mis-positioned move produces. + */ + suspend fun expectTextCursor(moment: String) { + driver.moveTo(fixture.center(Region.Field) + Offset(CURSOR_NUDGE_PX, 0f)) + converge("$moment: the I-beam is requested over the text field") { lastCursor() == TaoCursorIcon.TEXT } + // Stability is the robot's to check: it owns the real pointer. With + // the synthetic driver the real pointer is wherever the desktop left + // it — on a live session, possibly over this very window's edge. + if (driver !is RobotPointerDriver) return + repeat(CURSOR_STILL_SAMPLES) { + scope.settle(CURSOR_STILL_SAMPLE_MILLIS) + val now = lastCursor() + check(now == TaoCursorIcon.TEXT) { + "$moment: the cursor flickered to $now over a text field under a still pointer" + } + } + } + + /** + * One keyboard owner, and the right one. Clicking the field gives Compose + * the keys and takes them from the embed; clicking the embed does the + * reverse; a typed letter lands where the focus says. The embed half only + * runs when the driver reaches the widget at all. + */ + suspend fun expectKeyboardAgrees(moment: String) { + driver.click(fixture.center(Region.Field)) + converge("$moment: the field takes Compose focus") { fixture.fieldFocused } + converge("$moment: the embed does not hold native focus while the field is focused") { + fixture.probe?.hasNativeFocus() != true + } + val fieldBefore = fixture.fieldText + val letter = nextLetter() + driver.type(letter) + converge("$moment: a letter typed into the focused field arrives there") { + fixture.fieldText == fieldBefore + letter + } + // Caret keys travel as KeyDown, not as typed text — a second path an + // embed's focus can cut. The caret itself is what moves, so that is + // what is asserted: where the next letter lands then depends on the + // field's own editing behaviour, not on the key having arrived. + // A frame on either side: a real keyboard never delivers two keys + // inside one frame, and the field applies the move on recomposition. + scope.settle(KEY_SETTLE_MILLIS) + val caretBefore = fixture.caret + driver.arrowLeft() + // "Back", not "back exactly one": the field clamps a caret the value + // left past the end of the text before it moves it. + converge("$moment: the left arrow moved the caret back") { + if (caretBefore == 0) fixture.caret == 0 else fixture.caret < caretBefore + } + scope.settle(KEY_SETTLE_MILLIS) + + val probe = fixture.probe + if (!driver.reachesNative || probe == null || !fixture.nativeMounted) return + driver.click(fixture.center(Region.Native)) + converge("$moment: a click on the embed gives it native focus") { probe.hasNativeFocus() } + converge("$moment: the field drops Compose focus once the embed has the keyboard") { !fixture.fieldFocused } + if (!driver.typesIntoNative) return + val fieldNow = fixture.fieldText + val second = nextLetter() + driver.type(second) + // "Ends with", not "appended": a GtkEntry selects its whole text when + // it takes focus (`gtk-entry-select-on-focus`), so the letter may as + // well have replaced what an earlier keystroke left there. + converge( + "$moment: a letter typed into the focused embed arrives there", + ) { probe.text().endsWith(second) } + check(fixture.fieldText == fieldNow) { + "$moment: a letter typed into the embed also reached the Compose field ('${fixture.fieldText}')" + } + } + + private fun lastCursor(): Int? = NativeTaoBridge.lastCursorIcon[scope.window.handle] + + private fun nextLetter(): Char { + val letter = typed + typed = if (typed == 'z') 'a' else typed + 1 + return letter + } + + private suspend fun converge( + description: String, + predicate: () -> Boolean, + ) { + scope.awaitUntil( + description, + timeoutMillis = CONVERGE_MILLIS, + detail = { fixture.describe(driver) }, + predicate = predicate, + ) + } +} + +/** + * The embed against its slot: the platform's own frame for the widget versus + * the Compose rect of the `NativeView`, both in content px. Off by more than + * [tolerancePx] is "not on the slot". + */ +private class EmbedGeometryProbe( + private val scope: TaoWindowTestScope, + private val fixture: NativeViewFixture, +) { + var samples = 0 + private set + var offSlotSamples = 0 + private set + + /** The farthest the embed was seen from its slot across [sample] calls, in px. */ + var worstDistancePx = 0 + private set + + private val tolerancePx: Int get() = maxOf(GEOMETRY_TOLERANCE_PX, scope.window.scaleFactor.roundToInt()) + + /** How far the embed is from its slot right now, in px, or null when either side is unknown. */ + fun distancePx(): Int? { + val slot = fixture.rect(Region.Native) ?: return null + val frame = fixture.probe?.framePx() ?: return null + return maxOf( + kotlin.math.abs(frame[0] - slot.left.roundToInt()), + kotlin.math.abs(frame[1] - slot.top.roundToInt()), + kotlin.math.abs(frame[2] - slot.width.roundToInt()), + kotlin.math.abs(frame[3] - slot.height.roundToInt()), + ) + } + + fun isOnSlot(): Boolean = distancePx()?.let { it <= tolerancePx } == true + + fun sample() { + samples++ + val distance = distancePx() ?: return + if (distance > tolerancePx) offSlotSamples++ + worstDistancePx = maxOf(worstDistancePx, distance) + } + + suspend fun expectOnSlot(moment: String) { + scope.awaitUntil( + "$moment: the embed sits on its Compose slot", + timeoutMillis = CONVERGE_MILLIS, + detail = { "distance=${distancePx()} tolerance=$tolerancePx ${fixture.describeGeometry()}" }, + ) { isOnSlot() } + } + + /** + * Asks for [wDp]×[hDp], waits until Compose has laid the slot out at the + * new size, then measures how long the embed takes to land on it. + */ + suspend fun resizeAndMeasureLag( + wDp: Int, + hDp: Int, + ): Long { + val before = fixture.rect(Region.Native) + scope.window.setInnerSize(wDp.toDouble(), hDp.toDouble()) + scope.awaitUntil("Compose laid the slot out for ${wDp}x$hDp", detail = { fixture.describeGeometry() }) { + fixture.rect(Region.Native) != before && fixture.sceneSize.width > 0 + } + val start = System.nanoTime() + expectOnSlot("after resizing to ${wDp}x$hDp") + return (System.nanoTime() - start) / NANOS_PER_MILLI + } +} + +private fun NativeViewFixture.describeGeometry(): String = + "slot=${rect(Region.Native)} frame=${probe?.framePx()?.toList()} scene=$sceneSize " + + "outer=${window?.outerBoundsPx()?.toList()} scale=${window?.scaleFactor}" + +private fun NativeViewFixture.describe(driver: PointerDriver): String = + "driver=${driver.name} windowFocused=${window?.isFocused} fieldFocused=$fieldFocused field='$fieldText' " + + "caret=$caret sel=${fieldValue.selection} " + + "header=$headerClicks overlay=$overlayClicks mounted=$nativeMounted " + + "probe=${probe?.handle?.toString(HEX)}/disposed=${probe?.isDisposed}/nativeFocus=${probe?.hasNativeFocus()}" + + "/text='${probe?.text()}' cursor=${NativeTaoBridge.lastCursorIcon} " + + "probes=${NativeProbe.createdCount.get()}/${NativeProbe.disposedCount.get()} ${robotAim()} " + + "rects=${Region.entries.map { + "$it=${rect( + it, + )}" + }} scene=$sceneSize outer=${window?.outerBoundsPx()?.toList()} " + + "sceneEvents=${recentPointerEvents()}" + +/** One atomic thing the monkey can do; drawn uniformly. */ +private enum class NativeViewAction { + ClickField, + ClickHeaderButton, + ClickOverlayButton, + ClickNative, + DoubleClickNative, + RightClickNative, + HoverField, + HoverNative, + HoverHeaderButton, + + /** A press on one region released on another — the gesture that crosses the boundary. */ + DragAcross, + + /** Six clicks alternating between two random regions with no settle at all. */ + Burst, + TypeLetter, + PointerExit, + + /** Drops the native view from composition, or puts it back. */ + ToggleNativeMounted, + ResizeWindow, + + /** Injects a scale change (synthetic driver only: the robot aims through the real scale). */ + ChangeDpi, + + /** Asks the OS to focus the window again. */ + RefocusWindow, +} + +private class NativeViewMonkey( + private val scope: TaoWindowTestScope, + private val fixture: NativeViewFixture, + private val driver: PointerDriver, + seed: Long, +) { + private val random = Random(seed) + private val journal = MonkeyJournal("native-view-monkey", seed) + private val probe = ResponsivenessProbe(scope, fixture, driver) + private val geometry = EmbedGeometryProbe(scope, fixture) + private var worstStallMillis = 0L + private var letter = 'a' + + /** A journal pasted back through the script property, or null for the random walk. */ + private val script: List? = monkeyScript()?.map { NativeViewAction.valueOf(it) } + + /** Windows alive when the run started: earlier cases may have left some behind, they are not this run's. */ + private val windowsAtStart = TaoApplication.liveWindowCount() + + suspend fun run() { + System.err.println( + "[native-view-monkey] seed=${journal.seed} driver=${driver.name} actions=$MONKEY_ACTIONS " + + "(replay with -D$MONKEY_SEED_PROPERTY=${journal.seed})", + ) + val watchdog = MainLoopWatchdog("native-view-monkey", journal::report).start() + try { + while (journal.step < (script?.size ?: MONKEY_ACTIONS)) { + val action = + script?.get(journal.step) ?: NativeViewAction.entries[random.nextInt(NativeViewAction.entries.size)] + journal.record(action) + fixture.note("> ${journal.step} $action") + monkeyAction({ journal.failure("$action", fixture.describe(driver)) }) { apply(action) } + if ((journal.step + 1) % CHECKPOINT_EVERY == 0) checkpoint() + journal.step++ + } + } finally { + worstStallMillis = watchdog.stop() + } + } + + /** Back to the plain desktop, and every probe of [ResponsivenessProbe] strictly. */ + suspend fun quiesceAndAssert() { + // No blind release here: every gesture above released what it + // pressed, and a Robot release of a button that was never pressed + // segfaults the JVM on macOS. + restoreScale() + scope.window.setInnerSize(WINDOW_W_DP.toDouble(), WINDOW_H_DP.toDouble()) + if (!fixture.nativeMounted) { + fixture.nativeMounted = true + journal.reach("remountedForQuiesce") + } + scope.window.focus() + scope.settle(SETTLE_AFTER_MAP_MILLIS) + scope.awaitUntil("the native view is back with a live probe", detail = { fixture.describe(driver) }) { + fixture.probe?.isDisposed == false + } + + geometry.expectOnSlot("after the monkey") + probe.expectResponsive("after the monkey") + probe.expectKeyboardAgrees("after the monkey") + + // An unmount must dispose the probe it embedded, and a remount must + // bring a fresh one: created − disposed is the number still mounted. + fixture.nativeMounted = false + scope.awaitUntil("unmounting disposes the embedded probe", detail = { fixture.describe(driver) }) { + fixture.probe?.isDisposed == true + } + fixture.nativeMounted = true + scope.awaitUntil("remounting creates a fresh probe", detail = { fixture.describe(driver) }) { + fixture.probe?.isDisposed == false + } + val live = NativeProbe.createdCount.get() - NativeProbe.disposedCount.get() + check(live == 1) { "$live probes are alive with one native view mounted — an unmount leaked its widget" } + + check(TaoApplication.liveWindowCount() == windowsAtStart) { + "${TaoApplication.liveWindowCount()} native windows are alive, $windowsAtStart when the run started" + } + // Park the real pointer outside the window: a later case's windows may + // map under wherever the last gesture left it. + driver.exit() + System.err.println( + "[native-view-monkey] seed=${journal.seed} driver=${driver.name} survived $MONKEY_ACTIONS actions; " + + "worst main-dispatcher round trip ${worstStallMillis}ms; reached ${journal.reachedSummary()}", + ) + check(worstStallMillis <= MONKEY_MAX_STALL_MILLIS) { + "the main dispatcher took ${worstStallMillis}ms to answer a heartbeat — the loop stalled" + } + if (script == null) { + check(journal.reachedCount("clickNative") > 0) { "the run never clicked the native view" } + check(journal.reachedCount("toggledMount") > 0) { "the run never unmounted the native view" } + } + } + + private suspend fun apply(action: NativeViewAction) { + when (action) { + NativeViewAction.ClickField -> driver.click(fixture.center(Region.Field)) + NativeViewAction.ClickHeaderButton -> driver.click(fixture.center(Region.HeaderButton)) + NativeViewAction.ClickOverlayButton -> + if (fixture.nativeMounted) { + driver.click( + fixture.center(Region.OverlayButton), + ) + } + NativeViewAction.ClickNative -> { + driver.click(fixture.center(Region.Native)) + journal.reach("clickNative") + } + NativeViewAction.DoubleClickNative -> { + val point = fixture.center(Region.Native) + driver.click(point) + driver.click(point) + } + NativeViewAction.RightClickNative -> { + driver.click(fixture.center(Region.Native), TaoMouseButton.RIGHT) + // Dismiss the embed's menu, if it opened one; see the pinned case. + scope.settle(STEP_SETTLE_MILLIS) + driver.click(fixture.backdropPoint()) + } + NativeViewAction.HoverField -> driver.moveTo(randomPointIn(Region.Field)) + NativeViewAction.HoverNative -> driver.moveTo(randomPointIn(Region.Native)) + NativeViewAction.HoverHeaderButton -> driver.moveTo(randomPointIn(Region.HeaderButton)) + NativeViewAction.DragAcross -> dragAcross() + NativeViewAction.Burst -> burst() + NativeViewAction.TypeLetter -> driver.type(nextLetter()) + NativeViewAction.PointerExit -> driver.exit() + NativeViewAction.ToggleNativeMounted -> { + fixture.nativeMounted = !fixture.nativeMounted + journal.reach("toggledMount") + } + NativeViewAction.ResizeWindow -> + scope.window.setInnerSize( + MIN_INNER_W_DP + random.nextDouble(INNER_W_SPAN_DP), + MIN_INNER_H_DP + random.nextDouble(INNER_H_SPAN_DP), + ) + NativeViewAction.ChangeDpi -> + if (driver is SyntheticPointerDriver) { + val scale = SCALE_HOPS[random.nextInt(SCALE_HOPS.size)] + scope.window.dispatch(TaoEventCode.SCALE_FACTOR_CHANGED, (scale * SCALE_MILLI).roundToInt(), 0) + journal.reach("dpiChanged") + } + NativeViewAction.RefocusWindow -> scope.window.focus() + } + scope.settle(STEP_SETTLE_MILLIS) + } + + private suspend fun dragAcross() { + val from = randomRegion() + val to = randomRegion() + fixture.note("> drag $from -> $to") + driver.moveTo(fixture.center(from)) + driver.press() + val start = fixture.center(from) + val end = fixture.center(to) + for (step in 1..DRAG_STEPS) { + val t = step / DRAG_STEPS.toFloat() + driver.moveTo(start + (end - start) * t) + } + driver.release() + journal.reach("dragged") + } + + private suspend fun burst() { + val a = randomRegion() + val b = randomRegion() + fixture.note("> burst $a/$b") + repeat(BURST_CLICKS / 2) { + driver.click(fixture.center(a)) + driver.click(fixture.center(b)) + } + journal.reach("burst") + } + + /** + * The converging checks of a checkpoint: a click on Compose ground is + * still counted, and the field still takes focus. The keyboard checks are + * kept for the end — they type, which the monkey does on its own. + */ + private suspend fun checkpoint() { + // A resize may have shrunk the window past where the layout has a + // useful slot; put the size back before aiming. And undo an injected + // scale: it moves Compose's density without the platform's, so the + // slot and the embed's frame are measured in different pixels. + restoreScale() + scope.window.setInnerSize(WINDOW_W_DP.toDouble(), WINDOW_H_DP.toDouble()) + scope.settle(STEP_SETTLE_MILLIS) + fixture.note("> checkpoint ${journal.step}") + if (fixture.nativeMounted) geometry.expectOnSlot("checkpoint at step ${journal.step}") + probe.expectResponsive("checkpoint at step ${journal.step}") + } + + /** Puts the platform's real scale back after a [NativeViewAction.ChangeDpi]. */ + private fun restoreScale() { + if (driver !is SyntheticPointerDriver) return + scope.window.dispatch( + TaoEventCode.SCALE_FACTOR_CHANGED, + (scope.window.scaleFactor * SCALE_MILLI).roundToInt(), + 0, + ) + } + + private fun randomRegion(): Region { + val regions = if (fixture.nativeMounted) Region.entries else Region.entries - Region.OverlayButton + return regions[random.nextInt(regions.size)] + } + + private fun randomPointIn(region: Region): Offset { + val rect = fixture.rect(region) ?: return Offset.Zero + return Offset( + rect.left + INSET_PX + random.nextFloat() * (rect.width - 2 * INSET_PX).coerceAtLeast(1f), + rect.top + INSET_PX + random.nextFloat() * (rect.height - 2 * INSET_PX).coerceAtLeast(1f), + ) + } + + private fun nextLetter(): Char { + val current = letter + letter = if (letter == 'z') 'a' else letter + 1 + return current + } +} + +private const val MONKEY_ACTIONS = 150 +private const val CHECKPOINT_EVERY = 15 +private const val STORM_ROUNDS = 30 + +/** Resize storm: discrete steps, the burst, and the animated pass. */ +private const val RESIZE_ROUNDS = 12 +private const val RESIZE_SPAN = 4 +private const val RESIZE_STEP_DP = 60 +private const val RESIZE_BURST = 6 +private const val ANIMATION_STEPS = 24 +private const val ANIMATION_FRAME_MILLIS = 16L + +/** + * How many animation steps the embed may trail the layout by before it is + * "peeling away". One step is the pipeline (Compose places, the platform + * allocates a frame later) and a software-rendered X server adds a couple + * more; a widget visibly detached from the window edge is tens of steps. + */ +private const val ANIMATION_LAG_FRAMES = 8 + +/** The interactive phase drags the bottom-right corner by this much, in steps of this size. */ +private const val EDGE_DRAG_STEPS = 20 +private const val EDGE_DRAG_STEP_PX = 10 +private const val EDGE_DRAG_STEP_MILLIS = 20L + +/** Where inside the outer frame the resize band is pressed (`FrameDecoration.DEFAULT_RESIZE_EDGE_THICKNESS = 5`). */ +private const val EDGE_PRESS_INSET_PX = 2 + +/** Embed frame vs Compose slot: a pixel of rounding each side, more at scale. */ +private const val GEOMETRY_TOLERANCE_PX = 2 + +/** How long an embed may trail its slot after a resize before it is "struggling to follow". */ +private const val EMBED_LAG_BUDGET_MILLIS = 500L +private const val NANOS_PER_MILLI = 1_000_000L +private const val BURST_CLICKS = 6 +private const val DRAG_STEPS = 4 + +private const val STORM_CASE_TIMEOUT_MILLIS = 120_000L +private const val MONKEY_CASE_TIMEOUT_MILLIS = 300_000L +private const val CONVERGE_MILLIS = 5_000L + +/** Long enough for the loop to deliver a frame, short enough to stay a storm. */ +private const val STEP_SETTLE_MILLIS = 25L + +/** Samples of the cursor under a still pointer, and their spacing. */ +private const val CURSOR_STILL_SAMPLES = 6 +private const val CURSOR_STILL_SAMPLE_MILLIS = 50L + +/** A one-pixel move off the centre, so the hover is a real move even after a click there. */ +private const val CURSOR_NUDGE_PX = 1f + +/** Random hover points stay this far inside a region: a pixel on the edge is anyone's. */ +private const val INSET_PX = 6f + +private const val POINTER_LOG_DEPTH = 48 +private const val KEY_SETTLE_MILLIS = 60L + +private const val WINDOW_X_DP = 120 +private const val WINDOW_Y_DP = 80 +private const val WINDOW_W_DP = 760 +private const val WINDOW_H_DP = 520 +private const val HEADER_H_DP = 64 +private const val PAD_DP = 8 +private const val BUTTON_W_DP = 160 +private const val OVERLAY_H_DP = 56 +private const val FONT_SP = 16 + +private const val MIN_INNER_W_DP = 480.0 +private const val INNER_W_SPAN_DP = 400.0 +private const val MIN_INNER_H_DP = 320.0 +private const val INNER_H_SPAN_DP = 300.0 + +private val SCALE_HOPS = floatArrayOf(1f, 1.25f, 1.5f, 2f) +private const val SCALE_MILLI = 1000 + +private const val BACKDROP_ARGB = 0xFF2B2B2B +private const val HEADER_BUTTON_ARGB = 0xFF2D6CDF +private const val OVERLAY_BUTTON_ARGB = 0xFF3AA655 +private const val EMPTY_SLOT_ARGB = 0xFF555555 + +private const val HEX = 16 +private const val OUTER_W = 2 +private const val OUTER_H = 3 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PointerDrivers.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PointerDrivers.kt new file mode 100644 index 000000000..f37826724 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/PointerDrivers.kt @@ -0,0 +1,203 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.IntSize +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoEventCode +import dev.nucleusframework.window.tao.TaoKeyLocation +import dev.nucleusframework.window.tao.TaoMouseButton +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.workspace.clientOriginPx +import java.awt.event.InputEvent +import java.awt.event.KeyEvent +import kotlin.math.roundToInt + +/** + * The two ways a headful case can put a pointer and a keyboard on the case + * window, behind one interface so a storm or a monkey runs unchanged on both. + * + * Positions are **content** pixels (physical, top-left of the Compose scene), + * the space every fixture measures its rects in. + * + * - [SyntheticPointerDriver] posts the very events the native loop posts, + * straight into the window. Deterministic, runs everywhere including + * native Wayland, and reaches everything Compose owns — but it enters + * *after* the platform's own routing, so on Linux a click on an embed + * never becomes a GDK event and the widget never sees it ([reachesNative]). + * - [RobotPointerDriver] moves the real OS pointer and presses the real + * buttons. It is the only way to exercise the platform half of a native + * view — the GtkEventBox capture, AppKit's responder chain, Win32 focus — + * which is where the focus races live. Needs an X server (or a real + * macOS / Windows session); see [HeadfulRobot]. + */ +internal interface PointerDriver { + val name: String + + /** Whether a press on an embedded native widget reaches the widget itself. */ + val reachesNative: Boolean + + /** Whether a key typed while the embed holds the keyboard reaches the widget itself. */ + val typesIntoNative: Boolean + + suspend fun moveTo(contentPx: Offset) + + suspend fun press(button: Int = TaoMouseButton.LEFT) + + suspend fun release(button: Int = TaoMouseButton.LEFT) + + /** Takes the pointer out of the window. */ + suspend fun exit() + + /** Types one lower-case ASCII letter into whatever holds the keyboard. */ + suspend fun type(letter: Char) + + /** Presses and releases the left arrow — a caret move, which only a `KeyDown` can carry. */ + suspend fun arrowLeft() + + suspend fun click( + contentPx: Offset, + button: Int = TaoMouseButton.LEFT, + ) { + moveTo(contentPx) + press(button) + release(button) + } +} + +/** In-process injection through `TaoWindow.dispatch` — see [PointerDriver]. */ +internal class SyntheticPointerDriver( + private val window: TaoWindow, +) : PointerDriver { + override val name: String = "synthetic" + + // GTK only forwards a *live* GDK event onto an embed; a dispatched press + // has none. AppKit and Win32 synthesise a real event from the position. + override val reachesNative: Boolean = Platform.Current != Platform.Linux + + // Win32 delivers keys to the focused HWND itself, so a key dispatched + // into the Tao window enters above the child and never reaches it. The + // AppKit host forwards to the first responder either way. + override val typesIntoNative: Boolean = reachesNative && Platform.Current != Platform.Windows + + override suspend fun moveTo(contentPx: Offset) = window.pointerMove(contentPx) + + override suspend fun press(button: Int) = window.pointerPress(button) + + override suspend fun release(button: Int) = window.pointerRelease(button) + + override suspend fun exit() = window.pointerExit() + + override suspend fun type(letter: Char) { + window.dispatchKey(TaoEventCode.KEY_TYPED, 0, TaoKeyLocation.STANDARD, 0, letter.code) + } + + override suspend fun arrowLeft() { + window.dispatchKey(TaoEventCode.KEY_DOWN, KeyEvent.VK_LEFT, TaoKeyLocation.STANDARD, 0, 0) + window.dispatchKey(TaoEventCode.KEY_UP, KeyEvent.VK_LEFT, TaoKeyLocation.STANDARD, 0, 0) + } +} + +/** + * Real OS input through the AWT Robot — see [PointerDriver]. [sceneSize] + * reads the scene's current size in physical px, which together with the + * window's outer frame locates the content on screen (`clientOriginPx`); + * the Robot itself speaks logical screen points. + */ +internal class RobotPointerDriver( + private val window: TaoWindow, + private val sceneSize: () -> IntSize, +) : PointerDriver { + override val name: String = "robot" + override val reachesNative: Boolean = true + override val typesIntoNative: Boolean = true + + override suspend fun moveTo(contentPx: Offset) { + val (x, y) = screenPoint(contentPx) + inject { robot -> + robot.mouseMove(x, y) + HeadfulRobot.noteAim(x, y) + } + } + + override suspend fun press(button: Int) { + val mask = mask(button) + inject { robot -> + HeadfulRobot.notePress() + robot.mousePress(mask) + } + } + + override suspend fun release(button: Int) { + val mask = mask(button) + inject { robot -> robot.mouseRelease(mask) } + } + + override suspend fun exit() { + val outer = window.outerBoundsPx() ?: return + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + // Just past the right edge, level with the middle: on screen for any + // window the suite places, outside anything the window owns. + val x = ((outer[0] + outer[OUTER_W] + EXIT_MARGIN_PX) / scale).roundToInt() + val y = ((outer[1] + outer[OUTER_H] / 2) / scale).roundToInt() + inject { robot -> robot.mouseMove(x, y) } + } + + override suspend fun type(letter: Char) { + require(letter in 'a'..'z') { "only lower-case ASCII letters are typed: '$letter'" } + val code = KeyEvent.getExtendedKeyCodeForChar(letter.code) + inject { robot -> + robot.keyPress(code) + robot.keyRelease(code) + } + } + + override suspend fun arrowLeft() { + inject { robot -> + robot.keyPress(KeyEvent.VK_LEFT) + robot.keyRelease(KeyEvent.VK_LEFT) + } + } + + private fun screenPoint(contentPx: Offset): Pair { + val outer = requireNotNull(window.outerBoundsPx()) { "the case window is not mapped" } + val origin = clientOriginPx(outer, sceneSize()) + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + return ((origin.x + contentPx.x) / scale).roundToInt() to ((origin.y + contentPx.y) / scale).roundToInt() + } + + private suspend fun inject(gesture: (java.awt.Robot) -> Unit) { + val ok = + HeadfulRobot.inject { robot -> + gesture(robot) + true + } + checkNotNull(ok) { "the AWT Robot became unavailable mid-run: ${HeadfulRobot.unavailableReason}" } + } + + private fun mask(button: Int): Int = + when (button) { + TaoMouseButton.RIGHT -> InputEvent.BUTTON3_DOWN_MASK + TaoMouseButton.MIDDLE -> InputEvent.BUTTON2_DOWN_MASK + else -> InputEvent.BUTTON1_DOWN_MASK + } + + private companion object { + const val OUTER_W = 2 + const val OUTER_H = 3 + const val EXIT_MARGIN_PX = 40 + } +} + +/** + * Why the [RobotPointerDriver] cannot run here, or null when it can: the + * Robot's own latched failure, or a Wayland session — the JDK routes + * injection through the RemoteDesktop portal there, which blocks until the + * suite gives up on it and then silently skips every robot case. + */ +internal fun robotDriverSkipReason(): String? { + robotSkipReason()?.let { return it } + if (Platform.Current == Platform.Linux && System.getenv("WAYLAND_DISPLAY") != null) { + return "the AWT Robot cannot inject into a Wayland compositor (WAYLAND_DISPLAY is set)" + } + return null +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatellitePlacementHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatellitePlacementHeadfulCases.kt new file mode 100644 index 000000000..a33171596 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatellitePlacementHeadfulCases.kt @@ -0,0 +1,329 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteWindowState +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.math.abs + +/** + * Where a satellite is the first time it is *seen*, on real windows. + * + * A window that appears at the platform's default position and only then jumps + * to its anchor is correct by every state assertion and wrong to every user: + * the palette flashes in the middle of the screen for a few frames before + * snapping beside its document. Nothing in the placement API says when the + * window becomes visible, so this file asserts the one thing the user actually + * sees — every position the window ever occupies, from its first mapped frame + * onwards, is its anchored one. + * + * The trajectory is sampled rather than checked at the end: the end state is + * right in the buggy case too. + * + * Native Wayland is skipped — the compositor places satellites there and no + * client can say where they are. + */ +internal object SatellitePlacementHeadfulCases { + fun all(): List = + listOf( + aSatelliteInItsParentsContentNeverFlashesElsewhere(), + aSatelliteOfAnAlreadyMappedParentNeverFlashesElsewhere(), + aReopenedSatelliteComesBackWhereItWas(), + aPanelLiftedOutOfItsDockNeverFlashesElsewhere(), + ) + + /** + * The hard case, and the one an app hits first: the satellite is declared + * inside its parent's content, so it composes in the same frame the parent + * window is created — before the parent has a frame to anchor to. Whatever + * the implementation does about that, the satellite must not be *shown* + * anywhere but at its anchor. + */ + private fun aSatelliteInItsParentsContentNeverFlashesElsewhere(): TaoWindowTestCase { + val state = + SatelliteWindowState( + size = workspaceSatelliteSize(), + positioner = workspaceRightEdgePositioner(), + ) + return TaoWindowTestCase( + name = "satellite placement declared in its parent's content, never seen away from its anchor", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + satelliteState = state, + satelliteContent = { Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) }, + driver = { + val satellite = requireNotNull(satelliteWindow) { "the satellite never published itself" } + val trajectory = sampleUntilAnchored(satellite, window) + assertNoFlash(trajectory, window) + }, + ) + } + + /** + * The same satellite whose parent is already on screen — the shape of a + * palette opened from a menu. There is no excuse for a detour here: the + * anchor is computable before the window exists. + */ + private fun aSatelliteOfAnAlreadyMappedParentNeverFlashesElsewhere(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "satellite placement opened over a mapped parent, never seen away from its anchor", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + // Let the first satellite settle, then close and reopen it: the + // second window is created against a parent that has been on + // screen for a while. + awaitFloating(fixture) + fixture.workspace.close(SATELLITE_ID) + awaitUntil("the satellite went") { fixture.floatingWindow.value == null } + settle(SETTLE_AFTER_MAP_MILLIS) + + fixture.workspace.open(SATELLITE_ID) + awaitUntil("a new satellite window appeared") { fixture.floatingWindow.value != null } + val satellite = requireNotNull(fixture.floatingWindow.value) + val trajectory = sampleUntilAnchored(satellite, window) + assertNoFlash(trajectory, window) + }, + ) + } + + /** + * Closed and reopened, the satellite has to come back where the user left + * it — including when they had dragged it away from its anchor. A reopen + * that goes through the platform default first is the same flash, and a + * reopen that lands back at the declared anchor loses their placement. + */ + private fun aReopenedSatelliteComesBackWhereItWas(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "satellite placement a reopened satellite comes back where the user left it", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val first = awaitFloating(fixture) + val before = requireNotNull(first.outerBoundsPx()) + // The user drags it somewhere of their own. + val scale = first.scaleFactor.toDouble() + first.setOuterPosition(before[0] / scale + MOVE_DELTA_DP, before[1] / scale + MOVE_DELTA_DP) + awaitUntil("the satellite moved") { + val now = first.outerBoundsPx() ?: return@awaitUntil false + abs(now[0] - before[0]) > 1L + } + awaitUntil("the workspace recorded the new offset") { + fixture.workspace + .satellite(SATELLITE_ID) + ?.windowState + ?.offsetFromParent != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + val moved = requireNotNull(first.outerBoundsPx()) + + fixture.workspace.close(SATELLITE_ID) + awaitUntil("the satellite went") { fixture.floatingWindow.value == null } + settle(SETTLE_AFTER_MAP_MILLIS) + fixture.workspace.open(SATELLITE_ID) + awaitUntil("it came back") { fixture.floatingWindow.value != null } + val second = requireNotNull(fixture.floatingWindow.value) + val trajectory = sampleUntilStable(second) + settle(SETTLE_AFTER_MAP_MILLIS) + + val now = requireNotNull(second.outerBoundsPx()) + check(abs(now[0] - moved[0]) <= REOPEN_TOLERANCE_PX && abs(now[1] - moved[1]) <= REOPEN_TOLERANCE_PX) { + "it came back at (${now[0]}, ${now[1]}), the user left it at (${moved[0]}, ${moved[1]})" + } + val strays = trays(trajectory, now) + check(strays.isEmpty()) { + "the reopened satellite lingered at $strays before settling at (${now[0]}, ${now[1]})" + } + }, + ) + } + + /** + * Undocking creates a window that is supposed to appear exactly over the + * panel it lifts off. Anywhere else — the platform default especially — and + * the panel visibly teleports out of the window instead of lifting off it. + */ + private fun aPanelLiftedOutOfItsDockNeverFlashesElsewhere(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val docked = mutableStateOf(false) + return TaoWindowTestCase( + name = "satellite placement a panel lifted out of its dock never flashes elsewhere", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + awaitFloating(fixture) + fixture.workspace.dock(SATELLITE_ID, DockSide.Right) + awaitUntil("the panel is docked") { fixture.panelHost.value === window } + awaitUntil("the layout published the panel's rect") { + fixture.workspace.satellite(SATELLITE_ID)?.dockedBoundsInWindowPx != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + docked.value = true + + fixture.workspace.undock(SATELLITE_ID) + awaitUntil("a floating window appeared") { fixture.floatingWindow.value != null } + val lifted = requireNotNull(fixture.floatingWindow.value) + val trajectory = sampleUntilStable(lifted) + settle(SETTLE_AFTER_MAP_MILLIS) + + val now = requireNotNull(lifted.outerBoundsPx()) + val strays = trays(trajectory, now) + check(strays.isEmpty()) { + "the lifted panel lingered at $strays before settling at (${now[0]}, ${now[1]})" + } + }, + ) + } + + // ── sampling ───────────────────────────────────────────────────────── + + /** + * Every distinct position [satellite] is seen at, from its first mapped + * frame until it has been anchored to [parent] and stopped moving. + * + * Sampled tightly on the event loop: the flash this file is about lasts a + * few frames, and a poll slower than that would report the settled state + * and call it a pass. + */ + private suspend fun TaoWindowTestScope.sampleUntilAnchored( + satellite: TaoWindow, + parent: TaoWindow, + ): Trajectory = + sample(satellite) { rect -> + val parentRect = parent.outerBoundsPx() + parentRect != null && rect[0] > parentRect[0] + } + + /** How long [window] is seen at each position, until it stops moving. */ + private suspend fun TaoWindowTestScope.sampleUntilStable(window: TaoWindow): Trajectory = sample(window) { true } + + /** + * Time spent at each position, in the order they were first seen, until + * [settled] holds for [STABLE_SAMPLES] samples in a row. + * + * Dwell rather than presence: a window the WM maps at its own spot and the + * client moves within a frame or two is not something anyone sees, while + * the flash this file is about lasts long enough to read. Only a duration + * tells them apart. + */ + private suspend fun TaoWindowTestScope.sample( + window: TaoWindow, + settled: (LongArray) -> Boolean, + ): Trajectory { + val dwell = LinkedHashMap, Long>() + var stable = 0 + var last: Pair? = null + var lastReal: LongArray? = null + repeat(SAMPLE_ROUNDS) { + // `hasRealFramePx`, not `> 0`: a frame the platform has not + // published yet reads as the screen origin, and sampling it makes + // the window look like it flashed there. + val rect = window.outerBoundsPx()?.takeIf { window.hasRealFramePx() } + if (rect != null) { + lastReal = rect + val at = rect[0] to rect[1] + dwell[at] = (dwell[at] ?: 0L) + SAMPLE_INTERVAL_MILLIS + stable = if (at == last) stable + 1 else 0 + last = at + if (stable >= STABLE_SAMPLES && settled(rect)) return Trajectory(dwell, rect) + } + settle(SAMPLE_INTERVAL_MILLIS) + } + return Trajectory(dwell, lastReal) + } + + /** + * The satellite was never on screen anywhere but at its anchor: every + * sampled position matches the settled one, and that one really is the + * anchored place rather than wherever the platform felt like. + */ + private fun assertNoFlash( + trajectory: Trajectory, + parent: TaoWindow, + ) { + check(trajectory.dwell.isNotEmpty()) { "the satellite was never seen with a real frame" } + val settled = requireNotNull(trajectory.settled) { "the satellite was never seen with a real frame" } + val parentRect = requireNotNull(parent.outerBoundsPx()) + // The positioner puts it off the parent's right edge; if the settled + // state is not that, the case is not measuring what it thinks. + check(settled[0] >= parentRect[0] + parentRect[RECT_W] - EDGE_SLOP_PX) { + "case premise: the satellite did not end up off the parent's right edge " + + "(${settled[0]} vs parent right ${parentRect[0] + parentRect[RECT_W]})" + } + val strays = trays(trajectory, settled) + check(strays.isEmpty()) { + "the satellite was shown away from its anchor for longer than ${FLASH_BUDGET_MILLIS}ms " + + "($strays) before settling at (${settled[0]}, ${settled[1]}) — a visible jump on screen" + } + } + + /** + * Positions in [trajectory] that are not [settled] and were held long + * enough for a user to see, with how long each was held. + */ + private fun trays( + trajectory: Trajectory, + settled: LongArray, + ): Map, Long> = + trajectory.dwell.filter { (at, millis) -> + millis > FLASH_BUDGET_MILLIS && + (abs(at.first - settled[0]) > FLASH_TOLERANCE_PX || abs(at.second - settled[1]) > FLASH_TOLERANCE_PX) + } + + /** + * How long a window was seen at each position it occupied, and the last + * frame it was seen with — the position the case treats as settled, taken + * from the sampling rather than re-read afterwards so it can never be a + * frame the platform had already taken away again. + */ + private class Trajectory( + val dwell: Map, Long>, + val settled: LongArray?, + ) + + /** How far a sampled position may differ from the settled one and still be the same place. */ + private const val FLASH_TOLERANCE_PX = 4L + + /** + * How long a window may be somewhere else before it counts as a visible + * jump. A frame or two is the WM's map-time placement being corrected — + * nobody sees that. What users report is a palette sitting at the wrong + * place long enough to read, which is an order of magnitude longer. + */ + private const val FLASH_BUDGET_MILLIS = 48L + + /** The parent's right edge, minus whatever the frame's shadow margin adds. */ + private const val EDGE_SLOP_PX = 40L + + private const val REOPEN_TOLERANCE_PX = 24L + private const val SAMPLE_INTERVAL_MILLIS = 8L + private const val SAMPLE_ROUNDS = 250 + private const val STABLE_SAMPLES = 12 + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWindowHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWindowHeadfulCases.kt new file mode 100644 index 000000000..0252c5dd0 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWindowHeadfulCases.kt @@ -0,0 +1,558 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.SatelliteWindowState +import dev.nucleusframework.window.tao.WindowAnchor +import dev.nucleusframework.window.tao.WindowConstraintAdjustment +import dev.nucleusframework.window.tao.WindowPositioner +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs + +/** + * Real-window coverage for `SatelliteWindow` — the Flutter satellite archetype + * on Tao. Everything here is asserted against live `outerBoundsPx()` rects of + * two actual OS windows, never against Kotlin-side caches: + * + * 1. the anchored initial placement resolved by the [WindowPositioner]; + * 2. the parent-relative follow, including re-capturing the offset after the + * satellite has been moved independently; + * 3. suppression while the parent is maximized, and re-anchoring on restore; + * 4. [SatelliteWindowState.reanchor] snapping a dragged satellite back; + * 5. reparenting in the very frame the old owner closes — the satellite stays + * where it is, is not taken down with its former owner, and follows the + * new one. + * + * Native Wayland is skipped: xdg-shell gives clients no way to position their + * own toplevels, so the anchoring and follow paths are documented no-ops there + * (the ownership and z-order half still applies, but is not observable through + * window rects). + */ +internal object SatelliteWindowHeadfulCases { + fun all(): List = + listOf( + anchorsAndFollowsParent(), + hidesWhileParentIsMaximized(), + staysWithTheParentWhenSuppressionIsOff(), + reanchorSnapsBackToThePositioner(), + reparentOutlivesOldOwner(), + parentFlickKeepsTheFollowOffset(), + ) + + /** Parent geometry every case starts from — well inside a 1024×768 work area. */ + private fun parentWindowState() = + WindowState( + position = WindowPosition.Absolute(PARENT_X_DP.dp, PARENT_Y_DP.dp), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + ) + + /** + * Hangs the satellite off the parent's right edge, vertically centred, with + * a fixed gap. [WindowConstraintAdjustment.None] keeps the expected rect + * arithmetic exact — no flip/slide can kick in at this position. + */ + private fun rightEdgeState() = + SatelliteWindowState( + size = DpSize(SATELLITE_W_DP.dp, SATELLITE_H_DP.dp), + positioner = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + offset = DpOffset(GAP_DP.dp, 0.dp), + constraintAdjustment = WindowConstraintAdjustment.None, + ), + ) + + private fun anchorsAndFollowsParent(): TaoWindowTestCase { + val satellite = rightEdgeState() + return TaoWindowTestCase( + name = "satellite anchors to the parent's right edge and follows it", + skip = ::skipReason, + windowState = parentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + satelliteState = satellite, + satelliteContent = { Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) }, + driver = { + val satelliteWindow = awaitSatellite(satellite) + val scale = window.scaleFactor + awaitUntil("satellite reached anchored position") { + val pRect = bounds() ?: return@awaitUntil false + val sRect = satelliteBounds() ?: return@awaitUntil false + val expLeft = pRect[0] + pRect[2] + (GAP_DP * scale).toLong() + abs(sRect[0] - expLeft) <= ANCHOR_TOLERANCE_PX + } + val parentRect = requireNotNull(bounds()) + val satelliteRect = requireNotNull(satelliteBounds()) + + // ── 1. anchored placement ── + val expectedLeft = parentRect[0] + parentRect[2] + (GAP_DP * scale).toLong() + // The initial placement predates the native window, so it uses + // the *requested* height; the real frame may include a CSD + // shadow margin. Fold that difference into the tolerance + // instead of pretending the centring is pixel-exact. + val requestedHeightPx = (SATELLITE_H_DP * scale).toLong() + val centringTolerance = + ANCHOR_TOLERANCE_PX + abs(satelliteRect[3] - requestedHeightPx) / 2 + val parentCentreY = parentRect[1] + parentRect[3] / 2 + val satelliteCentreY = satelliteRect[1] + satelliteRect[3] / 2 + check(abs(satelliteCentreY - parentCentreY) <= centringTolerance) { + "satellite is not vertically centred on its parent: " + + "$satelliteCentreY vs $parentCentreY (tolerance $centringTolerance)" + } + + // ── 2. the satellite follows the parent ── + val anchoredOffsetX = satelliteRect[0] - parentRect[0] + val anchoredOffsetY = satelliteRect[1] - parentRect[1] + moveParentBy(MOVE_DELTA_DP, MOVE_DELTA_DP) + awaitUntil("parent moved") { + val now = bounds() ?: return@awaitUntil false + now[0] != parentRect[0] || now[1] != parentRect[1] + } + awaitUntil("satellite kept its offset from the parent") { + keepsOffset(anchoredOffsetX, anchoredOffsetY) + } + + // ── 3. an independent move re-captures the offset ── + val movedParent = requireNotNull(bounds()) + val draggedX = (movedParent[0] + DRAG_DELTA_PX).toInt() + val draggedY = (movedParent[1] + DRAG_DELTA_PX).toInt() + satelliteWindow.setOuterPositionPx(draggedX, draggedY) + awaitUntil("satellite landed at the dragged position") { + val now = satelliteBounds() ?: return@awaitUntil false + abs(now[0] - draggedX) <= ANCHOR_TOLERANCE_PX && + abs(now[1] - draggedY) <= ANCHOR_TOLERANCE_PX + } + settle() + val userOffset = + requireNotNull(satellite.offsetFromParent) { + "offsetFromParent must be published once both windows are mapped" + } + val satelliteScale = satelliteWindow.scaleFactor + check(abs(userOffset.x.value * satelliteScale - DRAG_DELTA_PX) <= OFFSET_TOLERANCE_PX) { + "offsetFromParent.x (${userOffset.x}) does not reflect the manual move" + } + + // ── 4. and *that* offset is what the next parent move keeps ── + val beforeParent = requireNotNull(bounds()) + val beforeSatellite = requireNotNull(satelliteBounds()) + moveParentBy(-MOVE_DELTA_DP, MOVE_DELTA_DP) + awaitUntil("parent moved again") { + val now = bounds() ?: return@awaitUntil false + now[0] != beforeParent[0] || now[1] != beforeParent[1] + } + awaitUntil("satellite preserved the user-established offset") { + keepsOffset( + beforeSatellite[0] - beforeParent[0], + beforeSatellite[1] - beforeParent[1], + ) + } + }, + ) + } + + private fun hidesWhileParentIsMaximized(): TaoWindowTestCase { + val satellite = rightEdgeState() + return TaoWindowTestCase( + name = "satellite hides while its parent is maximized and re-anchors on restore", + skip = ::skipReason, + windowState = parentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + satelliteState = satellite, + satelliteContent = { Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) }, + driver = { + awaitSatellite(satellite) + check(!satellite.isHiddenByParent) { "satellite must start visible" } + val parentRect = requireNotNull(bounds()) + val satelliteRect = requireNotNull(satelliteBounds()) + val offsetX = satelliteRect[0] - parentRect[0] + val offsetY = satelliteRect[1] - parentRect[1] + + window.setMaximized(true) + awaitUntil("satellite suppressed while the parent is maximized") { + satellite.isHiddenByParent + } + + window.setMaximized(false) + awaitUntil("satellite restored after the parent is unmaximized") { + !satellite.isHiddenByParent + } + val realigned = + awaitOrFalse(RESTORE_TIMEOUT_MILLIS) { keepsOffset(offsetX, offsetY) } + check(realigned) { + "satellite was not re-anchored on the restored parent: " + + "parent=${bounds()?.toList()} satellite=${satelliteBounds()?.toList()} " + + "expected offset=($offsetX, $offsetY) " + + "published=${satellite.offsetFromParent}" + } + // Restoring must not have orphaned the window: it is still + // mapped with a real size. + val restored = requireNotNull(satelliteBounds()) + check(restored[2] > 0 && restored[3] > 0) { + "satellite has no size after restore: ${restored.toList()}" + } + }, + ) + } + + /** + * The opt-out of [hidesWhileParentIsMaximized]: with + * `hideWhileParentFullscreenOrMaximized = false` the satellite floats over + * its maximized parent instead of stepping aside. What is easy to get + * wrong — and what this pins — is that it survives the transition as a + * live, correctly placed, still-owned window: maximizing re-stacks the + * parent, and without the owner link being re-asserted the satellite ends + * up behind the window it belongs to. + * + * The z-order itself is not observable through window rects; what is + * asserted here is everything that goes with it — the satellite stays + * mapped, keeps its parent-relative offset across maximize and restore, + * and still follows the parent afterwards, which only holds while the + * owner link is intact. + */ + private fun staysWithTheParentWhenSuppressionIsOff(): TaoWindowTestCase { + val satellite = rightEdgeState() + return TaoWindowTestCase( + name = "satellite that does not hide stays with its parent across maximize and restore", + skip = ::skipReason, + windowState = parentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + satelliteState = satellite, + satelliteHideWhileParentFills = false, + satelliteContent = { Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) }, + driver = { + awaitSatellite(satellite) + val parentRect = requireNotNull(bounds()) + val satelliteRect = requireNotNull(satelliteBounds()) + val offsetX = satelliteRect[0] - parentRect[0] + val offsetY = satelliteRect[1] - parentRect[1] + + window.setMaximized(true) + awaitUntil("parent maximized") { + val now = bounds() ?: return@awaitUntil false + now[2] > parentRect[2] + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(!satellite.isHiddenByParent) { "the satellite must not hide when the app opted out" } + val overMaximized = + requireNotNull(satelliteBounds()) { "satellite lost while the parent was maximized" } + check(overMaximized[2] > 0 && overMaximized[3] > 0) { + "satellite has no size over the maximized parent: ${overMaximized.toList()}" + } + + window.setMaximized(false) + awaitUntil("parent restored") { + val now = bounds() ?: return@awaitUntil false + abs(now[2] - parentRect[2]) <= FOLLOW_TOLERANCE_PX + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(!satellite.isHiddenByParent) { "still not hidden after the restore" } + + // Still owned and still following: the offset is preserved and + // a later parent move carries the satellite along. + val restoredParent = requireNotNull(bounds()) + val restoredSatellite = requireNotNull(satelliteBounds()) + check( + abs((restoredSatellite[0] - restoredParent[0]) - offsetX) <= FOLLOW_TOLERANCE_PX && + abs((restoredSatellite[1] - restoredParent[1]) - offsetY) <= FOLLOW_TOLERANCE_PX, + ) { + "satellite lost its offset across maximize/restore: " + + "parent=${restoredParent.toList()} satellite=${restoredSatellite.toList()}" + } + moveParentBy(MOVE_DELTA_DP, MOVE_DELTA_DP) + awaitUntil("parent moved after the restore") { + val now = bounds() ?: return@awaitUntil false + now[0] != restoredParent[0] || now[1] != restoredParent[1] + } + awaitUntil("satellite still follows its parent") { keepsOffset(offsetX, offsetY) } + }, + ) + } + + private fun reanchorSnapsBackToThePositioner(): TaoWindowTestCase { + val satellite = rightEdgeState() + return TaoWindowTestCase( + name = "satellite reanchor re-applies the positioner after a manual move", + skip = ::skipReason, + windowState = parentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + satelliteState = satellite, + satelliteContent = { Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) }, + driver = { + val satelliteWindow = awaitSatellite(satellite) + val parentRect = requireNotNull(bounds()) + val anchoredLeft = requireNotNull(satelliteBounds())[0] + + satelliteWindow.setOuterPositionPx( + (parentRect[0] + DRAG_DELTA_PX).toInt(), + (parentRect[1] + DRAG_DELTA_PX).toInt(), + ) + awaitUntil("satellite left its anchor") { + val now = satelliteBounds() ?: return@awaitUntil false + abs(now[0] - anchoredLeft) > ANCHOR_TOLERANCE_PX + } + settle() + + satellite.reanchor() + val scale = window.scaleFactor + awaitUntil("reanchor put the satellite back on the parent's right edge") { + val parentNow = bounds() ?: return@awaitUntil false + val satelliteNow = satelliteBounds() ?: return@awaitUntil false + val expectedLeft = parentNow[0] + parentNow[2] + (GAP_DP * scale).toLong() + abs(satelliteNow[0] - expectedLeft) <= ANCHOR_TOLERANCE_PX + } + // reanchor() re-reads the real frame, so the centring is exact + // this time round. + val parentNow = requireNotNull(bounds()) + val satelliteNow = requireNotNull(satelliteBounds()) + val parentCentreY = parentNow[1] + parentNow[3] / 2 + val satelliteCentreY = satelliteNow[1] + satelliteNow[3] / 2 + check(abs(satelliteCentreY - parentCentreY) <= ANCHOR_TOLERANCE_PX) { + "reanchor did not re-centre the satellite: " + + "$satelliteCentreY vs $parentCentreY" + } + }, + ) + } + + /** + * The demo's "close the document the palette is attached to" flow. The + * satellite starts out owned by the suite's dialog window; the driver then + * hands it to the case window *and* drops the dialog in the same frame. + * Win32 and GTK destroy owned windows together with their owner, so this + * only holds because the satellite severs the owner link before the dialog + * goes — and the close decision is taken from composition, where the new + * owner is already known. + */ + private fun reparentOutlivesOldOwner(): TaoWindowTestCase { + val satellite = rightEdgeState() + val owner = mutableStateOf(SatelliteOwner.DialogWindow) + val dialogVisible = mutableStateOf(true) + val closeRequests = AtomicInteger() + return TaoWindowTestCase( + name = "satellite reparented as its owner closes keeps its place and follows the new owner", + skip = ::skipReason, + windowState = parentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { Box(Modifier.fillMaxSize().background(Color(0xFF3C8D5A))) }, + dialogVisible = dialogVisible, + satelliteState = satellite, + satelliteOwner = owner, + satelliteOnCloseRequest = { closeRequests.incrementAndGet() }, + satelliteContent = { Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) }, + driver = { + awaitSatellite(satellite) + val dialog = requireNotNull(dialogWindow) { "dialog window was never published" } + settle() + + // ── 1. owned by, and anchored to, the dialog — not the case window ── + val dialogRect = requireNotNull(dialog.outerBoundsPx()) + val before = requireNotNull(satelliteBounds()) + val scale = dialog.scaleFactor + val expectedLeft = dialogRect[0] + dialogRect[2] + (GAP_DP * scale).toLong() + check(abs(before[0] - expectedLeft) <= ANCHOR_TOLERANCE_PX) { + "satellite left ${before[0]} is not anchored to the dialog's right edge + gap " + + "($expectedLeft); dialog=${dialogRect.toList()} satellite=${before.toList()}" + } + + // ── 2. new owner and old owner gone, same frame ── + var dialogDestroyed = false + dialog.onDestroyed { dialogDestroyed = true } + owner.value = SatelliteOwner.CaseWindow + dialogVisible.value = false + awaitUntil("former owner destroyed") { dialogDestroyed } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(closeRequests.get() == 0) { + "the former owner's death was reported as the satellite's own close request" + } + val after = + requireNotNull(satelliteBounds()) { "satellite was destroyed together with its former owner" } + check(after[2] > 0 && after[3] > 0) { "satellite has no size after reparenting: ${after.toList()}" } + check( + abs(after[0] - before[0]) <= FOLLOW_TOLERANCE_PX && + abs(after[1] - before[1]) <= FOLLOW_TOLERANCE_PX, + ) { + "reparenting moved the satellite: before=${before.toList()} after=${after.toList()}" + } + + // ── 3. from here on it follows the case window ── + val parentRect = requireNotNull(bounds()) + val offsetX = after[0] - parentRect[0] + val offsetY = after[1] - parentRect[1] + val published = + requireNotNull(satellite.offsetFromParent) { "offsetFromParent lost across the reparent" } + val satelliteScale = requireNotNull(satelliteWindow).scaleFactor + check(abs(published.x.value * satelliteScale - offsetX) <= OFFSET_TOLERANCE_PX) { + "offsetFromParent.x (${published.x}) is not relative to the new owner (expected $offsetX px)" + } + moveParentBy(MOVE_DELTA_DP, MOVE_DELTA_DP) + awaitUntil("new owner moved") { + val now = bounds() ?: return@awaitUntil false + now[0] != parentRect[0] || now[1] != parentRect[1] + } + awaitUntil("satellite follows its new owner") { keepsOffset(offsetX, offsetY) } + }, + ) + } + + /** + * A parent thrown across the screen. The follow logic distinguishes its own + * catch-up moves from the user dragging the satellite by matching each move + * against the position it last commanded, with a small tolerance and a + * count of the moves still in flight. A burst of parent moves with no + * frame in between is what can desynchronise that bookkeeping: the + * satellite would then treat a follow move as a user drag and re-capture a + * wrong offset, drifting a little further with every burst. + */ + private fun parentFlickKeepsTheFollowOffset(): TaoWindowTestCase { + val satellite = rightEdgeState() + return TaoWindowTestCase( + name = "satellite keeps its offset through bursts of parent moves", + skip = ::skipReason, + windowState = parentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + satelliteState = satellite, + satelliteContent = { Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) }, + driver = { + awaitSatellite(satellite) + val parentRect = requireNotNull(bounds()) + val satelliteRect = requireNotNull(satelliteBounds()) + val offsetX = satelliteRect[0] - parentRect[0] + val offsetY = satelliteRect[1] - parentRect[1] + val scale = window.scaleFactor.toDouble() + val originX = parentRect[0] / scale + val originY = parentRect[1] / scale + + // Several bursts, each a run of moves issued with no settle in + // between, in alternating directions and with big jumps. + repeat(FLICK_BURSTS) { burst -> + val direction = if (burst % 2 == 0) 1 else -1 + for (step in 1..FLICK_MOVES_PER_BURST) { + val delta = direction * step * FLICK_STEP_DP + window.setOuterPosition(originX + delta, originY + delta / 2) + } + // Back to a known place, still without waiting. + window.setOuterPosition(originX, originY) + } + + // Once the burst has drained, the satellite is back where it + // belongs relative to its parent — no accumulated drift. + awaitUntil("satellite recovered its offset after the bursts") { + keepsOffset(offsetX, offsetY) + } + val published = + requireNotNull(satellite.offsetFromParent) { "offsetFromParent lost during the bursts" } + val satelliteScale = requireNotNull(satelliteWindow).scaleFactor + check(abs(published.x.value * satelliteScale - offsetX) <= OFFSET_TOLERANCE_PX) { + "published offset drifted: ${published.x} vs $offsetX px" + } + + // And a normal move afterwards is still followed. + moveParentBy(MOVE_DELTA_DP, MOVE_DELTA_DP) + awaitUntil("parent moved after the bursts") { + val now = bounds() ?: return@awaitUntil false + now[0] != parentRect[0] || now[1] != parentRect[1] + } + awaitUntil("satellite still follows after the bursts") { keepsOffset(offsetX, offsetY) } + }, + ) + } + + /** Waits until both windows are mapped and the follow offset is captured. */ + private suspend fun TaoWindowTestScope.awaitSatellite(state: SatelliteWindowState) = + run { + awaitUntil("parent mapped") { bounds() != null } + awaitUntil("satellite mapped with a real size") { + satelliteWindow?.hasRealFramePx() == true + } + awaitUntil("satellite captured its parent offset") { state.offsetFromParent != null } + settle(SETTLE_AFTER_MAP_MILLIS) + requireNotNull(satelliteWindow) { "satellite window was never published" } + } + + /** Bounded poll that reports the outcome instead of throwing, so the caller can log state. */ + private suspend fun awaitOrFalse( + timeoutMillis: Long, + predicate: () -> Boolean, + ): Boolean { + val deadline = System.currentTimeMillis() + timeoutMillis + while (System.currentTimeMillis() < deadline) { + if (predicate()) return true + kotlinx.coroutines.delay(POLL_MILLIS) + } + return predicate() + } + + /** True while the satellite still sits at ([offsetX], [offsetY]) off the parent. */ + private fun TaoWindowTestScope.keepsOffset( + offsetX: Long, + offsetY: Long, + ): Boolean { + val parentRect = bounds() ?: return false + val satelliteRect = satelliteBounds() ?: return false + return abs((satelliteRect[0] - parentRect[0]) - offsetX) <= FOLLOW_TOLERANCE_PX && + abs((satelliteRect[1] - parentRect[1]) - offsetY) <= FOLLOW_TOLERANCE_PX + } + + /** Moves the parent by a logical delta, in the dp space `WindowState` uses. */ + private fun TaoWindowTestScope.moveParentBy( + dxDp: Double, + dyDp: Double, + ) { + val rect = requireNotNull(bounds()) + val scale = window.scaleFactor.toDouble() + window.setOuterPosition(rect[0] / scale + dxDp, rect[1] / scale + dyDp) + } + + /** + * Native Wayland has no client-side toplevel positioning, so neither the + * anchored placement nor the follow is observable there. Mirrors the + * backend detection of the suite's own `setOuterPosition` case. + */ + private fun skipReason(): String? { + if (Platform.Current != Platform.Linux) return null + val backend = System.getenv("GDK_BACKEND")?.split(',')?.firstOrNull() + val forcedX11 = + backend == "x11" || + System.getenv("NUCLEUS_TAO_LINUX_RENDERER").orEmpty().equals("x11", ignoreCase = true) + val wayland = System.getenv("WAYLAND_DISPLAY") != null && !forcedX11 + return if (wayland) "no client window positioning on Wayland (xdg-shell)" else null + } + + private const val PARENT_X_DP = 120 + private const val PARENT_Y_DP = 90 + private const val PARENT_W_DP = 420 + private const val PARENT_H_DP = 300 + private const val SATELLITE_W_DP = 220 + private const val SATELLITE_H_DP = 160 + private const val DIALOG_W_DP = 260 + private const val DIALOG_H_DP = 200 + private const val GAP_DP = 10 + + private const val MOVE_DELTA_DP = 70.0 + private const val FLICK_BURSTS = 6 + private const val FLICK_MOVES_PER_BURST = 12 + private const val FLICK_STEP_DP = 40.0 + private const val DRAG_DELTA_PX = 60L + + /** Logical → physical rounding slack on a single edge. */ + private const val ANCHOR_TOLERANCE_PX = 6L + + /** Two rects sampled from two windows mid-flight; one extra rounding step. */ + private const val FOLLOW_TOLERANCE_PX = 8L + private const val OFFSET_TOLERANCE_PX = 8f + private const val SETTLE_AFTER_MAP_MILLIS = 400L + private const val RESTORE_TIMEOUT_MILLIS = 5_000L + private const val POLL_MILLIS = 25L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceFixture.kt new file mode 100644 index 000000000..27bce96f7 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceFixture.kt @@ -0,0 +1,416 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.Satellite +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.WindowAnchor +import dev.nucleusframework.window.tao.WindowConstraintAdjustment +import dev.nucleusframework.window.tao.WindowPositioner +import java.awt.MouseInfo +import java.awt.event.InputEvent +import kotlin.math.abs +import kotlin.math.roundToInt + +/** Everything one case observes; fresh per case, so cases never share windows or state. */ +internal class SatelliteWorkspaceFixture { + val workspace = SatelliteWorkspace() + + /** The satellite's own window while floating (the content's [LocalTaoWindow]). */ + val floatingWindow = mutableStateOf(null) + + /** The host window while docked. */ + val panelHost = mutableStateOf(null) + + /** Docked panel rect in host window px, and the host content size at that time. */ + val panelBoundsPx = mutableStateOf(null) + val hostContentSizePx = mutableStateOf(null) + + /** Content rect of the DockLayout's own content slot, in host window px. */ + val contentBoundsPx = mutableStateOf(null) + + /** + * A plain `remember` living in the DockLayout's *content* — the document, + * not the satellite. It survives only as long as that subtree keeps its + * identity, which is what docking a first panel must not disturb. + */ + val documentState = mutableStateOf?>(null) + + /** The `rememberSaveable` counter of the current host's composition. */ + val counter = mutableStateOf?>(null) + + /** Hosts currently composing the content; the two overlap for a frame when switching. */ + val composedHosts = mutableIntStateOf(0) + val isComposed: Boolean get() = composedHosts.value > 0 + + @Composable + fun ApplicationScope.ToolsSatellite() { + Satellite( + workspace = workspace, + id = SATELLITE_ID, + title = "Tools", + initialPlacement = + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ) { + val clicks = rememberSaveable { mutableStateOf(0) } + val window = LocalTaoWindow.current + val docked = isDocked + val container = LocalWindowInfo.current.containerSize + SideEffect { + counter.value = clicks + if (docked) { + panelHost.value = window + hostContentSizePx.value = container + } else { + floatingWindow.value = window + } + } + DisposableEffect(docked) { + composedHosts.value++ + onDispose { + composedHosts.value-- + // Cleared on the way out, so a case waiting for the panel + // cannot pass on a host published by an earlier dock — and + // the same for the floating window. Only when the value + // still names *this* host, though: a panel moved from one + // window's dock straight into another's keeps `docked` + // true on both sides, and the new host publishes itself + // before the old one is disposed. + if (docked) { + if (panelHost.value === window) panelHost.value = null + } else if (floatingWindow.value === window) { + floatingWindow.value = null + } + } + } + Box( + Modifier + .fillMaxSize() + .background(Color(0xFF2D6CDF)) + .onGloballyPositioned { if (docked) panelBoundsPx.value = it.boundsInWindow() }, + ) + } + } + + /** Window content: join the workspace, host the dock around a plain body. */ + @Composable + fun Body() { + JoinSatelliteWorkspace(workspace) + DockLayout(workspace, Modifier.fillMaxSize()) { + val kept = remember { mutableStateOf(0) } + SideEffect { documentState.value = kept } + Box( + Modifier + .fillMaxSize() + .background(Color.DarkGray) + .onGloballyPositioned { contentBoundsPx.value = it.boundsInWindow() }, + ) + } + } +} + +internal fun workspaceParentWindowState() = + WindowState( + position = WindowPosition.Absolute(PARENT_X_DP.dp, PARENT_Y_DP.dp), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + ) + +internal fun workspaceRightEdgePositioner() = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + offset = DpOffset(GAP_DP.dp, 0.dp), + constraintAdjustment = WindowConstraintAdjustment.None, + ) + +internal fun workspaceSatelliteSize() = DpSize(SATELLITE_W_DP.dp, SATELLITE_H_DP.dp) + +/** + * Real press and drag from [from] to [to] (physical screen px) with the AWT + * Robot, which speaks logical screen points. The button stays **down** so the + * caller can assert the in-flight state — the dock preview, the ghost — + * before [robotRelease] drops it; asserting only after the drop races the + * gesture and picks up whatever position the last processed move had. + * + * [steps] and [stepDelayMillis] shape the path: the defaults are a deliberate + * drag, `steps = 3, stepDelayMillis = 0` is a flick the OS coalesces into a + * couple of enormous deltas. `null` when the host cannot inject input. + */ +internal suspend fun robotPressAndDrag( + from: Offset, + to: Offset, + scale: Float, + steps: Int = ROBOT_DRAG_STEPS, + stepDelayMillis: Long = ROBOT_DRAG_STEP_MILLIS, +): Boolean? = + HeadfulRobot.inject { robot -> + fun x(p: Offset) = (p.x / scale).roundToInt() + + fun y(p: Offset) = (p.y / scale).roundToInt() + // Land on `from` in two hops. `Robot.mouseMove` warps the cursor on + // macOS, and the events that follow a warp carry the *pre-warp* + // location for a few hundred ms — a press sent inside that window is + // hit-tested where the pointer used to be. The second hop is a real + // move from the cursor's new home, which is what flushes the true + // location through. + robot.mouseMove(x(from) - ROBOT_NUDGE_PX, y(from) - ROBOT_NUDGE_PX) + Thread.sleep(ROBOT_PRESS_SETTLE_MILLIS) + robot.mouseMove(x(from), y(from)) + Thread.sleep(ROBOT_PRESS_SETTLE_MILLIS) + HeadfulRobot.noteAim(x(from), y(from)) + HeadfulRobot.notePress() + robot.mousePress(InputEvent.BUTTON1_DOWN_MASK) + Thread.sleep(ROBOT_PRESS_SETTLE_MILLIS) + for (step in 1..steps) { + val t = step / steps.toFloat() + robot.mouseMove(x(from + (to - from) * t), y(from + (to - from) * t)) + if (stepDelayMillis > 0) Thread.sleep(stepDelayMillis) + } + true + } + +/** + * Continues the gesture [robotPressAndDrag] is holding: interpolates from + * wherever the pointer is now to [to] (physical screen px) without touching + * the button, so a case can hover one target and then another before dropping. + * `null` when the host cannot inject input. + */ +internal suspend fun robotDragTo( + to: Offset, + scale: Float, + steps: Int = ROBOT_DRAG_STEPS, + stepDelayMillis: Long = ROBOT_DRAG_STEP_MILLIS, +): Boolean? = + HeadfulRobot.inject { robot -> + val targetX = (to.x / scale).roundToInt() + val targetY = (to.y / scale).roundToInt() + val start = MouseInfo.getPointerInfo()?.location + if (start == null) { + robot.mouseMove(targetX, targetY) + } else { + for (step in 1..steps) { + val t = step / steps.toFloat() + robot.mouseMove( + (start.x + (targetX - start.x) * t).roundToInt(), + (start.y + (targetY - start.y) * t).roundToInt(), + ) + if (stepDelayMillis > 0) Thread.sleep(stepDelayMillis) + } + } + true + } + +/** + * Moves the pointer to [to] (physical screen px) with **no button held**: a + * hover, not a drag. + * + * Interpolated like [robotDragTo], so the window under it gets the enter and + * move events a real pointer delivers rather than one teleport — which is + * what anything driven by hover, a tab's card among them, actually reacts to. + * `null` when the host cannot inject input. + */ +internal suspend fun robotMoveTo( + to: Offset, + scale: Float, + steps: Int = ROBOT_DRAG_STEPS, + stepDelayMillis: Long = ROBOT_DRAG_STEP_MILLIS, +): Boolean? = robotDragTo(to, scale, steps, stepDelayMillis) + +/** + * Where the last robot gesture aimed and where the pointer landed — worth + * putting in the description of anything a robot-driven case waits for, so a + * timeout on a runner nobody can attach to still says which of the two went + * wrong. + */ +internal fun robotAim(): String = HeadfulRobot.lastAimReport + +/** Drops what [robotPressAndDrag] is holding. */ +internal suspend fun robotRelease(): Boolean? = + HeadfulRobot.inject { robot -> + robot.mouseRelease(InputEvent.BUTTON1_DOWN_MASK) + true + } + +/** + * Waits until [host]'s dock layout is published *with a usable size*, and + * returns it. + * + * Published is not the same as measured: a layout that has been placed once + * but is still a fraction of its final size answers a point near its right + * edge with the *top* zone, because the nearest edge to its own centre is then + * the top one. A case that aims at a named zone has to wait for a layout whose + * edges are far enough apart to be told apart, which is what this is. + */ +internal suspend fun TaoWindowTestScope.awaitDockLayout( + workspace: SatelliteWorkspace, + host: TaoWindow, +): Rect { + awaitUntil("dock layout of the host is measured") { + val rect = workspace.dockHostGeometry(host)?.layoutScreenRectPx() ?: return@awaitUntil false + rect.width > MIN_DOCK_LAYOUT_PX && rect.height > MIN_DOCK_LAYOUT_PX + } + return requireNotNull(workspace.dockHostGeometry(host)?.layoutScreenRectPx()) +} + +/** Smallest dock layout whose four edge zones are far enough apart to aim at one of them. */ +private const val MIN_DOCK_LAYOUT_PX = 80f + +/** Waits until the floating satellite window is mapped and anchored to the current owner. */ +internal suspend fun TaoWindowTestScope.awaitFloating(fixture: SatelliteWorkspaceFixture): TaoWindow { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("floating satellite mapped with a real size") { + fixture.floatingWindow.value?.hasRealFramePx() == true + } + awaitUntil("satellite captured its owner offset") { + fixture.workspace + .satellite(SATELLITE_ID) + ?.windowState + ?.offsetFromParent != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull(fixture.floatingWindow.value) +} + +/** Moves [owner] and checks the floating satellite keeps its offset from it. */ +internal suspend fun TaoWindowTestScope.awaitFollows( + fixture: SatelliteWorkspaceFixture, + owner: TaoWindow, + label: String, +) { + awaitUntil("offset to the $label captured") { + fixture.workspace + .satellite(SATELLITE_ID) + ?.windowState + ?.offsetFromParent != null + } + settle() + val ownerBefore = requireNotNull(owner.outerBoundsPx()) + val satelliteBefore = requireNotNull(requireNotNull(fixture.floatingWindow.value).outerBoundsPx()) + val offsetX = satelliteBefore[0] - ownerBefore[0] + val offsetY = satelliteBefore[1] - ownerBefore[1] + val scale = owner.scaleFactor.toDouble() + owner.setOuterPosition(ownerBefore[0] / scale + MOVE_DELTA_DP, ownerBefore[1] / scale + MOVE_DELTA_DP) + awaitUntil("$label moved") { + val now = owner.outerBoundsPx() ?: return@awaitUntil false + now[0] != ownerBefore[0] || now[1] != ownerBefore[1] + } + awaitUntil("satellite followed the $label") { + val ownerNow = owner.outerBoundsPx() ?: return@awaitUntil false + val satelliteNow = fixture.floatingWindow.value?.outerBoundsPx() ?: return@awaitUntil false + abs((satelliteNow[0] - ownerNow[0]) - offsetX) <= FOLLOW_TOLERANCE_PX && + abs((satelliteNow[1] - ownerNow[1]) - offsetY) <= FOLLOW_TOLERANCE_PX + } +} + +/** + * Native Wayland has no client-side toplevel positioning, so neither the + * anchored placement nor the follow is observable there. + */ +internal fun workspaceSkipReason(): String? { + if (Platform.Current != Platform.Linux) return null + val backend = System.getenv("GDK_BACKEND")?.split(',')?.firstOrNull() + val forcedX11 = + backend == "x11" || + System.getenv("NUCLEUS_TAO_LINUX_RENDERER").orEmpty().equals("x11", ignoreCase = true) + val wayland = System.getenv("WAYLAND_DISPLAY") != null && !forcedX11 + return if (wayland) "no client window positioning on Wayland (xdg-shell)" else null +} + +internal const val SATELLITE_ID = "tools" +internal const val SAVED_CLICKS = 3 +internal const val DOCUMENT_MARK = 7 +internal const val PARENT_X_DP = 120 +internal const val PARENT_Y_DP = 90 +internal const val PARENT_W_DP = 520 +internal const val PARENT_H_DP = 360 +internal const val SATELLITE_W_DP = 220 +internal const val SATELLITE_H_DP = 160 +internal const val DIALOG_W_DP = 300 +internal const val DIALOG_H_DP = 240 +internal const val GAP_DP = 10 +internal const val MOVE_DELTA_DP = 70.0 + +internal const val ANCHOR_TOLERANCE_PX = 6L +internal const val FOLLOW_TOLERANCE_PX = 8L +internal const val LAYOUT_TOLERANCE_PX = 4f + +/** Rounding only: both sides of the comparison come from the same live geometry. */ +internal const val EXACT_TOLERANCE_PX = 4.0 + +/** Client-origin estimate vs. real frame, plus the lift-off's own rounding. */ +internal const val LIFT_OFF_TOLERANCE_PX = 24.0 + +/** Vertical grab point inside a header strip, in dp from its top. */ +internal const val HEADER_GRAB_Y_DP = 15f + +/** + * Vertical grab point in the title bar *above* the header strip, in dp from + * the window's top. The header centres itself in the bar, so a few dp down is + * bar and not strip. + * + * Past the resize edge band, deliberately: `ResizeFrameDecoration` claims the + * top 5 logical px of a resizable window, and it is right to — three px from + * the top edge of a palette is a resize grip on every desktop. A window whose + * frame adds nothing above its content (Tao on X11, Win32) puts that band + * exactly where a grab measured from the outer frame lands, which is why this + * has to clear it rather than sit "a few dp down". + */ +internal const val TITLE_BAR_TOP_GRAB_DP = 8f +internal const val DROP_INSET_PX = 20f +internal const val ROBOT_DRAG_STEPS = 12 +internal const val ROBOT_DRAG_STEP_MILLIS = 40L +internal const val ROBOT_PRESS_SETTLE_MILLIS = 150L + +/** Offset of the first of [robotPressAndDrag]'s two hops onto its start point. */ +internal const val ROBOT_NUDGE_PX = 3 +internal const val SETTLE_AFTER_MAP_MILLIS = 400L + +/** Enough dock/undock rounds to expose a leak, few enough to stay quick. */ +internal const val CHURN_CYCLES = 6 +internal const val JUMP_SETTLE_MILLIS = 60L +internal const val RESIZED_W_DP = 620.0 +internal const val RESIZED_H_DP = 430.0 +internal const val RESIZE_TOLERANCE_PX = 48L + +/** A flick: as few samples as the OS will deliver. */ +internal const val FLICK_STEPS = 3 +internal const val GRAB_INSET_PX = 12f +internal const val DRAG_AWAY_PX = 180f + +/** Far enough right of a layout that no dock zone of any window is under it. */ +internal const val DROP_FAR_PX = 420f + +/** Gap left between a window and a second dock host parked beside it. */ +internal const val DIALOG_PARK_GAP_PX = 12L diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceHeadfulCases.kt new file mode 100644 index 000000000..82e72ec14 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceHeadfulCases.kt @@ -0,0 +1,651 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockPanelHeaderHeight +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.Satellite +import dev.nucleusframework.window.tao.SatelliteDragOrigin +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.math.abs + +/** + * Real-window coverage for the satellite workspace: `Satellite` hosted by a + * `SatelliteWindow` while floating and by the owner's `DockLayout` while + * docked, with the workspace deciding who owns what. + * + * 1. dock / undock round trip — the floating window is destroyed, the panel + * appears on the requested side of the host's content with the extent + * seeded from the window, `rememberSaveable` state survives both moves, + * and the undocked window lifts off exactly where the panel was; + * 2. ownership follows focus between two members, and `pinTo` overrides it; + * 3. a layout snapshot restores a docked panel, and the open / visible flags + * take the content in and out of composition; + * 4. a satellite docked into a member that closes moves to the next owner; + * 5. dragging the floating window's header into the owner's right dock zone + * docks it, and dragging the panel's header back over the content lifts + * it out under the pointer — with a real mouse (AWT Robot) where the host + * allows input injection, else by driving the same drag session directly; + * 6. `rememberSaveable` state survives repeated host changes. + * + * The adversarial half — teleporting pointers, interrupted gestures, churn, + * overlapping drags — lives in [SatelliteWorkspaceStressHeadfulCases]. + * + * Native Wayland is skipped like the plain satellite cases: without client + * positioning neither the anchoring nor the lift-off is observable. + */ +internal object SatelliteWorkspaceHeadfulCases { + fun all(): List = + listOf( + dockAndUndockRoundTrip(), + ownerFollowsFocusAndPin(), + snapshotRestoresDockedLayout(), + dockHostDeathRehostsPanel(), + headerDragDocksAndLiftsOff(), + titleBarDragOutsideTheHeaderStripDocks(), + saveableStateSurvivesRepeatedHostChanges(), + panelsOnOneSideKeepTheirOwnSubtree(), + ) + + /** + * Two panels on one side, the first undocked: the one that stays keeps its + * own body. + * + * Compose identifies siblings by their position, so without a key per + * satellite the stack disposes the *last* slot and hands the first + * panel's subtree — its `remember`s, its saveable registry, the content + * lambda of the satellite that just left — to whichever panel takes its + * place. On screen the survivor then shows the departed satellite's + * content, and its own body is the one that was destroyed. + * + * Each body publishes the identity of the satellite it was composed for + * plus a `remember` marker; after the undock the survivor has to answer + * with *its* id and *its* marker, and the leaver's body must be gone. + * Found by `SatelliteWorkspaceMonkeyHeadfulCases`, pinned here. + */ + private fun panelsOnOneSideKeepTheirOwnSubtree(): TaoWindowTestCase { + val workspace = SatelliteWorkspace() + // id of the satellite each live panel body was composed for, by the + // marker its own `remember` handed out — so a body reused under + // another satellite shows up as a marker whose id has changed. + val bodies = mutableStateOf>(emptyMap()) + val markers = mutableStateOf>(emptyMap()) + var nextMarker = 0 + + @Composable + fun PanelBody(id: String) { + val marker = remember { nextMarker++ } + SideEffect { + bodies.value = bodies.value + (marker to id) + markers.value = markers.value + (id to marker) + } + DisposableEffect(marker) { + onDispose { bodies.value = bodies.value - marker } + } + Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) + } + return TaoWindowTestCase( + name = "workspace panels sharing a dock side keep their own subtree when one leaves", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { + JoinSatelliteWorkspace(workspace) + DockLayout(workspace, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color.DarkGray)) + } + }, + applicationContent = { + for (id in PANEL_IDS) { + key(id) { + Satellite( + workspace = workspace, + id = id, + title = "Panel $id", + initialPlacement = SatellitePlacement.Docked(DockSide.Right), + ) { PanelBody(id) } + } + } + }, + driver = { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("both panels are composed on the right side") { + PANEL_IDS.all { id -> + val marker = markers.value[id] + marker != null && bodies.value[marker] == id + } + } + settle() + val leaving = PANEL_IDS.first() + val staying = PANEL_IDS.last() + val stayingMarker = requireNotNull(markers.value[staying]) + val leavingMarker = requireNotNull(markers.value[leaving]) + + workspace.undock(leaving) + awaitUntil("$leaving floats") { workspace.satellite(leaving)?.isDocked == false } + settle(SETTLE_AFTER_MAP_MILLIS) + + // The survivor's own body, not the one the leaver was using. + check(bodies.value[stayingMarker] == staying) { + "the panel that stayed lost its body: marker $stayingMarker is now " + + "${bodies.value[stayingMarker]}, live bodies ${bodies.value}" + } + check(bodies.value.values.count { it == staying } == 1) { + "$staying is composed by ${bodies.value.values.count { it == staying }} bodies at once" + } + // And the leaver's panel body is gone, not transplanted. + check(bodies.value[leavingMarker] != staying) { + "the panel that left handed its body to $staying (marker $leavingMarker)" + } + }, + ) + } + + private fun dockAndUndockRoundTrip(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace satellite docks into the owner and lifts off again with its state", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + val parentRect = requireNotNull(bounds()) + val floatingRect = requireNotNull(floating.outerBoundsPx()) + val scale = window.scaleFactor + val expectedLeft = parentRect[0] + parentRect[2] + (GAP_DP * scale).toLong() + check(abs(floatingRect[0] - expectedLeft) <= ANCHOR_TOLERANCE_PX) { + "floating satellite is not anchored to the owner's right edge: " + + "left=${floatingRect[0]} expected=$expectedLeft" + } + + // Marked before the first dock: the document's own state, which + // no dock or undock may reset. + val documentState = requireNotNull(fixture.documentState.value) { "the document published no state" } + documentState.value = DOCUMENT_MARK + + // State the docking must carry over. The registry keeps values in + // memory, so the very same MutableState instance comes back in + // the next host — only its value is asserted on. + requireNotNull(fixture.counter.value).value = SAVED_CLICKS + settle() + + // ── dock ── + var destroyed = false + floating.onDestroyed { destroyed = true } + fixture.workspace.dock(SATELLITE_ID, DockSide.Right) + awaitUntil("floating window destroyed after docking") { destroyed } + awaitUntil("panel composed in the case window") { + fixture.panelHost.value === window && fixture.panelBoundsPx.value != null + } + settle() + val entry = requireNotNull(fixture.workspace.satellite(SATELLITE_ID)) + check(entry.isDocked && entry.dockHost === window) { "entry not docked into the case window" } + // The document itself must not have been rebuilt around the + // new panel: its `remember` — a scroll position in a real app — + // is the same instance with the same value. + check(fixture.documentState.value === documentState) { + "docking the first panel recreated the document's subtree" + } + check(documentState.value == DOCUMENT_MARK) { + "the document lost its state when the panel docked: ${documentState.value}" + } + + val panel = requireNotNull(fixture.panelBoundsPx.value) + val container = requireNotNull(fixture.hostContentSizePx.value) + check(abs(panel.right - container.width) <= LAYOUT_TOLERANCE_PX) { + "panel does not sit on the right edge: panel=$panel container=$container" + } + val expectedExtentPx = SATELLITE_W_DP * scale + check(abs(panel.width - expectedExtentPx) <= LAYOUT_TOLERANCE_PX) { + "dock extent was not seeded from the floating width: ${panel.width} vs $expectedExtentPx" + } + val content = requireNotNull(fixture.contentBoundsPx.value) + check(content.right <= panel.left && content.right > 0f) { + "document content was not narrowed by the docked panel: content=$content panel=$panel" + } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "rememberSaveable state lost when docking: ${fixture.counter.value?.value}" + } + + // ── undock: lifts off where the panel was ── + fixture.workspace.undock(SATELLITE_ID) + awaitUntil("floating window recreated") { + val now = fixture.floatingWindow.value + now != null && now !== floating && now.hasRealFramePx() + } + settle(SETTLE_AFTER_MAP_MILLIS) + val lifted = requireNotNull(requireNotNull(fixture.floatingWindow.value).outerBoundsPx()) + val hostOuter = requireNotNull(bounds()) + val clientX = hostOuter[0] + (hostOuter[2] - container.width) / 2.0 + val clientY = hostOuter[1] + (hostOuter[3] - container.height).toDouble() + // [panel] is the content area below the docked header; the window + // lifts off the whole panel, header included, so its frame starts + // one header height above. + val expectedX = clientX + panel.left + val expectedY = clientY + panel.top - DockPanelHeaderHeight.value * scale + check( + abs(lifted[0] - expectedX) <= LIFT_OFF_TOLERANCE_PX && + abs(lifted[1] - expectedY) <= LIFT_OFF_TOLERANCE_PX, + ) { + "undocked window did not lift off the panel: window=${lifted.toList()} " + + "expected≈($expectedX, $expectedY) host=${hostOuter.toList()} panel=$panel " + + "container=$container placement=${entry.placement}" + } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "rememberSaveable state lost when undocking: ${fixture.counter.value?.value}" + } + check(!entry.isDocked && entry.dockHost == null) { "entry still reads as docked after undock" } + check(fixture.documentState.value === documentState && documentState.value == DOCUMENT_MARK) { + "undocking the last panel recreated the document's subtree" + } + }, + ) + } + + private fun ownerFollowsFocusAndPin(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace owner follows focus between members and pinTo overrides it", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { JoinSatelliteWorkspace(fixture.workspace) }, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + awaitFloating(fixture) + val dialog = requireNotNull(dialogWindow) + awaitUntil("both members joined") { fixture.workspace.members.size == 2 } + + // ── focus picks the owner ── + dialog.focus() + awaitUntil("dialog became the owner") { fixture.workspace.owner === dialog } + awaitFollows(fixture, dialog, "dialog") + + // ── pinning overrides focus ── + fixture.workspace.pinTo(window) + awaitUntil("case window pinned as owner") { fixture.workspace.owner === window } + awaitFollows(fixture, window, "pinned case window") + + fixture.workspace.pinTo(null) + awaitUntil("owner back to the last focused member") { fixture.workspace.owner === dialog } + }, + ) + } + + private fun snapshotRestoresDockedLayout(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace snapshot restores a docked panel and open/visible flags gate the content", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + fixture.workspace.dock(SATELLITE_ID, DockSide.Left) + awaitUntil("panel docked left") { + fixture.panelHost.value === window && fixture.panelBoundsPx.value != null + } + settle() + val panelLeft = requireNotNull(fixture.panelBoundsPx.value) + check(panelLeft.left <= LAYOUT_TOLERANCE_PX) { "panel is not on the left edge: $panelLeft" } + val snapshot = fixture.workspace.snapshot() + + fixture.workspace.undock(SATELLITE_ID) + awaitUntil("floating again") { + val now = fixture.floatingWindow.value + now != null && now !== floating && now.hasRealFramePx() + } + val refloated = requireNotNull(fixture.floatingWindow.value) + var destroyed = false + refloated.onDestroyed { destroyed = true } + + fixture.workspace.restore(snapshot) + awaitUntil("restore docked the satellite again") { + destroyed && fixture.workspace.satellite(SATELLITE_ID)?.isDocked == true + } + awaitUntil("panel back in the case window") { fixture.panelHost.value === window && fixture.isComposed } + + // ── close / open ── + fixture.workspace.close(SATELLITE_ID) + awaitUntil("closed satellite leaves composition") { !fixture.isComposed } + fixture.workspace.open(SATELLITE_ID) + awaitUntil("opened satellite is composed again") { fixture.isComposed } + + // ── master visibility ── + fixture.workspace.visible = false + awaitUntil("hidden workspace leaves composition") { !fixture.isComposed } + fixture.workspace.visible = true + awaitUntil("visible workspace composes again") { fixture.isComposed } + check(fixture.workspace.satellite(SATELLITE_ID)?.isDocked == true) { + "visibility toggling must not change the placement" + } + }, + ) + } + + private fun dockHostDeathRehostsPanel(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val dialogVisible = mutableStateOf(true) + return TaoWindowTestCase( + name = "workspace panel docked into a closing member moves to the next owner", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { + JoinSatelliteWorkspace(fixture.workspace) + DockLayout(fixture.workspace, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color(0xFF3C8D5A))) + } + }, + dialogVisible = dialogVisible, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + awaitFloating(fixture) + val dialog = requireNotNull(dialogWindow) + awaitUntil("both members joined") { fixture.workspace.members.size == 2 } + dialog.focus() + awaitUntil("dialog is the owner") { fixture.workspace.owner === dialog } + + fixture.workspace.dock(SATELLITE_ID, DockSide.Bottom) + awaitUntil("panel docked into the dialog") { fixture.panelHost.value === dialog } + settle() + + var dialogDestroyed = false + dialog.onDestroyed { dialogDestroyed = true } + dialogVisible.value = false + awaitUntil("dialog destroyed") { dialogDestroyed } + awaitUntil("panel rehosted in the case window") { + fixture.workspace.satellite(SATELLITE_ID)?.dockHost === window && fixture.panelHost.value === window + } + check(fixture.workspace.owner === window) { "owner did not fall back to the surviving member" } + }, + ) + } + + private fun headerDragDocksAndLiftsOff(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace header drag docks the floating satellite and drags the panel back out", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + val workspace = fixture.workspace + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + val layout = awaitDockLayout(workspace, window) + + // ── 1. floating header → right zone ── + val outer = requireNotNull(floating.outerBoundsPx()) + val scale = floating.scaleFactor + // Middle of the title bar: clear of the traffic lights, on the header grip. + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * scale) + val dropIn = Offset(layout.right - DROP_INSET_PX, layout.center.y) + val robot = robotPressAndDrag(grab, dropIn, scale) != null + if (robot) { + // Button still down: the zone under the pointer must be + // previewed before the drop — that highlight is the whole + // affordance — and only then is the drop position certain. + awaitUntil("the right zone is previewed while the drag is held — ${robotAim()}") { + workspace.dockPreview == DockTarget(window, DockSide.Right) + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[workspace-drag] robot unavailable, driving the drag session directly") + val session = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) + session.update(Offset(layout.center.x, layout.center.y)) + check(workspace.dockPreview == null) { "the content area must not preview a dock" } + session.update(dropIn) + check(workspace.dockPreview == DockTarget(window, DockSide.Right)) { + "hovering the right zone must preview it: ${workspace.dockPreview}" + } + session.end(dropIn) + } + awaitUntil("satellite docked by the drag") { entry.isDocked && entry.dockHost === window } + awaitUntil("panel composed in the case window") { + fixture.panelHost.value === window && fixture.panelBoundsPx.value != null + } + settle() + check(workspace.dockPreview == null && workspace.dragGhost == null) { "drag feedback left behind" } + check((entry.placement as SatellitePlacement.Docked).side == DockSide.Right) { + "docked on ${entry.placement}, expected the right zone; layout=$layout drop=$dropIn" + } + + // ── 2. panel header → content: lifts off under the pointer ── + val panel = requireNotNull(entry.dockedBoundsInWindowPx) + val client = requireNotNull(workspace.dockHostGeometry(window)?.clientOriginPx()) + val panelGrab = + client + Offset(panel.left + panel.width / 2f, panel.top + HEADER_GRAB_Y_DP * window.scaleFactor) + val dropOut = Offset(layout.center.x, layout.center.y) + if (robot) { + checkNotNull(robotPressAndDrag(panelGrab, dropOut, scale)) { "robot became unavailable mid-case" } + awaitUntil("the torn-out panel is previewed under the pointer") { + workspace.dragGhost?.let { it.satellite === entry && it.screenRectPx.contains(dropOut) } == true + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + val session = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.DockedPanel(window), panelGrab), + ) + session.update(dropOut) + val ghost = requireNotNull(workspace.dragGhost) { "dragging a panel out must show a ghost" } + check(ghost.satellite === entry) { "the ghost must preview the dragged satellite" } + check(ghost.screenRectPx.contains(dropOut)) { + "the ghost must sit under the pointer: ${ghost.screenRectPx} vs $dropOut" + } + session.end(dropOut) + } + awaitUntil("satellite undocked by the drag") { !entry.isDocked } + check(workspace.dragGhost == null) { "the ghost must be gone once the drag ends" } + awaitUntil("floating window recreated") { + val now = fixture.floatingWindow.value + now != null && now !== floating && now.hasRealFramePx() + } + settle(SETTLE_AFTER_MAP_MILLIS) + val lifted = requireNotNull(requireNotNull(fixture.floatingWindow.value).outerBoundsPx()) + // The grab point stays under the pointer: window top-left = drop − grab offset. + val expectedX = dropOut.x - (panelGrab.x - (client.x + panel.left)) + val expectedY = dropOut.y - (panelGrab.y - (client.y + panel.top)) + check( + abs(lifted[0] - expectedX) <= LIFT_OFF_TOLERANCE_PX && + abs(lifted[1] - expectedY) <= LIFT_OFF_TOLERANCE_PX, + ) { + "undocked window did not land under the pointer: window=${lifted.toList()} " + + "expected≈($expectedX, $expectedY)" + } + check(workspace.dockPreview == null && workspace.dragGhost == null) { "drag feedback left behind" } + }, + ) + } + + /** + * The bar above the header strip. It is a few dp tall, it is where a user + * grabs a small palette, and it used to belong to the platform's own + * interactive move — which is a compositor grab, so a satellite dragged + * from there could never dock on release. The whole bar is the workspace + * handle now, and this pins it: a drag started clear of the header strip + * has to dock exactly like one started on the strip. + */ + private fun titleBarDragOutsideTheHeaderStripDocks(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace satellite dragged by its title bar above the header strip still docks", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + val workspace = fixture.workspace + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + val layout = awaitDockLayout(workspace, window) + val outer = requireNotNull(floating.outerBoundsPx()) + val scale = floating.scaleFactor + + // Deliberately above the strip: the header centres itself in the + // bar, so these few dp are the ones the platform used to own. + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + TITLE_BAR_TOP_GRAB_DP * scale) + check(grab.y < outer[1] + HEADER_GRAB_Y_DP * scale) { + "this case has to grab above the strip that ${'$'}HEADER_GRAB_Y_DP dp hits" + } + val dropIn = Offset(layout.right - DROP_INSET_PX, layout.center.y) + + floating.focus() + awaitUntil("floating window is focused") { floating.isFocused } + val robot = robotPressAndDrag(grab, dropIn, scale) != null + if (robot) { + awaitUntil("the right zone is previewed while the drag is held — ${robotAim()}") { + workspace.dockPreview == DockTarget(window, DockSide.Right) + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[title-bar-drag] robot unavailable, nothing to assert") + return@TaoWindowTestCase + } + awaitUntil("the satellite docked from a title-bar drag") { + entry.isDocked && entry.dockHost === window + } + awaitUntil("panel composed in the case window") { fixture.panelHost.value === window } + check((entry.placement as SatellitePlacement.Docked).side == DockSide.Right) { + "docked on ${'$'}{entry.placement}, expected the right zone" + } + check(workspace.dockPreview == null && workspace.dragGhost == null) { "drag feedback left behind" } + }, + ) + } + + /** + * The tools-palette shape: a scrollable column (whose `rememberScrollState` + * saves an `Int`) plus two `rememberSaveable` states, cycled docked → + * floating → docked → other side. Every value must come back where it + * belongs, i.e. the key relocation must never hand one call site another + * site's value. + */ + private fun saveableStateSurvivesRepeatedHostChanges(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val workspace = fixture.workspace + val tool = mutableStateOf?>(null) + val brush = mutableStateOf?>(null) + val composedIn = mutableStateOf(null) + return TaoWindowTestCase( + name = "workspace saveable state keeps every call site's value across repeated host changes", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { + Satellite( + workspace = workspace, + id = SATELLITE_ID, + title = "Palette", + initialPlacement = SatellitePlacement.Docked(DockSide.Left), + ) { + val selected = rememberSaveable { mutableStateOf("Move") } + val size = rememberSaveable { mutableStateOf(12f) } + val window = LocalTaoWindow.current + SideEffect { + tool.value = selected + brush.value = size + composedIn.value = window + if (!isDocked) fixture.floatingWindow.value = window + } + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { + Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) + } + } + }, + driver = { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("palette docked and composed") { composedIn.value === window && tool.value != null } + requireNotNull(tool.value).value = "Brush" + requireNotNull(brush.value).value = 33f + settle() + + fun assertValues(step: String) { + check(tool.value?.value == "Brush") { "$step: tool = ${tool.value?.value}" } + check(brush.value?.value == 33f) { "$step: brush = ${brush.value?.value}" } + } + + workspace.undock(SATELLITE_ID) + awaitUntil("palette floating") { + val w = fixture.floatingWindow.value + w != null && composedIn.value === w && w.hasRealFramePx() + } + settle(SETTLE_AFTER_MAP_MILLIS) + assertValues("after undock") + + workspace.dock(SATELLITE_ID, DockSide.Left) + awaitUntil("palette docked left again") { + composedIn.value === window && workspace.satellite(SATELLITE_ID)?.isDocked == true + } + settle() + assertValues("after re-dock") + + workspace.dock(SATELLITE_ID, DockSide.Right) + awaitUntil("palette moved to the right side") { + (workspace.satellite(SATELLITE_ID)?.placement as? SatellitePlacement.Docked)?.side == DockSide.Right + } + settle() + assertValues("after changing side") + + workspace.undock(SATELLITE_ID) + awaitUntil("palette floating again") { + val w = fixture.floatingWindow.value + w != null && composedIn.value === w && w.hasRealFramePx() + } + settle(SETTLE_AFTER_MAP_MILLIS) + assertValues("after second undock") + }, + ) + } +} + +/** Two satellites sharing one dock side, in declaration order. */ +private val PANEL_IDS = listOf("first", "second") diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceMonkeyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceMonkeyHeadfulCases.kt new file mode 100644 index 000000000..16b0d66b4 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceMonkeyHeadfulCases.kt @@ -0,0 +1,849 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.DecoratedWindow +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.Satellite +import dev.nucleusframework.window.tao.SatelliteDragOrigin +import dev.nucleusframework.window.tao.SatelliteDragSession +import dev.nucleusframework.window.tao.SatelliteEntry +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteScope +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventCode +import dev.nucleusframework.window.tao.TaoWindow +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import java.util.concurrent.ConcurrentLinkedDeque +import kotlin.collections.randomOrNull +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * The monkey: [MONKEY_ACTIONS] random actions on one [SatelliteWorkspace], in + * an order no case would ever write by hand. + * + * Every other workspace case drives a gesture the way a user performs it — + * begin, move, release, assert. That is how the intended behaviour is pinned + * down, and it is also why those cases only ever visit states someone thought + * of. This one draws each step from [MonkeyAction] with a seeded + * [Random], so the interleavings it reaches are the ones nobody wrote down: a + * window closing under a drag that started in another window, a palette docked + * into a host that is being resized while the workspace is hidden, a scale + * change landing between a tear-out and its window. + * + * What it asserts is deliberately not "the right thing happened" — for a random + * sequence there is no such expectation. It asserts that nothing is left + * **orphaned** and nothing **wedges**: + * + * - the workspace never names a window it does not have — no member is a + * destroyed window, no satellite is docked into a non-member, no owner or + * pin points outside the membership; + * - no drag feedback outlives its drag, and no satellite composes in two + * hosts once a step has settled; + * - native windows do not accumulate: the count stays under what the + * declaration can account for at every step, and comes back down to exactly + * the quiesced set at the end; + * - the Tao event loop and `Dispatchers.Main` keep answering each other. That + * one cannot be asserted from the driver — the driver runs *on* the + * dispatcher, so a deadlock stops it too and the case would simply run out + * of time with nothing said. [MainLoopWatchdog] measures it from a thread + * that is not on the loop and dumps every stack the moment a heartbeat goes + * unanswered, which is the whole diagnosis; + * - the workspace still *works* afterwards: the closing phase asks for a + * plain state (visible, nothing docked, one window) and it has to converge. + * + * A native panic cannot be asserted at all — a Rust `panic!` across JNI aborts + * the process, and no Kotlin frame survives to record it. Reaching the end of + * the case *is* the assertion, and the seed printed at the start is what makes + * an abort reproducible. + * + * Every failure carries the seed and the last [JOURNAL_DEPTH] actions, and + * `-Dnucleus.tao.headful.monkeySeed=` replays the *action sequence* + * exactly. It does not replay the run: the state each action lands on depends + * on what the loop and the compositor got done in the milliseconds before it, + * so a red seed usually needs a few attempts — and the journal, not the seed, + * is what identifies the sequence to turn into a case of its own. + */ +internal object SatelliteWorkspaceMonkeyHeadfulCases { + fun all(): List = listOf(randomActionsLeaveNothingBehind()) + + private fun randomActionsLeaveNothingBehind(): TaoWindowTestCase { + val fixture = MonkeyFixture() + return TaoWindowTestCase( + name = "workspace monkey $MONKEY_ACTIONS random actions leave no orphan and no deadlock", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + // Same gate as every other satellite case: without client-side + // screen placement `beginDrag` refuses, and half the actions would + // be no-ops. The Wayland gestures have their own suite. + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.HostBody() }, + applicationContent = { with(fixture) { Windows() } }, + driver = { + fixture.awaitReady(this) + val monkey = Monkey(this, fixture, monkeySeed()) + monkey.run() + monkey.quiesceAndAssert() + }, + ) + } +} + +/** + * One atomic thing the monkey can do. Drawn uniformly, so over + * [MONKEY_ACTIONS] steps each is exercised often enough to interleave with + * every other one — the point of the case is the pairs, not the actions. + * + * Each action is a single call plus a short settle: the monkey deliberately + * does **not** wait for a steady state in between, because the states worth + * finding are the ones a gesture is interrupted in. + */ +private enum class MonkeyAction { + /** Shows a palette that was closed. */ + OpenSatellite, + + /** Hides a palette; its placement and state are kept. */ + CloseSatellite, + + /** Docks a palette on a random side of a random member's dock layout. */ + Dock, + + /** Lifts a docked palette back into a floating window. */ + Undock, + + /** Adds a host window to the workspace (up to [MAX_EXTRA_WINDOWS]). */ + OpenWindow, + + /** Drops a host window from composition — the member leaves as it is destroyed. */ + CloseWindow, + + /** Focuses a member, which moves the owner floating palettes follow. */ + FocusWindow, + + /** Begins a drag of a random open palette from wherever it currently lives. */ + StartDrag, + + /** Feeds the live drag a pointer position: a dock zone, content, far away, or garbage. */ + MoveDrag, + + /** Releases the live drag wherever it last was — docks, re-docks or tears out. */ + EndDrag, + + /** Abandons the live drag the way a cancelled pointer gesture does. */ + CancelDrag, + + /** Maximizes or restores a window; a maximized owner also hides its floating palettes. */ + ToggleMaximize, + + /** Resizes a window to a random inner size, re-laying out whatever it hosts. */ + ResizeRandom, + + /** Injects a scale-factor change, as a display hop does. */ + ChangeDpi, + + /** Flips the workspace-wide visibility sweep, which takes every palette down and back. */ + ToggleVisible, +} + +/** + * The declaration the monkey plays with: one workspace, three palettes and up + * to [MAX_EXTRA_WINDOWS] host windows beside the case window. + * + * The palettes are declared for the whole run and opened / closed through the + * workspace, exactly as an app's View menu does — a palette withdrawn from + * composition would take its [SatelliteEntry] with it and there would be + * nothing left to find orphaned. The host windows are the opposite: they come + * and go from composition, so closing one is a real native destroy with a real + * membership change behind it. + */ +private class MonkeyFixture { + val workspace = SatelliteWorkspace() + + /** Palette ids, declared once for the whole run. */ + val satelliteIds = listOf("tools", "outline", "inspector") + + /** Host windows in composition, by slot. The case window is a member too, and never leaves. */ + private val slots = mutableStateListOf() + private var nextSlot = 0 + + private val hostWindows = mutableStateOf>(emptyMap()) + private val floating = mutableStateOf>(emptyMap()) + private val panelHost = mutableStateOf>(emptyMap()) + + /** + * Which hosts are composing each palette, as `role@windowHandle#n`. + * + * A count would say "two hosts" and leave the interesting half out: what + * matters when a palette is composed twice is *which* windows they are — + * the same one twice is a bookkeeping mistake here, two different ones is + * a composition the framework failed to dispose. + */ + private val liveHosts = mutableStateOf>>(emptyMap()) + private var nextIncarnation = 0 + + /** Host windows currently declared — not the same thing while one is being destroyed. */ + val declaredWindows: Int get() = slots.size + + /** The floating window of the palette [id], or `null` while it has none. */ + fun floatingWindow(id: String): TaoWindow? = floating.value[id] + + /** + * How many hosts are composing the palette [id] right now. Exactly one for + * an open palette; two only for the frame in which a dock or an undock + * hands it from one host to the next. + */ + fun composedHostCount(id: String): Int = liveHosts.value[id]?.size ?: 0 + + /** The hosts composing the palette [id], for a failure report. */ + fun composedHostsOf(id: String): List = liveHosts.value[id].orEmpty() + + /** Declares one more host window; `false` when the ceiling is already reached. */ + fun openWindow(): Boolean { + if (slots.size >= MAX_EXTRA_WINDOWS) return false + slots += nextSlot++ + return true + } + + /** Drops a random host window from composition; `false` when there is none. */ + fun closeWindow(random: Random): Boolean { + if (slots.isEmpty()) return false + slots.removeAt(random.nextInt(slots.size)) + return true + } + + /** Drops every host window, leaving the case window as the only member. */ + fun closeEveryWindow() { + slots.clear() + } + + @Composable + fun ApplicationScope.Windows() { + for (slot in slots) { + key(slot) { MonkeyHostWindow(slot) } + } + for (id in satelliteIds) { + key(id) { MonkeyPalette(id) } + } + } + + /** What every member window hosts: the workspace membership and a dock layout to drop into. */ + @Composable + fun HostBody() { + JoinSatelliteWorkspace(workspace) + DockLayout(workspace, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color.DarkGray)) + } + } + + /** A host window the monkey can destroy, offset from the others so they do not fully overlap. */ + @Composable + private fun ApplicationScope.MonkeyHostWindow(slot: Int) { + val lane = slot % MAX_EXTRA_WINDOWS + val state = + rememberWindowState( + position = + WindowPosition.Absolute( + (EXTRA_X_DP + lane * EXTRA_STEP_DP).dp, + (EXTRA_Y_DP + lane * EXTRA_STEP_DP).dp, + ), + size = DpSize(EXTRA_W_DP.dp, EXTRA_H_DP.dp), + ) + DecoratedWindow( + onCloseRequest = { /* the monkey owns the lifecycle */ }, + state = state, + title = "tao-headful-monkey host $slot", + ) { + HostBody() + val host = window + DisposableEffect(host) { + hostWindows.value = hostWindows.value + (slot to host) + onDispose { + if (hostWindows.value[slot] === host) hostWindows.value = hostWindows.value - slot + } + } + } + } + + @Composable + private fun ApplicationScope.MonkeyPalette(id: String) { + Satellite( + workspace = workspace, + id = id, + title = "Palette $id", + initialPlacement = + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ) { + PaletteBody(id) + } + } + + /** + * Publishes which window is composing the palette, and how many are. + * Keyed on both the host role and the window, so a palette moved from one + * window's dock straight into another's is counted as two hosts for the + * frame in which it is. + */ + @Composable + private fun SatelliteScope.PaletteBody(id: String) { + val host = LocalTaoWindow.current + val docked = isDocked + val label = + remember(docked, host) { + "${if (docked) "docked" else "floating"}@${host?.handle?.toString(HEX) ?: "none"}#${nextIncarnation++}" + } + SideEffect { + if (host == null) return@SideEffect + if (docked) { + panelHost.value = panelHost.value + (id to host) + } else { + floating.value = floating.value + (id to host) + } + } + DisposableEffect(label) { + liveHosts.value = liveHosts.value + (id to (liveHosts.value[id].orEmpty() + label)) + onDispose { + liveHosts.value = liveHosts.value + (id to (liveHosts.value[id].orEmpty() - label)) + if (docked) { + if (panelHost.value[id] === host) panelHost.value = panelHost.value - id + } else if (floating.value[id] === host) { + floating.value = floating.value - id + } + } + } + Box(Modifier.fillMaxSize().background(Color(PALETTE_ARGB))) + } + + /** Waits until the case window, its dock layout and all three palettes are up. */ + suspend fun awaitReady(scope: TaoWindowTestScope) { + with(scope) { + awaitUntil("the case window is mapped") { bounds() != null } + awaitUntil("it joined the workspace") { workspace.members.isNotEmpty() } + awaitUntil("every palette is declared") { satelliteIds.all { workspace.satellite(it) != null } } + awaitUntil("every palette floats with a real size") { + satelliteIds.all { id -> + val rect = floating.value[id]?.outerBoundsPx() + rect != null && rect[RECT_W] > 0L && rect[RECT_H] > 0L + } + } + awaitUntil("the dock layout published its geometry") { + workspace.dockHostGeometry(window)?.layoutScreenRectPx() != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + } + } +} + +/** + * The run itself: draws actions, applies them under a short budget, and checks + * after every one of them that the workspace still describes something that + * exists. + */ +private class Monkey( + private val scope: TaoWindowTestScope, + private val fixture: MonkeyFixture, + private val seed: Long, +) { + private val random = Random(seed) + + /** + * The last [JOURNAL_DEPTH] actions, newest last — the only thing that makes + * a random failure readable. Concurrent because [MainLoopWatchdog] prints + * it from its own thread, precisely when the main thread is not answering. + */ + private val journal = ConcurrentLinkedDeque() + + private val workspace get() = fixture.workspace + + private var drag: SatelliteDragSession? = null + private var lastDragPoint = Offset.Zero + private var step = 0 + private var worstStallMillis = 0L + + /** + * What the run actually reached, printed when it ends. A monkey that + * refuses every drag and never opens a window still passes every + * invariant, so a green run has to say what it did — otherwise the case + * silently stops testing anything the day a guard starts rejecting early. + */ + private val reached = mutableMapOf() + + suspend fun run() { + System.err.println( + "[monkey] seed=$seed actions=$MONKEY_ACTIONS " + + "(replay with -D$MONKEY_SEED_PROPERTY=$seed)", + ) + val watchdog = MainLoopWatchdog("satellite-monkey", ::journalReport).start() + try { + while (step < MONKEY_ACTIONS) { + val action = MonkeyAction.entries[random.nextInt(MonkeyAction.entries.size)] + record(action) + perform(action) + checkStepInvariants() + if ((step + 1) % CHECKPOINT_EVERY == 0) checkpoint() + step++ + } + } finally { + worstStallMillis = watchdog.stop() + } + } + + /** + * Puts the desktop back to a plain state and requires that it converges + * there. A workspace that survived the storm but can no longer be brought + * back to one visible window with three floating palettes is exactly as + * broken as one that failed mid-run — it just fails later, in the app. + */ + suspend fun quiesceAndAssert() { + cancelDrag() + workspace.visible = true + for (target in everyWindow()) { + target.setMaximized(false) + // Undo whatever fake scale the monkey injected: the scene's density + // is a listener away from the native value, and the geometry checks + // below read the real frames. + target.dispatch(TaoEventCode.SCALE_FACTOR_CHANGED, (target.scaleFactor * SCALE_MILLI).roundToInt(), 0) + } + scope.window.setInnerSize(PARENT_W_DP.toDouble(), PARENT_H_DP.toDouble()) + fixture.closeEveryWindow() + for (id in fixture.satelliteIds) { + workspace.undock(id) + workspace.open(id) + } + scope.settle(SETTLE_AFTER_MAP_MILLIS) + + awaitConverges("the workspace is down to the case window") { + workspace.members == listOf(scope.window) + } + awaitConverges("no drag feedback is left behind") { + workspace.draggedSatellite == null && workspace.dragGhost == null && workspace.dockPreview == null + } + awaitConverges("every palette floats again with a real size") { + fixture.satelliteIds.all { id -> + val rect = fixture.floatingWindow(id)?.outerBoundsPx() + rect != null && rect[RECT_W] > 0L && rect[RECT_H] > 0L + } + } + awaitConverges("exactly one host composes each palette") { + fixture.satelliteIds.all { fixture.composedHostCount(it) == 1 } + } + val quiesced = 1 + fixture.satelliteIds.size + awaitConverges("the run leaked no window (expected $quiesced)") { + TaoApplication.liveWindowCount() <= quiesced + } + for (entry in workspace.satellites) { + if (entry.dockHost != null) fail("${entry.id} still names a dock host while floating") + } + + System.err.println( + "[monkey] seed=$seed survived $MONKEY_ACTIONS actions; " + + "worst main-dispatcher round trip ${worstStallMillis}ms; " + + "reached ${reached.toSortedMap()}", + ) + if (worstStallMillis > MONKEY_MAX_STALL_MILLIS) { + fail("the main dispatcher took ${worstStallMillis}ms to answer a heartbeat — the loop stalled") + } + // A degenerate run passes every invariant above without having tested + // anything: if a guard starts refusing early, this is what notices. + val drags = (reached["dragFromWindow"] ?: 0) + (reached["dragFromPanel"] ?: 0) + if (drags == 0) fail("no drag ever began — the run exercised none of the gestures") + if ((reached["windowOpened"] ?: 0) == 0) fail("no host window ever opened") + if ((reached["windowClosed"] ?: 0) == 0) fail("no host window ever closed") + } + + // ── applying one action ────────────────────────────────────────────── + + /** + * The short watchdog: an action is a handful of calls and a 25 ms settle, + * so anything that does not come back inside [ACTION_BUDGET_MILLIS] has + * wedged — and saying *which* action did is worth far more than the case's + * own deadline firing minutes later. + */ + private suspend fun perform(action: MonkeyAction) { + try { + withTimeout(ACTION_BUDGET_MILLIS) { apply(action) } + } catch (timeout: TimeoutCancellationException) { + throw IllegalStateException(report("$action never returned (budget ${ACTION_BUDGET_MILLIS}ms)"), timeout) + } + } + + private suspend fun apply(action: MonkeyAction) { + when (action) { + MonkeyAction.OpenSatellite -> workspace.open(randomSatelliteId()) + MonkeyAction.CloseSatellite -> workspace.close(randomSatelliteId()) + MonkeyAction.Dock -> workspace.dock(randomSatelliteId(), randomSide(), host = randomMember()) + MonkeyAction.Undock -> workspace.undock(randomSatelliteId()) + MonkeyAction.OpenWindow -> if (fixture.openWindow()) reach("windowOpened") + MonkeyAction.CloseWindow -> if (fixture.closeWindow(random)) reach("windowClosed") + MonkeyAction.FocusWindow -> randomMember()?.focus() + MonkeyAction.StartDrag -> startDrag() + MonkeyAction.MoveDrag -> moveDrag() + MonkeyAction.EndDrag -> endDrag() + MonkeyAction.CancelDrag -> cancelDrag() + MonkeyAction.ToggleMaximize -> randomWindow()?.let { it.setMaximized(!it.isMaximized) } + MonkeyAction.ResizeRandom -> resizeRandom() + MonkeyAction.ChangeDpi -> changeDpi() + MonkeyAction.ToggleVisible -> workspace.visible = !workspace.visible + } + scope.settle(STEP_SETTLE_MILLIS) + } + + private fun startDrag() { + val entry = workspace.satellites.filter { it.isOpen }.randomOrNull(random) ?: return + val origin = originOf(entry) ?: return reach("dragWithoutAHost") + val grab = grabPointOf(entry, origin) ?: return reach("dragWithoutGeometry") + // `null` when the origin has no geometry yet — a legitimate refusal, + // and the next MoveDrag simply has nothing to feed. + drag = workspace.beginDrag(entry.id, origin, grab) + lastDragPoint = grab + reach( + when { + drag == null -> "dragRefused" + entry.isDocked -> "dragFromPanel" + else -> "dragFromWindow" + }, + ) + } + + private fun moveDrag() { + val session = drag ?: return + val point = randomDragPoint() + session.update(point) + if (point.x.isFinite() && point.y.isFinite()) lastDragPoint = point + } + + private fun endDrag() { + val session = drag ?: return + drag = null + reach(if (workspace.dockPreview != null) "dropInAZone" else "dropOutsideEveryZone") + session.end(lastDragPoint) + } + + private fun cancelDrag() { + val session = drag ?: return + drag = null + reach("dragCancelled") + session.cancel() + } + + private fun resizeRandom() { + val target = randomWindow() ?: return + target.setInnerSize( + MIN_INNER_W_DP + random.nextDouble(INNER_W_SPAN_DP), + MIN_INNER_H_DP + random.nextDouble(INNER_H_SPAN_DP), + ) + } + + /** + * The Kotlin seam of a display hop: the loop reports a new scale with no + * resize of its own. Inert on the GTK host, which re-derives the live scale + * from the window — the action still costs nothing there and the other two + * platforms take it. + */ + private fun changeDpi() { + val target = randomWindow() ?: return + val scale = SCALE_HOPS[random.nextInt(SCALE_HOPS.size)] + target.dispatch(TaoEventCode.SCALE_FACTOR_CHANGED, (scale * SCALE_MILLI).roundToInt(), 0) + } + + // ── what the monkey aims at ────────────────────────────────────────── + + private fun randomSatelliteId(): String = fixture.satelliteIds[random.nextInt(fixture.satelliteIds.size)] + + private fun randomSide(): DockSide = DockSide.entries[random.nextInt(DockSide.entries.size)] + + private fun randomMember(): TaoWindow? = workspace.members.randomOrNull(random) + + /** Any window the monkey may abuse: the members plus the floating palettes. */ + private fun everyWindow(): List = + workspace.members + fixture.satelliteIds.mapNotNull { fixture.floatingWindow(it) } + + private fun randomWindow(): TaoWindow? = everyWindow().randomOrNull(random) + + private fun originOf(entry: SatelliteEntry): SatelliteDragOrigin? = + if (entry.isDocked) { + entry.dockHost?.let { SatelliteDragOrigin.DockedPanel(it) } + } else { + fixture.floatingWindow(entry.id)?.let { SatelliteDragOrigin.FloatingWindow(it) } + } + + /** Where the gesture would have been grabbed: the header strip of whichever host holds it. */ + private fun grabPointOf( + entry: SatelliteEntry, + origin: SatelliteDragOrigin, + ): Offset? = + when (origin) { + is SatelliteDragOrigin.FloatingWindow -> { + val outer = origin.window.outerBoundsPx() + outer?.let { + Offset( + it[0] + it[RECT_W] / 2f, + it[1] + HEADER_GRAB_Y_DP * origin.window.scaleFactor, + ) + } + } + is SatelliteDragOrigin.DockedPanel -> { + val client = workspace.dockHostGeometry(origin.host)?.clientOriginPx() + val panel = entry.dockedBoundsInWindowPx + if (client == null || panel == null) { + null + } else { + client + panel.topLeft + Offset(GRAB_INSET_PX, GRAB_INSET_PX) + } + } + } + + /** + * A pointer position for the live drag. Half of these are somewhere a user + * could plausibly aim; the rest are what a synthetic event source, a + * coalesced flick or a display unplug actually hands over — a point on no + * screen at all, or one that is not a number. + */ + private fun randomDragPoint(): Offset { + val host = randomMember() + val layout = workspace.dockHostGeometry(host)?.layoutScreenRectPx() + return when (random.nextInt(DRAG_POINT_KINDS)) { + 0 -> + layout?.let { + when (randomSide()) { + DockSide.Left -> Offset(it.left + DROP_INSET_PX, it.center.y) + DockSide.Right -> Offset(it.right - DROP_INSET_PX, it.center.y) + DockSide.Top -> Offset(it.center.x, it.top + DROP_INSET_PX) + DockSide.Bottom -> Offset(it.center.x, it.bottom - DROP_INSET_PX) + } + } ?: farPoint() + 1 -> layout?.center ?: farPoint() + 2 -> farPoint() + 3 -> + Offset( + random.nextFloat() * DESKTOP_SPAN_PX - DESKTOP_SPAN_PX / 2f, + random.nextFloat() * DESKTOP_SPAN_PX - DESKTOP_SPAN_PX / 2f, + ) + else -> Offset(Float.NaN, Float.NaN) + } + } + + /** Clear of every dock layout, so a drop there can only mean "tear out". */ + private fun farPoint(): Offset { + val outer = scope.bounds() ?: return Offset(DROP_FAR_PX, DROP_FAR_PX) + return Offset(outer[0] + outer[RECT_W] + DROP_FAR_PX, outer[1] + DROP_INSET_PX) + } + + // ── invariants ─────────────────────────────────────────────────────── + + /** + * The checks that hold at every instant, whatever is in flight. All of + * them are about the workspace describing something that exists: a member + * list with no duplicate and no stranger in it, a dock host that is a + * member, drag feedback only while a drag runs, and no more native windows + * than the declaration can account for. + */ + private suspend fun checkStepInvariants() { + val members = workspace.members + if (members.distinct().size != members.size) fail("a window is a member twice: $members") + if (scope.window !in members) fail("the case window is no longer a member of its own workspace") + workspace.owner?.let { if (it !in members) fail("the owner is not a member") } + workspace.pinnedOwner?.let { if (it !in members) fail("the pinned owner is not a member") } + + for (entry in workspace.satellites) { + val host = entry.dockHost + if (host != null && host !in members) fail("${entry.id} is docked into a window that is not a member") + if (entry.isDocked && host == null) fail("${entry.id} is docked into nothing") + val hosts = fixture.composedHostCount(entry.id) + if (hosts < 0) fail("${entry.id} has a negative host count — a disposal ran twice") + // A dock hand-off overlaps two hosts for a frame, and a palette + // moved twice in as many frames can chain them — so more than two + // is not a failure by itself, a hand-off that never finishes is. + // Only the excess is paid for: the common case costs one read. + if (hosts > MAX_COMPOSED_HOSTS) { + awaitConverges("${entry.id} composes in $hosts hosts and does not come back to one") { + fixture.composedHostCount(entry.id) <= 1 + } + } + } + + if (workspace.draggedSatellite == null) { + workspace.dockPreview?.let { fail("a dock zone is previewed with no drag in flight: $it") } + if (workspace.dragGhost != null) fail("a drag ghost outlived its drag") + } + + val live = TaoApplication.liveWindowCount() + if (live > windowCeiling()) fail("$live native windows are alive, more than the ceiling ${windowCeiling()}") + } + + /** + * What the declaration can account for at once: the case window, the host + * windows, one floating window per palette and a drag ghost — plus a small + * slack, because a window that has just been dropped from composition is + * still counted until the platform confirms its destroy. + */ + private fun windowCeiling(): Int = + 1 + MAX_EXTRA_WINDOWS + fixture.satelliteIds.size + GHOST_WINDOWS + TEARDOWN_SLACK + + /** + * The checks that only hold once the dust of a step has settled, run every + * [CHECKPOINT_EVERY] actions. A member is removed as its window's + * `JoinSatelliteWorkspace` is disposed, one frame after the destroy, so + * "every member is a live window" is a *converging* invariant — asserted + * instantly it would fail on a window the monkey closed a millisecond ago. + */ + private suspend fun checkpoint() { + awaitConverges("every member is a live window") { + workspace.members.all { TaoApplication.lookup(it.handle) === it } + } + awaitConverges("no palette composes in two hosts") { + fixture.satelliteIds.all { fixture.composedHostCount(it) <= 1 } + } + awaitConverges("no more windows than the declaration accounts for") { + TaoApplication.liveWindowCount() <= 1 + fixture.declaredWindows + fixture.satelliteIds.size + } + } + + private suspend fun awaitConverges( + description: String, + predicate: () -> Boolean, + ) { + val deadline = System.currentTimeMillis() + CONVERGE_MILLIS + while (!predicate()) { + if (System.currentTimeMillis() >= deadline) { + fail("$description did not hold within ${CONVERGE_MILLIS}ms") + } + scope.settle(CONVERGE_POLL_MILLIS) + } + } + + // ── reporting ──────────────────────────────────────────────────────── + + private fun reach(what: String) { + reached[what] = (reached[what] ?: 0) + 1 + } + + private fun record(action: MonkeyAction) { + if (journal.size >= JOURNAL_DEPTH) journal.pollFirst() + journal.addLast("$step $action") + } + + private fun fail(reason: String): Nothing = error(report(reason)) + + private fun report(reason: String): String = + buildString { + appendLine("monkey failed at step $step: $reason") + appendLine(" seed: $seed (replay with -D$MONKEY_SEED_PROPERTY=$seed)") + appendLine(" workspace: ${describe()}") + append(journalReport()) + } + + /** Only the journal, the seed and the step: safe to read from another thread. */ + private fun journalReport(): String = + buildString { + appendLine(" monkey seed $seed, at step $step, last ${journal.size} actions:") + for (entry in journal) appendLine(" $entry") + } + + private fun describe(): String = + "members=${workspace.members.size} hostWindows=${fixture.declaredWindows} " + + "owner=${workspace.owner?.handle?.toString(HEX)}" + + "/maximized=${workspace.owner?.isMaximized}/fullscreen=${workspace.owner?.isFullscreen} " + + "live=${TaoApplication.liveWindowCount()} visible=${workspace.visible} " + + "dragging=${workspace.draggedSatellite?.id} preview=${workspace.dockPreview} " + + workspace.satellites.joinToString(prefix = "satellites=[", postfix = "]") { entry -> + val placement = entry.placement + val where = + if (placement is SatellitePlacement.Docked) "docked(${placement.side})" else "floating" + "${entry.id}:${if (entry.isOpen) "open" else "closed"}/$where" + + "/dockHost=${entry.dockHost?.handle?.toString(HEX)}" + + "/hiddenByOwner=${entry.windowState.isHiddenByParent}" + + "/hosts=${fixture.composedHostsOf(entry.id)}" + } +} + +/** Enough actions to interleave every pair of them, few enough to stay inside a CI budget. */ +private const val MONKEY_ACTIONS = 200 + +/** The whole run plus its quiesce; a starved CI runner needs the headroom. */ +private const val MONKEY_CASE_TIMEOUT_MILLIS = 240_000L + +/** + * The short watchdog around a single action. An action is a handful of calls + * and a settle, so this is orders of magnitude of slack — anything that + * exceeds it is stuck, not slow. + */ +private const val ACTION_BUDGET_MILLIS = 5_000L + +/** Long enough for the loop to deliver a frame, short enough to stay a storm. */ +private const val STEP_SETTLE_MILLIS = 25L + +private const val CHECKPOINT_EVERY = 25 +private const val CONVERGE_MILLIS = 5_000L +private const val CONVERGE_POLL_MILLIS = 50L +private const val JOURNAL_DEPTH = 40 + +/** Host windows beside the case window. Two is enough for every hand-off to have somewhere to go. */ +private const val MAX_EXTRA_WINDOWS = 2 + +/** A drag publishes at most one ghost window. */ +private const val GHOST_WINDOWS = 1 + +/** Windows dropped from composition are counted until the platform confirms the destroy. */ +private const val TEARDOWN_SLACK = 3 + +/** + * Two hosts overlap for the frame in which a dock or an undock hands a palette + * over. Beyond that the hand-off is asked to finish rather than failed outright + * — a palette moved twice in as many frames can legitimately chain two of them. + */ +private const val MAX_COMPOSED_HOSTS = 2 + +/** Scale factors a display hop can report. */ +private val SCALE_HOPS = floatArrayOf(1f, 1.25f, 1.5f, 2f) + +/** [TaoEventCode.SCALE_FACTOR_CHANGED] ships the scale as milli-units. */ +private const val SCALE_MILLI = 1000 + +private const val EXTRA_W_DP = 420 +private const val EXTRA_H_DP = 300 +private const val EXTRA_X_DP = 660 +private const val EXTRA_Y_DP = 130 +private const val EXTRA_STEP_DP = 48 + +private const val MIN_INNER_W_DP = 260.0 +private const val INNER_W_SPAN_DP = 420.0 +private const val MIN_INNER_H_DP = 200.0 +private const val INNER_H_SPAN_DP = 300.0 + +/** Kinds of pointer position [Monkey.randomDragPoint] draws from. */ +private const val DRAG_POINT_KINDS = 5 + +/** Wider than any desktop this runs on, so a quarter of the samples land on no screen. */ +private const val DESKTOP_SPAN_PX = 8_000f + +private const val PALETTE_ARGB = 0xFF7A5CD6 + +/** Window handles read better in hex — that is how every other log prints them. */ +private const val HEX = 16 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceStressHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceStressHeadfulCases.kt new file mode 100644 index 000000000..92a363682 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/SatelliteWorkspaceStressHeadfulCases.kt @@ -0,0 +1,414 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.SatelliteDragOrigin +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.TaoApplication +import kotlin.math.abs + +/** + * The satellite workspace under abuse: everything a user or a synthetic event + * source can do that a well-behaved gesture never does. + * + * 1. a pointer that teleports across and off the screen, and hands over + * unusable coordinates; + * 2. a gesture interrupted rather than finished — the host resized under it, + * the session abandoned — which must leave no preview behind; + * 3. dock / undock churn, which creates and destroys a real window each time; + * 4. a real mouse flick, where the OS coalesces the path into a few enormous + * deltas; + * 5. overlapping drags, a dock host closing mid-gesture, and the workspace + * hidden while a drag is live. + */ +internal object SatelliteWorkspaceStressHeadfulCases { + fun all(): List = + listOf( + abruptDragJumpsStillResolve(), + interruptedDragLeavesNoFeedback(), + dockChurnLeaksNoWindows(), + robotFlickDocksTheSatellite(), + overlappingDragsAndClosuresStaySane(), + ) + + /** + * A pointer that teleports: no intermediate samples, jumps far off-screen + * and back, crossing zones without ever hovering the space between them. + * A synthetic replay does this, and so does a fast flick on a real mouse — + * the OS coalesces motion, and what arrives is one enormous delta. + * + * Driven through the drag session rather than the Robot: the Robot cannot + * express "no samples in between" (the OS interpolates), and it is exactly + * the missing samples that this pins down. + */ + private fun abruptDragJumpsStillResolve(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace drag survives pointer jumps across and off the screen", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + val workspace = fixture.workspace + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + val layout = awaitDockLayout(workspace, window) + val outer = requireNotNull(floating.outerBoundsPx()) + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * window.scaleFactor) + val session = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) + + // Teleports, in one sample each: far negative, far positive, + // then straight onto opposite zones with nothing in between. + val jumps = + listOf( + Offset(-50_000f, -50_000f), + Offset(layout.left + DROP_INSET_PX, layout.center.y), + Offset(200_000f, 200_000f), + Offset(layout.right - DROP_INSET_PX, layout.center.y), + Offset(Float.NaN, Float.NaN), + ) + for (jump in jumps) { + session.update(jump) + settle(JUMP_SETTLE_MILLIS) + val bounds = requireNotNull(floating.outerBoundsPx()) { "the satellite window was lost at $jump" } + check(bounds[2] > 0 && bounds[3] > 0) { "satellite has no size after jumping to $jump" } + } + // The garbage sample left the last real one standing. + check(workspace.dockPreview == DockTarget(window, DockSide.Right)) { + "the right zone must still be previewed, got ${workspace.dockPreview}" + } + + session.end(Offset(layout.right - DROP_INSET_PX, layout.center.y)) + awaitUntil("docked right after the jumps") { + (entry.placement as? SatellitePlacement.Docked)?.side == DockSide.Right + } + awaitUntil("panel composed") { fixture.panelHost.value === window } + check(workspace.draggedSatellite == null && workspace.dragGhost == null) { + "drag feedback outlived the jumps" + } + }, + ) + } + + /** + * A gesture interrupted instead of finished. Resizing the host window + * re-keys the pointer input the drag runs in, so neither the release nor + * the cancel branch of the handle is reached — without the cleanup the + * zone hints and the ghost would stay on screen for the rest of the + * session. Here the interruption is made explicit by dropping the session + * on the floor after a resize, exactly as the cancelled coroutine does. + */ + private fun interruptedDragLeavesNoFeedback(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace drag interrupted by a resize leaves no preview behind", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + val workspace = fixture.workspace + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + val layout = awaitDockLayout(workspace, window) + val outer = requireNotNull(floating.outerBoundsPx()) + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * window.scaleFactor) + val session = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) + session.update(Offset(layout.right - DROP_INSET_PX, layout.center.y)) + check(workspace.draggedSatellite === entry) { "the drag must be published while it runs" } + + // The window resizes under the gesture, then the gesture is + // abandoned — the pointer input that owned it is gone. + window.setInnerSize(RESIZED_W_DP, RESIZED_H_DP) + awaitUntil("window resized") { + val now = bounds() ?: return@awaitUntil false + abs(now[2] - (RESIZED_W_DP * window.scaleFactor).toLong()) <= RESIZE_TOLERANCE_PX + } + session.cancel() + + check(workspace.draggedSatellite == null) { "the drag is still published after the interruption" } + check(workspace.dockPreview == null) { "a dock zone is still highlighted" } + check(workspace.dragGhost == null) { "the ghost is still on screen" } + check(!entry.isDocked) { "an interrupted drag must not dock anything" } + + // And the workspace still takes a new drag afterwards. + val next = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) { "the workspace refuses a new drag after an interrupted one" } + val liveLayout = awaitDockLayout(workspace, window) + next.update(Offset(liveLayout.right - DROP_INSET_PX, liveLayout.center.y)) + next.end(Offset(liveLayout.right - DROP_INSET_PX, liveLayout.center.y)) + awaitUntil("the new drag docked the satellite") { entry.isDocked } + }, + ) + } + + /** + * Docking and undocking as fast as the event loop allows. Each undock + * creates a real window and each dock destroys one, so a mistake here + * leaks native windows or strands the satellite between hosts. + */ + private fun dockChurnLeaksNoWindows(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace dock and undock churn leaks no windows and keeps the state", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + awaitFloating(fixture) + val workspace = fixture.workspace + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + requireNotNull(fixture.counter.value).value = SAVED_CLICKS + settle() + val baselineWindows = TaoApplication.liveWindowCount() + + val sides = DockSide.entries + repeat(CHURN_CYCLES) { index -> + val side = sides[index % sides.size] + workspace.dock(SATELLITE_ID, side) + awaitUntil("panel docked on $side") { + (entry.placement as? SatellitePlacement.Docked)?.side == side && + fixture.panelHost.value === window + } + workspace.undock(SATELLITE_ID) + awaitUntil("floating again after $side") { + !entry.isDocked && + ( + fixture.floatingWindow.value + ?.outerBoundsPx() + ?.get(2) ?: 0L + ) > 0L + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + + val windowsNow = TaoApplication.liveWindowCount() + check(windowsNow <= baselineWindows) { + "churn leaked windows: $baselineWindows before, $windowsNow after" + } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "state lost during the churn: ${fixture.counter.value?.value}" + } + check(workspace.draggedSatellite == null && workspace.dragGhost == null) { + "churn left drag feedback behind" + } + }, + ) + } + + /** + * A real mouse flick: press, three moves issued back to back with no delay + * at all, release. The OS coalesces them, so what the window sees is two + * or three enormous deltas rather than a path — the same shape as a user + * throwing a palette at a screen edge. + */ + private fun robotFlickDocksTheSatellite(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace satellite flicked into a zone with a real mouse docks there", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + val workspace = fixture.workspace + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + val layout = awaitDockLayout(workspace, window) + val outer = requireNotNull(floating.outerBoundsPx()) + val scale = floating.scaleFactor + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * scale) + val drop = Offset(layout.left + DROP_INSET_PX, layout.center.y) + + floating.focus() + awaitUntil("floating window is focused") { floating.isFocused } + val flicked = + robotPressAndDrag(grab, drop, scale, steps = FLICK_STEPS, stepDelayMillis = 0L) + if (flicked == null) { + System.err.println("[workspace-flick] robot unavailable — skipping the real-mouse half") + return@TaoWindowTestCase + } + awaitUntil("left zone previewed after the flick — ${robotAim()}") { + workspace.dockPreview == DockTarget(window, DockSide.Left) + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + awaitUntil("docked left by the flick") { + (entry.placement as? SatellitePlacement.Docked)?.side == DockSide.Left + } + awaitUntil("panel composed after the flick") { fixture.panelHost.value === window } + check(workspace.draggedSatellite == null && workspace.dragGhost == null) { + "the flick left drag feedback behind" + } + }, + ) + } + + /** + * Everything happening at once: two drags in flight over the same + * workspace, the dock host closing under one of them, and the master + * visibility flag toggled while a gesture is live. Each of these on its + * own is an interleaving the drag sessions have to survive; together they + * are the worst frame this API can be handed. + */ + @Suppress("LongMethod") + private fun overlappingDragsAndClosuresStaySane(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val dialogVisible = mutableStateOf(true) + return TaoWindowTestCase( + name = "workspace survives overlapping drags, a closing host and a visibility toggle", + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { + JoinSatelliteWorkspace(fixture.workspace) + DockLayout(fixture.workspace, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color(0xFF3C8D5A))) + } + }, + dialogVisible = dialogVisible, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val floating = awaitFloating(fixture) + val workspace = fixture.workspace + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + awaitUntil("both members joined and layout published") { + workspace.members.size == 2 && + workspace.dockHostGeometry(window)?.layoutScreenRectPx() != null + } + val dialog = requireNotNull(dialogWindow) + val layout = awaitDockLayout(workspace, window) + // The dialog is a dock host of its own, and a drop is answered + // by the topmost layout under the pointer. Its default + // placement centres it over the parent, so on a display small + // enough for the two to overlap it sits astride the very zone + // these drags aim at and previews *its* edge — this case is + // about two drags racing, not about which window is under + // them. Park it off the parent's right edge first. + val parked = requireNotNull(window.outerBoundsPx()) + dialog.setOuterPositionPx( + (parked[0] + parked[RECT_W] + DIALOG_PARK_GAP_PX).toInt(), + parked[1].toInt(), + ) + val dropPoints = + listOf( + Offset(layout.left + DROP_INSET_PX, layout.center.y), + Offset(layout.right - DROP_INSET_PX, layout.center.y), + ) + awaitUntil("the dialog is parked clear of the zones the drags aim at") { + val elsewhere = workspace.dockHostGeometry(dialog)?.layoutScreenRectPx() ?: return@awaitUntil false + dropPoints.none { elsewhere.contains(it) } + } + val outer = requireNotNull(floating.outerBoundsPx()) + val grab = Offset(outer[0] + outer[2] / 2f, outer[1] + HEADER_GRAB_Y_DP * window.scaleFactor) + + // ── 1. two sessions in flight: the second wins, the first is inert ── + val first = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) + first.update(dropPoints[0]) + val second = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.FloatingWindow(floating), grab), + ) + second.update(dropPoints[1]) + first.end(dropPoints[0]) + check(!entry.isDocked) { "the superseded drag docked the satellite" } + check(workspace.dockPreview == DockTarget(window, DockSide.Right)) { + "the superseded drag stole the live preview: ${workspace.dockPreview}" + } + second.end(dropPoints[1]) + awaitUntil("docked right by the surviving drag") { + (entry.placement as? SatellitePlacement.Docked)?.side == DockSide.Right + } + + // ── 2. dock into the dialog, then drag it while the dialog closes ── + dialog.focus() + awaitUntil("dialog is the owner") { workspace.owner === dialog } + workspace.dock(SATELLITE_ID, DockSide.Bottom, host = dialog) + awaitUntil("panel hosted by the dialog and geometry ready") { + fixture.panelHost.value === dialog && + workspace.dockHostGeometry(dialog)?.clientOriginPx() != null && + entry.dockedBoundsInWindowPx != null + } + settle() + val panelGrab = + requireNotNull(workspace.dockHostGeometry(dialog)?.clientOriginPx()) + + requireNotNull(entry.dockedBoundsInWindowPx).topLeft + + Offset(GRAB_INSET_PX, GRAB_INSET_PX) + val duringClose = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.DockedPanel(dialog), panelGrab), + ) + // Clear of every layout, so the drop can only mean "tear out". + val farFromEveryLayout = Offset(layout.right + DROP_FAR_PX, layout.top + DROP_INSET_PX) + duringClose.update(farFromEveryLayout) + var dialogDestroyed = false + dialog.onDestroyed { dialogDestroyed = true } + dialogVisible.value = false + awaitUntil("dialog destroyed mid-drag") { dialogDestroyed } + duringClose.end(farFromEveryLayout) + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.draggedSatellite == null && workspace.dragGhost == null) { + "a drag over a closing host left feedback behind" + } + check(workspace.owner === window) { "the owner did not fall back to the surviving member" } + check(!entry.isDocked) { "the tear-out from a closing host did not undock: ${entry.placement}" } + + // ── 3. a gesture live while everything is hidden and shown again ── + val liveFloating = awaitFloating(fixture) + val hiddenGrab = + requireNotNull(liveFloating.outerBoundsPx()).let { rect -> + Offset(rect[0] + rect[2] / 2f, rect[1] + HEADER_GRAB_Y_DP * window.scaleFactor) + } + val duringHide = + requireNotNull( + workspace.beginDrag(SATELLITE_ID, SatelliteDragOrigin.FloatingWindow(liveFloating), hiddenGrab), + ) + duringHide.update(hiddenGrab + Offset(DRAG_AWAY_PX, 0f)) + workspace.visible = false + awaitUntil("satellite left composition") { !fixture.isComposed } + duringHide.end(hiddenGrab + Offset(DRAG_AWAY_PX, 0f)) + workspace.visible = true + awaitUntil("satellite composed again") { fixture.isComposed } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.draggedSatellite == null && workspace.dragGhost == null) { + "a drag across a visibility toggle left feedback behind" + } + check(workspace.dockPreview == null) { "a dock zone is still highlighted" } + }, + ) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabSatellitesChaosHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabSatellitesChaosHeadfulCases.kt new file mode 100644 index 000000000..58a7cc356 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabSatellitesChaosHeadfulCases.kt @@ -0,0 +1,493 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteDragOrigin +import dev.nucleusframework.window.tao.SatellitePlacement +import kotlin.math.abs + +/** + * The composed archetype under pressure: gestures from both workspaces in + * flight at once, storms of tab changes, both layouts persisted together, and + * everything closed at the same time. + * + * The wiring itself — which window owns which palette, and what a dock does to + * it — is pinned by [TabSatellitesHeadfulCases]. What is left here is what + * happens when the two archetypes are asked to act *simultaneously*: a tab drag + * and a palette drag over the same desktop, a strip and a dock zone competing + * for a point, a window emptied while its palette is docked into it. + */ +internal object TabSatellitesChaosHeadfulCases { + fun all(): List = + listOf( + aTabDragAndAPaletteDragInFlightAtOnce(), + aTabMergesIntoAWindowWhosePaletteIsDocked(), + aStripPointIsNeverADockZone(), + tearingOffATabOutOfAWindowWithADockedPalette(), + aStormOfTabChangesLeavesOnePaletteBodyPerWindow(), + bothLayoutsSaveAndRestoreTogether(), + closingEveryTabTakesEveryPaletteWithIt(), + aPaletteDeclaredForAWindowThatNeverOpensIsNoLeak(), + ) + + /** + * A tab drag in one window and a palette drag in another, both in flight. + * They belong to different workspaces and must not clear each other's + * feedback or act on each other's release. + */ + private fun aTabDragAndAPaletteDragInFlightAtOnce(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab satellites a tab drag and a palette drag in flight at once", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta", "Gamma") + val home = requireNotNull(fixture.tabs.groups.first()) + val palette = awaitFloatingPalette(fixture, home) + val palettes = fixture.palettesOf(home.id) + val layout = requireNotNull(palettes.dockHostGeometry(first)?.layoutScreenRectPx()) + + // The palette, grabbed by its header and held over a dock zone. + val outer = requireNotNull(palette.outerBoundsPx()) + val paletteGrab = + Offset(outer[0] + outer[RECT_W] / 2f, outer[1] + HEADER_GRAB_Y_DP * first.scaleFactor) + val paletteDrag = + requireNotNull( + palettes.beginDrag( + fixture.paletteId(home.id), + SatelliteDragOrigin.FloatingWindow(palette), + paletteGrab, + ), + ) { "the palette drag must start" } + val zone = Offset(layout.right - DROP_INSET_PX, layout.center.y) + paletteDrag.update(zone) + check(palettes.dockPreview?.side == DockSide.Right) { + "the right zone is not previewed: ${palettes.dockPreview}" + } + + // And a tab, at the same time, out of the same window. + val gamma = fixture.tabId("Gamma") + val strip = requireNotNull(fixture.tabs.stripGeometry(home)?.layoutScreenRectPx()) + val tabGrab = requireNotNull(tabCenterOnScreenPx(fixture, "Gamma")) + val away = Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX) + val tabDrag = + requireNotNull(fixture.tabs.beginDrag(gamma, stripOrigin(first), tabGrab)) { + "the tab drag must start" + } + tabDrag.update(tabGrab) + tabDrag.update(away) + + check(fixture.tabs.draggedTab?.id == gamma) { "the tab drag was lost" } + check(palettes.draggedSatellite?.id == fixture.paletteId(home.id)) { + "the tab drag cleared the palette drag" + } + check(palettes.dockPreview?.side == DockSide.Right) { + "the tab drag cleared the dock preview: ${palettes.dockPreview}" + } + + // Released out of order: each acts on its own workspace only. + tabDrag.end(away) + awaitUntil("the tab landed in a window of its own") { + fixture.tabs.groups.size == 2 && fixture.groupOf("Gamma")?.ids == listOf(gamma) + } + check(palettes.draggedSatellite != null) { "the tab release ended the palette drag" } + paletteDrag.end(zone) + awaitUntil("the palette docked right") { + ( + palettes.satellite(fixture.paletteId(home.id))?.placement + as? SatellitePlacement.Docked + )?.side == DockSide.Right + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.tabs.draggedTab == null && fixture.tabs.dragGhost == null) { + "tab drag feedback outlived the gestures" + } + check(palettes.draggedSatellite == null && palettes.dragGhost == null) { + "palette drag feedback outlived the gestures" + } + }, + ) + } + + /** + * A docked panel takes width out of the tab body, not out of the strip. + * Merging a tab into that window has to keep working, and the panel must + * not move. + */ + private fun aTabMergesIntoAWindowWhosePaletteIsDocked(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites a tab merges into a window whose palette is docked", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, home) + val torn = tearOffTabWindow(fixture, "Beta", first) + val tornWindow = requireNotNull(torn.window) + awaitFloatingPalette(fixture, torn) + + fixture.palettesOf(home.id).dock(fixture.paletteId(home.id), DockSide.Right) + awaitUntil("the first window's palette is docked") { + fixture.panelHost.value[home.id] === first + } + settle(SETTLE_AFTER_MAP_MILLIS) + val panelBefore = + requireNotNull( + fixture.palettesOf(home.id).satellite(fixture.paletteId(home.id))?.dockedBoundsInWindowPx, + ) + + // Beta back into the docked window, dropped on its strip. + val strip = requireNotNull(fixture.tabs.stripGeometry(home)?.layoutScreenRectPx()) + val target = Offset(strip.left + strip.width * MERGE_X_FRACTION, strip.center.y) + val grab = requireNotNull(tabCenterOnScreenPx(fixture, "Beta")) + val session = + requireNotNull(fixture.tabs.beginDrag(fixture.tabId("Beta"), stripOrigin(tornWindow), grab)) + session.update(grab) + session.update(target) + check(fixture.tabs.dropPreview?.group === home) { + "the docked window's strip did not preview the drop: ${fixture.tabs.dropPreview}" + } + session.end(target) + awaitUntil("both tabs are back in the docked window") { + fixture.tabs.groups.size == 1 && home.ids.size == 2 + } + awaitUntil("the panel is still docked in it") { fixture.panelHost.value[home.id] === first } + settle(SETTLE_AFTER_MAP_MILLIS) + val panelAfter = + requireNotNull( + fixture.palettesOf(home.id).satellite(fixture.paletteId(home.id))?.dockedBoundsInWindowPx, + ) + check(abs(panelAfter.width - panelBefore.width) <= LAYOUT_TOLERANCE_PX) { + "the merge resized the panel: ${panelAfter.width} vs ${panelBefore.width}" + } + check(!fixture.hasPalettes(torn.id)) { "the emptied window's workspace was left behind" } + }, + ) + } + + /** + * The strip is in the title bar and the dock zones are inside the content, + * so no point can be both. If they ever overlapped, dragging a tab across + * the top of a window would dock a palette instead. + */ + private fun aStripPointIsNeverADockZone(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites a point on the strip is never a dock zone", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val group = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, group) + val palettes = fixture.palettesOf(group.id) + awaitUntil("the dock layout published its geometry") { + palettes.dockHostGeometry(first)?.layoutScreenRectPx() != null + } + val strip = requireNotNull(fixture.tabs.stripGeometry(group)?.layoutScreenRectPx()) + val layout = requireNotNull(palettes.dockHostGeometry(first)?.layoutScreenRectPx()) + + check(!strip.overlaps(layout)) { "the strip overlaps the dock layout: $strip vs $layout" } + for (fraction in listOf(STRIP_HEAD_FRACTION, MERGE_X_FRACTION, 0.9f)) { + val point = Offset(strip.left + strip.width * fraction, strip.center.y) + check(palettes.dockTargetAt(point) == null) { + "a point on the strip resolves to a dock zone: $point" + } + check(fixture.tabs.dropTargetAt(point)?.group === group) { + "a point on the strip does not resolve to the strip: $point" + } + } + val zone = Offset(layout.right - DROP_INSET_PX, layout.center.y) + check(palettes.dockTargetAt(zone)?.side == DockSide.Right) { "the right zone does not resolve" } + check(fixture.tabs.dropTargetAt(zone) == null) { "a dock zone resolves as a strip drop" } + }, + ) + } + + /** + * Tearing a tab out of a window whose palette is docked. The new window + * gets a floating palette of its own, and the docked one stays where it + * is — two windows in two different palette states at once. + */ + private fun tearingOffATabOutOfAWindowWithADockedPalette(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites tearing a tab out of a window whose palette is docked", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, home) + fixture.palettesOf(home.id).dock(fixture.paletteId(home.id), DockSide.Bottom) + awaitUntil("the palette is docked") { fixture.panelHost.value[home.id] === first } + settle(SETTLE_AFTER_MAP_MILLIS) + + val torn = tearOffTabWindow(fixture, "Beta", first) + val tornPalette = awaitFloatingPalette(fixture, torn) + awaitUntil("the first window's panel stayed docked") { + fixture.panelHost.value[home.id] === first + } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(tornPalette !== fixture.floatingPalette.value[home.id]) { + "the two windows share a palette window" + } + check(fixture.palettesOf(torn.id).satellite(fixture.paletteId(torn.id))?.isDocked == false) { + "the new window's palette inherited the docked placement" + } + check(fixture.composedPalettes.value == 2) { + "${fixture.composedPalettes.value} palette bodies for two windows" + } + awaitUntil("each palette draws its own window's tab") { + fixture.paletteShows.value[home.id] == "Alpha" && + fixture.paletteShows.value[torn.id] == "Beta" + } + }, + ) + } + + // ── 4. storms and shutdown ─────────────────────────────────────────── + + /** + * Hundreds of tab changes with no frame in between. The palette redraws + * as fast as the selection moves, and at the end exactly one body per + * window may be composing — the count is where a leak shows up. + */ + private fun aStormOfTabChangesLeavesOnePaletteBodyPerWindow(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabSatellitesFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab satellites a storm of tab changes leaves one palette body per window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabSatellites(fixture, *titles.toTypedArray()) + val group = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, group) + val palette = requireNotNull(fixture.floatingPalette.value[group.id]) + val incarnationsBefore = fixture.paletteIncarnations.value[group.id] + requireNotNull(fixture.paletteCounters.value[group.id]).value = SAVED_CLICKS + + repeat(SELECTION_STORM) { round -> + fixture.tabs.select(fixture.tabId(titles[round % titles.size])) + } + val last = titles[(SELECTION_STORM - 1) % titles.size] + awaitUntil("the storm settled on $last") { fixture.paletteShows.value[group.id] == last } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(fixture.composedPalettes.value == 1) { + "the storm left ${fixture.composedPalettes.value} palette bodies" + } + check(fixture.composedBodies.value == 1) { + "the storm left ${fixture.composedBodies.value} tab bodies" + } + check(fixture.floatingPalette.value[group.id] === palette) { + "the storm recreated the palette window" + } + check(fixture.paletteIncarnations.value[group.id] == incarnationsBefore) { + "the storm rebuilt the palette body" + } + check(requireNotNull(fixture.paletteCounters.value[group.id]).value == SAVED_CLICKS) { + "the storm lost the palette's state" + } + check(fixture.liveWorkspaces == 1) { "the storm created ${fixture.liveWorkspaces} workspaces" } + }, + ) + } + + /** + * Both layouts persisted together, which is what an application actually + * saves: which window holds which tabs, and where each window's palettes + * were. Restoring has to bring the windows back *and* put their palettes + * back in the state they were in. + */ + private fun bothLayoutsSaveAndRestoreTogether(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites both layouts save and restore together", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, home) + val torn = tearOffTabWindow(fixture, "Beta", first) + awaitFloatingPalette(fixture, torn) + fixture.palettesOf(home.id).dock(fixture.paletteId(home.id), DockSide.Left) + awaitUntil("the first window's palette is docked") { + fixture.panelHost.value[home.id] === first + } + settle(SETTLE_AFTER_MAP_MILLIS) + + val tabLayout = fixture.tabs.snapshot() + val paletteLayouts = fixture.tabs.groups.associate { it.id to fixture.palettesOf(it.id).snapshot() } + check(tabLayout.groups.size == 2) { "the tab snapshot missed a window" } + check(paletteLayouts.size == 2) { "a window's palette layout was not captured" } + + // Everything back into one window, palettes floating again. + fixture.palettesOf(home.id).undock(fixture.paletteId(home.id)) + awaitFloatingPalette(fixture, home) + fixture.tabs.move(fixture.tabId("Beta"), home) + awaitUntil("one window is left") { fixture.tabs.groups.size == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + + // And the saved layout applied again. + fixture.tabs.restore(tabLayout) + awaitUntil("the two windows are back") { + fixture.tabs.groups.size == 2 && + fixture.tabs.groups.all { (it.window?.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L } + } + for ((groupId, layout) in paletteLayouts) { + if (fixture.hasPalettes(groupId)) fixture.palettesOf(groupId).restore(layout) + } + awaitUntil("the first window's palette is docked again") { + fixture.panelHost.value[home.id] === first + } + awaitUntil("the other window's palette floats again") { + fixture.tabs.groups + .filter { it !== home } + .all { fixture.floatingPalette.value[it.id] != null } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.composedPalettes.value == 2) { + "${fixture.composedPalettes.value} palette bodies after the restore" + } + check(fixture.tabs.groups.sumOf { it.ids.size } == 2) { + "the restore lost a tab: ${fixture.tabs.groups.map { it.ids }}" + } + }, + ) + } + + /** + * The application quitting: every tab closed at once, with palettes both + * docked and floating. Nothing may be left composing, no workspace may + * survive its window, and the last window has to be reported once. + */ + private fun closingEveryTabTakesEveryPaletteWithIt(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab satellites closing every tab takes every palette with it", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta", "Gamma") + val home = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, home) + val torn = tearOffTabWindow(fixture, "Beta", first) + awaitFloatingPalette(fixture, torn) + fixture.palettesOf(torn.id).dock(fixture.paletteId(torn.id), DockSide.Right) + awaitUntil("one palette docked, one floating") { + fixture.panelHost.value[torn.id] === torn.window && + fixture.floatingPalette.value[home.id] != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + + fixture.tabs.tabs + .map { it.id } + .forEach(fixture.tabs::close) + awaitUntil("the tab workspace emptied") { + fixture.tabs.groups.isEmpty() && fixture.tabs.tabs.isEmpty() + } + awaitUntil("no body of either kind is composing") { + fixture.composedBodies.value == 0 && fixture.composedPalettes.value == 0 + } + awaitUntil("every satellite workspace was forgotten") { fixture.liveWorkspaces == 0 } + awaitUntil("the last window was reported once") { fixture.lastWindowClosedCount.value == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.lastWindowClosedCount.value == 1) { + "reported ${fixture.lastWindowClosedCount.value}× for one shutdown" + } + check(fixture.panelHost.value.isEmpty() && fixture.floatingPalette.value.isEmpty()) { + "a palette outlived every window" + } + }, + ) + } + + /** + * A window emptied and refilled in the same breath — the shape of a + * restore, and of a user closing the last tab and opening another. The + * palettes of the window that went must not come back attached to the new + * one, and the new window has to get palettes of its own. + */ + private fun aPaletteDeclaredForAWindowThatNeverOpensIsNoLeak(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha")) + return TaoWindowTestCase( + name = "tab satellites a window emptied and refilled gets palettes of its own", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabSatellites(fixture, "Alpha") + val first = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, first) + requireNotNull(fixture.paletteCounters.value[first.id]).value = SAVED_CLICKS + + fixture.tabs.close(fixture.tabId("Alpha")) + fixture.titles -= "Alpha" + awaitUntil("everything went") { + fixture.tabs.groups.isEmpty() && fixture.composedPalettes.value == 0 + } + awaitUntil("the workspace was forgotten") { fixture.liveWorkspaces == 0 } + settle(SETTLE_AFTER_MAP_MILLIS) + + fixture.titles += "Delta" + awaitUntil("a window opened for the new tab") { fixture.tabs.groups.size == 1 } + val second = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, second) + settle(SETTLE_AFTER_MAP_MILLIS) + + check(fixture.liveWorkspaces == 1) { "${fixture.liveWorkspaces} workspaces for one window" } + check(fixture.composedPalettes.value == 1) { + "${fixture.composedPalettes.value} palette bodies for one window" + } + awaitUntil("the new palette draws the new tab") { + fixture.paletteShows.value[second.id] == "Delta" + } + if (second.id != first.id) { + check(requireNotNull(fixture.paletteCounters.value[second.id]).value == 0) { + "the new window's palette came back with the old one's state" + } + } + }, + ) + } + + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L + private const val SELECTION_STORM = 200 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabSatellitesHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabSatellitesHeadfulCases.kt new file mode 100644 index 000000000..906081d84 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabSatellitesHeadfulCases.kt @@ -0,0 +1,506 @@ +package dev.nucleusframework.window.tao.headful + +import dev.nucleusframework.window.tao.DockSide +import kotlin.math.abs + +/** + * The two archetypes composed, on real windows: Chrome-like tabs where **each + * tab window** owns a satellite workspace whose palette draws the tab that + * window is showing — the shape of `examples/tab-satellites-demo`. + * + * Neither workspace knows about the other, which is exactly why they can go + * wrong together: + * + * 1. **who owns what** — a palette belongs to a window, not to a tab, so a tab + * change must not create or destroy one, and a tab torn into a window of + * its own must arrive with palettes of its own; + * 2. **windows going away** — the window a palette belongs to is created and + * destroyed by the *tab* workspace, so its satellite workspace has to go + * with it, and no other window's palettes may notice; + * 3. **docking under tabs** — the dock layout lives inside the tab body, so + * docking, switching tab and moving the drawn tab elsewhere all re-host the + * same panel while its state has to stay put; + * 4. **gestures at once** — a tab drag and a palette drag in flight over the + * same desktop, and a strip and a dock zone competing for a point; + * 5. **storms and shutdown** — hundreds of tab changes, both layouts saved and + * restored together, and everything closed at once. + */ +internal object TabSatellitesHeadfulCases { + fun all(): List = + listOf( + eachTabWindowOwnsOnePalette(), + aTabChangeOnlyChangesWhatThePaletteDraws(), + aTornOffTabArrivesWithPalettesOfItsOwn(), + mergingWindowsBackTakesTheSecondWindowsPaletteWithIt(), + aPaletteFollowsItsOwnWindowAndNotTheOther(), + dockingAPaletteIntoItsOwnWindowKeepsItsState(), + aDockedPaletteSurvivesATabChangeInItsWindow(), + aDockedPaletteStaysWhenTheTabItDrewLeaves(), + undockingLiftsThePaletteBackOffThePanel(), + aWindowClosingTakesItsDockedPaletteAndNoOther(), + ) + + // ── 1. who owns what ───────────────────────────────────────────────── + + /** + * The bootstrap of the composed archetype: the window the tabs opened + * joined a workspace of its own and its palette is floating over it. One + * window, one workspace, one palette — anything else and the two + * archetypes are not actually wired together. + */ + private fun eachTabWindowOwnsOnePalette(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites the first tab window owns one palette drawing its selected tab", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSatellites(fixture, "Alpha", "Beta") + val group = requireNotNull(fixture.tabs.groups.firstOrNull()) + val palette = awaitFloatingPalette(fixture, group) + + check(fixture.liveWorkspaces == 1) { "${fixture.liveWorkspaces} workspaces for one window" } + check(fixture.palettesOf(group.id).members == listOf(tabWindow)) { + "the workspace's members are not just its own window: " + + "${fixture.palettesOf(group.id).members.size} of them" + } + check(fixture.palettesOf(group.id).owner === tabWindow) { "the palette has the wrong owner" } + check(palette !== tabWindow) { "the palette is not a window of its own" } + awaitUntil("the palette draws the selected tab") { + fixture.paletteShows.value[group.id] == fixture.tabs.selectedTab(group)?.title + } + check(fixture.composedPalettes.value == 1) { + "${fixture.composedPalettes.value} palette bodies for one window" + } + }, + ) + } + + /** + * The design decision the archetype rests on: a palette belongs to the + * *window*. Switching tabs may change what it draws and nothing else — no + * native window destroyed and recreated (the user sees that as a flash), + * and no body rebuilt, which would lose everything in it. + */ + private fun aTabChangeOnlyChangesWhatThePaletteDraws(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab satellites a tab change redraws the palette without recreating it", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabSatellites(fixture, "Alpha", "Beta", "Gamma") + val group = requireNotNull(fixture.tabs.groups.firstOrNull()) + val palette = awaitFloatingPalette(fixture, group) + val incarnationsBefore = fixture.paletteIncarnations.value[group.id] + requireNotNull(fixture.paletteCounters.value[group.id]).value = SAVED_CLICKS + + for (title in listOf("Beta", "Gamma", "Alpha")) { + fixture.tabs.select(fixture.tabId(title)) + awaitUntil("the palette redrew for $title") { + fixture.paletteShows.value[group.id] == title + } + check(fixture.floatingPalette.value[group.id] === palette) { + "the palette window was recreated when the tab changed to $title" + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.paletteIncarnations.value[group.id] == incarnationsBefore) { + "the palette body was rebuilt by a tab change: " + + "${fixture.paletteIncarnations.value[group.id]} vs $incarnationsBefore" + } + check(requireNotNull(fixture.paletteCounters.value[group.id]).value == SAVED_CLICKS) { + "the palette lost its state on a tab change" + } + check(fixture.liveWorkspaces == 1) { "a tab change created a workspace" } + }, + ) + } + + /** + * A tab pulled into a window of its own arrives with a palette of its own: + * two windows, two workspaces, two palettes, each drawing its own window's + * selected tab. One shared palette would be the wrong archetype entirely. + */ + private fun aTornOffTabArrivesWithPalettesOfItsOwn(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites a tab torn into its own window arrives with a palette of its own", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, home) + + val torn = tearOffTabWindow(fixture, "Beta", first) + val tornWindow = requireNotNull(torn.window) + awaitFloatingPalette(fixture, torn) + + check(fixture.liveWorkspaces == 2) { "${fixture.liveWorkspaces} workspaces for two windows" } + check(fixture.palettesOf(torn.id).owner === tornWindow) { + "the new window's palette is owned by another window" + } + check(fixture.palettesOf(home.id).members == listOf(first)) { + "the first window's workspace picked up another window: " + + "${fixture.palettesOf(home.id).members.size} members" + } + awaitUntil("each palette draws its own window's tab") { + fixture.paletteShows.value[home.id] == "Alpha" && + fixture.paletteShows.value[torn.id] == "Beta" + } + check(fixture.composedPalettes.value == 2) { + "${fixture.composedPalettes.value} palette bodies for two windows" + } + val palettes = + listOfNotNull(fixture.floatingPalette.value[home.id], fixture.floatingPalette.value[torn.id]) + check(palettes.size == 2 && palettes[0] !== palettes[1]) { "the two windows share one palette" } + }, + ) + } + + /** + * And back: a window emptied of tabs takes its palette, its workspace and + * its native palette window with it. A workspace left behind is a leak + * that keeps a window alive on a dead member. + */ + private fun mergingWindowsBackTakesTheSecondWindowsPaletteWithIt(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites merging two windows back takes the second one's palette with it", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + val torn = tearOffTabWindow(fixture, "Beta", first) + val tornPalette = awaitFloatingPalette(fixture, torn) + var paletteDestroyed = false + tornPalette.onDestroyed { paletteDestroyed = true } + + fixture.tabs.move(fixture.tabId("Beta"), home) + awaitUntil("one window is left") { fixture.tabs.groups.size == 1 } + awaitUntil("the second window's palette was destroyed") { paletteDestroyed } + awaitUntil("its workspace was forgotten") { !fixture.hasPalettes(torn.id) } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(fixture.liveWorkspaces == 1) { "${fixture.liveWorkspaces} workspaces for one window" } + check(fixture.composedPalettes.value == 1) { + "${fixture.composedPalettes.value} palette bodies after the merge" + } + check(fixture.floatingPalette.value[home.id] != null) { "the surviving palette went too" } + awaitUntil("the survivor draws the tab that arrived") { + fixture.paletteShows.value[home.id] == "Beta" + } + check(home.ids.size == 2) { "the merge lost a tab: ${home.ids}" } + }, + ) + } + + /** + * Each palette is anchored to its own window: moving one window moves its + * palette and leaves the other one exactly where it was. + */ + private fun aPaletteFollowsItsOwnWindowAndNotTheOther(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites a palette follows its own window and ignores the other", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + val homePalette = awaitFloatingPalette(fixture, home) + val torn = tearOffTabWindow(fixture, "Beta", first) + val tornWindow = requireNotNull(torn.window) + val tornPalette = awaitFloatingPalette(fixture, torn) + awaitUntil("both palettes captured their owner offset") { + listOf(home, torn).all { group -> + fixture + .palettesOf(group.id) + .satellite(fixture.paletteId(group.id)) + ?.windowState + ?.offsetFromParent != null + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + + val ownerBefore = requireNotNull(tornWindow.outerBoundsPx()) + val followerBefore = requireNotNull(tornPalette.outerBoundsPx()) + val strangerBefore = requireNotNull(homePalette.outerBoundsPx()) + val offsetX = followerBefore[0] - ownerBefore[0] + val offsetY = followerBefore[1] - ownerBefore[1] + + val scale = tornWindow.scaleFactor.toDouble() + tornWindow.setOuterPosition( + ownerBefore[0] / scale + MOVE_DELTA_DP, + ownerBefore[1] / scale + MOVE_DELTA_DP, + ) + awaitUntil("its palette followed") { + val owner = tornWindow.outerBoundsPx() ?: return@awaitUntil false + val follower = tornPalette.outerBoundsPx() ?: return@awaitUntil false + owner[0] != ownerBefore[0] && + abs((follower[0] - owner[0]) - offsetX) <= FOLLOW_TOLERANCE_PX && + abs((follower[1] - owner[1]) - offsetY) <= FOLLOW_TOLERANCE_PX + } + settle() + val strangerNow = requireNotNull(homePalette.outerBoundsPx()) + check(abs(strangerNow[0] - strangerBefore[0]) <= FOLLOW_TOLERANCE_PX) { + "the other window's palette moved with a window it does not belong to" + } + }, + ) + } + + // ── 2. docking under tabs ──────────────────────────────────────────── + + /** + * Docking inside the composed archetype: the panel lands in the dock + * layout of the tab body, its floating window goes, and its saveable state + * comes across — the whole point of the relocation machinery. + */ + private fun dockingAPaletteIntoItsOwnWindowKeepsItsState(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites docking a palette into its tab window keeps its state", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSatellites(fixture, "Alpha", "Beta") + val group = requireNotNull(fixture.tabs.groups.first()) + val palette = awaitFloatingPalette(fixture, group) + requireNotNull(fixture.paletteCounters.value[group.id]).value = SAVED_CLICKS + var floatingDestroyed = false + palette.onDestroyed { floatingDestroyed = true } + + fixture.palettesOf(group.id).dock(fixture.paletteId(group.id), DockSide.Right) + awaitUntil("the panel is hosted by the tab window") { + fixture.panelHost.value[group.id] === tabWindow + } + awaitUntil("the floating window went") { floatingDestroyed } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(requireNotNull(fixture.paletteCounters.value[group.id]).value == SAVED_CLICKS) { + "the palette lost its state on the way into the dock" + } + check(fixture.composedPalettes.value == 1) { + "${fixture.composedPalettes.value} palette bodies after docking one" + } + check(fixture.composedBodies.value == 1) { "the dock disturbed the tab body count" } + }, + ) + } + + /** + * The dock layout lives inside the tab body, so a tab change destroys the + * layout the panel is in and builds another. The panel has to be re-hosted + * into it with its state — and the window it belongs to must not change. + */ + private fun aDockedPaletteSurvivesATabChangeInItsWindow(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab satellites a docked palette survives a tab change in its window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSatellites(fixture, "Alpha", "Beta", "Gamma") + val group = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, group) + val workspace = fixture.palettesOf(group.id) + workspace.dock(fixture.paletteId(group.id), DockSide.Bottom) + awaitUntil("the panel is docked") { fixture.panelHost.value[group.id] === tabWindow } + settle(SETTLE_AFTER_MAP_MILLIS) + requireNotNull(fixture.paletteCounters.value[group.id]).value = SAVED_CLICKS + + for (title in listOf("Beta", "Gamma", "Alpha", "Beta")) { + fixture.tabs.select(fixture.tabId(title)) + awaitUntil("the palette redrew for $title") { + fixture.paletteShows.value[group.id] == title + } + awaitUntil("and is still docked in the same window") { + fixture.panelHost.value[group.id] === tabWindow + } + check(requireNotNull(fixture.paletteCounters.value[group.id]).value == SAVED_CLICKS) { + "the docked palette lost its state switching to $title" + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.satellite(fixture.paletteId(group.id))?.isDocked == true) { + "the palette undocked itself across the tab changes" + } + check(fixture.composedPalettes.value == 1) { + "${fixture.composedPalettes.value} palette bodies after the tab changes" + } + check(fixture.floatingPalette.value[group.id] == null) { "a floating palette reappeared" } + }, + ) + } + + /** + * The panel belongs to the window, not to the tab it happens to be + * drawing. Moving that tab into another window leaves the panel where it + * is, drawing whatever the window shows now. + */ + private fun aDockedPaletteStaysWhenTheTabItDrewLeaves(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites a docked palette stays put when the tab it drew moves away", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, home) + fixture.palettesOf(home.id).dock(fixture.paletteId(home.id), DockSide.Left) + awaitUntil("the panel is docked in the first window") { + fixture.panelHost.value[home.id] === first + } + settle(SETTLE_AFTER_MAP_MILLIS) + requireNotNull(fixture.paletteCounters.value[home.id]).value = SAVED_CLICKS + + // The tab it is drawing goes to a window of its own. + fixture.tabs.select(fixture.tabId("Beta")) + awaitUntil("the panel draws Beta") { fixture.paletteShows.value[home.id] == "Beta" } + val torn = tearOffTabWindow(fixture, "Beta", first) + awaitUntil("the panel is still in the first window") { + fixture.panelHost.value[home.id] === first + } + awaitUntil("and now draws what that window shows") { + fixture.paletteShows.value[home.id] == "Alpha" + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(requireNotNull(fixture.paletteCounters.value[home.id]).value == SAVED_CLICKS) { + "the panel lost its state when the tab it drew left" + } + check(fixture.palettesOf(torn.id).satellite(fixture.paletteId(torn.id))?.isDocked == false) { + "the new window's own palette arrived docked" + } + }, + ) + } + + /** Undocking gives the palette a window back, over the panel it just was. */ + private fun undockingLiftsThePaletteBackOffThePanel(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites undocking lifts the palette back off its panel", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSatellites(fixture, "Alpha", "Beta") + val group = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, group) + val workspace = fixture.palettesOf(group.id) + val id = fixture.paletteId(group.id) + + workspace.dock(id, DockSide.Right) + awaitUntil("docked") { fixture.panelHost.value[group.id] === tabWindow } + settle(SETTLE_AFTER_MAP_MILLIS) + requireNotNull(fixture.paletteCounters.value[group.id]).value = SAVED_CLICKS + val panel = requireNotNull(workspace.satellite(id)?.dockedBoundsInWindowPx) + + workspace.undock(id) + val lifted = awaitFloatingPalette(fixture, group) + check(requireNotNull(fixture.paletteCounters.value[group.id]).value == SAVED_CLICKS) { + "the palette lost its state on the way out of the dock" + } + // The panel host clears when the docked body is disposed, which + // is a frame behind the floating window being mapped. + awaitUntil("the panel is no longer hosted") { fixture.panelHost.value[group.id] == null } + check(workspace.satellite(id)?.dockHost == null) { "the entry still names a dock host" } + val outer = requireNotNull(lifted.outerBoundsPx()) + val scale = lifted.scaleFactor + check(abs(outer[RECT_W] - panel.width * scale / tabWindow.scaleFactor) <= LIFT_OFF_TOLERANCE_PX * 2) { + "the lifted window is ${outer[RECT_W]}px wide, the panel was ${panel.width}px" + } + check(fixture.composedPalettes.value == 1) { + "${fixture.composedPalettes.value} palette bodies after undocking" + } + }, + ) + } + + /** + * A window with a docked palette, closed by the user. Its workspace, its + * panel and its tabs go; the other window's palette must not so much as + * blink. + */ + private fun aWindowClosingTakesItsDockedPaletteAndNoOther(): TaoWindowTestCase { + val fixture = TabSatellitesFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab satellites a window closing takes its docked palette and no other", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture, "Alpha", "Beta") + val home = requireNotNull(fixture.tabs.groups.first()) + awaitFloatingPalette(fixture, home) + val torn = tearOffTabWindow(fixture, "Beta", first) + val tornWindow = requireNotNull(torn.window) + awaitFloatingPalette(fixture, torn) + fixture.palettesOf(torn.id).dock(fixture.paletteId(torn.id), DockSide.Top) + awaitUntil("the second window's palette is docked") { + fixture.panelHost.value[torn.id] === tornWindow + } + settle(SETTLE_AFTER_MAP_MILLIS) + val survivorIncarnations = fixture.paletteIncarnations.value[home.id] + var destroyed = false + tornWindow.onDestroyed { destroyed = true } + + tornWindow.requestUserClose() + awaitUntil("the window went with its tab") { destroyed && fixture.tabs.groups.size == 1 } + awaitUntil("its workspace was forgotten") { !fixture.hasPalettes(torn.id) } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(fixture.panelHost.value[torn.id] == null) { "the closed window's panel outlived it" } + check(fixture.liveWorkspaces == 1) { "${fixture.liveWorkspaces} workspaces after the close" } + check(fixture.paletteIncarnations.value[home.id] == survivorIncarnations) { + "the surviving window's palette was rebuilt by another window closing" + } + check(fixture.floatingPalette.value[home.id] != null) { "the survivor's palette went too" } + check(fixture.composedPalettes.value == 1) { + "${fixture.composedPalettes.value} palette bodies after the close" + } + }, + ) + } + + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabStripMotionHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabStripMotionHeadfulCases.kt new file mode 100644 index 000000000..d91e2f197 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabStripMotionHeadfulCases.kt @@ -0,0 +1,369 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.abs + +/** + * Real-window coverage for the tab strip's *motion*: what a tab does on its + * way to a new place, and what the strip's published geometry does while it + * happens. + * + * 1. a reorder animates the drawing only — the slots a drop resolves against + * are the settled layout from the first frame; + * 2. a tab dragged along its own strip stays in the strip's hands: no ghost + * window, and the release reorders it; + * 3. a right-to-left strip runs from the right and carries a tab the same way; + * 4. the close button plays the tab out before the workspace drops it, and a + * new tab arrives to be opened rather than already open; + * 5. the numbers behind the motion: the carried tab is drawn at the pointer's + * travel, a crossed neighbour stands exactly one tab aside, the rest are at + * rest, and the release slides home before the order changes. + * + * Native Wayland is skipped: the drag there rides the platform's + * drag-and-drop session, which tells the source nothing about the pointer. + */ +internal object TabStripMotionHeadfulCases { + fun all(): List = + listOf( + aReorderAnimatesTheDrawingNotTheGeometry(), + aTabDraggedInItsOwnStripStaysInIt(), + aRightToLeftStripRunsFromTheRight(), + theCloseButtonPlaysTheTabOut(), + theCarriedTabAndItsNeighboursMoveByTheNumbers(), + ) + + /** + * A reorder moves the tabs at once as far as the workspace is concerned — + * only the drawing travels ([dev.nucleusframework.window.tao.TabReorderAnimation]). + * + * Sampled one frame after the reorder, well inside the animation: the slot + * rects have already swapped, and a drop resolved from a pointer over the + * first slot answers with the first index. Were the geometry animated, the + * strip would promise for a fifth of a second a drop it does not do. + */ + private fun aReorderAnimatesTheDrawingNotTheGeometry(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace animates a reorder without moving the geometry a drop resolves against", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val firstSlot = requireNotNull(fixture.tabSlotInWindowPx("Alpha")) + val gamma = fixture.tabId("Gamma") + + workspace.reorder(gamma, 0) + awaitUntil("Gamma is the first tab of the strip") { group.ids.first() == gamma } + // One frame, deep inside the 180 ms the drawing takes. + settle(ONE_FRAME_MILLIS) + val gammaSlot = requireNotNull(fixture.tabSlotInWindowPx("Gamma")) + check(abs(gammaSlot.left - firstSlot.left) <= LAYOUT_TOLERANCE_PX) { + "the slot a drop resolves against is still travelling: $gammaSlot vs $firstSlot" + } + check(abs(gammaSlot.width - firstSlot.width) <= LAYOUT_TOLERANCE_PX) { + "the first slot changed width on a reorder: $gammaSlot vs $firstSlot" + } + + // What the workspace answers a pointer, mid-animation: the + // left edge of the first slot is the first index. + val client = requireNotNull(workspace.stripGeometry(group)?.clientOriginPx()) + val atStart = client + Offset(firstSlot.left + EDGE_PROBE_PX, firstSlot.center.y) + val target = requireNotNull(workspace.dropTargetAt(atStart)) { "no drop target over the first slot" } + check(target.group === group && target.index == 0) { + "a drop over the first slot resolved to ${target.index}, not the first place" + } + + // And it settles where it was put. + settle(REORDER_SETTLE_MILLIS) + check(group.ids == listOf(gamma, fixture.tabId("Alpha"), fixture.tabId("Beta"))) { + "the strip order drifted after the animation: ${group.ids}" + } + check( + abs( + requireNotNull(fixture.tabSlotInWindowPx("Gamma")).left - firstSlot.left, + ) <= LAYOUT_TOLERANCE_PX, + ) { + "the settled slot moved" + } + }, + ) + } + + /** + * The browser gesture: a tab dragged along its own strip never leaves it. + * No ghost window is published while the pointer is over the strip — the + * strip draws the tab under the pointer and its neighbours make room — and + * the release is a reorder. Leave the strip and the ghost appears, which is + * what says the tab is being taken out; come back and it is put away again. + */ + private fun aTabDraggedInItsOwnStripStaysInIt(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace a tab dragged along its own strip is held by the strip, not by a ghost", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val gamma = fixture.tabId("Gamma") + val onGamma = requireNotNull(fixture.tabCenterPx("Gamma")) + // The leading edge of the first tab, where the insertion index + // is the first place — its centre would already be "after it". + val client = requireNotNull(workspace.stripGeometry(group)?.clientOriginPx()) + val alphaSlot = requireNotNull(fixture.tabSlotInWindowPx("Alpha")) + val onAlpha = client + Offset(alphaSlot.left + EDGE_PROBE_PX, alphaSlot.center.y) + val strip = requireNotNull(fixture.stripRectPx(group)) + + val session = requireNotNull(workspace.beginDrag(gamma, stripOrigin(first), onGamma)) + session.update(onGamma) + // Along the strip, over the first tab: in hand, still home. + session.update(onAlpha) + settle() + check(workspace.dragGhost == null) { "a ghost window for a tab still in its strip" } + check(workspace.draggedTab?.id == gamma) { "the drag lost its tab" } + check(workspace.dropPreview?.group === group && workspace.dropPreview?.index == 0) { + "the strip does not show the tab landing first: ${workspace.dropPreview}" + } + check(workspace.dragPointerScreenPx == onAlpha) { + "the strip was not told where the pointer is: ${workspace.dragPointerScreenPx}" + } + + // Out of the strip: now it really is leaving, so the ghost takes it. + val below = Offset(onAlpha.x, strip.bottom + OUT_OF_STRIP_PX) + session.update(below) + settle() + check(workspace.dragGhost?.tab?.id == gamma) { "no ghost once the tab left the strip" } + + // Back on the strip: the strip takes it in hand again. + session.update(onAlpha) + settle() + check(workspace.dragGhost == null) { "the ghost outlived the tab's return to the strip" } + + session.end(onAlpha) + awaitUntil("the tab was reordered rather than torn out") { + workspace.groups.size == 1 && group.ids.first() == gamma + } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + check(workspace.dragPointerScreenPx == null) { "the pointer outlived the drag" } + }, + ) + } + + /** + * A strip composed right to left — a Hebrew or Arabic app: the first tab is + * the *rightmost*, and a tab carried along it resolves the same insertion + * indices, since the strip's own geometry is what a drop is measured + * against whichever way the tabs run. + */ + private fun aRightToLeftStripRunsFromTheRight(): TaoWindowTestCase { + val fixture = + TabWorkspaceFixture( + initialTitles = listOf("Alpha", "Beta", "Gamma"), + layoutDirection = LayoutDirection.Rtl, + ) + return TaoWindowTestCase( + name = "tab workspace a right-to-left strip runs from the right and carries a tab the same way", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val alpha = requireNotNull(fixture.tabSlotInWindowPx("Alpha")) + val beta = requireNotNull(fixture.tabSlotInWindowPx("Beta")) + val gammaSlot = requireNotNull(fixture.tabSlotInWindowPx("Gamma")) + + // The first tab is the rightmost, the last the leftmost. + check(alpha.left > beta.left && beta.left > gammaSlot.left) { + "the strip does not run from the right: alpha=$alpha beta=$beta gamma=$gammaSlot" + } + + // Carried from the last place to the first: the pointer aims at + // the trailing edge of the first tab, which in this direction is + // its right edge. + val client = requireNotNull(workspace.stripGeometry(group)?.clientOriginPx()) + val gamma = fixture.tabId("Gamma") + val onGamma = requireNotNull(fixture.tabCenterPx("Gamma")) + val atFirst = client + Offset(alpha.right - EDGE_PROBE_PX, alpha.center.y) + val session = requireNotNull(workspace.beginDrag(gamma, stripOrigin(first), onGamma)) + session.update(onGamma) + session.update(atFirst) + settle() + check(workspace.dragGhost == null) { "a ghost for a tab still in its own strip" } + check(workspace.dropPreview?.group === group && workspace.dropPreview?.index == 0) { + "the right edge of the first tab is not the first place: ${workspace.dropPreview}" + } + session.end(atFirst) + awaitUntil("the tab took the first place") { group.ids.first() == gamma } + settle(REORDER_SETTLE_MILLIS) + // And it is the rightmost tab now, geometry included. + val settled = requireNotNull(fixture.tabSlotInWindowPx("Gamma")) + check(abs(settled.right - alpha.right) <= LAYOUT_TOLERANCE_PX) { + "the reordered tab is not where the first slot is: $settled vs $alpha" + } + }, + ) + } + + /** + * The strip's close button shuts the tab's width before the workspace hears + * about it, which is what makes a close a motion rather than a jump: right + * after the click the tab is still there, and it is gone once the animation + * has had its time. + * + * The other half of the same contract: a tab the strip has not shown yet is + * marked as arriving, so it opens by width instead of appearing at its full + * one — see `TabEntry.isEntering`. + */ + private fun theCloseButtonPlaysTheTabOut(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace the close button plays the tab out before the workspace drops it", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val slot = requireNotNull(fixture.tabSlotInWindowPx("Beta")) + val driver = SyntheticPointerDriver(first) + + // The close button of the stock tab sits at its trailing edge. + val closeButton = Offset(slot.right - CLOSE_BUTTON_INSET_PX, slot.center.y) + driver.click(closeButton) + settle(ONE_FRAME_MILLIS) + check(workspace.tab(beta) != null) { + "the workspace dropped the tab before the strip could play it out" + } + awaitUntil("the tab is gone once its width has shut") { workspace.tab(beta) == null } + check(requireNotNull(fixture.groupOf("Alpha")).ids.size == 2) { + "the strip did not settle on two tabs: ${fixture.groupOf("Alpha")?.ids}" + } + + // A tab declared now has not been shown yet: it is marked as arriving. + fixture.titles += "Delta" + awaitUntil("Delta is declared") { workspace.tab(fixture.tabId("Delta")) != null } + awaitUntil("and the strip has taken it in hand") { + workspace.tab(fixture.tabId("Delta"))?.isEntering == false + } + settle() + check(requireNotNull(fixture.groupOf("Alpha")).ids.size == 3) { "Delta did not join the strip" } + }, + ) + } + + /** + * What the strip's motion actually is, asserted rather than looked at: the + * tab in hand is drawn at exactly the pointer's travel since the grab, a + * neighbour whose centre that tab's leading edge has crossed comes to rest + * exactly one tab-width aside, a neighbour it has not reached stays at + * zero, and the release slides the carried tab into the crossed + * neighbour's slot *before* the order changes — every offset back to zero + * once it has. + */ + private fun theCarriedTabAndItsNeighboursMoveByTheNumbers(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace the carried tab and its neighbours move by the numbers", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val motion = requireNotNull(workspace.motionOf(group)) { "the strip published no motion" } + val gamma = fixture.tabId("Gamma") + val beta = fixture.tabId("Beta") + val alpha = fixture.tabId("Alpha") + val gammaSlot = requireNotNull(motion.slotOf(gamma)) { "no slot for the tab to be carried" } + val betaSlot = requireNotNull(motion.slotOf(beta)) + val width = gammaSlot.width + check(width > MIN_TAB_WIDTH_PX) { "a tab of $width px is too narrow to carry meaningfully" } + + // Grabbed in the middle of the last tab, then carried far + // enough left that its leading edge passes the middle tab's + // centre — the library's rule, and ours. + // The workspace's own drag: where the app places its windows, + // that is what the strip animates from. The local gesture of a + // compositor-placed window is covered on the Wayland leg. + val grab = requireNotNull(fixture.tabCenterPx("Gamma")) + val session = requireNotNull(workspace.beginDrag(gamma, stripOrigin(first), grab)) + session.update(grab) + val travel = -(width * CARRY_SLOTS) + val carriedTo = grab + Offset(travel, 0f) + session.update(carriedTo) + + awaitUntil("the middle tab has stepped aside by one tab: ${motion.drawnOffsetOf(beta)}") { + abs(motion.drawnOffsetOf(beta) - width) <= MOTION_TOLERANCE_PX + } + check(abs(motion.drawnOffsetOf(gamma) - travel) <= MOTION_TOLERANCE_PX) { + "the carried tab is drawn at ${motion.drawnOffsetOf(gamma)} px, the pointer travelled $travel" + } + check(abs(motion.drawnOffsetOf(alpha)) <= MOTION_TOLERANCE_PX) { + "a tab the carried one never reached moved: ${motion.drawnOffsetOf(alpha)}" + } + check(motion.slotOf(gamma) == gammaSlot && motion.slotOf(beta) == betaSlot) { + "the motion moved the layout: the slots a drop resolves against must not budge" + } + + // Released: it slides into the middle tab's slot, and only then + // is the order changed — with every offset back to zero. + session.end(carriedTo) + awaitUntil("the reorder is applied once the slide is over") { + group.ids == listOf(alpha, gamma, beta) + } + check(abs(motion.drawnOffsetOf(gamma)) <= MOTION_TOLERANCE_PX) { + "the tab kept an offset after the order changed: ${motion.drawnOffsetOf(gamma)}" + } + check(abs(motion.drawnOffsetOf(beta)) <= MOTION_TOLERANCE_PX) { + "a neighbour kept an offset after the order changed: ${motion.drawnOffsetOf(beta)}" + } + check(workspace.pendingReorder == null) { "the settle was never cleared" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** One frame at 60 Hz: long enough for the reorder to be laid out, far from the animation's end. */ + private const val ONE_FRAME_MILLIS = 24L + + /** Comfortably past the slide home. */ + private const val REORDER_SETTLE_MILLIS = 400L + + /** Just inside a slot's leading edge: the index before that tab. */ + private const val EDGE_PROBE_PX = 4f + + /** Below the strip: the window's body, where a dragged tab is out of the strip's hands. */ + private const val OUT_OF_STRIP_PX = 60f + + /** Inside a tab's trailing edge, where the stock strip puts its close button. */ + private const val CLOSE_BUTTON_INSET_PX = 12f + + /** Far enough for the carried tab's leading edge to pass one neighbour's centre. */ + private const val CARRY_SLOTS = 0.8f + + /** A spring settles within a pixel; anything larger is a wrong number, not a rounding. */ + private const val MOTION_TOLERANCE_PX = 2f + + /** Below this a tab is too narrow for the case to mean anything. */ + private const val MIN_TAB_WIDTH_PX = 40f +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceConcurrencyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceConcurrencyHeadfulCases.kt new file mode 100644 index 000000000..af6c33b98 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceConcurrencyHeadfulCases.kt @@ -0,0 +1,403 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TabWindowGroup + +/** + * The tab workspace under load, on real windows: many tabs, many windows, and + * operations that overlap instead of taking turns. + * + * 1. **scale** — a dozen tabs spread over four windows and merged back, with + * one body composing per window the whole way; + * 2. **interleaving** — tabs declared while others are being torn off, so + * registration and window creation land in the same frames; + * 3. **churn** — out and back, over and over, with the saveable state of the + * travelling tab checked every round; + * 4. **overlapping gestures** — several drag sessions alive at once, ended out + * of order, and tabs (or every window) closed while they are in flight; + * 5. **storms and stacks** — the rest of the load story lives in + * [TabWorkspaceStormHeadfulCases]: hundreds of selections, reorders and + * pointer samples, and windows stacked on the same spot. + * + * Native Wayland is skipped along with the rest of the tab suite. + */ +internal object TabWorkspaceConcurrencyHeadfulCases { + fun all(): List = + listOf( + manyTabsSpreadOverFourWindowsAndMergedBack(), + declarationsInterleavedWithTearOffs(), + churnKeepsStateAndOneBodyPerWindow(), + severalLiveSessionsOnlyTheLastActs(), + closingTheDraggedTabMidGestureIsSurvivable(), + closingEveryTabWhileSessionsAreLive(), + ) + + /** + * The shape of a real session after an hour's work: a dozen tabs spread + * over four windows, then merged back into one. Windows must appear and + * disappear with the tabs and exactly one body must compose per window at + * every step — a body left behind in a window that lost its tab is a leak + * the user pays for in memory and in effects that keep running. + */ + private fun manyTabsSpreadOverFourWindowsAndMergedBack(): TaoWindowTestCase { + val titles = (1..TAB_CROWD).map { "T$it" } + val fixture = + TabWorkspaceFixture( + initialTitles = titles, + windowSize = DpSize(CROWD_WINDOW_W_DP.dp, CROWD_WINDOW_H_DP.dp), + ) + return TaoWindowTestCase( + name = "tab concurrency a dozen tabs spread over four windows and merge back", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + check(workspace.tabs.size == TAB_CROWD) { "declared ${workspace.tabs.size} of $TAB_CROWD tabs" } + + // Four windows of three tabs each: the first tab of each triple + // is torn off, the other two follow it. + val homes = ArrayList() + homes += requireNotNull(fixture.groupOf(titles.first())) + for (start in TABS_PER_WINDOW until TAB_CROWD step TABS_PER_WINDOW) { + val lead = fixture.tabId(titles[start]) + val group = + requireNotNull(workspace.tearOff(lead, tearOffRectPx(first), first.scaleFactor)) { + "tearing ${titles[start]} off produced no window" + } + awaitMappedStrip(fixture, group) + for (offset in 1 until TABS_PER_WINDOW) { + workspace.move(fixture.tabId(titles[start + offset]), group) + } + awaitUntil("window ${homes.size + 1} holds $TABS_PER_WINDOW tabs") { + group.ids.size == TABS_PER_WINDOW + } + homes += group + } + check(workspace.groups.size == WINDOW_CROWD) { + "expected $WINDOW_CROWD windows, got ${workspace.groups.size}" + } + for (group in homes) awaitMappedStrip(fixture, group) + awaitUntil("one body per window composes") { fixture.composedBodies.value == WINDOW_CROWD } + check(workspace.groups.sumOf { it.ids.size } == TAB_CROWD) { + "tabs went missing across the spread: ${workspace.groups.map { it.ids.size }}" + } + + // And everything back into the first window, in order. + val home = homes.first() + for (title in titles.drop(TABS_PER_WINDOW)) { + workspace.move(fixture.tabId(title), home) + } + awaitUntil("one window holds every tab") { + workspace.groups.size == 1 && home.ids.size == TAB_CROWD + } + awaitUntil("only that window's body composes") { fixture.composedBodies.value == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(home.ids.toSet() == titles.map(fixture::tabId).toSet()) { + "the merge lost or duplicated a tab: ${home.ids}" + } + check(requireNotNull(first.outerBoundsPx())[2] > 0) { "the surviving window was destroyed" } + }, + ) + } + + /** + * Registration and window creation landing in the same frames: the app + * opens tabs while the user is pulling others out. Both paths write the + * same group list, and a new tab must join the window that is active *now* + * rather than one being dismantled. + */ + private fun declarationsInterleavedWithTearOffs(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab concurrency declarations interleaved with tear-offs lose no tabs", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + + repeat(INTERLEAVE_ROUNDS) { round -> + // A new tab is declared in the same frame as a tear-off of + // the tab declared last round. + val fresh = "New$round" + fixture.titles += fresh + val previous = if (round == 0) "Beta" else "New${round - 1}" + val id = fixture.tabId(previous) + val from = fixture.groupOf(previous)?.window ?: first + workspace.tearOff(id, tearOffRectPx(from), from.scaleFactor) + awaitUntil("round $round: the fresh tab was registered") { + workspace.tab(fixture.tabId(fresh)) != null + } + } + + val expected = 2 + INTERLEAVE_ROUNDS + awaitUntil("every declared tab is in a group") { + workspace.tabs.size == expected && workspace.tabs.all { it.group != null } + } + awaitUntil("every group has a mapped window") { + workspace.groups.all { it.window?.hasRealFramePx() == true } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.sumOf { it.ids.size } == expected) { + "tabs went missing: ${workspace.groups.map { it.ids }}" + } + check(workspace.groups.none { it.ids.isEmpty() }) { "an empty group survived" } + awaitUntil("one body per window composes") { + fixture.composedBodies.value == workspace.groups.size + } + }, + ) + } + + /** + * Out and back, over and over. Every round the travelling tab's saveable + * state has to come along, and the body count has to match the window + * count — the two things a leak shows up in. + */ + private fun churnKeepsStateAndOneBodyPerWindow(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab concurrency churning a tab out and back keeps its state and one body per window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + workspace.select(beta) + awaitUntil("Beta is composed") { fixture.windowOf("Beta") != null } + requireNotNull(fixture.counters.value[beta]).value = TAB_SAVED_CLICKS + + repeat(CHURN_ROUNDS) { round -> + val torn = + requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) { + "round $round: tear-off produced no window" + } + awaitUntil("round $round: Beta composed in its own window") { + val window = torn.window + window != null && fixture.windowOf("Beta") === window && window !== first + } + awaitUntil("round $round: two bodies compose") { fixture.composedBodies.value == 2 } + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "round $round: state lost on the way out" + } + + workspace.move(beta, requireNotNull(fixture.groupOf("Alpha")), index = round % 2) + awaitUntil("round $round: Beta is back") { + workspace.groups.size == 1 && fixture.windowOf("Beta") === first + } + awaitUntil("round $round: one body composes") { fixture.composedBodies.value == 1 } + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "round $round: state lost on the way back" + } + } + check(workspace.tabs.size == 3) { "the churn lost a tab: ${workspace.tabs.map { it.id }}" } + check(requireNotNull(fixture.groupOf("Beta")).ids.size == 3) { + "the strip ended up with ${fixture.groupOf("Beta")?.ids}" + } + }, + ) + } + + /** + * Several sessions alive at once — a synthetic replay, a stuck gesture, a + * second pointer — and ended out of order. Exactly one may act: the one + * the workspace is publishing. Everything else has to be inert, including + * when it is ended *after* the live one. + */ + private fun severalLiveSessionsOnlyTheLastActs(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab concurrency several live drag sessions leave only the last one acting", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val away = requireNotNull(fixture.farFromStripPx(group)) + + val sessions = + titles.mapNotNull { title -> + val grab = fixture.tabCenterPx(title) ?: return@mapNotNull null + val session = + workspace.beginDrag(fixture.tabId(title), stripOrigin(first), grab) + ?: return@mapNotNull null + session.update(grab) + title to session + } + check(sessions.size >= 2) { "not enough sessions to supersede: ${sessions.size}" } + val (liveTitle, live) = sessions.last() + check(workspace.draggedTab?.id == fixture.tabId(liveTitle)) { + "the last session must be the published one, got ${workspace.draggedTab?.id}" + } + + // Every superseded session, driven and ended: all inert. + for ((title, session) in sessions.dropLast(1)) { + session.update(away) + session.end(away) + check(workspace.groups.size == 1) { "superseded session ($title) moved a tab" } + check(workspace.draggedTab?.id == fixture.tabId(liveTitle)) { + "superseded session ($title) took the live one down" + } + } + + live.update(away) + live.end(away) + awaitUntil("only the live session tore its tab off") { + workspace.groups.size == 2 && + fixture.groupOf(liveTitle)?.ids == listOf(fixture.tabId(liveTitle)) + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.tabs.size == titles.size) { "a tab was lost: ${workspace.tabs.map { it.id }}" } + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "drag feedback outlived the sessions" + } + // Ending a superseded session again changes nothing. + for ((_, session) in sessions.dropLast(1)) session.end(away) + settle() + check(workspace.groups.size == 2) { "a second end resurrected a gesture" } + }, + ) + } + + /** + * The tab under the pointer, closed mid-gesture — by a shortcut, by the + * app, by another window. The release must not move a tab that no longer + * exists, nor bring it back. + */ + private fun closingTheDraggedTabMidGestureIsSurvivable(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab concurrency closing the dragged tab mid-gesture is survivable", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val group = requireNotNull(fixture.groupOf("Beta")) + val strip = requireNotNull(fixture.stripRectPx(group)) + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val away = requireNotNull(fixture.farFromStripPx(group)) + + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + session.update(away) + check(workspace.dragGhost != null) { "the tear-out must be previewed" } + + workspace.close(beta) + awaitUntil("the dragged tab is gone") { workspace.tab(beta) == null } + // Samples keep arriving after the tab went — the pointer does + // not know anything happened. + session.update(Offset(strip.center.x, strip.center.y)) + session.update(away) + session.end(away) + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.tab(beta) == null) { "the closed tab came back" } + check(workspace.groups.size == 1) { "the release opened a window: ${workspace.groups.size}" } + check(workspace.tabs.size == 2) { "tabs went missing: ${workspace.tabs.map { it.id }}" } + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "drag feedback outlived the closed tab" + } + awaitUntil("one body composes") { fixture.composedBodies.value == 1 } + // And the workspace still works. + val alpha = fixture.tabId("Alpha") + val nextGrab = requireNotNull(fixture.tabCenterPx("Alpha")) + val next = requireNotNull(workspace.beginDrag(alpha, stripOrigin(first), nextGrab)) + next.update(nextGrab) + next.update(away) + next.end(away) + awaitUntil("a later drag still works") { workspace.groups.size == 2 } + }, + ) + } + + /** + * Everything closed while three gestures are in flight — the shape of an + * app quitting under the user's hands. The workspace has to end up empty, + * with no window, no body and no feedback, and report the last window + * exactly once. + */ + private fun closingEveryTabWhileSessionsAreLive(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab concurrency closing every tab while gestures are live empties cleanly", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + val away = requireNotNull(fixture.farFromStripPx(group)) + + val sessions = + titles.mapNotNull { title -> + val grab = fixture.tabCenterPx(title) ?: return@mapNotNull null + val session = + workspace.beginDrag(fixture.tabId(title), stripOrigin(first), grab) + ?: return@mapNotNull null + session.update(grab) + session.update(away) + session + } + + workspace.tabs.map { it.id }.forEach(workspace::close) + awaitUntil("the workspace emptied") { workspace.groups.isEmpty() && workspace.tabs.isEmpty() } + for (session in sessions) { + session.update(away) + session.end(away) + } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.groups.isEmpty()) { "a release resurrected a window: ${workspace.groups.size}" } + check(workspace.tabs.isEmpty()) { "a release resurrected a tab: ${workspace.tabs.map { it.id }}" } + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "drag feedback outlived the workspace" + } + awaitUntil("no body is composing") { fixture.composedBodies.value == 0 } + awaitUntil("the last window was reported once") { fixture.lastWindowClosedCount.value == 1 } + settle() + check(fixture.lastWindowClosedCount.value == 1) { + "reported ${fixture.lastWindowClosedCount.value}× for one emptying" + } + }, + ) + } + + private const val TAB_CROWD = 12 + private const val TABS_PER_WINDOW = 3 + private const val WINDOW_CROWD = TAB_CROWD / TABS_PER_WINDOW + private const val CROWD_WINDOW_W_DP = 1100 + private const val CROWD_WINDOW_H_DP = 360 + private const val INTERLEAVE_ROUNDS = 5 + private const val CHURN_ROUNDS = 6 + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt new file mode 100644 index 000000000..6c9f131fc --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceFixture.kt @@ -0,0 +1,537 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.Tab +import dev.nucleusframework.window.tao.TabDragOrigin +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabStrip +import dev.nucleusframework.window.tao.TabWindowGroup +import dev.nucleusframework.window.tao.TabWindows +import dev.nucleusframework.window.tao.TabWorkspace +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.time.Duration.Companion.milliseconds + +/** + * Everything one tab case observes; fresh per case, so cases never share + * windows or state. + * + * The tabs are declared at application scope next to [TabWindows], exactly as + * an app declares them, and each publishes the window it is currently composed + * in plus its `rememberSaveable` state — which is what lets a case assert that + * a tab really moved and really kept its state. + */ +internal class TabWorkspaceFixture( + initialTitles: List = listOf("Alpha", "Beta"), + private val windowSize: DpSize = DpSize(TAB_WINDOW_W_DP.dp, TAB_WINDOW_H_DP.dp), + /** + * When `true`, every tab body is also an inbound file-drop target and what + * it receives is recorded in [dropLog]. Off by default: it adds a + * drag-and-drop node to the body, which no case that is not about file + * drops should have to reason about. + */ + private val fileDropTargets: Boolean = false, + /** The direction the strip is composed in: a right-to-left app lays its tabs out from the right. */ + private val layoutDirection: LayoutDirection = LayoutDirection.Ltr, + /** + * When `true`, the strip is given a hover card that records itself in + * [shownHoverCard]. Off by default: it puts a popup over the window, which + * no case that is not about hovering should have to reason about. + */ + private val hoverPreview: Boolean = false, +) { + val workspace = TabWorkspace(defaultWindowSize = windowSize) + + private val dropLogs = HashMap() + + /** What the body of the tab titled [title] received from inbound file drags. */ + fun dropLog(title: String): FileDropLog = dropLogs.getOrPut(tabId(title)) { FileDropLog() } + + /** Ids in declaration order; a case may add to this to open a tab mid-run. */ + val titles = mutableStateListOf(*initialTitles.toTypedArray()) + + /** Bounds of the window-chrome strip the body wrapper draws, per group, in window px. */ + val bodyWrapperBounds = mutableStateOf>(emptyMap()) + + /** How many times a body wrapper was built, over every window of the run. */ + val bodyWrapperBuilds = mutableIntStateOf(0) + + /** + * The windows each tab's body is composed in, by tab id, oldest host first. + * + * A list, not a single window: a tab that leaves a multi-tab window is + * composed in *both* windows until the window it left renders again, and + * Compose coalesces frames — so the two hosts genuinely overlap for as long + * as the source window has a frame pending. Recording one window per tab + * made the arriving host overwrite the departing one, and the departing + * one's disposal then erased the entry for a body that was still composed. + */ + val composedIn = mutableStateOf>>(emptyMap()) + + /** The `rememberSaveable` counter of each tab's current composition, by tab id. */ + val counters = mutableStateOf>>(emptyMap()) + + /** The scroll state of each tab's body — a `rememberSaveable` Int under the hood. */ + val scrolls = mutableStateOf>(emptyMap()) + + /** How many tab bodies are composing right now; two overlap for a frame while moving. */ + val composedBodies = mutableIntStateOf(0) + + /** + * How many times each tab's body has been built from scratch. A move to + * another window necessarily rebuilds it — the two windows are two + * compositions — but a reorder or a selection change must not. + */ + val bodyIncarnations = mutableStateOf>(emptyMap()) + + /** The tab whose hover card is composed right now, or `null` while none is. */ + val shownHoverCard = mutableStateOf(null) + + /** How many hover cards have been composed over the run. */ + val hoverCardBuilds = mutableIntStateOf(0) + + /** + * The card the strip is given when the fixture was built with + * `hoverPreview`: a plain square that reports which tab it belongs to for + * as long as it is composed. + * + * A short delay rather than the stock one, so a case does not spend most + * of its time waiting; the delay itself is not asserted — a wall-clock + * threshold is exactly what makes a case flaky on a loaded runner. + */ + private val hoverCard: TabHoverPreview? = + if (!hoverPreview) { + null + } else { + TabHoverPreview(delay = HOVER_CARD_DELAY_MILLIS.milliseconds) { + DisposableEffect(tab.id) { + shownHoverCard.value = tab.id + hoverCardBuilds.value++ + onDispose { if (shownHoverCard.value == tab.id) shownHoverCard.value = null } + } + Box(Modifier.size(HOVER_CARD_W_DP.dp, HOVER_CARD_H_DP.dp).background(Color(0xFF3AA76D))) + } + } + + /** Set once [TabWindows] reports the last window gone. */ + val lastWindowClosed = mutableStateOf(false) + + /** + * How many times [TabWindows] has reported the last window gone. The + * callback fires per non-empty → empty transition, so a workspace that is + * emptied, filled and emptied again reports twice — and never for the + * empty workspace of the first composition. + */ + val lastWindowClosedCount = mutableIntStateOf(0) + + fun tabId(title: String): String = "tab-${title.lowercase()}" + + /** The group of the tab titled [title], or `null` while it has none. */ + fun groupOf(title: String): TabWindowGroup? = workspace.tab(tabId(title))?.group + + /** The window showing the tab titled [title], or `null` while it is not composed. */ + fun windowOf(title: String): TaoWindow? = composedIn.value[tabId(title)]?.lastOrNull() + + /** Strip rect of [group] on screen (physical px), or `null` before its first layout. */ + fun stripRectPx(group: TabWindowGroup): Rect? = workspace.stripGeometry(group)?.layoutScreenRectPx() + + /** Slot of the tab titled [title] on screen (physical px), or `null` before its first layout. */ + fun tabRectPx(title: String): Rect? { + val group = groupOf(title) ?: return null + val index = group.ids.indexOf(tabId(title)).takeIf { it >= 0 } ?: return null + val slot = group.slotsInWindowPx.getOrNull(index) ?: return null + val client = workspace.stripGeometry(group)?.clientOriginPx() ?: return null + return slot.translate(client) + } + + /** + * Slot of the tab titled [title] in its **window's** content space + * (physical px) — where a pointer event aims, and the one space that is + * meaningful on every platform, screen placement or not. + */ + fun tabSlotInWindowPx(title: String): Rect? { + val group = groupOf(title) ?: return null + val index = group.ids.indexOf(tabId(title)).takeIf { it >= 0 } ?: return null + return group.slotsInWindowPx.getOrNull(index) + } + + /** Centre of [tabSlotInWindowPx]. */ + fun tabPointInWindowPx(title: String): Offset? = tabSlotInWindowPx(title)?.center + + /** + * What the aim of a robot gesture was derived from, for a case that timed + * out: the frame the platform reported, the content size the strip was + * measured in, the client origin those two imply, and the slot itself. + * + * `aimed (x, y), pointer at (x, y)` on its own only proves the pointer + * went where the case asked. Whether *that* was the right place is this. + */ + fun geometryReport(title: String): String { + val group = groupOf(title) ?: return "no group for $title" + val geometry = workspace.stripGeometry(group) ?: return "no strip geometry for $title" + val outer = group.window?.outerBoundsPx()?.toList() + return "outer=$outer content=${geometry.containerSizePx} client=${geometry.clientOriginPx()} " + + "strip=${geometry.layoutBoundsInWindowPx} slot=${tabSlotInWindowPx(title)} " + + "scale=${group.window?.scaleFactor} focused=${group.window?.isFocused} " + + "windowsOverAim=${groupsCovering(HeadfulRobot.lastAimPoint)}" + } + + /** + * Which groups' windows cover [point] (logical screen points), in + * workspace order — a press lands in whichever of them the platform has on + * top, so a case that aimed right and saw nothing has its answer here. + */ + private fun groupsCovering(point: java.awt.Point?): List { + if (point == null) return emptyList() + return workspace.groups + .filter { group -> + val window = group.window ?: return@filter false + val outer = window.outerBoundsPx() ?: return@filter false + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + val x = point.x * scale + val y = point.y * scale + x >= outer[0] && x < outer[0] + outer[2] && y >= outer[1] && y < outer[1] + outer[3] + }.map { it.id } + } + + /** Screen position (physical px) of the centre of the tab titled [title] in its strip. */ + fun tabCenterPx(title: String): Offset? { + val group = groupOf(title) ?: return null + val index = group.ids.indexOf(tabId(title)).takeIf { it >= 0 } ?: return null + val slot = group.slotsInWindowPx.getOrNull(index) ?: return null + val client = workspace.stripGeometry(group)?.clientOriginPx() ?: return null + return client + slot.center + } + + @Composable + fun ApplicationScope.Windows() { + TabWindows( + workspace = workspace, + onLastWindowClosed = { + lastWindowClosed.value = true + lastWindowClosedCount.value++ + }, + strip = { + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + TabStrip(hoverPreview = hoverCard) + } + }, + // The app's window-level chrome: a strip of its own above the tab + // body, recording where it landed and how many times it was built, + // so a case can tell "moved" from "rebuilt". + windowBodyWrapper = { body -> + val id = workspace.groupOf(window)?.id + val incarnation = remember { Any() } + DisposableEffect(incarnation) { + bodyWrapperBuilds.value++ + onDispose { if (id != null) bodyWrapperBounds.value = bodyWrapperBounds.value - id } + } + Column(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxWidth() + .height(BODY_CHROME_H_DP.dp) + .onGloballyPositioned { + if (id != + null + ) { + bodyWrapperBounds.value = + bodyWrapperBounds.value + (id to it.boundsInWindow()) + } + }, + ) + Box(Modifier.fillMaxWidth().weight(1f)) { body() } + } + }, + ) + for (title in titles) { + val id = tabId(title) + Tab(workspace = workspace, id = id, title = title) { + val clicks = rememberSaveable { mutableStateOf(0) } + val scroll = rememberScrollState() + val window = LocalTaoWindow.current + // A plain `remember`: it comes back at 0 whenever this subtree + // is rebuilt rather than moved, which is what a body must not + // do when its tab only changes window. + val incarnation = remember { Any() } + SideEffect { + counters.value = counters.value + (id to clicks) + scrolls.value = scrolls.value + (id to scroll.value) + } + // The host is published for exactly this body's lifetime, not + // on every recomposition: a body that outlives its selection + // for a frame never recomposes again, so a SideEffect would + // never get to republish it. + DisposableEffect(incarnation) { + composedBodies.value++ + bodyIncarnations.value = bodyIncarnations.value + (id to (bodyIncarnations.value[id] ?: 0) + 1) + if (window != null) composedIn.value = composedIn.value.plusHost(id, window) + onDispose { + composedBodies.value-- + if (window != null) composedIn.value = composedIn.value.minusHost(id, window) + } + } + val body = + if (fileDropTargets) { + Modifier.fillMaxSize().fileDropRecorder(dropLog(title)).verticalScroll(scroll) + } else { + Modifier.fillMaxSize().verticalScroll(scroll) + } + Column(body) { + Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) + Box(Modifier.fillMaxSize().background(Color(0xFF1F4E9C))) + } + } + } + } +} + +/** [window] added as the newest host composing the body of [id]. */ +internal fun Map>.plusHost( + id: String, + window: TaoWindow, +): Map> = this + (id to ((this[id] ?: emptyList()) + window)) + +/** [window] dropped as a host of [id], leaving whatever other host is still composing it. */ +internal fun Map>.minusHost( + id: String, + window: TaoWindow, +): Map> { + val rest = (this[id] ?: return this).filterNot { it === window } + return if (rest.isEmpty()) this - id else this + (id to rest) +} + +internal const val TAB_WINDOW_W_DP = 560 +internal const val TAB_WINDOW_H_DP = 380 +internal const val TAB_SAVED_CLICKS = 5 + +/** Vertical grab point inside a tab strip, in dp from the strip's top. */ +internal const val TAB_GRAB_Y_DP = 10f + +/** The fixture's hover card: quick to appear, and big enough to be seen on a screenshot. */ +private const val HOVER_CARD_DELAY_MILLIS = 120 +private const val HOVER_CARD_W_DP = 180 +private const val HOVER_CARD_H_DP = 90 + +/** Far enough from every window that a drop there can only mean "tear off". */ +internal const val TAB_DROP_FAR_PX = 340f + +/** + * The case window a tab case does not use: the harness always composes one and + * hands it to the driver, so it is parked out of the way of the tab windows and + * kept small. The tab windows are the ones the assertions are about. + */ +internal fun idleCaseWindowState() = + WindowState( + position = WindowPosition.Absolute(IDLE_CASE_X_DP.dp, IDLE_CASE_Y_DP.dp), + size = idleCaseWindowSize(), + ) + +internal fun idleCaseWindowSize() = DpSize(IDLE_CASE_W_DP.dp, IDLE_CASE_H_DP.dp) + +/** A strip origin for [window], the call site a real drag handle uses. */ +internal fun stripOrigin(window: TaoWindow) = TabDragOrigin.Strip(window) + +/** + * A rect for tearing a tab off [window] without a pointer: the same size, + * offset down and to the right so the new window is visibly its own. + */ +internal fun tearOffRectPx(window: TaoWindow): Rect { + val outer = requireNotNull(window.outerBoundsPx()) { "the source window is not mapped" } + val offset = TEAR_OFF_OFFSET_DP * window.scaleFactor + return Rect( + outer[0] + offset, + outer[1] + offset, + outer[0] + offset + outer[2], + outer[1] + offset + outer[3], + ) +} + +/** + * Waits until every named tab has been declared and the window showing the + * selected one is mapped, and returns that window. + */ +internal suspend fun TaoWindowTestScope.awaitTabWindows( + fixture: TabWorkspaceFixture, + vararg titles: String, +): TaoWindow { + awaitUntil("case window mapped") { bounds() != null } + awaitUntil("every tab declared") { titles.all { fixture.workspace.tab(fixture.tabId(it)) != null } } + awaitUntil("a tab window is mapped with a real size") { + val window = + fixture.workspace.groups + .firstOrNull() + ?.window ?: return@awaitUntil false + window.hasRealFramePx() + } + awaitUntil("the selected tab's body is composed") { fixture.composedBodies.value > 0 } + awaitUntil("the strip published its slots") { + val group = fixture.workspace.groups.firstOrNull() ?: return@awaitUntil false + fixture.stripRectPx(group) != null && group.slotsInWindowPx.size >= group.ids.size + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull( + fixture.workspace.groups + .first() + .window, + ) +} + +/** + * [awaitTabWindows] without the screen half: waits for the window, the body + * and the strip's slots *in the window*, which is all a compositor-placed + * surface publishes. + */ +internal suspend fun TaoWindowTestScope.awaitTabWindowsInWindow( + fixture: TabWorkspaceFixture, + vararg titles: String, +): TaoWindow { + awaitUntil("case window mapped") { bounds() != null } + awaitUntil("every tab declared") { titles.all { fixture.workspace.tab(fixture.tabId(it)) != null } } + awaitUntil("a tab window is mapped with a real size") { + fixture.workspace.groups + .firstOrNull() + ?.window + ?.hasRealFramePx() == true + } + awaitUntil("the selected tab's body is composed") { fixture.composedBodies.value > 0 } + awaitUntil("the strip published its slots in the window") { + val group = fixture.workspace.groups.firstOrNull() ?: return@awaitUntil false + val strip = fixture.workspace.stripGeometry(group)?.layoutBoundsInWindowPx + strip?.isEmpty == false && group.slotsInWindowPx.size >= group.ids.size + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull( + fixture.workspace.groups + .first() + .window, + ) +} + +/** Waits until [group]'s window is mapped with a laid-out strip, and returns it. */ +internal suspend fun TaoWindowTestScope.awaitMappedStrip( + fixture: TabWorkspaceFixture, + group: TabWindowGroup, +): TaoWindow { + awaitUntil("the group's window is mapped with a real size") { + group.window?.hasRealFramePx() == true + } + awaitUntil("its strip published its geometry and slots") { + fixture.stripRectPx(group) != null && group.slotsInWindowPx.size >= group.ids.size + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull(group.window) +} + +/** Screen point on [group]'s strip, [fraction] of the way along it. */ +internal fun TabWorkspaceFixture.stripPointPx( + group: TabWindowGroup, + fraction: Float, +): Offset? { + val strip = stripRectPx(group) ?: return null + return Offset(strip.left + strip.width * fraction, strip.center.y) +} + +/** A point far below [group]'s strip: a drop there can only mean "tear off". */ +internal fun TabWorkspaceFixture.farFromStripPx(group: TabWindowGroup): Offset? { + val strip = stripRectPx(group) ?: return null + return Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX) +} + +/** Skip reason for a case that needs the AWT Robot, or `null` when input can be injected. */ +internal fun robotSkipReason(): String? = HeadfulRobot.unavailableReason?.let { "no input injection: $it" } + +/** Inside the first tab of a strip, so a drop there inserts at the head. */ +internal const val STRIP_HEAD_FRACTION = 0.02f + +/** A little further along a strip, past the first tab's midpoint. */ +internal const val STRIP_MID_FRACTION = 0.2f + +private const val IDLE_CASE_X_DP = 40 +private const val IDLE_CASE_Y_DP = 620 +private const val IDLE_CASE_W_DP = 220 +private const val IDLE_CASE_H_DP = 120 +private const val TEAR_OFF_OFFSET_DP = 60f + +/** Rounding across a dp round trip, plus whatever the WM adds to a frame. */ +internal const val TAB_SIZE_TOLERANCE_PX = 40L + +/** Where along a strip a merge drops: past the midpoint of a single tab, so it appends. */ +internal const val MERGE_X_FRACTION = 0.35f + +/** Enough out-and-back rounds to expose a state leak, few enough to stay quick. */ +internal const val TAB_CHURN_CYCLES = 2 + +/** + * How far the ghost may trail the pointer, in physical px: one step of a + * robot drag, since the last synthetic move may still be in flight when the + * assertion runs. + */ +internal const val GHOST_FOLLOW_TOLERANCE_PX = 60f + +/** + * Waits until every named tab is declared, its window mapped and its strip has + * published a slot per tab. + * + * The counterpart of [awaitTabWindows] for cases that aim at a tab in **window** + * coordinates: it asks for nothing that native Wayland cannot answer, so a + * pointer case built on it runs on every backend. + */ +internal suspend fun TaoWindowTestScope.awaitTabSlots( + fixture: TabWorkspaceFixture, + vararg titles: String, +): TaoWindow { + awaitUntil("case window mapped") { bounds() != null } + awaitUntil("every tab declared") { titles.all { fixture.workspace.tab(fixture.tabId(it)) != null } } + awaitUntil("a tab window is mapped with a real size") { + fixture.workspace.groups + .firstOrNull() + ?.window + ?.hasRealFramePx() == true + } + awaitUntil("the selected tab's body is composed") { fixture.composedBodies.value > 0 } + awaitUntil("the strip published a slot per tab with a real width") { + val group = fixture.workspace.groups.firstOrNull() ?: return@awaitUntil false + group.slotsInWindowPx.size >= group.ids.size && group.slotsInWindowPx.all { it.width > 1f } + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull( + fixture.workspace.groups + .first() + .window, + ) +} + +/** Height of the window-chrome strip the fixture's body wrapper draws above the tab body. */ +internal const val BODY_CHROME_H_DP = 24 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceHeadfulCases.kt new file mode 100644 index 000000000..257661343 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceHeadfulCases.kt @@ -0,0 +1,455 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.math.abs + +/** + * Real-window coverage for the tab workspace: one [dev.nucleusframework.window.tao.DecoratedWindow] + * per group, tabs moving between them, and the windows appearing and + * disappearing with the tabs. + * + * 1. the whole lifecycle — two tabs in one window, one torn off into a second + * window with a real mouse, merged back by dropping it on the first strip, + * then closed until the last window goes and `onLastWindowClosed` fires; + * 2. `rememberSaveable` state and scroll position survive every move, while a + * reorder inside one window rebuilds nothing; + * 3. a snapshot restores the windows it described, tabs declared afterwards + * included; + * 4. selection: closing the selected tab picks a neighbour, in real windows; + * 5. the app's `windowBodyWrapper` is composed once per window, under the + * strip and above the tab body, and neither a selection change nor a + * tear-off rebuilds it. + * + * The strip's motion — carrying a tab, the neighbours stepping aside, tabs + * opening and closing — lives in [TabStripMotionHeadfulCases]. + * + * The edge cases — abrupt pointer jumps, a backing-scale change, minimize, + * maximize, interrupted gestures — live in [TabWorkspaceStressHeadfulCases]. + * + * Native Wayland is skipped: without client-side window positioning neither + * the tear-off placement nor the window drag is observable. + */ +internal object TabWorkspaceHeadfulCases { + fun all(): List = + listOf( + tearOffMergeAndCloseLifecycle(), + stateSurvivesMovesAndReordersDoNotRebuild(), + snapshotRestoresWindows(), + closingTheSelectedTabPicksANeighbour(), + theWindowBodyWrapperHoldsTheWindowsOwnChrome(), + ) + + private fun theWindowBodyWrapperHoldsTheWindowsOwnChrome(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab workspace hosts the window's own chrome under the strip, built once per window", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + awaitUntil("the window chrome is measured") { fixture.bodyWrapperBounds.value[group.id] != null } + val chrome = requireNotNull(fixture.bodyWrapperBounds.value[group.id]) + val strip = requireNotNull(workspace.stripGeometry(group)).layoutBoundsInWindowPx + val scale = first.scaleFactor + check(chrome.top >= strip.bottom - LAYOUT_TOLERANCE_PX) { + "the window chrome is not under the strip: chrome=$chrome strip=$strip" + } + check(abs(chrome.height - BODY_CHROME_H_DP * scale) <= LAYOUT_TOLERANCE_PX) { + "the chrome is ${chrome.height} px tall, asked for ${BODY_CHROME_H_DP * scale}" + } + check(chrome.width > 0f) { "the chrome has no width" } + val builtOnce = fixture.bodyWrapperBuilds.value + check(builtOnce == 1) { "the body wrapper was built $builtOnce times for one window" } + + // A selection change is a tab change: the window's chrome is not part of it. + workspace.select(fixture.tabId("Beta")) + awaitUntil("Beta is composed") { fixture.windowOf("Beta") != null } + settle() + check(fixture.bodyWrapperBuilds.value == builtOnce) { + "a selection change rebuilt the window chrome: ${fixture.bodyWrapperBuilds.value}" + } + check(fixture.bodyWrapperBounds.value[group.id] == chrome) { "the chrome moved on a tab change" } + + // A tear-off adds a window, and with it one chrome of its own. + workspace.tearOff(fixture.tabId("Beta"), tearOffRectPx(first), scale) + awaitUntil("a second window is mapped") { + workspace.groups.size == 2 && workspace.groups.all { it.window?.hasRealFramePx() == true } + } + awaitUntil("the second window's chrome is measured") { fixture.bodyWrapperBounds.value.size == 2 } + settle() + check(fixture.bodyWrapperBuilds.value == builtOnce + 1) { + "the second window did not get exactly one chrome: ${fixture.bodyWrapperBuilds.value}" + } + check(fixture.bodyWrapperBounds.value[group.id] == chrome) { "the first window's chrome was rebuilt" } + }, + ) + } + + /** + * The gesture an app is judged on: pull a tab out into its own window with + * a real mouse, push it back into the other window's strip, then close + * everything and watch the windows go with the tabs. + */ + private fun tearOffMergeAndCloseLifecycle(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture() + return TaoWindowTestCase( + name = "tab workspace tears a tab into its own window, merges it back and closes out", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + check(workspace.groups.size == 1) { "two tabs must open one window, got ${workspace.groups.size}" } + // The workspace is empty on the composition that declares the + // tabs, which must not read as "every window is gone" — an app + // wiring this to exitApplication would never open at all. + check(!fixture.lastWindowClosed.value) { "onLastWindowClosed fired before a window ever opened" } + requireNotNull(fixture.counters.value[fixture.tabId("Beta")]).value = TAB_SAVED_CLICKS + settle() + + val robot = tearBetaOff(fixture, first) + mergeBetaBack(fixture, first, robot) + closeEverything(fixture) + }, + ) + } + + /** Pulls "Beta" out of the shared strip into a window of its own. Returns whether a real mouse drove it. */ + private suspend fun TaoWindowTestScope.tearBetaOff( + fixture: TabWorkspaceFixture, + first: TaoWindow, + ): Boolean { + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val grab = requireNotNull(fixture.tabCenterPx("Beta")) { "Beta published no slot" } + val strip = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta")))) + val dropOut = Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX) + val scale = first.scaleFactor + first.focus() + awaitUntil("first window is focused") { first.isFocused } + val robot = robotPressAndDrag(grab, dropOut, scale) != null + if (robot) { + // Button still down: the ghost is the whole affordance, and only + // while it is held is the drop position certain. + awaitUntil( + "the press-drag started a drag of Beta — ${robotAim()}; ${fixture.geometryReport("Beta")}", + ) { workspace.draggedTab?.id == beta } + // Tracks the pointer within a drag step: the robot's last sample may + // still be in flight, and pinning the exact pixel would race it. + awaitUntil("the ghost follows the pointer down to the drop") { + val ghost = workspace.dragGhost ?: return@awaitUntil false + ghost.tab.id == beta && + (ghost.screenRectPx.center - dropOut).getDistance() <= GHOST_FOLLOW_TOLERANCE_PX + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + System.err.println("[tab-drag] robot unavailable, driving the drag session directly") + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + session.update(dropOut) + val ghost = requireNotNull(workspace.dragGhost) { "dragging a tab out must show a ghost" } + check(ghost.screenRectPx.contains(dropOut)) { "the ghost must sit under the pointer" } + session.end(dropOut) + } + awaitUntil("a second window holds Beta on its own") { + workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + val torn = requireNotNull(fixture.groupOf("Beta")) + awaitUntil("the torn-off window is mapped and composing Beta") { + val window = torn.window ?: return@awaitUntil false + window !== first && window.hasRealFramePx() && fixture.windowOf("Beta") != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.groupOf("Alpha")?.ids == listOf(fixture.tabId("Alpha"))) { + "Alpha should be alone in the first window: ${fixture.groupOf("Alpha")?.ids}" + } + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "Beta lost its saveable state when torn off" + } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + // The new window inherits the size of the one it came from. + val tornBounds = requireNotNull(requireNotNull(torn.window).outerBoundsPx()) + val expectedWidthPx = TAB_WINDOW_W_DP * first.scaleFactor + check(abs(tornBounds[2] - expectedWidthPx) <= TAB_SIZE_TOLERANCE_PX) { + "torn-off window is ${tornBounds[2]}px wide, expected \u2248${expectedWidthPx}px" + } + return robot + } + + /** Drops "Beta" back on the first window's strip, which empties and destroys its own window. */ + private suspend fun TaoWindowTestScope.mergeBetaBack( + fixture: TabWorkspaceFixture, + first: TaoWindow, + robot: Boolean, + ) { + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val tornWindow = requireNotNull(requireNotNull(fixture.groupOf("Beta")).window) + var tornDestroyed = false + tornWindow.onDestroyed { tornDestroyed = true } + val alphaGroup = requireNotNull(fixture.groupOf("Alpha")) + val alphaStrip = requireNotNull(fixture.stripRectPx(alphaGroup)) + val betaGrab = requireNotNull(fixture.tabCenterPx("Beta")) + // Past the midpoint of the only tab there, so Beta is appended after it. + val mergeAt = Offset(alphaStrip.left + alphaStrip.width * MERGE_X_FRACTION, alphaStrip.center.y) + if (robot) { + tornWindow.focus() + awaitUntil("torn window is focused") { tornWindow.isFocused } + checkNotNull(robotPressAndDrag(betaGrab, mergeAt, first.scaleFactor)) { + "robot became unavailable mid-case" + } + awaitUntil("the first strip previews the insertion") { workspace.dropPreview?.group === alphaGroup } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + } else { + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(tornWindow), betaGrab)) + session.update(betaGrab) + session.update(mergeAt) + check(workspace.dropPreview?.group === alphaGroup) { + "hovering the other strip must preview it: ${workspace.dropPreview}" + } + session.end(mergeAt) + } + awaitUntil("both tabs are back in one window") { + workspace.groups.size == 1 && fixture.groupOf("Beta") === alphaGroup + } + awaitUntil("the emptied window was destroyed") { tornDestroyed } + settle() + check(alphaGroup.ids == listOf(fixture.tabId("Alpha"), beta)) { + "merged in the wrong order: ${alphaGroup.ids}" + } + check(alphaGroup.selectedId == beta) { "the arriving tab must be selected" } + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "Beta lost its saveable state on the way back" + } + } + + /** Closes the tabs one by one: the last one has to take the last window with it. */ + private suspend fun TaoWindowTestScope.closeEverything(fixture: TabWorkspaceFixture) { + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + var lastDestroyed = false + requireNotNull(group.window).onDestroyed { lastDestroyed = true } + + workspace.close(fixture.tabId("Beta")) + awaitUntil("one tab left, still one window") { workspace.tabs.size == 1 && workspace.groups.size == 1 } + check(!lastDestroyed) { "closing one of two tabs must not close the window" } + + workspace.close(fixture.tabId("Alpha")) + awaitUntil("the last window was destroyed") { lastDestroyed && workspace.groups.isEmpty() } + awaitUntil("onLastWindowClosed fired") { fixture.lastWindowClosed.value } + check(fixture.composedBodies.value == 0) { "a tab body outlived every window" } + } + + /** + * The tools an app actually keeps in a tab: a scroll position and a + * `rememberSaveable` counter. Both must cross every window boundary, and a + * reorder — which changes nothing about where the body lives — must not + * rebuild it at all. + */ + private fun stateSurvivesMovesAndReordersDoNotRebuild(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace keeps saveable state across windows and rebuilds nothing on a reorder", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + workspace.select(beta) + awaitUntil("Beta is composed") { fixture.windowOf("Beta") != null } + requireNotNull(fixture.counters.value[beta]).value = TAB_SAVED_CLICKS + settle() + val incarnationsBefore = + requireNotNull(fixture.bodyIncarnations.value[beta]) { + "no body incarnation recorded for Beta: ${fixture.bodyIncarnations.value} " + + "composedIn=${fixture.composedIn.value.keys} bodies=${fixture.composedBodies.value}" + } + + // ── a reorder inside one window ── + workspace.reorder(beta, 0) + awaitUntil("Beta moved to the front of the strip") { + requireNotNull(fixture.groupOf("Beta")).ids.first() == beta + } + settle() + check(fixture.bodyIncarnations.value[beta] == incarnationsBefore) { + "a reorder rebuilt the tab body: ${fixture.bodyIncarnations.value[beta]} vs $incarnationsBefore" + } + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) + check(fixture.windowOf("Beta") === first) { "a reorder must not move the tab to another window" } + + // ── a change of selection: each body keeps its own state ── + // Compose remembers by position, so the arriving body must not + // be handed the slots — and the saveable values — of the one + // that left. + val gamma = fixture.tabId("Gamma") + workspace.select(gamma) + awaitUntil("Gamma is the composed body") { fixture.windowOf("Gamma") != null } + settle() + check(requireNotNull(fixture.counters.value[gamma]).value == 0) { + "Gamma inherited Beta's saveable state: ${fixture.counters.value[gamma]?.value}" + } + check(fixture.bodyIncarnations.value[gamma] != null) { "Gamma's body never ran its effects" } + workspace.select(beta) + awaitUntil("Beta is back") { fixture.windowOf("Beta") != null } + settle() + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "Beta lost its state across a selection round trip" + } + + // ── out into its own window and back, twice ── + repeat(TAB_CHURN_CYCLES) { cycle -> + val torn = + requireNotNull( + workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor), + ) { "tear-off $cycle produced no window" } + awaitUntil("cycle $cycle: Beta composed in its own window") { + val window = torn.window + window != null && fixture.windowOf("Beta") === window && window !== first + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "cycle $cycle: saveable state lost on tear-off" + } + + workspace.move(beta, requireNotNull(fixture.groupOf("Alpha")), index = 0) + awaitUntil("cycle $cycle: Beta back in the first window") { + workspace.groups.size == 1 && fixture.windowOf("Beta") === first + } + settle() + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "cycle $cycle: saveable state lost on the way back" + } + } + check(workspace.tabs.size == 3) { "the churn lost a tab: ${workspace.tabs.size}" } + check(fixture.composedBodies.value == 1) { + "one body per window should compose, got ${fixture.composedBodies.value}" + } + }, + ) + } + + /** A layout snapshot has to bring the windows back, including for tabs declared afterwards. */ + private fun snapshotRestoresWindows(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab workspace snapshot restores the windows and their tabs", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + + val torn = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + awaitUntil("two windows") { workspace.groups.size == 2 && torn.window != null } + settle(SETTLE_AFTER_MAP_MILLIS) + val snapshot = workspace.snapshot() + check(snapshot.groups.size == 2) { "the snapshot missed a window: ${snapshot.groups}" } + + // Merge everything back, then ask for the two windows again. + workspace.move(beta, requireNotNull(fixture.groupOf("Alpha"))) + awaitUntil("one window") { workspace.groups.size == 1 } + settle() + + workspace.restore(snapshot) + awaitUntil("the snapshot's two windows are back") { + workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + awaitUntil("both tabs are composed again") { + fixture.windowOf("Alpha") != null && fixture.windowOf("Beta") != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.windowOf("Alpha") !== fixture.windowOf("Beta")) { + "the restored tabs ended up in the same window" + } + + // A snapshot applies once: a tab closed and declared again is a + // new tab, and opens in the active window like any other. + workspace.close(beta) + awaitUntil("Beta's window is gone") { workspace.groups.size == 1 } + fixture.titles += "Beta" + awaitUntil("the redeclared tab opened in the surviving window") { + workspace.groups.size == 1 && fixture.groupOf("Beta") === fixture.groupOf("Alpha") + } + // And asking for the layout again does put it back in its own window. + workspace.restore(snapshot) + awaitUntil("the second restore split them again") { + workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + }, + ) + } + + /** Closing the visible tab has to leave a visible tab behind, in a real window. */ + private fun closingTheSelectedTabPicksANeighbour(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace closing the selected tab shows a neighbour instead", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + workspace.select(fixture.tabId("Beta")) + awaitUntil("Beta is the composed body") { + fixture.windowOf("Beta") != null && fixture.windowOf("Alpha") == null + } + + workspace.close(fixture.tabId("Beta")) + awaitUntil("Gamma took over as the visible tab") { fixture.windowOf("Gamma") != null } + check(fixture.composedBodies.value == 1) { + "exactly one body composes per window, got ${fixture.composedBodies.value}" + } + + workspace.close(fixture.tabId("Gamma")) + awaitUntil("Alpha is all that is left") { + fixture.windowOf("Alpha") != null && workspace.tabs.size == 1 + } + check(workspace.groups.size == 1) { "the window closed too early" } + }, + ) + } + + /** One frame at 60 Hz: long enough for the reorder to be laid out, far from the animation's end. */ + private const val ONE_FRAME_MILLIS = 24L + + /** Comfortably past [dev.nucleusframework.window.tao.TabReorderAnimation]. */ + private const val REORDER_SETTLE_MILLIS = 400L + + /** Just inside a slot's leading edge: the index before that tab. */ + private const val EDGE_PROBE_PX = 4f + + /** Below the strip: the window's body, where a dragged tab is out of the strip's hands. */ + private const val OUT_OF_STRIP_PX = 60f + + /** Inside a tab's trailing edge, where the stock strip puts its close button. */ + private const val CLOSE_BUTTON_INSET_PX = 12f + + /** Far enough for the carried tab's leading edge to pass one neighbour's centre. */ + private const val CARRY_SLOTS = 0.8f + + /** A spring settles within a pixel; anything larger is a wrong number, not a rounding. */ + private const val MOTION_TOLERANCE_PX = 2f + + /** Below this a tab is too narrow for the case to mean anything. */ + private const val MIN_TAB_WIDTH_PX = 40f +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceLifecycleHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceLifecycleHeadfulCases.kt new file mode 100644 index 000000000..afe34cdf0 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceLifecycleHeadfulCases.kt @@ -0,0 +1,562 @@ +package dev.nucleusframework.window.tao.headful + +import dev.nucleusframework.window.tao.TabWindowGroup +import kotlin.math.abs + +/** + * The lifecycle half of the tab workspace, on real windows: every point where a + * window, a tab or a body comes into existence or leaves it. + * + * 1. **bootstrap** — the tabs are declared *after* `TabWindows`, so the first + * window exists only because a write that lands mid-composition is picked + * up; nothing else in the archetype works if this does not; + * 2. **the last window** — `onLastWindowClosed` fires per non-empty → empty + * transition, never for the empty workspace of the first composition, and + * again after the app re-opens a tab; + * 3. **who closes what** — a window closed by the user takes its own tabs and + * no others; the last tab out of a window takes the window with it; + * 4. **declaration** — a tab dropped from composition keeps its place with no + * body, comes back when re-declared, and a tab closed and declared again is + * a *new* tab with fresh state; + * 5. **restore** — a snapshot brings the windows back after every one of them + * has been destroyed, and applying one under a live drag is survivable. + * + * Native Wayland is skipped along with the rest of the tab suite. + */ +internal object TabWorkspaceLifecycleHeadfulCases { + fun all(): List = + listOf( + firstWindowOpensForTabsDeclaredAfterTabWindows(), + lastWindowClosedFiresPerTransition(), + userClosingAWindowClosesOnlyItsOwnTabs(), + theLastTabOutOfAWindowTakesTheWindow(), + aTabDroppedFromCompositionKeepsItsPlace(), + aTabClosedAndDeclaredAgainIsANewTab(), + everyWindowGoingAtOnceLeavesNothingComposed(), + snapshotRestoresAfterEveryWindowWasDestroyed(), + restoreUnderALiveDragStaysConsistent(), + selectionSurvivesTheGroupItPointsAtBeingDropped(), + ) + + /** + * The bootstrap, and the regression that hid behind the test harness: an + * app declares its tabs next to `TabWindows`, hence *after* it, so the + * first group is created by a write that lands during the composition + * which has already read the group list. If that write is not picked up, + * an application whose only windows come from the workspace never opens + * one — and `onLastWindowClosed` must not read the startup emptiness as + * "every window is gone" either. + */ + private fun firstWindowOpensForTabsDeclaredAfterTabWindows(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab lifecycle opens the first window for tabs declared after TabWindows", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + + check(workspace.groups.size == 1) { "three tabs must share one window: ${workspace.groups.size}" } + check(requireNotNull(first.outerBoundsPx())[2] > 0) { "the first window has no size" } + check(fixture.lastWindowClosedCount.value == 0) { + "onLastWindowClosed fired ${fixture.lastWindowClosedCount.value}× before a window ever opened" + } + val group = requireNotNull(fixture.groupOf("Alpha")) + check(group.ids.size == 3) { "the strip is missing tabs: ${group.ids}" } + check(group.selectedId != null) { "no tab is selected in a window that holds three" } + awaitUntil("exactly one body composes") { fixture.composedBodies.value == 1 } + check(fixture.stripRectPx(group) != null) { "the strip never published its geometry" } + }, + ) + } + + /** + * `onLastWindowClosed` is the app's exit hook, so it has to fire exactly + * once per emptying — not at startup, not twice for one close — and it has + * to fire *again* if the app carries on and opens another tab. + */ + private fun lastWindowClosedFiresPerTransition(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab lifecycle reports the last window gone once per emptying", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + check(fixture.lastWindowClosedCount.value == 0) { "fired at startup" } + + // Two windows, so emptying goes through an intermediate state + // that must not count as "the last one". + val torn = + requireNotNull( + workspace.tearOff(fixture.tabId("Beta"), tearOffRectPx(first), first.scaleFactor), + ) + awaitMappedStrip(fixture, torn) + workspace.close(fixture.tabId("Beta")) + awaitUntil("one window left") { workspace.groups.size == 1 } + settle() + check(fixture.lastWindowClosedCount.value == 0) { + "closing one of two windows counted as the last one" + } + + workspace.close(fixture.tabId("Alpha")) + awaitUntil("the workspace is empty and reported it") { + workspace.groups.isEmpty() && fixture.lastWindowClosedCount.value == 1 + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.lastWindowClosedCount.value == 1) { + "fired ${fixture.lastWindowClosedCount.value}× for one emptying" + } + check(fixture.composedBodies.value == 0) { "a body outlived every window" } + + // The app did not exit: a new tab opens a window again, and + // emptying it reports a second time. + fixture.titles += "Delta" + awaitUntil("a new window opened for the new tab") { + workspace.groups.size == 1 && fixture.groupOf("Delta")?.ids == listOf(fixture.tabId("Delta")) + } + awaitMappedStrip(fixture, requireNotNull(fixture.groupOf("Delta"))) + check(fixture.lastWindowClosedCount.value == 1) { "re-opening fired the callback" } + + workspace.close(fixture.tabId("Delta")) + awaitUntil("emptied again and reported again") { + workspace.groups.isEmpty() && fixture.lastWindowClosedCount.value == 2 + } + }, + ) + } + + /** + * The user hitting the close button of one window: the native request goes + * through the window's `onCloseRequest`, which closes the tabs that window + * holds. Tabs in another window must not notice. + */ + private fun userClosingAWindowClosesOnlyItsOwnTabs(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab lifecycle closing a window closes its own tabs and no others", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val gamma = fixture.tabId("Gamma") + + // Beta and Gamma into a window of their own. + val second = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, second) + workspace.move(gamma, second) + awaitUntil("the second window holds two tabs") { second.ids.size == 2 } + val secondWindow = awaitMappedStrip(fixture, second) + var destroyed = false + secondWindow.onDestroyed { destroyed = true } + + // The user-close path: what the native X and Alt+F4 fire, and + // what a title-bar close button must fire — `requestClose` + // would destroy the window behind the composition's back. + secondWindow.requestUserClose() + awaitUntil("the second window went with its tabs") { + destroyed && workspace.groups.size == 1 + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.tab(beta) == null && workspace.tab(gamma) == null) { + "the closed window's tabs survived: ${workspace.tabs.map { it.id }}" + } + check(fixture.groupOf("Alpha")?.ids == listOf(fixture.tabId("Alpha"))) { + "the surviving window lost its tab: ${fixture.groupOf("Alpha")?.ids}" + } + check(requireNotNull(first.outerBoundsPx())[2] > 0) { "the surviving window was destroyed too" } + awaitUntil("one body composes") { fixture.composedBodies.value == 1 } + check(fixture.lastWindowClosedCount.value == 0) { "one window closing reported the last one" } + }, + ) + } + + /** + * Windows follow the tabs in both directions: the tab that leaves a window + * empty destroys it, and the *only* tab of a window is moved rather than + * torn into a second one. + */ + private fun theLastTabOutOfAWindowTakesTheWindow(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab lifecycle the last tab out of a window takes the window with it", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + + val second = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + val secondWindow = awaitMappedStrip(fixture, second) + var destroyed = false + secondWindow.onDestroyed { destroyed = true } + + // Tearing off the only tab of a window is a move of that + // window, not a second window for the same tab. + val movedTo = tearOffRectPx(secondWindow) + val again = workspace.tearOff(beta, movedTo, secondWindow.scaleFactor) + check(again === second) { "the only tab of a window was duplicated into another one" } + settle(SETTLE_AFTER_MAP_MILLIS) + check(!destroyed) { "the window was destroyed by a move" } + check(workspace.groups.size == 2) { "an extra window appeared: ${workspace.groups.size}" } + awaitUntil("the moved window is where the move asked") { + val now = secondWindow.outerBoundsPx() ?: return@awaitUntil false + abs(now[0] - movedTo.left.toLong()) <= TAB_SIZE_TOLERANCE_PX + } + + // Back into the first window: the second one goes. + workspace.move(beta, requireNotNull(fixture.groupOf("Alpha"))) + awaitUntil("the emptied window was destroyed") { destroyed && workspace.groups.size == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.windowOf("Beta") === first) { "Beta is not composed in the surviving window" } + check(fixture.composedBodies.value == 1) { + "bodies left over: ${fixture.composedBodies.value}" + } + }, + ) + } + + /** + * A tab the app takes out of composition — a document closed in the model + * but not in the workspace — keeps its place in the strip with no body, and + * gets it back when the app declares it again. What it must never do is + * take its window down or move. + */ + private fun aTabDroppedFromCompositionKeepsItsPlace(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab lifecycle a tab dropped from composition keeps its place and comes back", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + workspace.select(beta) + awaitUntil("Beta is the composed body") { fixture.windowOf("Beta") != null } + requireNotNull(fixture.counters.value[beta]).value = TAB_SAVED_CLICKS + val idsBefore = requireNotNull(fixture.groupOf("Beta")).ids + + // The app stops declaring it while it is the selected tab. + fixture.titles -= "Beta" + awaitUntil("Beta's body left") { fixture.composedBodies.value == 0 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.tab(beta) != null) { "an undeclared tab was forgotten entirely" } + check(requireNotNull(fixture.groupOf("Beta")).ids == idsBefore) { + "the strip lost or moved the undeclared tab: ${fixture.groupOf("Beta")?.ids}" + } + check(workspace.groups.size == 1) { "the window went with the undeclared tab" } + check(requireNotNull(first.outerBoundsPx())[2] > 0) { "the window was destroyed" } + + // And the window is still usable: selecting a declared tab + // brings a body back. + workspace.select(fixture.tabId("Gamma")) + awaitUntil("Gamma took over") { fixture.windowOf("Gamma") === first } + + // Declared again, it composes again — in the same place. + fixture.titles += "Beta" + awaitUntil("Beta's body is back") { workspace.tab(beta)?.content != null } + workspace.select(beta) + awaitUntil("Beta composes again") { fixture.windowOf("Beta") === first } + settle() + check(requireNotNull(fixture.groupOf("Beta")).ids.contains(beta)) { "Beta lost its strip place" } + }, + ) + } + + /** + * A *closed* tab is gone, state included — unlike one that merely left + * composition. Declaring the same id afterwards is a new tab: fresh + * saveable state, and placed like any other new tab. + */ + private fun aTabClosedAndDeclaredAgainIsANewTab(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab lifecycle a closed tab declared again is a new tab with fresh state", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + workspace.select(beta) + awaitUntil("Beta is composed") { fixture.windowOf("Beta") != null } + requireNotNull(fixture.counters.value[beta]).value = TAB_SAVED_CLICKS + + // Torn into its own window first, so the redeclaration also has + // to pick a *window*, not just a strip slot. + val second = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, second) + first.focus() + awaitUntil("the first window is focused again") { first.isFocused } + + workspace.close(beta) + fixture.titles -= "Beta" + awaitUntil("Beta and its window are gone") { + workspace.tab(beta) == null && workspace.groups.size == 1 + } + settle(SETTLE_AFTER_MAP_MILLIS) + + fixture.titles += "Beta" + awaitUntil("the new Beta opened in the focused window") { + fixture.groupOf("Beta") === fixture.groupOf("Alpha") + } + awaitUntil("its body composed") { fixture.counters.value[beta] != null } + settle() + check(requireNotNull(fixture.counters.value[beta]).value == 0) { + "a closed tab's saveable state came back: ${fixture.counters.value[beta]?.value}" + } + check(workspace.groups.size == 1) { "the redeclared tab opened a window of its own" } + }, + ) + } + + /** + * Everything down at once, the way an app quits: several windows, each with + * a composed body, all emptied in one pass. Nothing may outlive it — no + * window, no body, no drag feedback — and the report must come exactly + * once. + */ + private fun everyWindowGoingAtOnceLeavesNothingComposed(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma", "Delta")) + return TaoWindowTestCase( + name = "tab lifecycle every window going at once leaves nothing composed", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma", "Delta") + val workspace = fixture.workspace + val spread = spreadOverWindows(fixture, first, listOf("Beta", "Gamma", "Delta")) + check(workspace.groups.size == 4) { "expected four windows, got ${workspace.groups.size}" } + awaitUntil("every window composes its body") { fixture.composedBodies.value == 4 } + + val destroyed = BooleanArray(spread.size) + spread.forEachIndexed { index, group -> + requireNotNull(group.window).onDestroyed { destroyed[index] = true } + } + + workspace.tabs.map { it.id }.forEach(workspace::close) + awaitUntil("every window reported destroyed") { destroyed.all { it } } + awaitUntil("the workspace is empty and reported once") { + workspace.groups.isEmpty() && fixture.lastWindowClosedCount.value == 1 + } + awaitUntil("no body is composing") { fixture.composedBodies.value == 0 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.lastWindowClosedCount.value == 1) { + "reported ${fixture.lastWindowClosedCount.value}× for one shutdown" + } + check(workspace.tabs.isEmpty()) { "tabs survived: ${workspace.tabs.map { it.id }}" } + check(workspace.draggedTab == null && workspace.dragGhost == null) { "drag feedback outlived the app" } + }, + ) + } + + /** + * The persistence story an app really needs: save the layout, lose every + * window (a restart, or the user closing them all), declare the tabs again, + * and get the windows back where they were. + */ + private fun snapshotRestoresAfterEveryWindowWasDestroyed(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab lifecycle a snapshot restores the windows after all of them were destroyed", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + spreadOverWindows(fixture, first, listOf("Beta", "Gamma")) + check(workspace.groups.size == 3) { "expected three windows" } + + val snapshot = workspace.snapshot() + check(snapshot.groups.size == 3) { "the snapshot missed a window: ${snapshot.groups.size}" } + val savedOf = snapshot.groups.associateBy { it.id } + + // Everything down, including the declarations. + workspace.tabs.map { it.id }.forEach(workspace::close) + fixture.titles.clear() + awaitUntil("nothing is left") { + workspace.groups.isEmpty() && workspace.tabs.isEmpty() && fixture.composedBodies.value == 0 + } + settle(SETTLE_AFTER_MAP_MILLIS) + + // The app asks for the layout back before declaring anything, + // which is the order a real restart has. + workspace.restore(snapshot) + fixture.titles += listOf("Alpha", "Beta", "Gamma") + awaitUntil("the three windows are back with one tab each") { + workspace.groups.size == 3 && workspace.groups.all { it.ids.size == 1 } + } + awaitUntil("every restored window is mapped") { + workspace.groups.all { it.window?.hasRealFramePx() == true } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.map { it.id }.toSet() == savedOf.keys) { + "restored under different group ids: ${workspace.groups.map { it.id }} vs ${savedOf.keys}" + } + for (group in workspace.groups) { + val saved = requireNotNull(savedOf[group.id]) + check(group.ids == saved.tabIds) { "group ${group.id} holds ${group.ids}, saved ${saved.tabIds}" } + val window = requireNotNull(group.window) + val bounds = requireNotNull(window.outerBoundsPx()) + val savedPosition = requireNotNull(saved.position) + val scale = window.scaleFactor + check(abs(bounds[0] - (savedPosition.x.value * scale).toLong()) <= RESTORE_TOLERANCE_PX) { + "group ${group.id} came back at ${bounds[0]}px, saved ${savedPosition.x}" + } + } + awaitUntil("three bodies compose again") { fixture.composedBodies.value == 3 } + }, + ) + } + + /** + * A restore arriving mid-gesture. The app is free to apply a saved layout + * whenever it likes, including while the user is holding a tab — the + * release must then act on the world as it is, not as it was at the grab. + */ + private fun restoreUnderALiveDragStaysConsistent(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab lifecycle a layout restored under a live drag leaves no debris", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + spreadOverWindows(fixture, first, listOf("Gamma")) + val snapshot = workspace.snapshot() + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val away = requireNotNull(fixture.farFromStripPx(requireNotNull(fixture.groupOf("Beta")))) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + session.update(away) + check(workspace.dragGhost != null) { "the tear-out must be previewed" } + + // The layout comes back under the pointer. + workspace.restore(snapshot) + settle() + session.end(away) + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "a restore under a drag left feedback behind" + } + check(workspace.tabs.size == 3) { "a tab was lost: ${workspace.tabs.map { it.id }}" } + check(workspace.groups.all { it.ids.isNotEmpty() }) { "an empty group survived" } + for (group in workspace.groups) { + awaitUntil("group ${group.id} is mapped") { + group.window?.hasRealFramePx() == true + } + } + awaitUntil("one body per window composes") { + fixture.composedBodies.value == workspace.groups.size + } + }, + ) + } + + /** + * A group can be dropped while it is the one the workspace considers + * active — the window whose tab a new declaration would join. The next tab + * must find a home anyway rather than land in a group that no longer + * exists. + */ + private fun selectionSurvivesTheGroupItPointsAtBeingDropped(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab lifecycle a new tab finds a window after the active one was dropped", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + + val second = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + val secondWindow = awaitMappedStrip(fixture, second) + secondWindow.focus() + awaitUntil("the torn-off window is the focused one") { secondWindow.isFocused } + awaitUntil("and the workspace agrees it is active") { workspace.activeGroup === second } + + // The active window goes; a new tab must not follow it into + // nothing. + workspace.close(beta) + awaitUntil("the active group was dropped") { workspace.groups.size == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.activeGroup === fixture.groupOf("Alpha")) { + "the workspace still points at a dropped group" + } + + fixture.titles += "Delta" + awaitUntil("the new tab joined the surviving window") { + fixture.groupOf("Delta") === fixture.groupOf("Alpha") && workspace.groups.size == 1 + } + awaitUntil("its body composes") { fixture.windowOf("Delta") === first } + }, + ) + } + + /** + * Tears each of [titles] into a window of its own, waits for every one of + * them to map, and returns the groups in that order. + */ + private suspend fun TaoWindowTestScope.spreadOverWindows( + fixture: TabWorkspaceFixture, + source: dev.nucleusframework.window.tao.TaoWindow, + titles: List, + ): List { + val groups = ArrayList(titles.size) + for (title in titles) { + val id = fixture.tabId(title) + val from = requireNotNull(fixture.groupOf(title)?.window) { "$title has no window to leave" } + val group = + requireNotNull(fixture.workspace.tearOff(id, tearOffRectPx(from), source.scaleFactor)) { + "tearing $title off produced no window" + } + awaitMappedStrip(fixture, group) + groups += group + } + return groups + } + + /** Position after a snapshot round trip: dp rounding on both sides, plus whatever the WM adds. */ + private const val RESTORE_TOLERANCE_PX = 60L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMotionHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMotionHeadfulCases.kt new file mode 100644 index 000000000..8e5462ae4 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMotionHeadfulCases.kt @@ -0,0 +1,587 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.LayoutDirection +import dev.nucleusframework.window.tao.TabWindowGroup +import kotlin.math.abs + +/** + * How the tab workspace behaves under motion, on real windows: what the + * pointer does between the grab and the drop. + * + * 1. **a real mouse** — driven by the AWT Robot, in + * [TabWorkspaceMouseHeadfulCases]; + * 2. **teleports** — samples with nothing in between, which is what a fast + * drag actually delivers once the OS has coalesced it, and what a synthetic + * replay delivers by construction; + * 3. **the strip edge** — a pointer that crosses in and out of a strip dozens + * of times must leave the preview in step with the last sample, not one + * behind; + * 4. **a window that moves under the gesture** — the target resized or moved + * mid-drag, so the strip the drop resolves against is not where it was at + * the grab; + * 5. **the single-tab window drag** — the window itself follows the pointer, + * its own strip travels under it, and only *another* window's strip may + * answer the drop. + * + * Native Wayland is skipped along with the rest of the tab suite. + */ +internal object TabWorkspaceMotionHeadfulCases { + fun all(): List = + listOf( + teleportsBetweenTwoStripsResolveEveryTime(), + sweepOverASingleTabRightToLeftStripFlipsOnce(), + zigZagAcrossTheStripEdgeKeepsThePreviewInStep(), + offScreenExcursionsKeepTheGestureSane(), + singleTabWindowFollowsThePointerAndMerges(), + targetWindowMovingMidDragMovesTheDropTarget(), + targetWindowResizingMidDragMovesTheDropTarget(), + backToBackDragsLeaveOneConsistentState(), + ) + + /** + * Two strips, and a pointer that jumps between them with nothing in + * between — no sample on the desktop, none on the frame, none on the way. + * Each jump has to resolve on its own rather than depend on having been + * walked into. + */ + private fun teleportsBetweenTwoStripsResolveEveryTime(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab motion teleports between two strips resolve every time", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val gamma = fixture.tabId("Gamma") + val beta = fixture.tabId("Beta") + + val second = requireNotNull(workspace.tearOff(gamma, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, second) + val home = requireNotNull(fixture.groupOf("Alpha")) + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val onSecond = requireNotNull(fixture.stripPointPx(second, STRIP_HEAD_FRACTION)) + val onHome = requireNotNull(fixture.stripPointPx(home, STRIP_MID_FRACTION)) + val nowhere = requireNotNull(fixture.farFromStripPx(home)) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + + repeat(TELEPORT_ROUNDS) { round -> + session.update(onSecond) + settle(JUMP_SETTLE_MILLIS) + check(workspace.dropPreview?.group === second) { + "round $round: the other strip did not answer a teleport: ${workspace.dropPreview}" + } + // Another window's strip is a move, not a reorder: the tab + // is leaving this window, so the ghost carries it there. + val ghost = requireNotNull(workspace.dragGhost) { "round $round: the ghost was lost" } + check(ghost.screenRectPx.width > 0f) { "round $round: the ghost has no size" } + session.update(nowhere) + settle(JUMP_SETTLE_MILLIS) + check(workspace.dropPreview == null) { "round $round: empty space previewed a drop" } + session.update(onHome) + settle(JUMP_SETTLE_MILLIS) + check(workspace.dropPreview?.group === home) { + "round $round: its own strip did not answer a teleport: ${workspace.dropPreview}" + } + // Back over its own strip the tab is in the strip's hands, + // which draws it under the pointer: no ghost window, and + // the pointer published for the strip to follow. + check(workspace.dragGhost == null) { + "round $round: a ghost over its own strip: ${workspace.dragGhost}" + } + check(workspace.dragPointerScreenPx == onHome) { + "round $round: the strip was not told the pointer: ${workspace.dragPointerScreenPx}" + } + } + + // The last sample is the one that decides. + session.update(onSecond) + session.end(onSecond) + awaitUntil("the tab landed where the last teleport pointed") { + fixture.groupOf("Beta") === second && second.ids.contains(beta) + } + check(workspace.groups.size == 2) { "the teleports changed the window count" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * A tab carried slowly across another window's strip that holds a single + * tab, in a right-to-left app. The insertion index may change once, where + * the pointer passes the tab's middle, and not again: the drop preview + * opening on one side of the tab moves the tab, and a rule that read the + * strip's direction off the tab order — impossible with one tab — flipped + * with every sample, two cards sliding about under a still pointer. + */ + private fun sweepOverASingleTabRightToLeftStripFlipsOnce(): TaoWindowTestCase { + val fixture = + TabWorkspaceFixture( + initialTitles = listOf("Alpha", "Beta", "Gamma"), + layoutDirection = LayoutDirection.Rtl, + ) + return TaoWindowTestCase( + name = "tab motion a sweep over a single-tab right-to-left strip flips the insertion index once", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val gamma = fixture.tabId("Gamma") + val beta = fixture.tabId("Beta") + + val second = requireNotNull(workspace.tearOff(gamma, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, second) + val strip = requireNotNull(fixture.stripRectPx(second)) + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + + // Left to right in small steps, the layout answering each one — + // the preview opening is what moves the tab under the pointer. + val indices = ArrayList() + var x = strip.left + SWEEP_MARGIN_PX + while (x <= strip.right - SWEEP_MARGIN_PX) { + session.update(Offset(x, strip.center.y)) + settle(SWEEP_SETTLE_MILLIS) + val preview = workspace.dropPreview + check(preview?.group === second) { "at x=$x the sweep was not over the strip: $preview" } + indices += preview.index + x += SWEEP_STEP_PX + } + val flips = indices.zipWithNext().count { (a, b) -> a != b } + check(flips <= 1) { "the insertion index flipped $flips times across one strip: $indices" } + // Right to left: the far left of the strip is after the tab, the far right before it. + check(indices.first() == 1 && indices.last() == 0) { + "a right-to-left strip resolved left to right: $indices" + } + + session.cancel() + awaitUntil("the drag feedback cleared") { + workspace.dragGhost == null && workspace.dropPreview == null + } + }, + ) + } + + /** + * The strip edge, crossed dozens of times: a pointer sliding along the + * boundary between "insert here" and "tear off". Every sample has to move + * the preview with it — one stale frame and the release lands somewhere the + * user was not pointing. + */ + private fun zigZagAcrossTheStripEdgeKeepsThePreviewInStep(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab motion a zig-zag across the strip edge keeps the preview in step", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val home = requireNotNull(fixture.groupOf("Beta")) + val strip = requireNotNull(fixture.stripRectPx(home)) + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val inside = Offset(strip.left + strip.width * STRIP_MID_FRACTION, strip.center.y) + val outside = Offset(inside.x, strip.bottom + EDGE_EXCURSION_PX) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + + repeat(ZIGZAG_ROUNDS) { round -> + session.update(outside) + check(workspace.dropPreview == null) { + "round $round: outside the strip still previewed ${workspace.dropPreview}" + } + session.update(inside) + check(workspace.dropPreview?.group === home) { + "round $round: back inside the strip previewed ${workspace.dropPreview}" + } + } + // No settle in the loop on purpose: the preview is snapshot + // state written by the session, so it must be right as soon as + // the sample is taken, not a frame later. + session.end(inside) + awaitUntil("the tab stayed in its window") { + workspace.groups.size == 1 && fixture.groupOf("Beta") === home + } + check(workspace.dragGhost == null) { "the ghost survived the zig-zag" } + }, + ) + } + + /** + * Excursions no real screen can hold: coordinates far outside every + * display, non-finite samples, and the same sample repeated. None of them + * may reach window geometry, and the gesture has to stay usable + * afterwards. + */ + private fun offScreenExcursionsKeepTheGestureSane(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab motion off-screen and non-finite samples never reach the windows", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val home = requireNotNull(fixture.groupOf("Beta")) + val strip = requireNotNull(fixture.stripRectPx(home)) + val boundsBefore = requireNotNull(first.outerBoundsPx()) + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + + val onTheStrip = Offset(strip.left + strip.width * STRIP_MID_FRACTION, strip.center.y) + // Clear of its own strip, where the ghost is what carries the + // tab: over the strip itself there is none to compare against, + // since the strip holds the tab under the pointer instead. + val offTheStrip = Offset(onTheStrip.x, strip.bottom + OFF_STRIP_PX) + session.update(offTheStrip) + val ghostAtStrip = requireNotNull(workspace.dragGhost).screenRectPx + + val garbage = + listOf( + Offset(Float.NaN, onTheStrip.y), + Offset(onTheStrip.x, Float.NaN), + Offset(Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY), + Offset(Float.NaN, Float.NaN), + ) + for (sample in garbage) { + session.update(sample) + val ghost = requireNotNull(workspace.dragGhost) { "the ghost was lost at $sample" } + check(ghost.screenRectPx == ghostAtStrip) { + "an unusable sample ($sample) moved the ghost to ${ghost.screenRectPx}" + } + check(workspace.dropPreview == null) { "an unusable sample invented a drop target" } + } + + // Far outside every display, then the same sample twice. + val faraway = Offset(-1_000_000f, 1_000_000f) + session.update(faraway) + session.update(faraway) + settle(JUMP_SETTLE_MILLIS) + val ghostFaraway = requireNotNull(workspace.dragGhost) + check(ghostFaraway.screenRectPx.width > 0f && ghostFaraway.screenRectPx.height > 0f) { + "the ghost lost its size off-screen: ${ghostFaraway.screenRectPx}" + } + check(workspace.dropPreview == null) { "a point off every display previewed a drop" } + val boundsDuring = requireNotNull(first.outerBoundsPx()) + check(boundsDuring[2] == boundsBefore[2] && boundsDuring[3] == boundsBefore[3]) { + "the source window was resized by the excursion" + } + + // And the gesture still works: back on the strip — where the + // strip takes the tab back in hand — and released. + session.update(onTheStrip) + check(workspace.dragGhost == null && workspace.dropPreview?.group === home) { + "its own strip did not take the tab back: ${workspace.dragGhost} ${workspace.dropPreview}" + } + session.end(onTheStrip) + awaitUntil("the tab is still in its window") { + workspace.groups.size == 1 && fixture.groupOf("Beta") === home + } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * The Chrome gesture: the only tab of a window, dragged. The window itself + * follows the pointer, so its own strip travels under it the whole time and + * is also the focused one — the drop has to look *past* it and answer with + * the strip underneath, or a merge can never resolve. + */ + private fun singleTabWindowFollowsThePointerAndMerges(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab motion dragging a single-tab window follows the pointer and still merges", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val home = requireNotNull(fixture.groupOf("Alpha")) + + val second = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + val secondWindow = awaitMappedStrip(fixture, second) + secondWindow.focus() + awaitUntil("the dragged window is the focused one") { secondWindow.isFocused } + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val before = requireNotNull(secondWindow.outerBoundsPx()) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(secondWindow), grab)) + session.update(grab) + check(workspace.dragGhost == null) { + "the only tab of a window must move the window, not raise a ghost" + } + + // A step away first: the window follows the pointer. + val step = grab + Offset(WINDOW_DRAG_STEP_PX, WINDOW_DRAG_STEP_PX) + session.update(step) + awaitUntil("the window followed the pointer") { + val now = secondWindow.outerBoundsPx() ?: return@awaitUntil false + abs(now[0] - (before[0] + WINDOW_DRAG_STEP_PX.toLong())) <= WINDOW_FOLLOW_TOLERANCE_PX && + abs(now[1] - (before[1] + WINDOW_DRAG_STEP_PX.toLong())) <= WINDOW_FOLLOW_TOLERANCE_PX + } + + // Then over the other window's strip: its own strip is under the + // pointer too, and must not be the one that answers. + val target = requireNotNull(fixture.stripPointPx(home, STRIP_HEAD_FRACTION)) + session.update(target) + settle(JUMP_SETTLE_MILLIS) + val preview = requireNotNull(workspace.dropPreview) { "no merge target while over the other strip" } + check(preview.group === home) { "the dragged window answered its own drop: ${preview.group.id}" } + check(preview.index == 0) { "dropped at the head of the strip, previewed index ${preview.index}" } + + session.end(target) + awaitUntil("the windows merged and Beta composes in the first window") { + workspace.groups.size == 1 && + fixture.groupOf("Beta") === home && + fixture.windowOf("Beta") === first + } + check(home.ids.first() == beta) { "dropped at the head, landed at ${home.ids}" } + check(workspace.draggedTab == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * The target window moved while a tab is held over it — a follower window, + * a workspace switch, the app repositioning things. The drop resolves + * against where the strip *is*, so the old position must go cold and the + * new one must answer. + */ + private fun targetWindowMovingMidDragMovesTheDropTarget(): TaoWindowTestCase = + movingTargetCase( + name = "tab motion a target window moved mid-drag takes its drop target with it", + ) { window -> + val bounds = requireNotNull(window.outerBoundsPx()) + val scale = window.scaleFactor.toDouble() + window.setOuterPosition( + bounds[0] / scale + TARGET_MOVE_DP, + bounds[1] / scale + TARGET_MOVE_DP, + ) + awaitUntil("the target window moved") { + val now = window.outerBoundsPx() ?: return@awaitUntil false + now[0] != bounds[0] || now[1] != bounds[1] + } + } + + /** + * The same, resized: a strip that got wider or narrower under the pointer + * has to be hit-tested at its new width. + */ + private fun targetWindowResizingMidDragMovesTheDropTarget(): TaoWindowTestCase = + movingTargetCase( + name = "tab motion a target window resized mid-drag republishes its drop target", + ) { window -> + window.setInnerSize(TARGET_RESIZED_W_DP, TARGET_RESIZED_H_DP) + awaitUntil("the target window resized") { + val now = window.outerBoundsPx() ?: return@awaitUntil false + abs(now[2] - TARGET_RESIZED_W_DP * window.scaleFactor) <= RESIZE_TOLERANCE_PX + } + } + + /** + * Shared shape of the two "the target moves under the gesture" cases: a + * tab held over another window's strip, that window disturbed by + * [disturb], and then the drop. + */ + private fun movingTargetCase( + name: String, + disturb: suspend TaoWindowTestScope.(window: dev.nucleusframework.window.tao.TaoWindow) -> Unit, + ): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = name, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val gamma = fixture.tabId("Gamma") + val beta = fixture.tabId("Beta") + + // Gamma into the window that will be disturbed. + val target = requireNotNull(workspace.tearOff(gamma, tearOffRectPx(first), first.scaleFactor)) + val targetWindow = awaitMappedStrip(fixture, target) + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val pointBefore = requireNotNull(fixture.stripPointPx(target, STRIP_HEAD_FRACTION)) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + session.update(pointBefore) + check(workspace.dropPreview?.group === target) { "the target strip did not answer before the move" } + + val stripRectBefore = requireNotNull(fixture.stripRectPx(target)) + val windowBefore = requireNotNull(targetWindow.outerBoundsPx()) + disturb(targetWindow) + // The published geometry reads the window's frame live, so the + // strip travels with it: same delta in position, same delta in + // width. Comparing deltas rather than absolutes is what makes + // this independent of where the platform controls sit. + awaitUntil("the strip travelled with its window") { + val stripNow = fixture.stripRectPx(target) ?: return@awaitUntil false + val windowNow = targetWindow.outerBoundsPx() ?: return@awaitUntil false + val movedX = (windowNow[0] - windowBefore[0]).toFloat() + val grewW = (windowNow[2] - windowBefore[2]).toFloat() + (abs(movedX) > 1f || abs(grewW) > 1f) && + abs((stripNow.left - stripRectBefore.left) - movedX) <= STRIP_FOLLOW_TOLERANCE_PX && + abs((stripNow.width - stripRectBefore.width) - grewW) <= STRIP_FOLLOW_TOLERANCE_PX + } + settle(SETTLE_AFTER_MAP_MILLIS) + + // The point that used to be on the strip is stale; the one that + // is on it now answers. + session.update(pointBefore) + val stalePreview = workspace.dropPreview + check(stalePreview?.group !== target || stripStillCovers(fixture, target, pointBefore)) { + "the old strip position still answers after the window moved" + } + val stripNow = requireNotNull(fixture.stripPointPx(target, STRIP_HEAD_FRACTION)) + session.update(stripNow) + settle(JUMP_SETTLE_MILLIS) + check(workspace.dropPreview?.group === target) { + "the moved strip does not answer at its new position: ${workspace.dropPreview}" + } + + session.end(stripNow) + awaitUntil("the tab merged into the disturbed window and composes there") { + fixture.groupOf("Beta") === target && + target.ids.contains(beta) && + fixture.windowOf("Beta") === targetWindow + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.size == 2) { "the window count changed: ${workspace.groups.size}" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * Drag after drag with nothing in between: no settle, no frame to recover + * in. Whatever the intermediate states are, the workspace has to come out + * of it with every tab in exactly one window and no feedback on screen. + */ + private fun backToBackDragsLeaveOneConsistentState(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab motion drags back to back leave one consistent state", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + + // Two windows that both keep a tab of their own, so neither can + // disappear under the churn and both strips stay published. + val second = + requireNotNull( + workspace.tearOff(fixture.tabId("Gamma"), tearOffRectPx(first), first.scaleFactor), + ) + awaitMappedStrip(fixture, second) + val home = requireNotNull(fixture.groupOf("Alpha")) + + // Beta thrown from one strip to the other, over and over, with + // no settling in between: every gesture starts before the + // previous one has been through a frame. + var started = 0 + repeat(BACK_TO_BACK_DRAGS) { round -> + val group = fixture.groupOf("Beta") ?: return@repeat + val window = group.window ?: return@repeat + val target = if (group === home) second else home + val grab = fixture.stripPointPx(group, STRIP_MID_FRACTION) ?: return@repeat + val drop = fixture.stripPointPx(target, STRIP_HEAD_FRACTION) ?: return@repeat + val session = workspace.beginDrag(beta, stripOrigin(window), grab) ?: return@repeat + started++ + session.update(grab) + session.update(drop) + check(workspace.dropPreview?.group === target) { + "round $round: the target strip did not answer mid-storm: ${workspace.dropPreview}" + } + session.end(drop) + check(fixture.groupOf("Beta") === target) { + "round $round: the tab did not land where it was dropped" + } + } + check(started >= BACK_TO_BACK_DRAGS) { "only $started of $BACK_TO_BACK_DRAGS gestures ran" } + + awaitUntil("the workspace settled with every tab placed") { + workspace.tabs.size == 3 && workspace.tabs.all { it.group != null } + } + awaitUntil("both windows are still mapped") { + workspace.groups.size == 2 && + workspace.groups.all { it.window?.hasRealFramePx() == true } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.sumOf { it.ids.size } == 3) { + "tabs went missing or got duplicated: ${workspace.groups.map { it.ids }}" + } + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "the churn left drag feedback behind" + } + awaitUntil("one body per window composes") { + fixture.composedBodies.value == workspace.groups.size + } + }, + ) + } + + private fun stripStillCovers( + fixture: TabWorkspaceFixture, + group: TabWindowGroup, + point: Offset, + ): Boolean = fixture.stripRectPx(group)?.contains(point) == true + + private fun robotSkipReason(): String? = HeadfulRobot.unavailableReason?.let { "no input injection: $it" } + + private const val EDGE_EXCURSION_PX = 60f + private const val SWEEP_STEP_PX = 16f + private const val SWEEP_MARGIN_PX = 8f + private const val SWEEP_SETTLE_MILLIS = 60L + private const val ZIGZAG_ROUNDS = 40 + private const val TELEPORT_ROUNDS = 6 + private const val BACK_TO_BACK_DRAGS = 12 + private const val WINDOW_DRAG_STEP_PX = 40f + private const val WINDOW_FOLLOW_TOLERANCE_PX = 24L + private const val TARGET_MOVE_DP = 90.0 + private const val TARGET_RESIZED_W_DP = 640.0 + private const val TARGET_RESIZED_H_DP = 440.0 + + /** Both sides come from the same live geometry: rounding only. */ + private const val STRIP_FOLLOW_TOLERANCE_PX = 8f + + /** Just under the strip: the body, where a dragged tab is out of the strip's hands. */ + private const val OFF_STRIP_PX = 40f +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMouseHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMouseHeadfulCases.kt new file mode 100644 index 000000000..f41927141 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceMouseHeadfulCases.kt @@ -0,0 +1,417 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset + +/** + * The tab workspace under a real mouse, on real windows: every case here is + * driven by the AWT Robot, so what it exercises is the pointer pipeline the + * user actually goes through — press, move, release, with the OS coalescing + * whatever it likes in between. + * + * 1. **a reorder** inside one strip, which must not rebuild the tab's body; + * 2. **a press that never moves**, which has to stay a plain selection so the + * close button and click-to-select keep working under a drag handle; + * 3. **a click anywhere in a tab**, top edge to bottom edge: a tab is one + * target, not a patchwork of a grip and a selector; + * 4. **a hover across two strips and back**, where the preview follows the + * pointer from window to window and the drop acts on where it ended; + * 5. **a flick**, delivering as few samples as the OS will give; + * 6. **a pointer resting on a tab**, which offers that tab's hover card — + * and every case where the card has to stay away. + * + * Native Wayland is skipped along with the rest of the tab suite; so is a host + * that cannot inject input. + */ +internal object TabWorkspaceMouseHeadfulCases { + fun all(): List = + listOf( + robotReorderInsideTheStrip(), + robotPressWithoutMovingOnlySelects(), + robotClicksAnywhereInATabSelectIt(), + robotHoverCrossesTwoStripsAndComesBack(), + robotFlickBetweenStripsMerges(), + robotRestingOnATabOffersItsCard(), + ) + + /** + * The most ordinary gesture there is, with a real mouse: pick a tab up and + * put it down further along its own strip. It must reorder, stay in its + * window, and — since the body does not change host — not be rebuilt. + */ + private fun robotReorderInsideTheStrip(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab mouse reorders inside one strip without rebuilding the body", + skip = { workspaceSkipReason() ?: robotSkipReason() }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val alpha = fixture.tabId("Alpha") + workspace.select(alpha) + awaitUntil("Alpha is the composed body") { fixture.windowOf("Alpha") === first } + val incarnationsBefore = requireNotNull(fixture.bodyIncarnations.value[alpha]) + + val grab = requireNotNull(fixture.tabCenterPx("Alpha")) + val betaCenter = requireNotNull(fixture.tabCenterPx("Beta")) + val gammaCenter = requireNotNull(fixture.tabCenterPx("Gamma")) + // Past Beta's midpoint, short of Gamma's: index 1. + val dropAt = Offset((betaCenter.x + gammaCenter.x) / 2f, grab.y) + + first.focus() + awaitUntil("first window is focused") { first.isFocused } + if (robotPressAndDrag(grab, dropAt, first.scaleFactor) == null) { + System.err.println("[tab-mouse] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + awaitUntil( + "the drag started — ${robotAim()}; ${fixture.geometryReport("Alpha")}", + ) { workspace.draggedTab?.id == alpha } + awaitUntil("its own strip previews the new index") { + val preview = workspace.dropPreview + preview != null && preview.group === fixture.groupOf("Alpha") && preview.index == 1 + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + + awaitUntil("Alpha sits second in the strip") { + requireNotNull(fixture.groupOf("Alpha")).ids == + listOf( + fixture.tabId("Beta"), + alpha, + fixture.tabId("Gamma"), + ) + } + settle() + check(workspace.groups.size == 1) { "a reorder opened a window: ${workspace.groups.size}" } + check(fixture.windowOf("Alpha") === first) { "a reorder moved the tab to another window" } + check(fixture.bodyIncarnations.value[alpha] == incarnationsBefore) { + "a reorder rebuilt the body: ${fixture.bodyIncarnations.value[alpha]} vs $incarnationsBefore" + } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * A press with no movement is a click: the close button and plain + * click-to-select still have to work with a drag handle over the whole tab, + * so nothing may be dragged, previewed or ghosted. + */ + private fun robotPressWithoutMovingOnlySelects(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab mouse press that never moves only selects", + skip = { workspaceSkipReason() ?: robotSkipReason() }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val alpha = fixture.tabId("Alpha") + workspace.select(fixture.tabId("Beta")) + awaitUntil("Beta is the composed body") { fixture.windowOf("Beta") === first } + val idsBefore = requireNotNull(fixture.groupOf("Alpha")).ids + + first.focus() + awaitUntil("first window is focused") { first.isFocused } + val grab = requireNotNull(fixture.tabCenterPx("Alpha")) + if (robotPressAndDrag(grab, grab, first.scaleFactor, steps = 1, stepDelayMillis = 0) == null) { + System.err.println("[tab-mouse] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + settle() + check(workspace.draggedTab == null) { "a press without movement started a drag" } + check(workspace.dragGhost == null) { "a press without movement produced a ghost" } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + + awaitUntil( + "the click selected the tab — ${robotAim()}; ${fixture.geometryReport("Alpha")}", + ) { fixture.windowOf("Alpha") === first } + settle() + check(requireNotNull(fixture.groupOf("Alpha")).ids == idsBefore) { + "a click reordered the strip: ${fixture.groupOf("Alpha")?.ids}" + } + check(workspace.groups.size == 1) { "a click opened a window" } + }, + ) + } + + /** + * A tab is one target, not a patchwork: a click anywhere inside its slot + * selects it — top edge, bottom edge, left of the label, right of it. + * + * The trap this guards against is real and easy to walk into with custom + * chrome: put the drag grip on the label alone and it claims the press + * wherever it sits, leaving only the padding around the label to select + * with. The tab then has two different active areas and a sliver that does + * one but not the other. The stock strip carries the slot, the grip and the + * click on one element that fills the tab, and this is what says so. + */ + private fun robotClicksAnywhereInATabSelectIt(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab mouse click anywhere in a tab selects it", + skip = { workspaceSkipReason() ?: robotSkipReason() }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val alpha = fixture.tabId("Alpha") + val beta = fixture.tabId("Beta") + + // Well inside the slot horizontally — the close button owns the + // trailing end — and hard against the top and bottom of it. + val spots = + listOf( + "top edge" to Offset(SLOT_NEAR_X, SLOT_NEAR_Y), + "bottom edge" to Offset(SLOT_NEAR_X, SLOT_FAR_Y), + "left of the label" to Offset(SLOT_EDGE_X, SLOT_MID_Y), + "past the label" to Offset(SLOT_MID_X, SLOT_MID_Y), + ) + for ((where, fractions) in spots) { + first.focus() + awaitUntil("first window is focused") { first.isFocused } + workspace.select(beta) + awaitUntil("$where: Beta is the composed body") { fixture.windowOf("Beta") === first } + val slot = requireNotNull(fixture.tabRectPx("Alpha")) { "$where: Alpha has no slot" } + val point = + Offset( + slot.left + slot.width * fractions.x, + slot.top + slot.height * fractions.y, + ) + if (robotPressAndDrag(point, point, first.scaleFactor, steps = 1, stepDelayMillis = 0) == null) { + System.err.println("[tab-mouse] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + checkNotNull(robotRelease()) { "$where: robot became unavailable mid-case" } + awaitUntil( + "$where selected Alpha — ${robotAim()}; ${fixture.geometryReport("Alpha")}", + ) { fixture.windowOf("Alpha") === first } + settle() + check(workspace.groups.size == 1) { "$where opened a window" } + check(requireNotNull(fixture.groupOf("Alpha")).ids == listOf(alpha, beta)) { + "$where reordered the strip: ${fixture.groupOf("Alpha")?.ids}" + } + check(workspace.draggedTab == null && workspace.dragGhost == null) { + "$where left drag feedback behind" + } + } + }, + ) + } + + /** + * The hesitant user: a tab held over another window's strip, brought back + * over its own, and dropped at home. Every strip in the workspace shows + * where the tab would land while it is held, so the preview has to follow + * the pointer from one window to the other and back — and the drop has to + * act on where the pointer *ended*. + */ + private fun robotHoverCrossesTwoStripsAndComesBack(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab mouse crosses two strips and drops back home", + skip = { workspaceSkipReason() ?: robotSkipReason() }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val gamma = fixture.tabId("Gamma") + + // Gamma into a window of its own, well clear of the first one. + val second = requireNotNull(workspace.tearOff(gamma, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, second) + val home = requireNotNull(fixture.groupOf("Alpha")) + val beta = fixture.tabId("Beta") + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val awayStrip = requireNotNull(fixture.stripPointPx(second, STRIP_HEAD_FRACTION)) + if (robotPressAndDrag(grab, awayStrip, first.scaleFactor) == null) { + System.err.println("[tab-mouse] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + awaitUntil("the other window's strip previews the drop — ${robotAim()}") { + workspace.draggedTab?.id == beta && workspace.dropPreview?.group === second + } + + // Back over its own strip, past Alpha's midpoint. + val backHome = requireNotNull(fixture.stripPointPx(home, STRIP_MID_FRACTION)) + checkNotNull(robotDragTo(backHome, first.scaleFactor)) { "robot became unavailable mid-case" } + awaitUntil("its own strip takes the preview back") { + workspace.dropPreview?.group === home + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + + awaitUntil("Beta stayed home") { fixture.groupOf("Beta") === home } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.size == 2) { "the round trip changed the window count" } + check(second.ids == listOf(gamma)) { "the hovered window kept a tab it never got: ${second.ids}" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** The same merge, flicked: as few samples as the OS will deliver. */ + private fun robotFlickBetweenStripsMerges(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab mouse flick from one strip to another merges the tab", + skip = { workspaceSkipReason() ?: robotSkipReason() }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + + val second = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + val secondWindow = awaitMappedStrip(fixture, second) + val home = requireNotNull(fixture.groupOf("Alpha")) + + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val target = requireNotNull(fixture.stripPointPx(home, STRIP_HEAD_FRACTION)) + val flicked = + robotPressAndDrag( + grab, + target, + secondWindow.scaleFactor, + steps = FLICK_STEPS, + stepDelayMillis = 0, + ) + if (flicked == null) { + System.err.println("[tab-mouse] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + awaitUntil("the flick started the window drag — ${robotAim()}") { workspace.draggedTab?.id == beta } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + + awaitUntil("the flicked tab merged into the first window") { + workspace.groups.size == 1 && fixture.groupOf("Beta") === home + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(home.ids.size == 2) { "the merged strip holds ${home.ids}" } + check(fixture.windowOf("Beta") === first) { "the tab is composed in the wrong window" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * Fractions of a tab's slot the click case aims at: clear of the close + * button, hard against the edges — but past the resize band. + * + * A strip sits flush with the top of its window, and the top 5 logical px + * of a resizable window belong to `ResizeFrameDecoration`, rightly: a + * press there is a resize grip in every browser too. On a frame that adds + * nothing above its content (Tao on X11, Win32) that band covers the first + * eighth of a 40 dp tab, so "hard against the top edge" has to mean the + * first pixel of the tab that is the tab's to claim. + */ + private const val SLOT_NEAR_X = 0.25f + private const val SLOT_MID_X = 0.5f + private const val SLOT_EDGE_X = 0.06f + private const val SLOT_NEAR_Y = 0.2f + private const val SLOT_MID_Y = 0.5f + private const val SLOT_FAR_Y = 0.88f + + /** + * The hover card, under a real pointer: resting on a tab offers *that* + * tab's card, and the three places it has to stay away from — the tab + * already on screen, anywhere off the strip, and a tab that has just been + * clicked. + * + * The delay itself is not asserted. A wall-clock threshold on a loaded + * runner is exactly what makes a case flaky; what matters here is that a + * real pointer reaches the strip's slots at all, and that the popup opens + * over a real window — neither of which a headless case can tell. + */ + private fun robotRestingOnATabOffersItsCard(): TaoWindowTestCase { + val fixture = + TabWorkspaceFixture( + initialTitles = listOf("Alpha", "Beta", "Gamma"), + hoverPreview = true, + ) + return TaoWindowTestCase( + name = "tab mouse resting on a tab offers its hover card", + skip = { workspaceSkipReason() ?: robotSkipReason() }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val alpha = fixture.tabId("Alpha") + val beta = fixture.tabId("Beta") + workspace.select(alpha) + awaitUntil("Alpha is the composed body") { fixture.windowOf("Alpha") === first } + first.focus() + awaitUntil("first window is focused") { first.isFocused } + + val onAlpha = requireNotNull(fixture.tabCenterPx("Alpha")) + val onBeta = requireNotNull(fixture.tabCenterPx("Beta")) + val strip = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Alpha")))) + val inTheBody = Offset(strip.center.x, strip.bottom + BELOW_STRIP_PX) + + // Resting on a tab that is not the one being read: its card. + if (robotMoveTo(onBeta, first.scaleFactor) == null) { + System.err.println("[tab-mouse] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + awaitUntil( + "the strip offers Beta's card — ${robotAim()}; ${fixture.geometryReport("Beta")}", + ) { fixture.shownHoverCard.value == beta } + + // The tab already on screen gets none: its body is right there. + checkNotNull(robotMoveTo(onAlpha, first.scaleFactor)) { "robot became unavailable mid-case" } + awaitUntil("the card goes away over the selected tab — ${robotAim()}") { + fixture.shownHoverCard.value == null + } + settle(HOVER_HOLD_MILLIS) + check(fixture.shownHoverCard.value == null) { "a card was offered for the tab on screen" } + + // Back on Beta, and it comes back. + checkNotNull(robotMoveTo(onBeta, first.scaleFactor)) { "robot became unavailable mid-case" } + awaitUntil("Beta's card comes back — ${robotAim()}") { fixture.shownHoverCard.value == beta } + + // Off the strip entirely: nothing is being pointed at. + checkNotNull(robotMoveTo(inTheBody, first.scaleFactor)) { "robot became unavailable mid-case" } + awaitUntil("the card goes away below the strip — ${robotAim()}") { + fixture.shownHoverCard.value == null + } + + // A click leaves no card under the pointer, however long it + // rests there: the tab it selected is now the one on screen. + checkNotNull( + robotPressAndDrag(onBeta, onBeta, first.scaleFactor, steps = 1, stepDelayMillis = 0), + ) { "robot became unavailable mid-case" } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + awaitUntil("the click selected Beta — ${robotAim()}") { + requireNotNull(fixture.groupOf("Beta")).selectedId == beta + } + settle(HOVER_HOLD_MILLIS) + check(fixture.shownHoverCard.value == null) { "a card sat under the tab that was just clicked" } + check(workspace.draggedTab == null && workspace.dragGhost == null) { "the click became a drag" } + }, + ) + } +} + +/** How far below a strip a case reaches to leave it: well inside the body. */ +private const val BELOW_STRIP_PX = 80f + +/** Long enough for a card that should not be there to have shown up. */ +private const val HOVER_HOLD_MILLIS = 400L diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspacePointerHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspacePointerHeadfulCases.kt new file mode 100644 index 000000000..ffb9a6ff1 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspacePointerHeadfulCases.kt @@ -0,0 +1,722 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.window.tao.TaoMouseButton +import dev.nucleusframework.window.tao.TaoWindow + +/** + * The tab strip under a real pointer, on real windows — clicks fired faster + * than a human can, gestures that stop just short of being drags, and buttons + * that must do nothing at all. + * + * The events are posted into the window the way the native loop posts them + * (see the pointer helpers in `WorkspaceChaosSupport`), so everything from the + * sub-pixel deadband up is the real pipeline: the resize-edge band, Compose's + * hit-testing, `clickable`, the touch slop and `Modifier.tabDragHandle`. Unlike + * the `HeadfulRobot` cases next door, this runs on Wayland too, where no + * process can inject into the compositor's pointer at all. + * + * 1. **clicks** — one, then bursts of them, alternating, with sub-pixel drift, + * and on the buttons that are not the left one; + * 2. **the line between a click and a drag** — a press under the touch slop + * selects and nothing else; a press past it becomes a gesture; + * 3. **the close button** — it closes and never drags, however fast it is hit; + * 4. **clicks against everything else** — during a live drag, on an unfocused + * window, and on a tab that goes away under the pointer. + */ +internal object TabWorkspacePointerHeadfulCases { + fun all(): List = + listOf( + aClickSelectsTheTabUnderIt(), + aBurstOfClicksOnOneTabSelectsItOnce(), + clicksAlternatingBetweenTabsAlwaysLandOnTheLast(), + aClickWithSubPixelDriftStillSelects(), + aPressUnderTheTouchSlopOnlySelects(), + aPressPastTheSlopBecomesADragAndBackAgain(), + aPointerDragOutOfTheStripTearsTheTabOff(), + aPointerDragOntoAnotherStripMergesTheTab(), + theCloseButtonClosesAndNeverDrags(), + closeClicksInSuccessionCloseOneTabEach(), + aRightClickOnATabNeitherSelectsNorDrags(), + aMiddleClickOnATabDoesNothing(), + clicksOnAnUnfocusedWindowSelectInThatWindow(), + aPressWhoseTabIsClosedUnderItLeavesNoDrag(), + aClickStormAcrossTwoWindowsKeepsBothStripsConsistent(), + aPointerLeavingMidDragKeepsTheGestureAlive(), + ) + + // ── 1. clicks ──────────────────────────────────────────────────────── + + /** The plainest gesture there is: click a tab, that tab is showing. */ + private fun aClickSelectsTheTabUnderIt(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer a click selects the tab under it", + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + for (title in listOf("Gamma", "Alpha", "Beta")) { + val point = requireNotNull(fixture.tabPointInWindowPx(title)) { "$title has no slot" } + tabWindow.pointerClick(point) + awaitUntil("$title became the selected tab") { + fixture.groupOf(title)?.selectedId == fixture.tabId(title) + } + awaitUntil("and its body composed") { fixture.windowOf(title) === tabWindow } + } + check(fixture.workspace.groups.size == 1) { "a click opened a window" } + check(fixture.composedBodies.value == 1) { "clicks left extra bodies composing" } + }, + ) + } + + /** + * A burst of clicks on the tab that is already selected — a double click, + * a triple, an impatient user. Selection is idempotent, nothing may be + * dragged, and no window may appear. + */ + private fun aBurstOfClicksOnOneTabSelectsItOnce(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab pointer a burst of clicks on one tab changes nothing but the selection", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, "Alpha", "Beta") + val point = requireNotNull(fixture.tabPointInWindowPx("Beta")) + val incarnationsBefore = fixture.bodyIncarnations.value[fixture.tabId("Beta")] ?: 0 + + repeat(CLICK_BURST) { tabWindow.pointerClick(point) } + awaitUntil("Beta is selected") { + fixture.groupOf("Beta")?.selectedId == fixture.tabId("Beta") + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.groups.size == 1) { "the burst opened a window" } + check(fixture.workspace.draggedTab == null && fixture.workspace.dragGhost == null) { + "the burst started a drag" + } + check(fixture.composedBodies.value == 1) { + "the burst left ${fixture.composedBodies.value} bodies composing" + } + val after = fixture.bodyIncarnations.value[fixture.tabId("Beta")] ?: 0 + check(after - incarnationsBefore <= 1) { + "$CLICK_BURST clicks rebuilt Beta's body ${after - incarnationsBefore} times" + } + }, + ) + } + + /** + * Clicks alternating between two tabs as fast as they can be posted. Every + * change swaps which body is composed, so this is where a body left behind + * shows up — and the last click has to win. + */ + private fun clicksAlternatingBetweenTabsAlwaysLandOnTheLast(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer clicks alternating between tabs always end on the last one", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + val points = titles.associateWith { requireNotNull(fixture.tabPointInWindowPx(it)) } + + repeat(ALTERNATION_STORM) { round -> + tabWindow.pointerClick(requireNotNull(points[titles[round % titles.size]])) + } + val last = titles[(ALTERNATION_STORM - 1) % titles.size] + awaitUntil("the storm settled on $last") { + fixture.groupOf(last)?.selectedId == fixture.tabId(last) + } + awaitUntil("only its body composes") { fixture.composedBodies.value == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.windowOf(last) === tabWindow) { "$last is not the composed body" } + check(fixture.workspace.groups.size == 1) { "the storm opened a window" } + check(fixture.workspace.draggedTab == null) { "the storm left a drag behind" } + // Every tab is still where it was: clicks reorder nothing. + check(requireNotNull(fixture.groupOf("Alpha")).ids == titles.map(fixture::tabId)) { + "the storm reordered the strip: ${fixture.groupOf("Alpha")?.ids}" + } + }, + ) + } + + /** + * The #615 shape, at the workspace level: a click whose cursor drifts a + * fraction of a pixel between press and release. Without the sub-pixel + * deadband the drift starts the tab's drag gesture, which consumes the + * move, and the tab is never selected — "tabs need two clicks". + */ + private fun aClickWithSubPixelDriftStillSelects(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab pointer a click that drifts a fraction of a pixel still selects", + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, "Alpha", "Beta") + val point = requireNotNull(fixture.tabPointInWindowPx("Beta")) + + tabWindow.pointerMove(point) + tabWindow.pointerPress() + // The drift a real mouse reports between press and release. + tabWindow.pointerMove(point + Offset(SUB_PIXEL_DRIFT_PX, SUB_PIXEL_DRIFT_PX)) + tabWindow.pointerRelease() + + awaitUntil("the drifting click selected Beta") { + fixture.groupOf("Beta")?.selectedId == fixture.tabId("Beta") + } + settle() + check(fixture.workspace.draggedTab == null && fixture.workspace.dragGhost == null) { + "sub-pixel drift started a drag" + } + check(fixture.workspace.groups.size == 1) { "sub-pixel drift tore the tab off" } + }, + ) + } + + /** + * A press that moves a couple of pixels and comes back — a hand that is not + * quite steady. Under the touch slop it is a click, so it selects and + * starts no gesture. + */ + private fun aPressUnderTheTouchSlopOnlySelects(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab pointer a press that wobbles under the touch slop only selects", + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, "Alpha", "Beta") + val point = requireNotNull(fixture.tabPointInWindowPx("Beta")) + val scale = tabWindow.scaleFactor + + tabWindow.pointerMove(point) + tabWindow.pointerPress() + settle(POINTER_DRAG_STEP_MILLIS) + for (dx in listOf(1f, -1f, 1f)) { + tabWindow.pointerMove(point + Offset(dx * WOBBLE_DP * scale, 0f)) + settle(POINTER_DRAG_STEP_MILLIS) + } + check(fixture.workspace.draggedTab == null) { "a wobble under the slop started a drag" } + tabWindow.pointerMove(point) + tabWindow.pointerRelease() + + awaitUntil("the wobbling press selected Beta") { + fixture.groupOf("Beta")?.selectedId == fixture.tabId("Beta") + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.groups.size == 1) { "a wobble tore the tab off" } + check(fixture.workspace.dragGhost == null) { "a wobble left a ghost behind" } + }, + ) + } + + /** + * Past the slop it is a gesture: the workspace publishes the drag, and + * releasing back over the tab's own slot puts it back where it was rather + * than tearing it out. + */ + private fun aPressPastTheSlopBecomesADragAndBackAgain(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer a press past the slop drags and releasing home reorders nothing", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + val beta = fixture.tabId("Beta") + val home = requireNotNull(fixture.tabPointInWindowPx("Beta")) + val slot = requireNotNull(fixture.tabSlotInWindowPx("Beta")) + val idsBefore = requireNotNull(fixture.groupOf("Beta")).ids + + // A few pixels to the right, well past the slop but inside the + // tab's own slot, then back home. + pointerDragFrom(tabWindow, home, home + Offset(slot.width / 3f, 0f)) + awaitUntil("the gesture became a drag") { fixture.workspace.draggedTab?.id == beta } + tabWindow.pointerMove(home) + settle(POINTER_DRAG_STEP_MILLIS) + tabWindow.pointerRelease() + + awaitUntil("the drag ended") { fixture.workspace.draggedTab == null } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.groups.size == 1) { "releasing home tore the tab off" } + check(requireNotNull(fixture.groupOf("Beta")).ids == idsBefore) { + "releasing home reordered the strip: ${fixture.groupOf("Beta")?.ids}" + } + check(fixture.workspace.dragGhost == null && fixture.workspace.dropPreview == null) { + "drag feedback outlived the gesture" + } + }, + ) + } + + /** + * The whole tear-off gesture with nothing but pointer events: press a tab, + * drag it out of the strip, release. A window appears under the pointer + * with that tab in it. + */ + private fun aPointerDragOutOfTheStripTearsTheTabOff(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer a drag out of the strip tears the tab into its own window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + val beta = fixture.tabId("Beta") + val home = requireNotNull(fixture.tabPointInWindowPx("Beta")) + val outer = requireNotNull(tabWindow.outerBoundsPx()) + // Straight down into the body, far clear of the strip. + val out = Offset(home.x, outer[RECT_H] * DEEP_IN_BODY) + + pointerDragFrom(tabWindow, home, out) + awaitUntil("the tab is being dragged") { fixture.workspace.draggedTab?.id == beta } + check(fixture.workspace.dragGhost != null) { "the tear-out is not previewed" } + tabWindow.pointerRelease() + + awaitUntil("it landed in a window of its own") { + fixture.workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + val torn = awaitMappedStrip(fixture, requireNotNull(fixture.groupOf("Beta"))) + check(torn !== tabWindow) { "the tab stayed in its old window" } + check(fixture.workspace.dragGhost == null && fixture.workspace.dropPreview == null) { + "drag feedback outlived the tear-off" + } + awaitUntil("one body per window composes") { fixture.composedBodies.value == 2 } + }, + ) + } + + /** + * And the way back, by pointer: the tab dragged out of its window and + * released on another window's strip merges into it at the insertion point + * under the pointer. + */ + private fun aPointerDragOntoAnotherStripMergesTheTab(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab pointer a drag released on another strip merges the tab into it", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val gamma = fixture.tabId("Gamma") + // Gamma and Beta into a second window, so the source strip has + // two tabs and the gesture is a lift-out rather than a window move. + val second = requireNotNull(workspace.tearOff(gamma, tearOffRectPx(first), first.scaleFactor)) + val secondWindow = awaitMappedStrip(fixture, second) + workspace.move(fixture.tabId("Beta"), second) + awaitUntil("the second window holds both") { second.ids.size == 2 } + awaitMappedStrip(fixture, second) + settle(SETTLE_AFTER_MAP_MILLIS) + + val home = requireNotNull(fixture.groupOf("Alpha")) + val grab = requireNotNull(fixture.tabPointInWindowPx("Gamma")) + val targetOnScreen = + requireNotNull(fixture.stripPointPx(home, MERGE_X_FRACTION)) { "no target strip point" } + // The gesture is driven in the source window's coordinates; the + // drop lands wherever that is on screen. + val client = requireNotNull(fixture.workspace.stripGeometry(second)?.clientOriginPx()) + val targetInSource = targetOnScreen - client + + pointerDragFrom(secondWindow, grab, targetInSource) + awaitUntil("the drop is previewed in the other window") { + workspace.dropPreview?.group === home + } + secondWindow.pointerRelease() + + awaitUntil("Gamma merged into the first window") { + fixture.groupOf("Gamma") === home && home.ids.contains(gamma) + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.size == 2) { "the merge changed the window count" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { + "drag feedback outlived the merge" + } + }, + ) + } + + // ── 3. the close button ────────────────────────────────────────────── + + /** + * The × of a tab: it closes, and because `clickable` consumes the press it + * must never start the tab's drag — a close that tears the tab into a new + * window on the way out is the worst possible outcome. + */ + private fun theCloseButtonClosesAndNeverDrags(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer the close button closes the tab and never drags it", + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + val beta = fixture.tabId("Beta") + val close = requireNotNull(closePointInWindowPx(fixture, tabWindow, "Beta")) + + tabWindow.pointerClick(close) + awaitUntil("Beta was closed") { fixture.workspace.tab(beta) == null } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.groups.size == 1) { "the close opened a window" } + check(fixture.workspace.draggedTab == null && fixture.workspace.dragGhost == null) { + "the close started a drag" + } + check(requireNotNull(fixture.groupOf("Alpha")).ids.size == 2) { + "the close took more than one tab: ${fixture.groupOf("Alpha")?.ids}" + } + }, + ) + } + + /** + * Closing tab after tab by hitting the × where the *next* tab has just + * slid — the strip re-lays out between clicks, so each click has to be + * aimed at the strip as it is now, and each has to close exactly one tab. + */ + private fun closeClicksInSuccessionCloseOneTabEach(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer close clicks in succession close one tab each", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + for (title in listOf("Delta", "Gamma")) { + val before = fixture.workspace.tabs.size + val close = + requireNotNull(closePointInWindowPx(fixture, tabWindow, title)) { + "$title has no close button" + } + tabWindow.pointerClick(close) + awaitUntil("$title closed") { fixture.workspace.tab(fixture.tabId(title)) == null } + awaitUntil("the strip re-laid out around it") { + val group = fixture.groupOf("Alpha") ?: return@awaitUntil false + group.slotsInWindowPx.size >= group.ids.size && + group.slotsInWindowPx.take(group.ids.size).all { it.width > 1f } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.tabs.size == before - 1) { + "closing $title took ${before - fixture.workspace.tabs.size} tabs" + } + } + check(fixture.workspace.groups.size == 1) { "the closes opened a window" } + check(fixture.composedBodies.value == 1) { "the closes left extra bodies composing" } + }, + ) + } + + // ── 4. buttons that are not the left one, and everything else ──────── + + /** A right click is for a context menu, not for selecting or dragging. */ + private fun aRightClickOnATabNeitherSelectsNorDrags(): TaoWindowTestCase = + secondaryButtonCase( + name = "tab pointer a right click on a tab neither selects nor drags", + button = TaoMouseButton.RIGHT, + ) + + /** Middle click is close-tab in a browser, and nothing at all here. */ + private fun aMiddleClickOnATabDoesNothing(): TaoWindowTestCase = + secondaryButtonCase( + name = "tab pointer a middle click on a tab does nothing", + button = TaoMouseButton.MIDDLE, + ) + + private fun secondaryButtonCase( + name: String, + button: Int, + ): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = name, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, "Alpha", "Beta") + val selected = requireNotNull(fixture.groupOf("Alpha")).selectedId + val point = requireNotNull(fixture.tabPointInWindowPx("Beta")) + + repeat(SECONDARY_CLICKS) { tabWindow.pointerClick(point, button) } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(requireNotNull(fixture.groupOf("Alpha")).selectedId == selected) { + "a non-left click changed the selection to " + + "${fixture.groupOf("Alpha")?.selectedId}" + } + check(fixture.workspace.draggedTab == null && fixture.workspace.dragGhost == null) { + "a non-left click started a drag" + } + check(fixture.workspace.groups.size == 1) { "a non-left click opened a window" } + check(fixture.workspace.tabs.size == 2) { "a non-left click closed a tab" } + // And the left button still works right after. + tabWindow.pointerClick(point) + awaitUntil("a left click still selects") { + fixture.groupOf("Beta")?.selectedId == fixture.tabId("Beta") + } + }, + ) + } + + /** + * A click on a window that is not the focused one. Every window has its own + * scene and its own strip, so the click belongs to the window it landed on + * whatever the desktop thinks is focused. + */ + private fun clicksOnAnUnfocusedWindowSelectInThatWindow(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer a click on an unfocused window selects in that window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val second = + requireNotNull( + workspace.tearOff(fixture.tabId("Gamma"), tearOffRectPx(first), first.scaleFactor), + ) + val secondWindow = awaitMappedStrip(fixture, second) + workspace.move(fixture.tabId("Beta"), second) + awaitUntil("the second window holds two tabs") { second.ids.size == 2 } + awaitMappedStrip(fixture, second) + first.focus() + settle(SETTLE_AFTER_MAP_MILLIS) + + // Aimed at the window that is (probably) not focused. + val point = requireNotNull(fixture.tabPointInWindowPx("Gamma")) + secondWindow.pointerClick(point) + awaitUntil("Gamma is selected in its own window") { + second.selectedId == fixture.tabId("Gamma") + } + check(requireNotNull(fixture.groupOf("Alpha")).selectedId == fixture.tabId("Alpha")) { + "the click changed the other window's selection" + } + check(workspace.groups.size == 2) { "the click changed the window count" } + }, + ) + } + + /** + * The tab under the pointer, closed by the application while the button is + * still down. The gesture has nothing left to act on and must simply end. + */ + private fun aPressWhoseTabIsClosedUnderItLeavesNoDrag(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer a press whose tab is closed under it leaves no drag behind", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + val beta = fixture.tabId("Beta") + val point = requireNotNull(fixture.tabPointInWindowPx("Beta")) + + tabWindow.pointerMove(point) + tabWindow.pointerPress() + settle(POINTER_DRAG_STEP_MILLIS) + fixture.workspace.close(beta) + awaitUntil("the tab is gone") { fixture.workspace.tab(beta) == null } + settle(SETTLE_AFTER_MAP_MILLIS) + tabWindow.pointerRelease() + settle(SETTLE_AFTER_MAP_MILLIS) + + check(fixture.workspace.tab(beta) == null) { "the release brought the tab back" } + check(fixture.workspace.draggedTab == null && fixture.workspace.dragGhost == null) { + "the release left drag feedback behind" + } + check(fixture.workspace.groups.size == 1) { "the release opened a window" } + // And the strip still answers a click. + val alpha = requireNotNull(fixture.tabPointInWindowPx("Alpha")) + tabWindow.pointerClick(alpha) + awaitUntil("clicking still works") { + fixture.groupOf("Alpha")?.selectedId == fixture.tabId("Alpha") + } + }, + ) + } + + /** + * Two windows clicked in turn, over and over. Each strip keeps its own + * selection and its own slots; a shared piece of state anywhere in the + * chain shows up here as one window answering for the other. + */ + private fun aClickStormAcrossTwoWindowsKeepsBothStripsConsistent(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer a click storm across two windows keeps both strips consistent", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val second = + requireNotNull( + workspace.tearOff(fixture.tabId("Gamma"), tearOffRectPx(first), first.scaleFactor), + ) + awaitMappedStrip(fixture, second) + workspace.move(fixture.tabId("Delta"), second) + awaitUntil("two windows of two tabs") { + workspace.groups.size == 2 && workspace.groups.all { it.ids.size == 2 } + } + val secondWindow = awaitMappedStrip(fixture, second) + settle(SETTLE_AFTER_MAP_MILLIS) + + repeat(CROSS_WINDOW_CLICKS) { round -> + val onFirst = if (round % 2 == 0) "Alpha" else "Beta" + val onSecond = if (round % 2 == 0) "Delta" else "Gamma" + fixture.tabPointInWindowPx(onFirst)?.let { first.pointerClick(it) } + fixture.tabPointInWindowPx(onSecond)?.let { secondWindow.pointerClick(it) } + } + val lastFirst = if ((CROSS_WINDOW_CLICKS - 1) % 2 == 0) "Alpha" else "Beta" + val lastSecond = if ((CROSS_WINDOW_CLICKS - 1) % 2 == 0) "Delta" else "Gamma" + awaitUntil("each window settled on its own last click") { + requireNotNull(fixture.groupOf(lastFirst)).selectedId == fixture.tabId(lastFirst) && + requireNotNull(fixture.groupOf(lastSecond)).selectedId == fixture.tabId(lastSecond) + } + awaitUntil("one body per window composes") { fixture.composedBodies.value == 2 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.size == 2) { "the storm changed the window count" } + check(workspace.groups.all { it.ids.size == 2 }) { + "the storm moved a tab: ${workspace.groups.map { it.ids }}" + } + check(workspace.draggedTab == null) { "the storm left a drag behind" } + }, + ) + } + + /** + * The pointer leaving the window mid-drag — which it does the moment a tab + * is dragged past the window's edge. The platform grab keeps delivering + * positions, so a `CURSOR_LEFT` in the middle of a gesture must not end it. + */ + private fun aPointerLeavingMidDragKeepsTheGestureAlive(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab pointer leaving the window mid-drag does not end the gesture", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + val beta = fixture.tabId("Beta") + val home = requireNotNull(fixture.tabPointInWindowPx("Beta")) + val outer = requireNotNull(tabWindow.outerBoundsPx()) + val out = Offset(home.x, outer[RECT_H] * DEEP_IN_BODY) + + pointerDragFrom(tabWindow, home, out) + awaitUntil("the tab is being dragged") { fixture.workspace.draggedTab?.id == beta } + + // Past the bottom edge: the OS reports the pointer as gone. + tabWindow.pointerExit() + settle(POINTER_DRAG_STEP_MILLIS) + check(fixture.workspace.draggedTab?.id == beta) { "leaving the window ended the drag" } + tabWindow.pointerMove(Offset(home.x, outer[RECT_H] + BEYOND_EDGE_PX)) + settle(POINTER_DRAG_STEP_MILLIS) + check(fixture.workspace.draggedTab?.id == beta) { "a position outside the window ended the drag" } + tabWindow.pointerRelease() + + awaitUntil("the release outside tore the tab off") { + fixture.workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.dragGhost == null) { "drag feedback outlived the gesture" } + }, + ) + } + + // ── helpers ────────────────────────────────────────────────────────── + + /** + * The centre of the × of the tab titled [title], in window content px. + * + * The button sits at the trailing edge of the slot, inside the item's + * horizontal padding — close enough to the edge that the offset is derived + * from the strip's own metrics rather than hard-coded pixels. + */ + private fun closePointInWindowPx( + fixture: TabWorkspaceFixture, + window: TaoWindow, + title: String, + ): Offset? { + val slot = fixture.tabSlotInWindowPx(title) ?: return null + val inset = CLOSE_BUTTON_INSET_DP * window.scaleFactor + if (slot.width <= inset) return null + return Offset(slot.right - inset, slot.center.y) + } + + /** Distance from a tab slot's trailing edge to the centre of its close button, in dp. */ + private const val CLOSE_BUTTON_INSET_DP = 15f + + /** Sub-pixel drift: under the 1 dp deadband, over Compose's own mouse slop. */ + private const val SUB_PIXEL_DRIFT_PX = 0.3f + + /** A wobble in dp: over the deadband, under the touch slop. */ + private const val WOBBLE_DP = 2f + + /** How far down the window body a torn-off drop lands. */ + private const val DEEP_IN_BODY = 0.8f + + private const val BEYOND_EDGE_PX = 40f + private const val CLICK_BURST = 40 + private const val ALTERNATION_STORM = 60 + private const val SECONDARY_CLICKS = 5 + private const val CROSS_WINDOW_CLICKS = 12 + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStormHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStormHeadfulCases.kt new file mode 100644 index 000000000..1ba81ba75 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStormHeadfulCases.kt @@ -0,0 +1,377 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.window.tao.TabWindowGroup +import kotlin.math.abs + +/** + * The tab workspace under storms, on real windows: operations fired faster than + * the loop can settle, and windows the geometry alone cannot tell apart. + * + * 1. **selection storms** — hundreds of selection changes, after which exactly + * one body per window may be composing and every tab must still own its own + * saveable state; + * 2. **reorder storms** — the strip republishes a slot per tab on every + * layout, so what has to hold at the end is that the slots describe the + * strip that is drawn; + * 3. **sample storms** — hundreds of pointer samples inside one window drag, + * which is what a slow drag across a large screen really delivers; + * 4. **stacked windows** — several windows at the same position, where only + * focus recency decides which strip answers a drop; + * 5. **a snapshot against churn** — a saved layout has to be a description, + * not a moment: it must put everything back after a burst of moves. + * + * Native Wayland is skipped along with the rest of the tab suite. + */ +internal object TabWorkspaceStormHeadfulCases { + fun all(): List = + listOf( + aStormOfSelectionsLeaksNoBodies(), + aStormOfReordersKeepsTheSlotsConsistent(), + stackedWindowsResolveToTheFocusedStrip(), + aSnapshotConvergesBackAfterChurn(), + hundredsOfSamplesInOneWindowDrag(), + ) + + /** + * Selection changed hundreds of times with no frame in between. Each + * arriving body must get its own state and each leaving body must go, so + * the count of composing bodies stays at one per window however fast the + * selection moves — and every tab's saveable state has to survive its + * turns off screen. + */ + private fun aStormOfSelectionsLeaksNoBodies(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab storm of selections leaks no bodies and no state", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + + // Give each tab a distinct saveable value first. + for ((index, title) in titles.withIndex()) { + workspace.select(fixture.tabId(title)) + awaitUntil("$title composed") { fixture.counters.value[fixture.tabId(title)] != null } + requireNotNull(fixture.counters.value[fixture.tabId(title)]).value = index + 1 + } + + repeat(SELECTION_STORM) { round -> + workspace.select(fixture.tabId(titles[round % titles.size])) + } + awaitUntil("the storm settled on the last selection") { + fixture.windowOf(titles[(SELECTION_STORM - 1) % titles.size]) != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.composedBodies.value == 1) { + "the storm left ${fixture.composedBodies.value} bodies composing" + } + // Every tab keeps its own value: a body must never be handed + // the saveable registry of the one it replaced. + for ((index, title) in titles.withIndex()) { + workspace.select(fixture.tabId(title)) + awaitUntil("$title is back") { fixture.windowOf(title) != null } + settle(SELECTION_SETTLE_MILLIS) + val counter = requireNotNull(fixture.counters.value[fixture.tabId(title)]) + check(counter.value == index + 1) { + "$title came back with ${counter.value}, expected ${index + 1}" + } + } + check(workspace.groups.size == 1) { "the storm opened a window" } + }, + ) + } + + /** + * Reordered as fast as the workspace will take it. The strip republishes a + * slot per tab on every layout, so what this pins down is that the slot + * list never ends up shorter than the strip or stale enough to resolve a + * drop to a tab that is somewhere else. + */ + private fun aStormOfReordersKeepsTheSlotsConsistent(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab storm of reorders keeps the strip slots consistent", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + + repeat(REORDER_STORM) { round -> + val title = titles[round % titles.size] + workspace.reorder(fixture.tabId(title), round % titles.size) + } + // A slot per tab is not enough: the storm reordered them, so the + // published slots have to have caught up with the strip order — + // left to right, no crossings. That is what makes an insertion + // index mean anything, and the wait the assertions below need. + awaitUntil("the strip republished its slots in strip order") { + val slots = group.slotsInWindowPx + slots.size == group.ids.size && + slots.zipWithNext().all { (left, right) -> left.left < right.left } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(group.ids.toSet() == titles.map(fixture::tabId).toSet()) { + "the storm lost or duplicated a tab: ${group.ids}" + } + check(group.ids.size == titles.size) { "the strip holds ${group.ids.size} tabs" } + check(workspace.groups.size == 1) { "the storm opened a window" } + + // The published slots still describe the strip that is drawn: + // each tab's own centre resolves to the index it occupies. + for ((index, id) in group.ids.withIndex()) { + val title = titles.first { fixture.tabId(it) == id } + val centre = requireNotNull(fixture.tabCenterPx(title)) { "$title has no slot" } + val entry = requireNotNull(workspace.tab(id)) + val resolved = requireNotNull(workspace.dropTargetAt(centre, exclude = entry)) + check(resolved.group === group) { "$title's centre resolves to another window" } + check(resolved.index == index) { + "$title sits at $index but its centre resolves to ${resolved.index}" + } + } + }, + ) + } + + /** + * Windows stacked exactly on top of each other: geometry alone cannot say + * which strip a drop belongs to, so focus recency has to. This is the + * everyday case of two document windows on the same spot, and the one + * where a stale focus order silently drops tabs into the window behind. + */ + private fun stackedWindowsResolveToTheFocusedStrip(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab storm stacked windows resolve a drop to the focused strip", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + + // Beta and Gamma into windows of their own, all three stacked + // at the same place. + val groups = ArrayList() + groups += requireNotNull(fixture.groupOf("Alpha")) + for (title in listOf("Beta", "Gamma")) { + val group = + requireNotNull( + workspace.tearOff(fixture.tabId(title), tearOffRectPx(first), first.scaleFactor), + ) + awaitMappedStrip(fixture, group) + groups += group + } + val anchor = requireNotNull(first.outerBoundsPx()) + val scale = first.scaleFactor.toDouble() + for (group in groups.drop(1)) { + requireNotNull(group.window).setOuterPosition(anchor[0] / scale, anchor[1] / scale) + } + awaitUntil("every window is stacked on the first one") { + groups.all { group -> + val now = group.window?.outerBoundsPx() ?: return@all false + abs(now[0] - anchor[0]) <= STACK_TOLERANCE_PX && abs(now[1] - anchor[1]) <= STACK_TOLERANCE_PX + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + + // Whichever window was focused last owns the point. + for (group in groups) { + val window = requireNotNull(group.window) + window.focus() + awaitUntil("the ${group.id} window reports focus") { window.isFocused } + awaitUntil("and its strip owns the shared point") { + val strip = fixture.stripRectPx(group) ?: return@awaitUntil false + workspace.dropTargetAt(strip.center)?.group === group + } + } + + // A drop on the shared point lands in the focused window, and + // the dragged window's own strip never answers for itself. + val front = groups.last() + requireNotNull(front.window).focus() + awaitUntil("the front window is focused") { requireNotNull(front.window).isFocused } + val alpha = fixture.tabId("Alpha") + val source = groups.first() + val sourceWindow = requireNotNull(source.window) + val grab = requireNotNull(fixture.tabCenterPx("Alpha")) + val shared = requireNotNull(fixture.stripRectPx(front)).center + val session = requireNotNull(workspace.beginDrag(alpha, stripOrigin(sourceWindow), grab)) + session.update(grab) + session.update(shared) + settle(JUMP_SETTLE_MILLIS) + val preview = requireNotNull(workspace.dropPreview) { "the stack previewed no drop" } + check(preview.group === front) { "the drop resolved to ${preview.group.id}, not the focused window" } + session.end(shared) + awaitUntil("the tab landed in the focused window") { + fixture.groupOf("Alpha") === front && front.ids.contains(alpha) + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.size == 2) { "the merge left ${workspace.groups.size} windows" } + }, + ) + } + + /** + * A snapshot has to be a description of a layout, not of a moment: taken + * before a burst of moves, reorders and tear-offs, applying it afterwards + * must put every window and every strip back exactly as they were. + */ + private fun aSnapshotConvergesBackAfterChurn(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "tab storm a snapshot converges back after a burst of churn", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + + // Two windows of two tabs each, which is the layout to get back. + val second = + requireNotNull( + workspace.tearOff(fixture.tabId("Gamma"), tearOffRectPx(first), first.scaleFactor), + ) + awaitMappedStrip(fixture, second) + workspace.move(fixture.tabId("Delta"), second) + awaitUntil("two windows of two tabs") { + workspace.groups.size == 2 && workspace.groups.all { it.ids.size == 2 } + } + settle(SETTLE_AFTER_MAP_MILLIS) + val snapshot = workspace.snapshot() + val savedOf = snapshot.groups.associate { it.id to it.tabIds } + val savedSelection = snapshot.groups.associate { it.id to it.selectedId } + + // A burst that ends somewhere else entirely. + repeat(CHURN_ROUNDS) { round -> + val title = titles[round % titles.size] + val id = fixture.tabId(title) + val group = fixture.groupOf(title) ?: return@repeat + if (round % 3 == 0) { + val window = group.window ?: return@repeat + workspace.tearOff(id, tearOffRectPx(window), window.scaleFactor) + } else { + val other = workspace.groups.firstOrNull { it !== group } ?: return@repeat + workspace.move(id, other, index = round % 2) + } + } + awaitUntil("the churn settled") { workspace.tabs.all { it.group != null } } + settle(SETTLE_AFTER_MAP_MILLIS) + + workspace.restore(snapshot) + awaitUntil("the saved layout is back") { + workspace.groups.size == snapshot.groups.size && + workspace.groups.all { savedOf[it.id] == it.ids } + } + awaitUntil("both restored windows are mapped") { + workspace.groups.all { it.window?.hasRealFramePx() == true } + } + settle(SETTLE_AFTER_MAP_MILLIS) + for (group in workspace.groups) { + check(group.selectedId == savedSelection[group.id]) { + "group ${group.id} came back showing ${group.selectedId}, saved ${savedSelection[group.id]}" + } + } + awaitUntil("one body per window composes") { + fixture.composedBodies.value == workspace.groups.size + } + }, + ) + } + + /** + * Hundreds of samples in one gesture, which is what a slow deliberate drag + * across a 4K screen actually delivers. Each one moves a real window, so + * this is also the throughput check: the loop has to stay responsive and + * the window has to end up under the pointer, not somewhere behind it. + */ + private fun hundredsOfSamplesInOneWindowDrag(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta")) + return TaoWindowTestCase( + name = "tab storm hundreds of samples in one window drag stay in step", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + + val second = + requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + val secondWindow = awaitMappedStrip(fixture, second) + val start = requireNotNull(fixture.tabCenterPx("Beta")) + val before = requireNotNull(secondWindow.outerBoundsPx()) + + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(secondWindow), start)) + session.update(start) + // A slow arc, one sample at a time, ending back where it began + // so the window's own strip cannot drift off the pointer. + repeat(SAMPLE_STORM) { step -> + val t = step / SAMPLE_STORM.toFloat() + val wobble = SAMPLE_ARC_PX * kotlin.math.sin(t * Math.PI * 2).toFloat() + session.update(start + Offset(wobble, wobble / 2f)) + } + session.update(start) + settle() + awaitUntil("the window came back to where the pointer is") { + val now = secondWindow.outerBoundsPx() ?: return@awaitUntil false + abs(now[0] - before[0]) <= SAMPLE_END_TOLERANCE_PX && + abs(now[1] - before[1]) <= SAMPLE_END_TOLERANCE_PX + } + session.end(start) + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.groups.size == 2) { "the storm changed the window count" } + check(fixture.groupOf("Beta") === second) { "the storm moved the tab" } + check(workspace.draggedTab == null && workspace.dragGhost == null) { "drag feedback left behind" } + // The loop is still alive: another gesture works right after. + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val home = requireNotNull(fixture.groupOf("Alpha")) + val target = requireNotNull(fixture.stripPointPx(home, 0.02f)) + val merge = requireNotNull(workspace.beginDrag(beta, stripOrigin(secondWindow), grab)) + merge.update(grab) + merge.update(target) + merge.end(target) + awaitUntil("the follow-up gesture merged the windows") { + workspace.groups.size == 1 && fixture.groupOf("Beta") === home + } + }, + ) + } + + private const val CHURN_ROUNDS = 6 + private const val SELECTION_STORM = 200 + private const val REORDER_STORM = 120 + private const val SAMPLE_STORM = 400 + private const val SAMPLE_ARC_PX = 120f + private const val SAMPLE_END_TOLERANCE_PX = 24L + private const val STACK_TOLERANCE_PX = 24L + private const val SELECTION_SETTLE_MILLIS = 120L + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStressHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStressHeadfulCases.kt new file mode 100644 index 000000000..f28104a5a --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TabWorkspaceStressHeadfulCases.kt @@ -0,0 +1,567 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.core.runtime.Platform +import kotlin.math.abs + +/** + * The adversarial half of the tab workspace, on real windows: everything that + * happens between a clean grab and a clean drop. + * + * 1. **abrupt movement** — a pointer that teleports across and off the screen + * in single samples, then a real mouse flick the OS coalesces into a + * couple of enormous deltas; + * 2. **backing-scale change** — the display flips between its 1x and HiDPI + * twin while tabs are open, and a tear-off after it must still land under + * the pointer at a window of the right logical size; + * 3. **minimize** — a minimized window is not a drop target, and comes back + * as one when restored; + * 4. **maximize** — a maximized window's strip is where a drop lands, and a + * tab torn out of it gets a window of its own rather than a maximized one; + * 5. **interrupted gestures** — a drag whose window is resized under it, a + * superseded drag, and a drag whose target window closes mid-gesture; + * 6. **window close mid-drag** — the source window destroyed while its tab is + * in flight. + * + * Native Wayland is skipped along with the rest of the tab suite. + */ +internal object TabWorkspaceStressHeadfulCases { + private val isMac: Boolean get() = Platform.Current == Platform.MacOS + + fun all(): List = + listOf( + abruptPointerJumpsStillResolve(), + robotFlickTearsOffTheTab(), + backingScaleChangeKeepsDropsHonest(), + minimizedWindowIsNoDropTarget(), + maximizedWindowTakesAndGivesTabs(), + interruptedAndSupersededDragsLeaveNoFeedback(), + sourceWindowClosingMidDragStaysSane(), + ) + + /** + * A pointer that teleports: no intermediate samples, jumps far off-screen + * and back, crossing strips without ever hovering the space between them — + * a synthetic replay does this, and so does a fast flick, since the OS + * coalesces motion into one enormous delta. + * + * Driven through the drag session rather than the Robot: the Robot cannot + * express "no samples in between" (the OS interpolates), and it is exactly + * the missing samples this pins down. + */ + private fun abruptPointerJumpsStillResolve(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture() + return TaoWindowTestCase( + name = "tab drag survives pointer jumps across and off the screen", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val strip = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta")))) + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + + val jumps = + listOf( + Offset(-50_000f, -50_000f), + Offset(strip.left + JUMP_INSET_PX, strip.center.y), + Offset(200_000f, 200_000f), + Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX), + Offset(Float.NaN, Float.NaN), + ) + for (jump in jumps) { + session.update(jump) + settle(JUMP_SETTLE_MILLIS) + // Over its own strip the strip holds the tab under the + // pointer, so there is no ghost to check — anywhere else + // the ghost is what the user is dragging. + val ownStrip = workspace.dropPreview?.group === fixture.groupOf("Beta") + if (ownStrip) { + check(workspace.dragGhost == null) { "a ghost over its own strip at $jump" } + } else { + val ghost = requireNotNull(workspace.dragGhost) { "the ghost was lost at $jump" } + check(ghost.screenRectPx.width > 0f && ghost.screenRectPx.height > 0f) { + "the ghost has no size after jumping to $jump: ${ghost.screenRectPx}" + } + } + val bounds = requireNotNull(first.outerBoundsPx()) { "the source window was lost at $jump" } + check(bounds[2] > 0 && bounds[3] > 0) { "the source window has no size after $jump" } + } + // The garbage sample left the last real one standing. + val expected = Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX) + check(requireNotNull(workspace.dragGhost).screenRectPx.contains(expected)) { + "the ghost moved to the unusable sample" + } + check(workspace.dropPreview == null) { "empty space must preview no insertion" } + + session.end(expected) + awaitUntil("torn off after the jumps") { + workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + check(workspace.draggedTab == null && workspace.dragGhost == null) { + "drag feedback outlived the jumps" + } + }, + ) + } + + /** The same gesture with a real mouse, flicked: as few samples as the OS will deliver. */ + private fun robotFlickTearsOffTheTab(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture() + return TaoWindowTestCase( + name = "tab flicked out of the strip with a real mouse tears off", + skip = { + workspaceSkipReason() ?: HeadfulRobot.unavailableReason?.let { "no input injection: $it" } + }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val strip = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta")))) + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val dropOut = Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX) + + val flicked = + robotPressAndDrag(grab, dropOut, first.scaleFactor, steps = FLICK_STEPS, stepDelayMillis = 0) + if (flicked == null) { + System.err.println("[tab-flick] robot became unavailable, nothing to assert") + return@TaoWindowTestCase + } + awaitUntil("the flick started a drag — ${robotAim()}") { + workspace.draggedTab?.id == fixture.tabId("Beta") + } + checkNotNull(robotRelease()) { "robot became unavailable mid-case" } + + awaitUntil("the flicked tab landed in its own window") { + workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(fixture.tabId("Beta")) + } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * A backing-scale change with the window's frame in points untouched — the + * one transition a single display can produce ([MacDisplayModeTool]), and + * the one that catches strip geometry cached in physical pixels. + * + * Two things must hold afterwards: the strip is hit-tested at the new + * scale, and a tear-off produces a window of the same *logical* size as + * the one it came from. + */ + private fun backingScaleChangeKeepsDropsHonest(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture() + return TaoWindowTestCase( + name = "tab workspace survives a backing-scale change and still drops where the pointer is", + timeoutMillis = SCALE_TIMEOUT_MILLIS, + skip = { + workspaceSkipReason() + ?: if (!isMac) { + "needs a display whose backing scale can be flipped (macOS)" + } else { + null + ?: MacDisplayModeTool.unavailableReason() + } + }, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val baseScale = first.scaleFactor + val fromMode = if (baseScale >= HIDPI_SCALE) "2x" else "1x" + val toMode = if (baseScale >= HIDPI_SCALE) "1x" else "2x" + val expectedScale = if (baseScale >= HIDPI_SCALE) baseScale / 2f else baseScale * 2f + val stripBefore = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta")))) + val logicalWidthBefore = stripBefore.width / baseScale + System.err.println("[tab-scale] baseline scale=$baseScale strip=$stripBefore") + + try { + System.err.println("[tab-scale] setmode $toMode -> ${MacDisplayModeTool.run(toMode)}") + awaitUntil("the tab window reports the new backing scale ($expectedScale)") { + abs(first.scaleFactor - expectedScale) < SCALE_TOLERANCE + } + settle(SETTLE_AFTER_SCALE_MILLIS) + awaitUntil("the strip republished its geometry at the new scale") { + val strip = + fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta"))) + ?: return@awaitUntil false + abs(strip.width / expectedScale - logicalWidthBefore) <= LOGICAL_TOLERANCE_DP + } + + // ── the strip is still hit-tested where it is drawn ── + val strip = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta")))) + val betaCenter = requireNotNull(fixture.tabCenterPx("Beta")) + check(strip.contains(betaCenter)) { + "the tab's own slot fell outside its strip after the scale change: $betaCenter in $strip" + } + val target = requireNotNull(workspace.dropTargetAt(betaCenter)) + check(target.group === fixture.groupOf("Beta")) { + "a point on the strip no longer resolves to its window after the scale change" + } + + // ── and a tear-off lands under the pointer, at the right size ── + val dropOut = Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), betaCenter)) + session.update(betaCenter) + session.update(dropOut) + session.end(dropOut) + awaitUntil("torn off after the scale change") { + workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + val torn = requireNotNull(fixture.groupOf("Beta")) + awaitUntil("the torn-off window is mapped") { + torn.window?.hasRealFramePx() == true + } + settle(SETTLE_AFTER_MAP_MILLIS) + val tornWindow = requireNotNull(torn.window) + val tornBounds = requireNotNull(tornWindow.outerBoundsPx()) + val sourceBounds = requireNotNull(first.outerBoundsPx()) + // Same logical size as the window it came from, whatever the + // scale of the display it ended up on. + val tornLogicalW = tornBounds[2] / tornWindow.scaleFactor + val sourceLogicalW = sourceBounds[2] / first.scaleFactor + check(abs(tornLogicalW - sourceLogicalW) <= LOGICAL_TOLERANCE_DP) { + "torn-off window is ${tornLogicalW}dp wide, source is ${sourceLogicalW}dp " + + "(scale ${tornWindow.scaleFactor} vs ${first.scaleFactor})" + } + check(tornBounds[2] > 0 && tornBounds[3] > 0) { "the torn-off window has no size" } + } finally { + System.err.println("[tab-scale] restoring $fromMode -> ${MacDisplayModeTool.run(fromMode)}") + awaitUntil("back at the original scale ($baseScale)") { + abs(first.scaleFactor - baseScale) < SCALE_TOLERANCE + } + settle(SETTLE_AFTER_SCALE_MILLIS) + } + }, + ) + } + + /** + * A minimized window keeps its frame on record but shows nothing, so a + * drop over where it used to be must not land in it — that would move the + * tab into a window the user cannot see. + */ + private fun minimizedWindowIsNoDropTarget(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab workspace never drops into a minimized window", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val gamma = fixture.tabId("Gamma") + + // Gamma into a window of its own, which then gets minimized. + val torn = requireNotNull(workspace.tearOff(gamma, tearOffRectPx(first), first.scaleFactor)) + val tornWindow = awaitMappedStrip(fixture, torn) + val stripOnScreen = requireNotNull(fixture.stripRectPx(torn)) + val onTheStrip = stripOnScreen.center + check(workspace.dropTargetAt(onTheStrip)?.group === torn) { + "the torn-off strip is not a drop target to begin with" + } + + var minimized = false + tornWindow.onMinimizedChanged { min -> minimized = min } + tornWindow.setMinimized(true) + awaitUntil("the torn-off window reports minimized") { minimized && tornWindow.isMinimized } + settle() + + check(workspace.dropTargetAt(onTheStrip) == null) { + "a minimized window is still offering a drop target" + } + // And the gesture behaves: dropping Beta there tears it off + // rather than merging it into the invisible window. + val beta = fixture.tabId("Beta") + val betaGrab = requireNotNull(fixture.tabCenterPx("Beta")) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), betaGrab)) + session.update(betaGrab) + session.update(onTheStrip) + check(workspace.dropPreview == null) { "the minimized window previewed a drop" } + session.end(onTheStrip) + awaitUntil("Beta got a window of its own instead") { + workspace.groups.size == 3 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + check(torn.ids == listOf(gamma)) { "the minimized window took the tab anyway: ${torn.ids}" } + + // Restored, it is a target again. + tornWindow.setMinimized(false) + awaitUntil("the window reports restored") { !minimized && !tornWindow.isMinimized } + settle(SETTLE_AFTER_MAP_MILLIS) + // Both other windows cover this strip — the one Beta was torn + // into landed on the very point that was dropped on — so which + // of them answers a point on it is decided by focus recency, + // not by geometry. Make the restored window the most recent + // one and wait for the platform to agree: an activation asked + // for while the window is still being re-mapped is dropped by + // more than one window manager. + awaitUntil("the restored window took focus") { + if (!tornWindow.isFocused) tornWindow.focus() + tornWindow.isFocused + } + awaitUntil("its strip takes drops again") { + val strip = fixture.stripRectPx(torn) ?: return@awaitUntil false + workspace.dropTargetAt(strip.center)?.group === torn + } + }, + ) + } + + /** + * A maximized window: its strip covers the top of the screen, which is + * where drops must land, and a tab pulled out of it has to get an ordinary + * window rather than inherit the maximized frame. + */ + private fun maximizedWindowTakesAndGivesTabs(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture() + return TaoWindowTestCase( + name = "tab workspace drops into a maximized window and tears back out of it", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val alpha = fixture.tabId("Alpha") + + // Beta out first, so there are two windows to work with. + val torn = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, torn) + + // ── maximize the first window ── + val before = requireNotNull(first.outerBoundsPx()) + first.setMaximized(true) + awaitUntil("the first window grew") { + val now = first.outerBoundsPx() ?: return@awaitUntil false + now[2] > before[2] && now[3] >= before[3] + } + settle(SETTLE_AFTER_MAP_MILLIS) + awaitUntil("its strip republished at the maximized size") { + val strip = + fixture.stripRectPx(requireNotNull(fixture.groupOf("Alpha"))) + ?: return@awaitUntil false + val now = requireNotNull(first.outerBoundsPx()) + strip.width > before[2] && strip.left >= now[0] - 1f + } + + // ── drop Beta into the maximized strip ── + val maximizedGroup = requireNotNull(fixture.groupOf("Alpha")) + val maximizedStrip = requireNotNull(fixture.stripRectPx(maximizedGroup)) + val betaGrab = requireNotNull(fixture.tabCenterPx("Beta")) + val mergeAt = Offset(maximizedStrip.left + MERGE_INSET_PX, maximizedStrip.center.y) + val tornWindow = requireNotNull(torn.window) + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(tornWindow), betaGrab)) + session.update(betaGrab) + session.update(mergeAt) + check(workspace.dropPreview?.group === maximizedGroup) { + "the maximized strip did not preview the drop: ${workspace.dropPreview}" + } + session.end(mergeAt) + awaitUntil("both tabs are in the maximized window") { + workspace.groups.size == 1 && fixture.groupOf("Beta") === maximizedGroup + } + settle() + check(maximizedGroup.ids.first() == beta) { + "dropped at the left of the strip, so it should be first: ${maximizedGroup.ids}" + } + + // ── and back out: an ordinary window, not a maximized one ── + val maximizedBounds = requireNotNull(first.outerBoundsPx()) + val stripNow = requireNotNull(fixture.stripRectPx(maximizedGroup)) + val alphaGrab = requireNotNull(fixture.tabCenterPx("Alpha")) + val dropOut = Offset(stripNow.center.x, stripNow.top + stripNow.height + TAB_DROP_FAR_PX) + val outSession = requireNotNull(workspace.beginDrag(alpha, stripOrigin(first), alphaGrab)) + outSession.update(alphaGrab) + outSession.update(dropOut) + outSession.end(dropOut) + awaitUntil("Alpha is in a window of its own") { + workspace.groups.size == 2 && fixture.groupOf("Alpha")?.ids == listOf(alpha) + } + val second = awaitMappedStrip(fixture, requireNotNull(fixture.groupOf("Alpha"))) + settle(SETTLE_AFTER_MAP_MILLIS) + check(!second.isMaximized) { "the torn-off window came out maximized" } + val newBounds = requireNotNull(second.outerBoundsPx()) + check(newBounds[2] < maximizedBounds[2]) { + "the torn-off window is as wide as the maximized one it came from: " + + "${newBounds[2]} vs ${maximizedBounds[2]}" + } + first.setMaximized(false) + awaitUntil("the first window was restored") { + val now = first.outerBoundsPx() ?: return@awaitUntil false + now[2] < maximizedBounds[2] + } + }, + ) + } + + /** + * Gestures that end badly. A drag whose window is resized under it has its + * pointer input re-keyed, so neither the release nor the cancel branch of + * the handle is reached — without the cleanup the preview and the ghost + * would stay on screen for the rest of the session. A superseded drag must + * go inert instead of fighting the live one. + */ + private fun interruptedAndSupersededDragsLeaveNoFeedback(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab drags that are interrupted or superseded leave no preview behind", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val gamma = fixture.tabId("Gamma") + val strip = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta")))) + + // ── 1. interrupted by a resize: dropped on the floor ── + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val interrupted = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + interrupted.update(grab) + interrupted.update(Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX)) + check(workspace.draggedTab?.id == beta) { "the drag must be published while it runs" } + first.setInnerSize(RESIZED_W_DP, RESIZED_H_DP) + awaitUntil("the window resized under the drag") { + val now = first.outerBoundsPx() ?: return@awaitUntil false + abs(now[2] - RESIZED_W_DP * first.scaleFactor) <= RESIZE_TOLERANCE_PX + } + // What the cancelled pointer-input coroutine does, and all it does. + interrupted.cancel() + settle() + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "an interrupted drag left feedback on screen" + } + check(workspace.groups.size == 1) { "an interrupted drag moved a tab" } + check(fixture.groupOf("Beta")?.ids?.contains(beta) == true) { "Beta left its window" } + + // ── 2. superseded: the first session goes inert ── + val stripNow = requireNotNull(fixture.stripRectPx(requireNotNull(fixture.groupOf("Beta")))) + val betaGrab = requireNotNull(fixture.tabCenterPx("Beta")) + val gammaGrab = requireNotNull(fixture.tabCenterPx("Gamma")) + val outside = Offset(stripNow.center.x, stripNow.bottom + TAB_DROP_FAR_PX) + val superseded = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), betaGrab)) + superseded.update(betaGrab) + superseded.update(outside) + val live = requireNotNull(workspace.beginDrag(gamma, stripOrigin(first), gammaGrab)) + live.update(gammaGrab) + check(workspace.draggedTab?.id == gamma) { "the new drag must take over" } + + superseded.update(outside) + superseded.end(outside) + check(workspace.groups.size == 1) { "the superseded drag tore a tab off" } + check(workspace.draggedTab?.id == gamma) { "the superseded drag cleared the live one" } + + live.update(outside) + live.end(outside) + awaitUntil("only the surviving drag moved its tab") { + workspace.groups.size == 2 && fixture.groupOf("Gamma")?.ids == listOf(gamma) + } + check(fixture.groupOf("Beta")?.ids?.contains(beta) == true) { "Beta moved after all" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { "drag feedback left behind" } + }, + ) + } + + /** + * The window a tab is being dragged out of, destroyed mid-gesture: the app + * closed it, or the user did. The release must not resurrect it, move a tab + * that no longer exists, or leave the ghost behind. + */ + private fun sourceWindowClosingMidDragStaysSane(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "tab drag whose window closes mid-gesture leaves the workspace consistent", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val gamma = fixture.tabId("Gamma") + + // Beta and Gamma into a second window, so closing it destroys a + // real window with a drag in flight. + val second = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, second) + workspace.move(gamma, second) + awaitUntil("the second window holds both") { second.ids.size == 2 } + settle(SETTLE_AFTER_MAP_MILLIS) + val secondWindow = requireNotNull(second.window) + var destroyed = false + secondWindow.onDestroyed { destroyed = true } + + val grab = requireNotNull(fixture.tabCenterPx("Gamma")) + val strip = requireNotNull(fixture.stripRectPx(second)) + val away = Offset(strip.center.x, strip.bottom + TAB_DROP_FAR_PX) + val session = requireNotNull(workspace.beginDrag(gamma, stripOrigin(secondWindow), grab)) + session.update(grab) + session.update(away) + check(workspace.dragGhost != null) { "the tear-out must be previewed" } + + // The app closes the window under the gesture. + second.ids.toList().forEach(workspace::close) + awaitUntil("the second window was destroyed mid-drag") { destroyed } + settle() + + session.end(away) + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.tab(gamma) == null && workspace.tab(beta) == null) { + "closed tabs came back: ${workspace.tabs.map { it.id }}" + } + check(workspace.groups.size == 1) { "the release resurrected a window: ${workspace.groups.size}" } + check(fixture.groupOf("Alpha")?.ids == listOf(fixture.tabId("Alpha"))) { + "the surviving window lost its tab: ${fixture.groupOf("Alpha")?.ids}" + } + check(workspace.draggedTab == null && workspace.dragGhost == null) { + "a drag over a closing window left feedback behind" + } + check(fixture.composedBodies.value == 1) { + "one body should be composing, got ${fixture.composedBodies.value}" + } + }, + ) + } + + private const val JUMP_INSET_PX = 20f + private const val MERGE_INSET_PX = 12f + private const val JUMP_SETTLE_MILLIS = 60L + private const val SETTLE_AFTER_SCALE_MILLIS = 600L + private const val SCALE_TIMEOUT_MILLIS = 90_000L + private const val HIDPI_SCALE = 1.5f + private const val SCALE_TOLERANCE = 0.05f + + /** A logical size compared across a scale change: dp rounding on both sides. */ + private const val LOGICAL_TOLERANCE_DP = 12f + private const val RESIZED_W_DP = 620.0 + private const val RESIZED_H_DP = 430.0 + private const val RESIZE_TOLERANCE_PX = 48L + + /** A flick: as few samples as the OS will deliver. */ + private const val FLICK_STEPS = 3 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt index 611ef736b..5530911ce 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoHeadfulTestSuiteMain.kt @@ -3,6 +3,8 @@ package dev.nucleusframework.window.tao.headful import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue @@ -16,8 +18,12 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.window.tao.ApplicationScope import dev.nucleusframework.window.tao.DecoratedDialog import dev.nucleusframework.window.tao.DecoratedWindow +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.SatelliteWindow +import dev.nucleusframework.window.tao.TaoDecoratedWindowScope import dev.nucleusframework.window.tao.TaoWindow import dev.nucleusframework.window.tao.XdgPortalParent import dev.nucleusframework.window.tao.taoApplication @@ -36,16 +42,23 @@ import kotlin.system.exitProcess */ public object TaoHeadfulTestSuiteMain { // Substring match on the case name, e.g. - // `-Dnucleus.tao.headful.filter=#418` to run one probe on its own. + // `-Dnucleus.tao.headful.filter=#418` to run one probe on its own. Several + // substrings separated by `|` run every case matching any of them, in suite + // order — the way to replay an interference between two case families. private val nameFilter: String? = System.getProperty("nucleus.tao.headful.filter")?.takeIf { it.isNotBlank() } + private val nameFilters: List = + nameFilter + ?.split('|') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + .orEmpty() private val allCases: List = listOf( TaoWindowTestCase("window maps, paints and reports a real size") { awaitUntil("window mapped with non-zero outer bounds") { - val b = bounds() - b != null && b[2] > 0 && b[3] > 0 + window.hasRealFramePx() } }, TaoWindowTestCase("setInnerSize fires onResized with the requested size") { @@ -359,21 +372,66 @@ public object TaoHeadfulTestSuiteMain { UnspecifiedSizeHeadfulCases.all() + LinuxDiscreteScrollHeadfulCases.all() + MacOsTrackpadScrollHeadfulCases.all() + + TrackpadScaleHeadfulCases.all() + + LinuxTrackpadPinchHeadfulCases.all() + + MacOsTrackpadScaleHeadfulCases.all() + + MacOsTrackpadGestureMonkeyHeadfulCases.all() + ChromeReviewHeadfulCases.all() + ChromeCoverageHeadfulCases.all() + DisplayScaleHeadfulCases.all() + FramePacingHeadfulCases.all() + MacWindowChromeStateHeadfulCases.all() + PopupScaleHeadfulCases.all() + + NativePopupPlacementHeadfulCases.all() + + NativePopupMarginInputHeadfulCases.all() + + DialogAppearanceHeadfulCases.all() + ClipboardHeadfulCases.all() + AnimatedWindowSizeHeadfulCases.all() + + Issue444HeadfulCases.all() + AlwaysOnTopHeadfulCases.all() + - ImeHeadfulCases.all() + SatelliteWindowHeadfulCases.all() + + SatelliteWorkspaceHeadfulCases.all() + + SatelliteWorkspaceStressHeadfulCases.all() + + SatelliteWorkspaceMonkeyHeadfulCases.all() + + DockLayoutHeadfulCases.all() + + DockLayoutMonkeyHeadfulCases.all() + + TabWorkspaceHeadfulCases.all() + + TabStripMotionHeadfulCases.all() + + TabWorkspaceLifecycleHeadfulCases.all() + + TabWorkspaceMotionHeadfulCases.all() + + TabWorkspaceMouseHeadfulCases.all() + + TabWorkspaceConcurrencyHeadfulCases.all() + + TabWorkspaceStormHeadfulCases.all() + + TabWorkspaceStressHeadfulCases.all() + + WaylandWorkspaceHeadfulCases.all() + + WaylandWorkspaceStressHeadfulCases.all() + + WorkspaceFileDropHeadfulCases.all() + + TabSatellitesHeadfulCases.all() + + TabSatellitesChaosHeadfulCases.all() + + TabWorkspacePointerHeadfulCases.all() + + SatellitePlacementHeadfulCases.all() + + WindowExtremesHeadfulCases.all() + + WorkspaceLoadHeadfulCases.all() + + MonitorAndScaleHeadfulCases.all() + + WorkspaceRaceHeadfulCases.all() + + ImeHeadfulCases.all() + + WindowApiV2HeadfulCases.all() + + EventLoopWatchdogHeadfulCases.all() + + EventLoopWatchdogMonkeyHeadfulCases.all() + + EventLoopWatchdogAnimationHeadfulCases.all() + + FrameResumeAfterFreezeHeadfulCases.all() + + // Last: the monkeys are the longest cases, and the robot ones leave the + // real pointer wherever their last gesture ended. + NativeViewMonkeyHeadfulCases.all() + + TextureViewMonkeyHeadfulCases.all() private val cases: List = - allCases.filter { nameFilter == null || it.name.contains(nameFilter, ignoreCase = true) } + allCases.filter { case -> + nameFilters.isEmpty() || nameFilters.any { case.name.contains(it, ignoreCase = true) } + } @JvmStatic + @Suppress("LongMethod") // one flat harness: case hosting, then the driver fun main(args: Array) { if (cases.isEmpty()) { // Distinct from the failure-count exit codes: an unmatched filter @@ -390,6 +448,13 @@ public object TaoHeadfulTestSuiteMain { thread(isDaemon = true, name = "tao-headful-watchdog") { Thread.sleep(watchdogMillis) System.err.println("WATCHDOG: headful suite exceeded ${watchdogMillis / 1000}s — halting") + // A wedged loop thread is the usual reason we get here, and a CI + // log has no `jstack`: print where every thread is parked so the + // hang is diagnosable from the log alone (#658). + for ((thread, frames) in Thread.getAllStackTraces()) { + System.err.println("\"${thread.name}\" ${thread.state}") + for (frame in frames) System.err.println("\tat $frame") + } System.err.flush() Runtime.getRuntime().halt(WATCHDOG_EXIT_CODE) } @@ -416,46 +481,18 @@ public object TaoHeadfulTestSuiteMain { // level so it survives the window scene's attach/re-composition. val windowHolder = remember(current) { mutableStateOf(null) } val dialogHolder = remember(current) { mutableStateOf(null) } + val satelliteHolder = remember(current) { mutableStateOf(null) } if (skipReason == null) { androidx.compose.runtime.key(current) { - val fallbackState = - rememberWindowState( - size = case.size ?: DpSize(800.dp, 600.dp), - ) - DecoratedWindow( - onCloseRequest = { /* cases drive their own lifecycle */ }, - state = case.windowState ?: fallbackState, - title = "tao-headful: ${case.name}", - transparent = case.transparent, - nativePopupLayers = case.nativePopupLayers, - ) { - // Default chrome surface; cases may paint over it via - // [TaoWindowTestCase.content] (scaffold, backdrop, …). - // Fully-transparent probes opt out so the Skia clear is - // what the compositor sees in empty regions. - if (case.paintDefaultBackground) { - Box(Modifier.fillMaxSize().background(Color.DarkGray)) - } - case.content(this) - val w = window - LaunchedEffect(w) { windowHolder.value = w } - } - val dialogContent = case.dialogContent - if (dialogContent != null) { - DecoratedDialog( - onCloseRequest = { /* cases drive their own lifecycle */ }, - state = - rememberDialogState( - size = case.dialogSize ?: DpSize(400.dp, 300.dp), - ), - title = "tao-headful-dialog: ${case.name}", - ) { - dialogContent() - val w = window - LaunchedEffect(w) { dialogHolder.value = w } - } - } + CaseWindow(case, windowHolder, dialogHolder, satelliteHolder) + ApplicationScopeSatellite( + case = case, + windowHolder = windowHolder, + dialogHolder = dialogHolder, + satelliteHolder = satelliteHolder, + ) + case.applicationContent?.invoke(this, HeadfulWindows(windowHolder.value, dialogHolder.value)) } } @@ -474,7 +511,9 @@ public object TaoHeadfulTestSuiteMain { awaitPublishedWindows( windowHolder = windowHolder, dialogHolder = dialogHolder, + satelliteHolder = satelliteHolder, waitForDialog = running.dialogContent != null, + waitForSatellite = running.satelliteState != null, ) // Per-case budget: a driver that never completes must // fail its own case, not run out the global watchdog @@ -494,7 +533,11 @@ public object TaoHeadfulTestSuiteMain { ) { t } + // Whatever the case did, it does not get to hand the next one + // a held mouse button — see [HeadfulRobot.releaseEveryButton]. + HeadfulRobot.releaseEveryButton() System.err.println("[tao-headful] ${if (failure == null) "OK" else "FAIL"} ${running.name}") + failure?.printStackTrace(System.err) advance( TaoWindowTestResult( running.name, @@ -510,6 +553,40 @@ public object TaoHeadfulTestSuiteMain { reportAndExit(results) } + /** + * The reparenting call site: an application-scope satellite whose owner is + * picked from the case's [TaoWindowTestCase.satelliteOwner] state, exactly + * like a shared palette in an app. Composed only once the chosen owner has + * published itself; a no-op for cases that host their satellite inside the + * window content instead. + */ + @Composable + private fun ApplicationScope.ApplicationScopeSatellite( + case: TaoWindowTestCase, + windowHolder: MutableState, + dialogHolder: MutableState, + satelliteHolder: MutableState, + ) { + val satelliteState = case.satelliteState ?: return + val satelliteOwner = case.satelliteOwner ?: return + val owner = + when (satelliteOwner.value) { + SatelliteOwner.CaseWindow -> windowHolder.value + SatelliteOwner.DialogWindow -> dialogHolder.value + } ?: return + SatelliteWindow( + onCloseRequest = case.satelliteOnCloseRequest, + parent = owner, + state = satelliteState, + title = "tao-headful-satellite: ${case.name}", + hideWhileParentFullscreenOrMaximized = case.satelliteHideWhileParentFills, + ) { + case.satelliteContent(this) + val s = window + LaunchedEffect(s) { satelliteHolder.value = s } + } + } + private fun reportAndExit(results: List): Nothing { var failures = 0 println() @@ -549,7 +626,9 @@ public object TaoHeadfulTestSuiteMain { private suspend fun awaitPublishedWindows( windowHolder: MutableState, dialogHolder: MutableState, + satelliteHolder: MutableState, waitForDialog: Boolean, + waitForSatellite: Boolean, ): TaoWindowTestScope { val deadline = System.currentTimeMillis() + WINDOW_PUBLISH_TIMEOUT_MILLIS while (windowHolder.value == null) { @@ -562,9 +641,16 @@ public object TaoHeadfulTestSuiteMain { kotlinx.coroutines.delay(WINDOW_PUBLISH_POLL_MILLIS) } } + if (waitForSatellite) { + while (satelliteHolder.value == null) { + check(System.currentTimeMillis() < deadline) { "satellite never published its handle" } + kotlinx.coroutines.delay(WINDOW_PUBLISH_POLL_MILLIS) + } + } return TaoWindowTestScope( window = windowHolder.value!!, dialogWindow = dialogHolder.value, + satelliteWindow = satelliteHolder.value, ) } @@ -594,7 +680,7 @@ public object TaoHeadfulTestSuiteMain { private const val WINDOW_PUBLISH_TIMEOUT_MILLIS = 15_000L private const val WINDOW_PUBLISH_POLL_MILLIS = 25L - private const val GLOBAL_WATCHDOG_MILLIS = 240_000L + private const val GLOBAL_WATCHDOG_MILLIS = 900_000L private const val WATCHDOG_EXIT_CODE = 42 private const val BAD_FILTER_EXIT_CODE = 43 private const val RESIZE_W_DP = 640.0 @@ -603,3 +689,90 @@ public object TaoHeadfulTestSuiteMain { private const val RESTORE_TOLERANCE_PX = 32 private const val MOVE_DELTA_DP = 60.0 } + +/** + * One case's real window (and optional dialog), composed fresh per case. + * + * Extracted from `main` so the suite loop stays readable: the AWT-free window + * API v2 clone needs a second `DecoratedWindow` call site, since its state is a + * different type from Compose's. + */ +@Composable +private fun ApplicationScope.CaseWindow( + case: TaoWindowTestCase, + windowHolder: MutableState, + dialogHolder: MutableState, + satelliteHolder: MutableState, +) { + val fallbackState = + rememberWindowState( + size = case.size ?: DpSize(800.dp, 600.dp), + ) + // Default chrome surface; cases may paint over it via + // [TaoWindowTestCase.content] (scaffold, backdrop, …). + // Fully-transparent probes opt out so the Skia clear is + // what the compositor sees in empty regions. + val windowContent: @Composable TaoDecoratedWindowScope.() -> Unit = { + if (case.paintDefaultBackground) { + Box(Modifier.fillMaxSize().background(Color.DarkGray)) + } + case.content(this) + val w = window + LaunchedEffect(w) { windowHolder.value = w } + + // Composed inside the window content so the satellite resolves this + // case's window as its parent through LocalTaoWindow — the same call + // site an app uses. + val satelliteState = case.satelliteState + if (satelliteState != null && case.satelliteOwner == null) { + SatelliteWindow( + onCloseRequest = case.satelliteOnCloseRequest, + state = satelliteState, + title = "tao-headful-satellite: ${case.name}", + hideWhileParentFullscreenOrMaximized = case.satelliteHideWhileParentFills, + ) { + case.satelliteContent(this) + val s = window + LaunchedEffect(s) { satelliteHolder.value = s } + } + } + } + val nucleusState = case.nucleusWindowState + if (nucleusState != null) { + DecoratedWindow( + onCloseRequest = { /* cases drive their own lifecycle */ }, + state = nucleusState, + title = "tao-headful: ${case.name}", + transparent = case.transparent, + nativePopupLayers = case.nativePopupLayers, + content = windowContent, + ) + } else { + DecoratedWindow( + onCloseRequest = { /* cases drive their own lifecycle */ }, + state = case.windowState ?: fallbackState, + title = "tao-headful: ${case.name}", + transparent = case.transparent, + nativePopupLayers = case.nativePopupLayers, + content = windowContent, + ) + } + val dialogContent = case.dialogContent + val dialogParent = if (case.dialogParentedToWindow) windowHolder.value else null + if (dialogContent != null && case.dialogVisible.value && (dialogParent != null || !case.dialogParentedToWindow)) { + CompositionLocalProvider(LocalTaoWindow provides dialogParent) { + DecoratedDialog( + onCloseRequest = { /* cases drive their own lifecycle */ }, + state = + rememberDialogState( + size = case.dialogSize ?: DpSize(400.dp, 300.dp), + ), + title = "tao-headful-dialog: ${case.name}", + ) { + dialogContent() + val w = window + LaunchedEffect(w) { dialogHolder.value = w } + } + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt index d09334793..1cbd84717 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TaoWindowTestHarness.kt @@ -1,8 +1,12 @@ package dev.nucleusframework.window.tao.headful import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.unit.DpSize import androidx.compose.ui.window.WindowState +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.SatelliteWindowState import dev.nucleusframework.window.tao.TaoDecoratedDialogScope import dev.nucleusframework.window.tao.TaoDecoratedWindowScope import dev.nucleusframework.window.tao.TaoWindow @@ -60,6 +64,13 @@ internal class TaoWindowTestCase( * `DecoratedWindow(state)` (#576). */ val windowState: WindowState? = null, + /** + * When non-null, the suite drives the window through the AWT-free window + * API v2 clone ([dev.nucleusframework.window.tao.v2.WindowState]) instead of + * a v1 state. Takes precedence over [windowState] / [size]: the clone's own + * bounds provider owns the initial geometry. + */ + val nucleusWindowState: dev.nucleusframework.window.tao.v2.WindowState? = null, /** * When non-null, the suite also composes a [dev.nucleusframework.window.tao.DecoratedDialog] * at application scope (parented to this case's window). [dialogSize] @@ -67,8 +78,47 @@ internal class TaoWindowTestCase( */ val dialogSize: DpSize? = null, val dialogContent: (@Composable TaoDecoratedDialogScope.() -> Unit)? = null, + /** + * When true, the dialog is composed under this case's window as + * `LocalTaoWindow` — the parent an in-window `DecoratedDialog` call gets — + * and only once that window exists. Default: parentless, at application + * scope. + */ + val dialogParentedToWindow: Boolean = false, + /** + * Whether the dialog is in composition. Defaults to `true`; a driver flips + * it to `false` to close the dialog the way an app would — by dropping it. + */ + val dialogVisible: MutableState = mutableStateOf(true), + /** + * When non-null, the suite composes a + * [dev.nucleusframework.window.tao.SatelliteWindow] *inside* this case's + * window content — so it picks the case window up as its parent through + * `LocalTaoWindow` — driven by this state. The case keeps the reference and + * asserts against the anchoring state it publishes. + */ + val satelliteState: SatelliteWindowState? = null, + /** + * When non-null, the satellite is composed at *application* scope with an + * explicit `parent` picked from this state — the reparenting call site — + * instead of inside the case window's content. Flip it from the driver. + */ + val satelliteOwner: MutableState? = null, + /** Forwarded to the satellite's `hideWhileParentFullscreenOrMaximized`. */ + val satelliteHideWhileParentFills: Boolean = true, + /** Routed to the satellite's `onCloseRequest`; the suite never drops the satellite itself. */ + val satelliteOnCloseRequest: () -> Unit = {}, + /** Content of the satellite window; ignored without a [satelliteState]. */ + val satelliteContent: @Composable TaoDecoratedWindowScope.() -> Unit = {}, /** Optional extra window content composed inside the DecoratedWindow. */ val content: @Composable TaoDecoratedWindowScope.() -> Unit = {}, + /** + * Extra application-scope content composed next to the case window and + * dialog — for cases whose windows are declared at application level, such + * as workspace satellites. Receives the case's published windows and is + * recomposed as they appear. + */ + val applicationContent: (@Composable ApplicationScope.(HeadfulWindows) -> Unit)? = null, val driver: suspend TaoWindowTestScope.() -> Unit, ) { private companion object { @@ -76,10 +126,26 @@ internal class TaoWindowTestCase( } } +/** The suite's windows as published so far, handed to [TaoWindowTestCase.applicationContent]. */ +internal class HeadfulWindows( + val window: TaoWindow?, + val dialog: TaoWindow?, +) + +/** Which of the suite's windows owns the satellite — see [TaoWindowTestCase.satelliteOwner]. */ +internal enum class SatelliteOwner { + CaseWindow, + DialogWindow, +} + internal class TaoWindowTestScope( val window: TaoWindow, val dialogWindow: TaoWindow? = null, + val satelliteWindow: TaoWindow? = null, ) { + /** Outer bounds of the satellite window as `[x, y, w, h]` physical px. */ + fun satelliteBounds(): LongArray? = satelliteWindow?.outerBoundsPx() + /** * Polls [predicate] on the composition dispatcher (the Tao main thread) * until it holds — suspension keeps the event loop running in between. @@ -87,11 +153,16 @@ internal class TaoWindowTestScope( suspend fun awaitUntil( description: String, timeoutMillis: Long = AWAIT_TIMEOUT_MILLIS, + // Read when the wait times out, not when it starts: a snapshot of the + // state that was still missing is what makes a timeout diagnosable. + detail: (() -> String)? = null, predicate: () -> Boolean, ) { val deadline = System.currentTimeMillis() + timeoutMillis while (!predicate()) { - check(System.currentTimeMillis() < deadline) { "timed out waiting for: $description" } + check(System.currentTimeMillis() < deadline) { + "timed out waiting for: $description" + (detail?.let { " — ${it()}" } ?: "") + } delay(POLL_MILLIS) } } @@ -126,6 +197,21 @@ internal class TaoWindowTestScope( } } +/** + * `true` once the platform reports a frame with a real size for this window. + * + * `> 1`, not `> 0`: GTK maps a window at 1x1 until its first allocation, so a + * gate that only rules out zero lets a case measure the placeholder — a + * torn-off window "1 dp wide", a satellite anchored against a 1px-tall child. + * Slow, software-rendered hosts (the CI Xvfb runner) hold that placeholder for + * several frames where a real session passes through it in one. + */ +@Suppress("MagicNumber") // outer frame is [x, y, w, h] +internal fun TaoWindow.hasRealFramePx(): Boolean { + val rect = outerBoundsPx() ?: return false + return rect[2] > 1L && rect[3] > 1L +} + internal class TaoWindowTestResult( val name: String, val failure: Throwable?, diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TextureViewMonkeyHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TextureViewMonkeyHeadfulCases.kt new file mode 100644 index 000000000..13e89b670 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TextureViewMonkeyHeadfulCases.kt @@ -0,0 +1,698 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.D3D11TestTextureProducer +import dev.nucleusframework.window.tao.DmaBufTestTextureProducer +import dev.nucleusframework.window.tao.MetalTestTextureProducer +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventCode +import dev.nucleusframework.window.tao.TaoGpuRenderContext +import dev.nucleusframework.window.tao.TaoOpenGlRenderContext +import dev.nucleusframework.window.tao.TextureView +import dev.nucleusframework.window.tao.TextureViewController +import dev.nucleusframework.window.tao.TextureViewSource +import dev.nucleusframework.window.tao.hasGlTextureImports +import dev.nucleusframework.window.tao.hasMetalTextureImports +import dev.nucleusframework.window.tao.hasWindowsTextureImports +import dev.nucleusframework.window.tao.rememberTaoGpuRenderContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import org.jetbrains.skia.Image +import org.jetbrains.skia.ImageInfo +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Rect +import org.jetbrains.skia.Surface +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.concurrent.thread +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * External GPU textures and an in-process renderer on the scene's own GPU + * context, under a random walk of everything an app does to them. + * + * Two GPU paths share the window's Skia context: [TextureView] *imports* a + * texture a foreign producer keeps writing from its own thread (a DMA-BUF, an + * IOSurface, a D3D11 shared handle), and [rememberTaoGpuRenderContext] lets a + * renderer *draw* on the scene's context itself, under `withContextCurrent` / + * `runOnGpuThread`. Both live one context rebuild away from a stale handle — + * a Wayland hide/show tears the whole EGL stack down, a producer can be closed + * while its view is still composing, a burst of frames can land during a + * resize — and the failures there are freezes and GL errors, not assertions. + * + * So the monkey mounts, unmounts, swaps, resizes, hides, shows, minimizes, + * closes producers under live views and floods frames from off-thread, and + * checks the two things a video app cannot live without: the scene **keeps + * rendering** (a frame heartbeat advances after every checkpoint, the shared + * renderer keeps producing snapshots), and the loop **keeps answering** + * ([MainLoopWatchdog]). At the end nothing may be left: no import alive on the + * context once every view is gone, no producer thread that threw, one window. + * + * The producers are the platform test producers that ship with the module. + * Where none can be made (no render node under Xvfb, no D3D11 on a bare + * runner) the views run with a null source and the shared-context renderer + * carries the GPU half on its own — the case says so in its log. + */ +internal object TextureViewMonkeyHeadfulCases { + fun all(): List = listOf(randomActionsKeepTheSceneRendering()) + + private fun randomActionsKeepTheSceneRendering(): TaoWindowTestCase { + val fixture = TextureViewFixture() + return TaoWindowTestCase( + name = + "texture view monkey $MONKEY_ACTIONS random actions keep the scene rendering " + + "on the shared GPU context", + timeoutMillis = MONKEY_CASE_TIMEOUT_MILLIS, + windowState = + WindowState( + position = WindowPosition.Absolute(WINDOW_X_DP.dp, WINDOW_Y_DP.dp), + size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + ), + size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Content() }, + driver = { + fixture.awaitReady(this) + val monkey = TextureViewMonkey(this, fixture, monkeySeed()) + try { + monkey.run() + monkey.quiesceAndAssert() + } finally { + fixture.shutdown() + } + }, + ) + } +} + +/** A foreign producer behind one interface, whichever platform made it. */ +private class TestProducer( + val source: TextureViewSource, + val kind: String, + private val draw: (tick: Int, backgroundArgb: Int) -> Unit, + private val closeProducer: () -> Unit, +) { + private val closed = AtomicBoolean(false) + + val isClosed: Boolean get() = closed.get() + + /** Draws a frame unless closed; producers serialize draw and close themselves. */ + fun drawFrame(tick: Int) { + if (!closed.get()) draw(tick, PRODUCER_BACKGROUND_ARGB) + } + + fun close() { + if (closed.compareAndSet(false, true)) closeProducer() + } + + companion object { + /** The first producer this platform can make, or null. */ + fun create( + widthPx: Int, + heightPx: Int, + variant: Int, + ): TestProducer? { + D3D11TestTextureProducer.create(widthPx, heightPx, useKeyedMutex = variant % 2 == 0)?.let { + return TestProducer(it.source, "D3D11", it::drawTestPattern, it::close) + } + MetalTestTextureProducer.create(widthPx, heightPx)?.let { + return TestProducer(it.source, "IOSurface", it::drawTestPattern, it::close) + } + val planar = variant % PRODUCER_VARIANTS == PLANAR_VARIANT + val dmaBuf = + if (planar) { + DmaBufTestTextureProducer.createYuv(widthPx, heightPx) + } else { + DmaBufTestTextureProducer.create(widthPx, heightPx) + } + dmaBuf?.let { + return TestProducer( + it.source, + if (planar) "DMA-BUF I420" else "DMA-BUF", + it::drawTestPattern, + it::close, + ) + } + return null + } + } +} + +/** + * One [TextureView] slot: what it shows, how, and the producer thread feeding + * it. The producer is swapped and closed independently of the view on purpose + * — those orderings are the interesting ones. + */ +private class Slot( + val index: Int, +) { + var mounted by mutableStateOf(true) + var producer by mutableStateOf(null) + var sizeDp by mutableStateOf(DpSize(SLOT_W_DP.dp, SLOT_H_DP.dp)) + var filterQuality by mutableStateOf(FilterQuality.Low) + var contentScale by mutableStateOf(ContentScale.FillBounds) + val controller = TextureViewController() + + /** Frames the producer thread published. */ + val producedFrames = AtomicLong() +} + +private class TextureViewFixture { + val slots = List(SLOT_COUNT) { Slot(it) } + var sharedRendererMounted by mutableStateOf(true) + + /** The scene's GPU context as last published; a new instance means a rebuild. */ + var renderContext: TaoGpuRenderContext? = null + private set + val contextGenerations = AtomicInteger() + + /** Frames the scene rendered (the heartbeat) and the shared renderer produced. */ + val renderedFrames = AtomicLong() + val sharedFrames = AtomicLong() + + /** Whatever a producer thread or the shared renderer threw. */ + val errors = CopyOnWriteArrayList() + + private val stopProducers = AtomicBoolean(false) + private val producerThreads = mutableListOf() + private val producersMade = AtomicInteger() + + /** Read in `drawBehind` so every frame tick invalidates the draw and the clock keeps running. */ + private var heartbeatTick by mutableLongStateOf(0L) + + var producerKind: String? = null + private set + + fun newProducer(): TestProducer? { + val variant = producersMade.getAndIncrement() + val producer = TestProducer.create(PRODUCER_W_PX, PRODUCER_H_PX, variant) ?: return null + producerKind = producer.kind + return producer + } + + fun startProducers() { + for (slot in slots) { + producerThreads += + thread(isDaemon = true, name = "texture-monkey-producer-${slot.index}") { + val random = Random(slot.index.toLong()) + var tick = 0 + try { + while (!stopProducers.get()) { + val producer = slot.producer + if (producer != null && !producer.isClosed) { + producer.drawFrame(tick++) + slot.controller.markFrameAvailable() + slot.producedFrames.incrementAndGet() + } + Thread.sleep(MIN_PRODUCER_PERIOD_MILLIS + random.nextLong(PRODUCER_PERIOD_SPAN_MILLIS)) + } + } catch (_: InterruptedException) { + // shutdown + } catch (t: Throwable) { + errors += t + } + } + } + } + + fun shutdown() { + stopProducers.set(true) + for (t in producerThreads) t.interrupt() + for (t in producerThreads) t.join(PRODUCER_JOIN_MILLIS) + for (slot in slots) slot.producer?.close() + } + + @Composable + fun Content() { + val context = rememberTaoGpuRenderContext() + SideEffect { + if (context !== renderContext) { + renderContext = context + if (context != null) contextGenerations.incrementAndGet() + } + } + LaunchedEffect(Unit) { + while (isActive) { + withFrameNanos { renderedFrames.incrementAndGet() } + heartbeatTick++ + } + } + Box( + Modifier + .fillMaxSize() + .background(Color(BACKDROP_ARGB)) + .drawBehind { + // The read is the point: it ties the draw to the heartbeat. + if (heartbeatTick < 0L) drawRect(Color.Red) + }, + ) { + Column(Modifier.fillMaxSize().padding(PAD_DP.dp)) { + for (row in 0 until SLOT_ROWS) { + Row { + for (column in 0 until SLOT_COLUMNS) { + val slot = slots[row * SLOT_COLUMNS + column] + Box(Modifier.padding(PAD_DP.dp)) { + if (slot.mounted) { + TextureView( + source = slot.producer?.source, + modifier = Modifier.size(slot.sizeDp).background(Color(SLOT_ARGB)), + controller = slot.controller, + filterQuality = slot.filterQuality, + contentScale = slot.contentScale, + ) + } else { + Box(Modifier.size(slot.sizeDp).background(Color(EMPTY_SLOT_ARGB))) + } + } + } + } + } + if (sharedRendererMounted && context != null) { + SharedContextCanvas(context) + } + } + } + } + + /** + * The in-process renderer of the GPU-context demo, reduced to what the + * monkey needs: a render target on the scene's own Skia context, one + * snapshot per frame, freed inside a later frame's GPU scope. + */ + @Composable + private fun SharedContextCanvas(context: TaoGpuRenderContext) { + val renderer = remember(context) { SceneContextRenderer(context) } + var frame by remember(context) { mutableStateOf(null) } + DisposableEffect(renderer) { + onDispose { renderer.close() } + } + LaunchedEffect(renderer) { + var tick = 0 + while (isActive) { + val next = + try { + withFrameNanos { renderer.renderFrame(tick) } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + // The renderer left the composition — not a failure. + throw cancelled + } catch (t: Throwable) { + errors += t + throw t + } ?: continue + frame?.let(renderer::retire) + frame = next + sharedFrames.incrementAndGet() + tick++ + } + } + Canvas(Modifier.padding(PAD_DP.dp).size(SHARED_W_DP.dp, SHARED_H_DP.dp).background(Color(SLOT_ARGB))) { + val image = frame ?: return@Canvas + drawIntoCanvas { canvas -> + canvas.nativeCanvas.drawImageRect(image, Rect.makeWH(size.width, size.height)) + } + } + } + + suspend fun awaitReady(scope: TaoWindowTestScope) { + with(scope) { + awaitUntil("the case window is mapped with a real frame") { window.hasRealFramePx() } + awaitUntil("the scene published its GPU context") { renderContext != null } + for (slot in slots) slot.producer = newProducer() + startProducers() + settle(SETTLE_AFTER_MAP_MILLIS) + awaitUntil("the scene renders frames") { renderedFrames.get() > 0L } + System.err.println( + "[texture-monkey] backend=${renderContext?.backend} producers=" + + (producerKind ?: "none (null sources; the shared-context renderer carries the GPU half)"), + ) + } + } + + /** Whether any TextureView import is alive on the current context. */ + fun hasImports(): Boolean { + val context = renderContext?.skiaContext ?: return false + return when (Platform.Current) { + Platform.Linux -> hasGlTextureImports(context) + Platform.Windows -> hasWindowsTextureImports(context) + Platform.MacOS -> hasMetalTextureImports(context) + else -> false + } + } + + fun describe(): String = + "context=${renderContext?.let { System.identityHashCode(it).toString(HEX) }} " + + "generations=${contextGenerations.get()} rendered=${renderedFrames.get()} shared=${sharedFrames.get()} " + + "sharedMounted=$sharedRendererMounted producers=$producerKind errors=${errors.size} " + + slots.joinToString(prefix = "slots=[", postfix = "]") { + "${it.index}:${if (it.mounted) "mounted" else "unmounted"}/" + + "${it.producer?.let { p -> if (p.isClosed) "closed" else "live" } ?: "none"}/" + + "${it.sizeDp.width.value.toInt()}x${it.sizeDp.height.value.toInt()}/frames=${it.producedFrames.get()}" + } +} + +/** The GPU-context demo's renderer: see `GpuContextSection` in the tao demo. */ +private class SceneContextRenderer( + private val context: TaoGpuRenderContext, +) : AutoCloseable { + private var surface: Surface? = null + private val retired = ArrayDeque() + private val paint = Paint() + + private fun withGpuAccess(action: () -> T): T? = + when (context) { + is TaoOpenGlRenderContext -> context.withContextCurrent(action) + else -> context.runOnGpuThread(action) + } + + fun renderFrame(tick: Int): Image? = + withGpuAccess { + val target = + surface + ?: Surface + .makeRenderTarget(context.skiaContext, false, ImageInfo.makeN32Premul(RT_W, RT_H)) + .also { surface = it } + val canvas = target.canvas + canvas.clear(HUE_BASE_ARGB + (tick % HUE_SPAN) * HUE_STEP) + paint.color = WHITE_ARGB + canvas.drawCircle( + RT_W / 2f + (RT_W / 3f) * kotlin.math.cos(tick / TICKS_PER_RADIAN).toFloat(), + RT_H / 2f + (RT_H / 3f) * kotlin.math.sin(tick / TICKS_PER_RADIAN).toFloat(), + DOT_RADIUS, + paint, + ) + target.flushAndSubmit() + val snapshot = target.makeImageSnapshot() + while (retired.size > RETIRED_KEPT) retired.removeFirst().close() + snapshot + } + + fun retire(image: Image) { + retired.addLast(image) + } + + override fun close() { + withGpuAccess { + while (retired.isNotEmpty()) retired.removeFirst().close() + surface?.close() + surface = null + } + paint.close() + } + + private companion object { + const val RT_W = 256 + const val RT_H = 192 + const val HUE_BASE_ARGB = 0xFF203040.toInt() + const val HUE_SPAN = 64 + const val HUE_STEP = 0x010203 + const val WHITE_ARGB = 0xFFFFFFFF.toInt() + const val TICKS_PER_RADIAN = 30.0 + const val DOT_RADIUS = 20f + const val RETIRED_KEPT = 2 + } +} + +private enum class TextureAction { + MountSlot, + UnmountSlot, + + /** A fresh producer for a slot; the old one is closed after the swap has composed. */ + SwapProducer, + + /** Closes a slot's producer while its view is still composing it. */ + CloseProducerUnderView, + ResizeSlot, + ChangeFilter, + ChangeContentScale, + + /** Fifty frame signals from an IO thread with no drawing in between. */ + BurstFrames, + ToggleSharedRenderer, + ResizeWindow, + ToggleMaximize, + + /** Hides and shows the window; on Wayland this rebuilds the whole EGL stack. */ + HideShow, + MinimizeRestore, + ChangeDpi, + RedrawStorm, +} + +private class TextureViewMonkey( + private val scope: TaoWindowTestScope, + private val fixture: TextureViewFixture, + seed: Long, +) { + private val random = Random(seed) + private val journal = MonkeyJournal("texture-monkey", seed) + private var worstStallMillis = 0L + + /** Windows alive when the run started: earlier cases may have left some behind, they are not this run's. */ + private val windowsAtStart = TaoApplication.liveWindowCount() + + suspend fun run() { + System.err.println( + "[texture-monkey] seed=${journal.seed} actions=$MONKEY_ACTIONS " + + "(replay with -D$MONKEY_SEED_PROPERTY=${journal.seed})", + ) + val watchdog = MainLoopWatchdog("texture-monkey", journal::report).start() + try { + while (journal.step < MONKEY_ACTIONS) { + val action = TextureAction.entries[random.nextInt(TextureAction.entries.size)] + journal.record(action) + monkeyAction({ journal.failure("$action", fixture.describe()) }) { apply(action) } + checkNoErrors() + if ((journal.step + 1) % CHECKPOINT_EVERY == 0) checkpoint() + journal.step++ + } + } finally { + worstStallMillis = watchdog.stop() + } + } + + suspend fun quiesceAndAssert() { + restoreWindow() + for (slot in fixture.slots) slot.mounted = true + fixture.sharedRendererMounted = true + scope.settle(SETTLE_AFTER_MAP_MILLIS) + expectRendering("after the monkey") + + // Every view gone: nothing may still be imported on the context. + for (slot in fixture.slots) slot.mounted = false + converge("no texture import is left once every view is unmounted") { !fixture.hasImports() } + for (slot in fixture.slots) { + slot.producer?.close() + slot.producer = null + } + scope.settle(SETTLE_AFTER_MAP_MILLIS) + expectRendering("with every view gone") + checkNoErrors() + + check(TaoApplication.liveWindowCount() == windowsAtStart) { + "${TaoApplication.liveWindowCount()} native windows are alive, $windowsAtStart when the run started" + } + System.err.println( + "[texture-monkey] seed=${journal.seed} survived $MONKEY_ACTIONS actions; " + + "worst main-dispatcher round trip ${worstStallMillis}ms; context generations " + + "${fixture.contextGenerations.get()}; rendered ${fixture.renderedFrames.get()} frames, " + + "shared renderer ${fixture.sharedFrames.get()}; reached ${journal.reachedSummary()}", + ) + check(worstStallMillis <= MONKEY_MAX_STALL_MILLIS) { + "the main dispatcher took ${worstStallMillis}ms to answer a heartbeat — the loop stalled" + } + check(journal.reachedCount("hideShow") > 0) { "the run never hid the window" } + check(journal.reachedCount("swapped") > 0) { "the run never swapped a producer" } + check(fixture.sharedFrames.get() > 0L) { "the shared-context renderer never produced a frame" } + } + + private suspend fun apply(action: TextureAction) { + val slot = fixture.slots[random.nextInt(fixture.slots.size)] + when (action) { + TextureAction.MountSlot -> slot.mounted = true + TextureAction.UnmountSlot -> slot.mounted = false + TextureAction.SwapProducer -> { + val old = slot.producer + slot.producer = fixture.newProducer() + scope.settle(STEP_SETTLE_MILLIS) + old?.close() + journal.reach("swapped") + } + TextureAction.CloseProducerUnderView -> { + slot.producer?.close() + journal.reach("closedUnderView") + } + TextureAction.ResizeSlot -> + slot.sizeDp = + DpSize( + (MIN_SLOT_DP + random.nextInt(SLOT_SPAN_DP)).dp, + (MIN_SLOT_DP + random.nextInt(SLOT_SPAN_DP)).dp, + ) + TextureAction.ChangeFilter -> slot.filterQuality = FILTERS[random.nextInt(FILTERS.size)] + TextureAction.ChangeContentScale -> slot.contentScale = CONTENT_SCALES[random.nextInt(CONTENT_SCALES.size)] + TextureAction.BurstFrames -> + withContext(Dispatchers.IO) { + repeat(BURST_FRAMES) { slot.controller.markFrameAvailable() } + } + TextureAction.ToggleSharedRenderer -> fixture.sharedRendererMounted = !fixture.sharedRendererMounted + TextureAction.ResizeWindow -> + scope.window.setInnerSize( + MIN_INNER_W_DP + random.nextDouble(INNER_W_SPAN_DP), + MIN_INNER_H_DP + random.nextDouble(INNER_H_SPAN_DP), + ) + TextureAction.ToggleMaximize -> scope.window.setMaximized(!scope.window.isMaximized) + TextureAction.HideShow -> { + scope.window.hide() + scope.settle(HIDE_MILLIS) + scope.window.show() + journal.reach("hideShow") + } + TextureAction.MinimizeRestore -> { + scope.window.setMinimized(true) + scope.settle(HIDE_MILLIS) + scope.window.setMinimized(false) + journal.reach("minimized") + } + TextureAction.ChangeDpi -> { + val scale = SCALE_HOPS[random.nextInt(SCALE_HOPS.size)] + scope.window.dispatch(TaoEventCode.SCALE_FACTOR_CHANGED, (scale * SCALE_MILLI).roundToInt(), 0) + } + TextureAction.RedrawStorm -> repeat(REDRAW_STORM) { scope.window.requestRedraw() } + } + scope.settle(STEP_SETTLE_MILLIS) + } + + /** The scene must still be producing frames once the window is visible again. */ + private suspend fun checkpoint() { + restoreWindow() + expectRendering("checkpoint at step ${journal.step}") + } + + private suspend fun restoreWindow() { + scope.window.dispatch( + TaoEventCode.SCALE_FACTOR_CHANGED, + (scope.window.scaleFactor * SCALE_MILLI).roundToInt(), + 0, + ) + scope.window.setMinimized(false) + scope.window.setMaximized(false) + scope.window.show() + scope.window.setInnerSize(WINDOW_W_DP.toDouble(), WINDOW_H_DP.toDouble()) + scope.settle(STEP_SETTLE_MILLIS) + } + + private suspend fun expectRendering(moment: String) { + val rendered = fixture.renderedFrames.get() + converge("$moment: the scene keeps rendering frames") { + fixture.renderedFrames.get() >= rendered + HEARTBEAT_FRAMES + } + if (fixture.sharedRendererMounted) { + val shared = fixture.sharedFrames.get() + converge("$moment: the shared-context renderer keeps producing frames") { + fixture.sharedFrames.get() >= shared + HEARTBEAT_FRAMES + } + } + converge("$moment: the GPU context is published") { fixture.renderContext != null } + } + + private fun checkNoErrors() { + val first = fixture.errors.firstOrNull() ?: return + throw IllegalStateException( + journal.failure("a producer or the shared renderer threw: $first", fixture.describe()), + first, + ) + } + + private suspend fun converge( + description: String, + predicate: () -> Boolean, + ) { + scope.awaitUntil( + description, + timeoutMillis = CONVERGE_MILLIS, + detail = { fixture.describe() }, + predicate = predicate, + ) + } +} + +private const val MONKEY_ACTIONS = 200 +private const val CHECKPOINT_EVERY = 20 +private const val MONKEY_CASE_TIMEOUT_MILLIS = 300_000L +private const val CONVERGE_MILLIS = 6_000L +private const val STEP_SETTLE_MILLIS = 25L +private const val HIDE_MILLIS = 120L +private const val HEARTBEAT_FRAMES = 3L +private const val BURST_FRAMES = 50 +private const val REDRAW_STORM = 20 + +private const val SLOT_ROWS = 2 +private const val SLOT_COLUMNS = 2 +private const val SLOT_COUNT = SLOT_ROWS * SLOT_COLUMNS +private const val SLOT_W_DP = 240 +private const val SLOT_H_DP = 150 +private const val MIN_SLOT_DP = 40 +private const val SLOT_SPAN_DP = 260 +private const val SHARED_W_DP = 240 +private const val SHARED_H_DP = 120 +private const val PAD_DP = 6 + +private const val PRODUCER_W_PX = 320 +private const val PRODUCER_H_PX = 200 +private const val PRODUCER_VARIANTS = 3 +private const val PLANAR_VARIANT = 2 +private const val PRODUCER_BACKGROUND_ARGB = 0xFF1F2630.toInt() +private const val MIN_PRODUCER_PERIOD_MILLIS = 4L +private const val PRODUCER_PERIOD_SPAN_MILLIS = 28L +private const val PRODUCER_JOIN_MILLIS = 2_000L + +private const val WINDOW_X_DP = 120 +private const val WINDOW_Y_DP = 80 +private const val WINDOW_W_DP = 760 +private const val WINDOW_H_DP = 560 +private const val MIN_INNER_W_DP = 300.0 +private const val INNER_W_SPAN_DP = 600.0 +private const val MIN_INNER_H_DP = 200.0 +private const val INNER_H_SPAN_DP = 500.0 + +private val SCALE_HOPS = floatArrayOf(1f, 1.25f, 1.5f, 2f) +private const val SCALE_MILLI = 1000 +private val FILTERS = listOf(FilterQuality.None, FilterQuality.Low, FilterQuality.Medium, FilterQuality.High) +private val CONTENT_SCALES = listOf(ContentScale.FillBounds, ContentScale.Fit, ContentScale.Crop, ContentScale.None) + +private const val BACKDROP_ARGB = 0xFF2B2B2B +private const val SLOT_ARGB = 0xFF101418 +private const val EMPTY_SLOT_ARGB = 0xFF555555 +private const val HEX = 16 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TrackpadScaleHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TrackpadScaleHeadfulCases.kt new file mode 100644 index 000000000..1d615d3a0 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/TrackpadScaleHeadfulCases.kt @@ -0,0 +1,350 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntSize +import dev.nucleusframework.core.runtime.Platform +import java.awt.event.KeyEvent +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger + +/** + * #660 end-to-end: a platform-recognized pinch must reach Compose as + * `ScaleStart` / `ScaleChange` / `ScaleEnd` at the cursor, never as a scroll + * and never as two synthetic Touch contacts. + * + * The injected gesture is a real Ctrl+wheel through the AWT Robot, so the + * whole chain runs: OS wheel message → the vendored tao patch that routes a + * Ctrl-flagged `WM_MOUSEWHEEL` to the magnify hook (GTK: the host's own + * routing) → `onTrackpadGesture` → `TaoTrackpadScaleSession` → `ComposeScene` + * → foundation's `transformable`. + * + * Windows and Linux only: those are the two hosts that turn Ctrl+wheel into a + * scale gesture. macOS gets its pinch from AppKit's magnify recognizer; + * [MacOsTrackpadScaleHeadfulCases] injects those gesture NSEvents. + */ +internal object TrackpadScaleHeadfulCases { + fun all(): List = + listOf( + ctrlWheelArrivesAsScaleAndPlainWheelStaysScroll(), + ctrlWheelZoomsTransformable(), + ) + + /** + * A Ctrl+wheel burst opens one scale gesture, carries a zoom-in ratio on + * every tick and closes on the idle debounce — with no `Scroll` event and + * no movement of the scrollable under the cursor. A plain wheel notch + * afterwards is still an ordinary `Scroll` and produces no scale step. + */ + private fun ctrlWheelArrivesAsScaleAndPlainWheelStaysScroll(): TaoWindowTestCase { + val recorder = ScaleRecorder() + val scene = SceneSize() + val scrollPx = AtomicInteger(0) + val scrollMax = AtomicInteger(0) + return TaoWindowTestCase( + name = "#660 Ctrl+wheel arrives as Compose Scale events and a plain wheel stays Scroll", + skip = { ctrlWheelZoomOnly() }, + // The suite's default chrome is a fillMaxSize sibling stacked above + // [content]; leaving it on gives the recorder 0 height. + paintDefaultBackground = false, + content = { + Recording(recorder, scene) { ScrollableColumn(scrollPx, scrollMax) } + }, + ) { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("column has overflow") { scrollMax.get() > 0 } + awaitUntil("scene measured") { scene.value.width > 0 } + val driver = RobotPointerDriver(window) { scene.value } + driver.armInput( + scope = this, + center = scene.center(), + probed = { recorder.count(PointerEventType.ScaleChange) > 0 }, + reset = { recorder.reset() }, + ) + val scrollBefore = scrollPx.get() + + ctrlWheel(notches = -1, ticks = WHEEL_TICKS) + awaitUntil("a scale step reached Compose") { recorder.count(PointerEventType.ScaleChange) > 0 } + awaitUntilOrTimeout(SCALE_END_MILLIS) { recorder.count(PointerEventType.ScaleEnd) >= 1 } + + val gesture = recorder.snapshot() + check(gesture.firstOrNull()?.type == PointerEventType.ScaleStart) { + "a Ctrl+wheel burst must open with ScaleStart; recorded=${recorder.describe()}" + } + check(gesture.none { it.type == PointerEventType.Scroll }) { + "a Ctrl+wheel must never also be delivered as Scroll; recorded=${recorder.describe()}" + } + val changes = gesture.filter { it.type == PointerEventType.ScaleChange } + check(changes.isNotEmpty() && changes.all { it.scaleFactor > 1f }) { + "wheel-up must carry a zoom-in ratio (> 1) on every step; recorded=${recorder.describe()}" + } + check(gesture.last().type == PointerEventType.ScaleEnd) { + "the idle debounce must close the gesture with ScaleEnd; recorded=${recorder.describe()}" + } + check(gesture.count { it.type == PointerEventType.ScaleEnd } == 1) { + "exactly one ScaleEnd per burst; recorded=${recorder.describe()}" + } + check(scrollPx.get() == scrollBefore) { + "a Ctrl+wheel must zoom, never scroll the column " + + "(offset $scrollBefore → ${scrollPx.get()}); recorded=${recorder.describe()}" + } + + // Baseline taken right before the notch: whatever the burst still + // had in flight must not land in the plain wheel's window. + val before = recorder.snapshot().size + plainWheel(notches = 1) + awaitUntil("plain wheel notch recorded as Scroll") { recorder.count(PointerEventType.Scroll) >= 1 } + val afterWheel = recorder.snapshot().drop(before) + check(afterWheel.none { it.type.isScale() }) { + "a plain wheel notch must produce no scale step; recorded=${recorder.describe()}" + } + awaitUntilOrTimeout(SCROLL_REACTION_MILLIS) { scrollPx.get() != scrollBefore } + check(scrollPx.get() != scrollBefore) { + "a plain wheel notch must still scroll the column; offset=${scrollPx.get()}" + } + } + } + + /** + * Through foundation: `Modifier.transformable` consumes the scale gesture + * and zooms immediately — no touch slop, no span threshold, which is the + * whole point of #660. + */ + private fun ctrlWheelZoomsTransformable(): TaoWindowTestCase { + val scene = SceneSize() + val zoom = Zoom() + return TaoWindowTestCase( + name = "#660 Ctrl+wheel zooms Modifier.transformable with no slop", + skip = { ctrlWheelZoomOnly() }, + paintDefaultBackground = false, + content = { Transformable(zoom, scene) }, + ) { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("scene measured") { scene.value.width > 0 } + val driver = RobotPointerDriver(window) { scene.value } + driver.armInput( + scope = this, + center = scene.center(), + probed = { zoom.value != 1f }, + reset = { zoom.reset() }, + ) + + ctrlWheel(notches = -1, ticks = WHEEL_TICKS) + awaitUntilOrTimeout(SCROLL_REACTION_MILLIS) { zoom.value > 1f } + check(zoom.value > 1f) { + "wheel-up with Ctrl must zoom the transformable in; zoom=${zoom.value}" + } + + val zoomedIn = zoom.value + ctrlWheel(notches = 1, ticks = WHEEL_TICKS) + awaitUntilOrTimeout(SCROLL_REACTION_MILLIS) { zoom.value < zoomedIn } + check(zoom.value < zoomedIn) { + "wheel-down with Ctrl must zoom back out; zoom=$zoomedIn → ${zoom.value}" + } + } + } + + // ── Injection ─────────────────────────────────────────────────────────── + + /** + * Puts the pointer on [center] and makes sure an injected Ctrl+wheel + * actually reaches this window, then leaves the case a clean slate. + * + * Win32 delivers `WM_MOUSEWHEEL` to the **focused** window, not the hovered + * one, and `SetForegroundWindow` from a process the user never activated is + * a no-op — so a wheel injected right after the window maps can land + * wherever the session left the focus. A real click takes the foreground; + * [probed] is what proves it, since nothing the window publishes says + * whether the *wheel* is arriving. The click and the probe tick are on an + * empty / scrollable surface and zoom nothing the cases measure, and + * [reset] runs once the probe gesture has closed. + */ + private suspend fun RobotPointerDriver.armInput( + scope: TaoWindowTestScope, + center: Offset, + probed: () -> Boolean, + reset: () -> Unit, + ) { + moveTo(center) + repeat(ARM_ATTEMPTS) { attempt -> + scope.window.focus() + click(center) + scope.settle() + ctrlWheel(notches = -1, ticks = 1) + if (scope.awaitUntilOrTimeout(ARM_PROBE_MILLIS, probed)) { + // Let the idle debounce close the probe's gesture, so the + // case's own burst is the only one in the recording. + scope.settle(ARM_SETTLE_MILLIS) + reset() + return + } + System.err.println("[probe] Ctrl+wheel did not reach the window (attempt ${attempt + 1})") + } + error("an injected Ctrl+wheel never reached the case window; ${HeadfulRobot.lastAimReport}") + } + + /** + * [ticks] wheel notches with Ctrl held down for the whole burst — the + * shape a precision touchpad pinch takes on Windows. [notches] is AWT's + * sign: negative is wheel-up, i.e. zoom in. + */ + private suspend fun ctrlWheel( + notches: Int, + ticks: Int, + ) { + inject { robot -> + robot.keyPress(KeyEvent.VK_CONTROL) + try { + repeat(ticks) { + robot.mouseWheel(notches) + Thread.sleep(WHEEL_STEP_MILLIS) + } + } finally { + robot.keyRelease(KeyEvent.VK_CONTROL) + } + } + } + + private suspend fun plainWheel(notches: Int) = inject { robot -> robot.mouseWheel(notches) } + + private suspend fun inject(gesture: (java.awt.Robot) -> Unit) { + val ok = + HeadfulRobot.inject { robot -> + gesture(robot) + true + } + checkNotNull(ok) { "the AWT Robot became unavailable mid-run: ${HeadfulRobot.unavailableReason}" } + } + + // ── Compose content ───────────────────────────────────────────────────── + + /** Scene size in physical px, published by the recording root. */ + private class SceneSize { + @Volatile + var value: IntSize = IntSize.Zero + + fun center(): Offset = Offset(value.width / 2f, value.height / 2f) + } + + private class Zoom { + @Volatile + var value: Float = 1f + + fun apply(change: Float) { + value *= change + } + + fun reset() { + value = 1f + } + } + + private class Recorded( + val type: PointerEventType, + val scaleFactor: Float, + ) { + override fun toString(): String = if (type.isScale()) "$type($scaleFactor)" else type.toString() + } + + /** Scroll / Scale events seen at the window root on the Initial pass, in order. */ + private class ScaleRecorder { + private val events = Collections.synchronizedList(mutableListOf()) + + fun add(event: PointerEvent) { + val change = event.changes.firstOrNull() ?: return + events += Recorded(event.type, change.scaleFactor) + } + + fun snapshot(): List = synchronized(events) { events.toList() } + + /** Cases share their recorder with the registry; start each run clean. */ + fun reset() = events.clear() + + fun count(type: PointerEventType): Int = snapshot().count { it.type == type } + + fun describe(): String = snapshot().joinToString(prefix = "[", postfix = "]") + } + + @Composable + private fun Recording( + recorder: ScaleRecorder, + scene: SceneSize, + content: @Composable () -> Unit, + ) { + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { scene.value = it.size } + .pointerInput(recorder) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type == PointerEventType.Scroll || event.type.isScale()) { + recorder.add(event) + } + } + } + }, + ) { + content() + } + } + + @Composable + private fun Transformable( + zoom: Zoom, + scene: SceneSize, + ) { + val state = rememberTransformableState { zoomChange, _, _ -> zoom.apply(zoomChange) } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { scene.value = it.size } + .transformable(state), + ) + } + + // ── Helpers ───────────────────────────────────────────────────────────── + + private fun PointerEventType.isScale(): Boolean = + this == PointerEventType.ScaleStart || + this == PointerEventType.ScaleChange || + this == PointerEventType.ScaleEnd + + /** + * Ctrl+wheel is a scale gesture on Windows and Linux only; macOS takes its + * pinch from AppKit's own recognizer ([MacOsTrackpadScaleHeadfulCases]). + */ + private fun ctrlWheelZoomOnly(): String? = + when (Platform.Current) { + Platform.Windows, Platform.Linux -> robotDriverSkipReason() + else -> "Windows / Linux only — Ctrl+wheel is the injectable pinch" + } + + /** How many times a click + probe tick is retried before the case gives up. */ + private const val ARM_ATTEMPTS = 3 + private const val ARM_PROBE_MILLIS = 1_500L + + /** Idle debounce (120 ms) plus slack, so the probe's gesture is closed and recorded before the reset. */ + private const val ARM_SETTLE_MILLIS = 500L + + /** Notches per burst: enough steps that a slop-gated path would still be visible. */ + private const val WHEEL_TICKS = 4 + private const val WHEEL_STEP_MILLIS = 16L + + /** Upper bound for the idle debounce that closes the gesture (120 ms) plus delivery. */ + private const val SCALE_END_MILLIS = 3_000L + + /** How long a scrollable / transformable gets to react before the soft wait gives up. */ + private const val SCROLL_REACTION_MILLIS = 2_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/UnspecifiedSizeHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/UnspecifiedSizeHeadfulCases.kt index 46da72388..70dccd98c 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/UnspecifiedSizeHeadfulCases.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/UnspecifiedSizeHeadfulCases.kt @@ -3,22 +3,41 @@ package dev.nucleusframework.window.tao.headful import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.WindowState import dev.nucleusframework.window.DialogTitleBar +import dev.nucleusframework.window.TitleBar +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.TaoWindow +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs /** * #532 — `Dp.Unspecified` on a window/dialog axis must wrap content, not * create a 0-tall (or NaN) native surface that Metal/EGL refuses to draw. + * + * #546 — and the position must be resolved against the *measured* size: + * `Aligned(Center)` centres the window on the work area, a dialog centres on + * its parent, once the wrap-content size is known. */ internal object UnspecifiedSizeHeadfulCases { fun all(): List = listOf( windowWrapContentHeight(), dialogWrapContentHeight(), + windowWrapContentCentred(), + dialogWrapContentCentredOnScreen(), + dialogWrapContentCentredOnParent(), + windowWrapContentWidthTitleBarSpans(), ) private fun windowWrapContentHeight(): TaoWindowTestCase = @@ -26,24 +45,16 @@ internal object UnspecifiedSizeHeadfulCases { name = "#532 window wrap-content height maps with non-zero size", paintDefaultBackground = false, size = DpSize(WRAP_WIDTH_DP.dp, Dp.Unspecified), - content = { - Box( - modifier = - Modifier - .size(WRAP_WIDTH_DP.dp, CONTENT_HEIGHT_DP.dp) - .background(Color.Red), - ) - }, + content = { RedBox() }, ) { awaitUntil("window mapped with wrap-content height") { val b = bounds() ?: return@awaitUntil false if (b[2] <= 0 || b[3] <= 0) return@awaitUntil false - val heightDp = b[3] / window.scaleFactor - heightDp in CONTENT_HEIGHT_DP..(CONTENT_HEIGHT_DP + MAX_CHROME_DP) + b.wrapsContent(window) } val b = checkNotNull(bounds()) val heightDp = b[3] / window.scaleFactor - check(heightDp in CONTENT_HEIGHT_DP..(CONTENT_HEIGHT_DP + MAX_CHROME_DP)) { + check(b.wrapsContent(window)) { "expected wrap-content height around ${CONTENT_HEIGHT_DP}dp, got ${heightDp}dp" } } @@ -55,26 +66,168 @@ internal object UnspecifiedSizeHeadfulCases { dialogSize = DpSize(WRAP_WIDTH_DP.dp, Dp.Unspecified), dialogContent = { DialogTitleBar { } - Box( - modifier = - Modifier - .size(WRAP_WIDTH_DP.dp, CONTENT_HEIGHT_DP.dp) - .background(Color.Red), - ) + RedBox() }, ) { val dialog = checkNotNull(dialogWindow) { "dialog window never published" } awaitUntil("dialog mapped with wrap-content height") { val b = dialog.outerBoundsPx() ?: return@awaitUntil false if (b[2] <= 0 || b[3] <= 0) return@awaitUntil false - val heightDp = b[3] / dialog.scaleFactor - heightDp in CONTENT_HEIGHT_DP..(CONTENT_HEIGHT_DP + MAX_CHROME_DP) + b.wrapsContent(dialog) + } + } + + private fun windowWrapContentCentred(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#546 window wrap-content height centres on the work area", + skip = { if (isNativeWayland) "xdg-shell ignores client positions" else null }, + paintDefaultBackground = false, + windowState = + WindowState( + size = DpSize(WRAP_WIDTH_DP.dp, Dp.Unspecified), + position = WindowPosition.Aligned(Alignment.Center), + ), + content = { RedBox() }, + ) { + awaitUntil( + "window centred at its wrap-content size", + detail = { "bounds=${bounds()?.toList()} workArea=${workArea().toList()}" }, + ) { + val b = bounds() ?: return@awaitUntil false + b.wrapsContent(window) && centresMatch(b, workArea(), CENTRE_TOLERANCE_DP * window.scaleFactor) + } + } + + /** A parentless dialog centres on the screen, as AWT's `setLocationRelativeTo(null)`. */ + private fun dialogWrapContentCentredOnScreen(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#546 parentless dialog wrap-content height centres on the work area", + skip = { if (isNativeWayland) "xdg-shell ignores client positions" else null }, + paintDefaultBackground = false, + dialogSize = DpSize(WRAP_WIDTH_DP.dp, Dp.Unspecified), + dialogContent = { + DialogTitleBar { } + RedBox() + }, + ) { + val dialog = checkNotNull(dialogWindow) { "dialog window never published" } + awaitUntil( + "dialog centred on the work area at its wrap-content size", + detail = { "dialog=${dialog.outerBoundsPx()?.toList()} workArea=${workArea().toList()}" }, + ) { + val d = dialog.outerBoundsPx() ?: return@awaitUntil false + d.wrapsContent(dialog) && centresMatch(d, workArea(), CENTRE_TOLERANCE_DP * dialog.scaleFactor) + } + } + + private fun dialogWrapContentCentredOnParent(): TaoWindowTestCase = + TaoWindowTestCase( + name = "#546 dialog wrap-content height centres on the parent", + skip = { if (isNativeWayland) "xdg-shell ignores client positions" else null }, + paintDefaultBackground = false, + dialogParentedToWindow = true, + dialogSize = DpSize(WRAP_WIDTH_DP.dp, Dp.Unspecified), + dialogContent = { + DialogTitleBar { } + RedBox() + }, + ) { + val dialog = checkNotNull(dialogWindow) { "dialog window never published" } + awaitUntil( + "dialog centred on its parent at its wrap-content size", + detail = { "dialog=${dialog.outerBoundsPx()?.toList()} parent=${bounds()?.toList()}" }, + ) { + val d = dialog.outerBoundsPx() ?: return@awaitUntil false + val p = bounds() ?: return@awaitUntil false + d.wrapsContent(dialog) && centresMatch(d, p, CENTRE_TOLERANCE_DP * dialog.scaleFactor) + } + } + + /** + * #546 (follow-up): under an unspecified width the scene's wrap modifier + * left the TitleBar an unbounded max width, so its `fillMaxWidth` collapsed + * to the buttons. Once measured, the bar must span the window. + */ + private fun windowWrapContentWidthTitleBarSpans(): TaoWindowTestCase { + val titleBarWidthPx = AtomicInteger(0) + val sceneWidthPx = AtomicInteger(0) + return TaoWindowTestCase( + name = "#546 wrap-content width: TitleBar spans the measured window", + paintDefaultBackground = false, + size = DpSize(Dp.Unspecified, WRAP_HEIGHT_DP.dp), + content = { + sceneWidthPx.set(LocalWindowInfo.current.containerSize.width) + TitleBar(Modifier.onSizeChanged { titleBarWidthPx.set(it.width) }) { } + RedBox() + }, + ) { + awaitUntil( + "window mapped at its wrap-content width", + detail = { "bounds=${bounds()?.toList()}" }, + ) { + val b = bounds() ?: return@awaitUntil false + val widthDp = b[2] / window.scaleFactor + widthDp in WRAP_WIDTH_DP..(WRAP_WIDTH_DP + MAX_CHROME_DP) } + awaitUntil( + "TitleBar as wide as the scene", + detail = { "titleBar=${titleBarWidthPx.get()}px scene=${sceneWidthPx.get()}px" }, + ) { + val scene = sceneWidthPx.get() + val settled = scene > 0 && scene <= (WRAP_WIDTH_DP + MAX_CHROME_DP) * window.scaleFactor + settled && titleBarWidthPx.get() == scene + } + } + } + + @Composable + private fun RedBox() { + Box( + modifier = + Modifier + .size(WRAP_WIDTH_DP.dp, CONTENT_HEIGHT_DP.dp) + .background(Color.Red), + ) + } + + /** Whether `[x, y, w, h]` outer bounds are the content height plus at most the platform chrome. */ + private fun LongArray.wrapsContent(window: TaoWindow): Boolean { + val heightDp = this[3] / window.scaleFactor + return heightDp in CONTENT_HEIGHT_DP..(CONTENT_HEIGHT_DP + MAX_CHROME_DP) + } + + private fun TaoWindowTestScope.workArea(): LongArray { + val wa = TaoMonitors.primary(window).workAreaPx + return longArrayOf(wa.left.toLong(), wa.top.toLong(), wa.width.toLong(), wa.height.toLong()) + } + + /** Whether the centres of two `[x, y, w, h]` rects are within [tolerancePx] on both axes. */ + private fun centresMatch( + a: LongArray, + b: LongArray, + tolerancePx: Float, + ): Boolean { + val dx = (a[0] + a[2] / 2.0) - (b[0] + b[2] / 2.0) + val dy = (a[1] + a[3] / 2.0) - (b[1] + b[3] / 2.0) + return abs(dx) <= tolerancePx && abs(dy) <= tolerancePx + } + + private val isNativeWayland: Boolean + get() { + val forcedX11 = + System.getenv("GDK_BACKEND")?.split(',')?.firstOrNull() == "x11" || + System.getenv("NUCLEUS_TAO_LINUX_RENDERER").orEmpty().equals("x11", ignoreCase = true) + return System.getenv("WAYLAND_DISPLAY") != null && !forcedX11 } private const val WRAP_WIDTH_DP = 300f + private const val WRAP_HEIGHT_DP = 300f private const val CONTENT_HEIGHT_DP = 137f // Title bar + Linux CSD shadow / macOS traffic-light chrome. private const val MAX_CHROME_DP = 220f + + // Outer-vs-inner chrome is not symmetric (title bar, Win32 invisible + // borders): the un-fixed offsets are hundreds of dp, this is well under. + private const val CENTRE_TOLERANCE_DP = 40f } diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WatchdogDialogSmokeMain.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WatchdogDialogSmokeMain.kt new file mode 100644 index 000000000..81f8211e4 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WatchdogDialogSmokeMain.kt @@ -0,0 +1,116 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.window.tao.DecoratedWindow +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TaoEventLoopWatchdog +import dev.nucleusframework.window.tao.taoApplication +import kotlinx.coroutines.delay +import java.util.concurrent.atomic.AtomicInteger +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger + +/** + * Black-box smoke for the #643 watchdog: shows a plain window, freezes the + * event loop for real, then prints a one-line machine-checkable verdict + * + * ``` + * [watchdog-smoke] severe=1 unresponsive=1 responsive=1 + * ``` + * + * and exits. Each configuration of the watchdog is one run of this main with + * different flags, which is how the whole switch surface is verified from the + * outside — see the `taoWatchdogSmoke` Gradle task: + * + * - default → `severe=1 unresponsive=1 responsive=1` + * - `-Dnucleus.tao.watchdog=false` → all zero + * - a JDWP agent on the command line → all zero (debug sessions are exempt) + * - a JDWP agent + `-Dnucleus.tao.watchdog=true` → back to one each + * - `-Dnucleus.tao.watchdog.smoke.expected=true` → all zero: the freeze runs + * inside `expectUnresponsive { }`, so it is a declared long operation + * - `-Dnucleus.tao.watchdogDialog=true` → same counts, plus the native + * "Application Not Responding" dialog on screen; `holdMs` keeps the process + * alive long enough to look at it. + */ +object WatchdogDialogSmokeMain { + @JvmStatic + fun main(args: Array) { + val freezeMs = longProperty("freezeMs", DEFAULT_FREEZE_MS) + val freezeAfterMs = longProperty("freezeAfterMs", DEFAULT_SETTLE_MS) + val drainMs = longProperty("drainMs", DEFAULT_DRAIN_MS) + val holdMs = longProperty("holdMs", 0L) + val expected = System.getProperty("nucleus.tao.watchdog.smoke.expected").toBoolean() + + val severe = AtomicInteger() + val unresponsive = AtomicInteger() + val responsive = AtomicInteger() + Logger.getLogger(TaoEventLoopWatchdog::class.java.name).addHandler( + object : Handler() { + override fun publish(record: LogRecord) { + if (record.level == Level.SEVERE) severe.incrementAndGet() + } + + override fun flush() = Unit + + override fun close() = Unit + }, + ) + TaoApplication.onUnresponsive { unresponsive.incrementAndGet() } + TaoApplication.onResponsive { responsive.incrementAndGet() } + + taoApplication { + DecoratedWindow( + onCloseRequest = ::exitApplication, + state = rememberWindowState(size = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp)), + title = "tao watchdog smoke #643", + ) { + Box(Modifier.fillMaxSize().background(Color(BACKDROP_ARGB))) + LaunchedEffect(Unit) { + delay(freezeAfterMs) + // Runs on Dispatchers.Main — the event-loop thread. This is + // what a deadlocked loop looks like from the outside. + println("[watchdog-smoke] freezing the event loop for $freezeMs ms") + if (expected) { + // The declared-long-operation path: same freeze, but + // the app told the watchdog to expect it. + TaoApplication.expectUnresponsive { Thread.sleep(freezeMs) } + } else { + Thread.sleep(freezeMs) + } + println("[watchdog-smoke] loop resumed") + // Let the watchdog take the sample that closes the episode. + delay(drainMs) + println( + "[watchdog-smoke] severe=${severe.get()} " + + "unresponsive=${unresponsive.get()} responsive=${responsive.get()}", + ) + // A dialog run is meant to be looked at; everything else exits at once. + delay(holdMs) + exitApplication() + } + } + } + } + + private fun longProperty( + name: String, + default: Long, + ): Long = System.getProperty("nucleus.tao.watchdog.smoke.$name")?.toLongOrNull() ?: default + + private const val DEFAULT_FREEZE_MS = 20_000L + private const val DEFAULT_SETTLE_MS = 3_000L + private const val DEFAULT_DRAIN_MS = 6_000L + private const val WINDOW_W_DP = 480 + private const val WINDOW_H_DP = 320 + private const val BACKDROP_ARGB = 0xFF1E1F22 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt new file mode 100644 index 000000000..618f7dcfb --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceHeadfulCases.kt @@ -0,0 +1,556 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.DockTransferTarget +import dev.nucleusframework.window.tao.SatelliteCaptionStripWidth +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.TabDropTarget +import dev.nucleusframework.window.tao.TransferDrop +import dev.nucleusframework.window.tao.WorkspaceDragKind +import kotlin.math.abs + +/** + * The native-**Wayland** contract of the two cross-window archetypes. + * + * A client has neither its windows' screen position nor a way to move them + * there, so the gestures ride the platform's drag-and-drop session instead: + * the source hands the session to the compositor, the window under the pointer + * resolves the drop in its *own* coordinates and records it on the session, + * and the source acts on that record when the session ends. These cases pin + * down that contract on real windows: + * + * 1. the screen-space API refuses to start, since starting it would mean + * moving windows, and the transfer session starts in its place; + * 2. a recorded dock zone docks the satellite, `rememberSaveable` state + * intact, and the floating window is really destroyed; + * 3. no record lifts a docked panel back out, and a record naming the side it + * already occupies leaves it alone; + * 4. a dock zone is resolved from a window coordinate — the only space an + * inbound drag event speaks — on every side; + * 5. the ownership half is untouched: a floating satellite still hides while + * its owner is maximized, and never publishes an owner offset it cannot + * know; + * 6. tabs the same way: no record tears off, a record merges back; + * 9. the strip's own gesture: a tab carried along its strip reorders with no + * screen coordinate at all, and leaving the strip hands the drag to the + * platform's session, which is what lets another window preview the drop; + * 8. chrome is told the compositor places its window, the title bar reserves + * the caption strip for the compositor's move and the app's slot is + * composed inside it, and a satellite drag reports itself as carried by + * the platform session with no ghost window; + * 7. a drop over a stack resolves the rank under the pointer from window + * coordinates — its own rank being no move — and the record reorders the + * layers without rebuilding one. + * + * The adversarial half — lifecycle, concurrency, bursts, edge cases — lives in + * [WaylandWorkspaceStressHeadfulCases]. Skipped everywhere that has + * client-side placement, where [SatelliteWorkspaceHeadfulCases] covers the + * pointer path with a real mouse. + */ +internal object WaylandWorkspaceHeadfulCases { + fun all(): List = + listOf( + screenApiRefusedTransferSessionStarts(), + recordedZoneDocksAndNoRecordUndocks(), + everyZoneResolvesFromAWindowCoordinate(), + tabTransferDragTearsOffAndMergesBack(), + aTransferDropResolvesARankAndReorders(), + chromeIsToldTheCompositorPlacesTheWindow(), + theStripReordersAndDefersItsDrops(), + ) + + /** + * The gesture a compositor-placed window *can* carry, on real windows. + * + * Reordering asks nothing of the screen: the strip is handed the travel in + * its own coordinates and answers with the place the tab would take. A + * release clear of the strip cannot be hit-tested — every toplevel reports + * a fake origin here — so the drop is deferred, and the window the + * compositor hands the pointer to next is the one that resolves it: into + * its strip, or into a window of its own. Nothing claims it and the tab is + * torn off, which is what a release over the desktop has always done. + */ + private fun theStripReordersAndDefersItsDrops(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma")) + return TaoWindowTestCase( + name = "native Wayland: the strip reorders without the screen and lets go when the tab leaves it", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindowsInWindow(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val group = requireNotNull(fixture.groupOf("Alpha")) + check(!first.canPlaceOnScreen) { "case premise: the window must be compositor-placed" } + check(workspace.beginDrag(fixture.tabId("Gamma"), stripOrigin(first), Offset.Zero) == null) { + "the screen-space drag started on a window the app cannot place" + } + + // ── a reorder, with nothing but window coordinates ── + val motion = requireNotNull(workspace.motionOf(group)) { "the strip published no motion" } + val gamma = fixture.tabId("Gamma") + val beta = fixture.tabId("Beta") + val alpha = fixture.tabId("Alpha") + val slot = requireNotNull(motion.slotOf(gamma)) + val driver = SyntheticPointerDriver(first) + driver.moveTo(slot.center) + driver.press() + driver.moveTo(slot.center + Offset(-SLOP_PX, 0f)) + driver.moveTo(slot.center + Offset(-slot.width * CARRY_FRACTION, 0f)) + awaitUntil("the strip shows the tab landing before its neighbour") { + workspace.dropPreview?.let { it.group === group && it.index == 1 } == true + } + check(workspace.dragGhost == null) { "a ghost window on a compositor-placed surface" } + driver.release() + awaitUntil("the reorder is applied once the tab has slid home") { + group.ids == listOf(alpha, gamma, beta) + } + + // ── leaving the strip hands the gesture to the platform ── + // + // The strip lets go the moment the pointer is out of it: from + // there the drag is the platform's own session, which is what + // gives every *other* window the pointer in its coordinates — + // the only way a compositor-placed client can preview a drop + // it does not own. The session itself is the compositor's to + // start, so what is asserted here is the strip's half: it + // stops carrying, and the tab is where it was. + val gammaSlot = requireNotNull(motion.slotOf(gamma)) + driver.moveTo(gammaSlot.center) + driver.press() + driver.moveTo(gammaSlot.center + Offset(0f, SLOP_PX)) + driver.moveTo(gammaSlot.center + Offset(0f, OUT_OF_STRIP_PX)) + awaitUntil("the strip let go of the tab it was carrying") { motion.held == null } + driver.release() + // Released with nothing under it, the platform session leaves + // the tab a window of its own — the tear-out a void release has + // always been, now reached through the session that also gives + // another window the drop preview. + awaitUntil("the tab left the strip it was dragged out of") { + !group.ids.contains(gamma) && workspace.tab(gamma) != null + } + check(workspace.dragGhost == null) { "a ghost window on a compositor-placed surface" } + check(group.ids == listOf(alpha, beta)) { "the tabs left behind are not in order: ${group.ids}" } + }, + ) + } + + /** + * The other half of the X11 case in `DockLayoutHeadfulCases`: here the + * compositor places the window, so [SatelliteScope.isCompositorPlaced] is + * `true` for the floating palette, its title bar reserves + * [SatelliteCaptionStripWidth] for the compositor's move with the app's + * `floatingCaption` composed inside it, and a satellite drag is a + * [WorkspaceDragKind.Transfer] that publishes no ghost window. + * + * The docked panel reads its host, which is compositor-placed too. + */ + private fun chromeIsToldTheCompositorPlacesTheWindow(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, extent = 120.dp)), + DockPanelSpec( + NOTES, + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ), + ), + ) + return TaoWindowTestCase( + name = "native Wayland: chrome is told the compositor places the window, and the caption strip is reserved", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodiesInWindow(fixture, TREE) + awaitUntil("the palette floats") { + fixture.floatingWindows.value[NOTES]?.hasRealFramePx() == true + } + settle(SETTLE_AFTER_MAP_MILLIS) + val floating = requireNotNull(fixture.floatingWindows.value[NOTES]) + check(!floating.canPlaceOnScreen) { "case premise: the palette must be compositor-placed" } + + awaitUntil("the palette's chrome learned how its window is placed") { + fixture.compositorPlacedFloating.value[NOTES] == true + } + check(fixture.compositorPlacedDocked.value[TREE] == true) { + "the panel was told its host places itself: ${fixture.compositorPlacedDocked.value}" + } + awaitUntil("the caption strip is composed") { fixture.captionBounds.value[NOTES] != null } + val caption = requireNotNull(fixture.captionBounds.value[NOTES]) + val expectedPx = SatelliteCaptionStripWidth.value * floating.scaleFactor + check(abs(caption.width - expectedPx) <= LAYOUT_TOLERANCE_PX) { + "the reserved strip is ${caption.width} px, SatelliteCaptionStripWidth is $expectedPx" + } + check(caption.height > 0f) { "the strip has no height, so nothing can be aimed at it" } + + // The drag says how it is carried, and no ghost window follows. + check(workspace.dragKind == null) { "a drag is reported before one starts" } + val session = requireNotNull(workspace.beginTransferDrag(NOTES, floatingOrigin(floating))) + check(workspace.dragKind == WorkspaceDragKind.Transfer) { + "the platform session carries it, but the kind is ${workspace.dragKind}" + } + check(workspace.dragGhost == null) { "a ghost window followed a transfer drag" } + check(workspace.draggedSatellite?.id == NOTES) { "the dragged satellite is not published" } + session.cancel() + check(workspace.dragKind == null && workspace.publishesNoDragFeedback()) { "feedback left behind" } + + // The screen-space API is still refused here, which is why the + // split exists in the first place. + check(workspace.beginDrag(NOTES, floatingOrigin(floating), Offset.Zero) == null) { + "a screen drag started on a window the app cannot place" + } + }, + ) + } + + private fun aTransferDropResolvesARankAndReorders(): TaoWindowTestCase { + val fixture = + DockLayoutFixture( + specs = + listOf( + DockPanelSpec(TREE, SatellitePlacement.Docked(DockSide.Right, order = 0, extent = 100.dp)), + DockPanelSpec(TOC, SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 120.dp)), + DockPanelSpec(NOTES, SatellitePlacement.Docked(DockSide.Right, order = 2, extent = 90.dp)), + ), + layeredSides = setOf(DockSide.Right), + ) + return TaoWindowTestCase( + name = "native Wayland: a transfer drop over a stack resolves the rank under the pointer and reorders", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { Satellites() } }, + driver = { + val workspace = fixture.workspace + awaitDockedBodiesInWindow(fixture, TREE, TOC, NOTES) + val geometry = requireNotNull(workspace.dockHostGeometry(window)) + val layout = geometry.layoutBoundsInWindowPx + val tree = requireNotNull(fixture.panelBounds.value[TREE]) + val notesBefore = requireNotNull(fixture.panelBounds.value[NOTES]) + + val session = requireNotNull(workspace.beginTransferDrag(NOTES, panelOrigin(window))) + awaitUntil("the layout published its drop zones") { geometry.zoneBoundsInWindowPx.isNotEmpty() } + val target = DockTransferTarget(workspace, window, geometry) + // Window coordinates, the only ones an inbound event carries. + val overTreeOuterHalf = Offset(tree.left + tree.width * OUTER_HALF, layout.center.y) + check(target.zoneAt(overTreeOuterHalf) == DockTarget(window, DockSide.Right, 0)) { + "the outer half of the first layer did not resolve to rank 0: ${target.zoneAt(overTreeOuterHalf)}" + } + check(target.zoneAt(notesBefore.center) == DockTarget(window, DockSide.Right, 2)) { + "the panel's own area did not resolve to its own rank: ${target.zoneAt(notesBefore.center)}" + } + check( + target.zoneAt(notesBefore.center) == session.own, + ) { "its own rank is not what the session calls its own" } + // Clear of the left strip and short of the layers: content. + val content = Offset(layout.left + CONTENT_PROBE_DP * window.scaleFactor, layout.center.y) + check(target.zoneAt(content) == null) { "the content is no zone: ${target.zoneAt(content)}" } + + session.drop = TransferDrop.Dock(requireNotNull(target.zoneAt(overTreeOuterHalf))) + session.end() + awaitUntil("the notes are the first rank") { + (workspace.satellite(NOTES)?.placement as? SatellitePlacement.Docked)?.order == 0 + } + awaitDockedBodiesInWindow(fixture, TREE, TOC, NOTES) + val notes = requireNotNull(fixture.panelBounds.value[NOTES]) + val treeAfter = requireNotNull(fixture.panelBounds.value[TREE]) + check( + near(notes.right, layout.right, LAYOUT_TOLERANCE_PX * 2) && + treeAfter.right <= notes.left + LAYOUT_TOLERANCE_PX, + ) { + "the notes are not the outermost layer: notes=$notes tree=$treeAfter" + } + check( + near(notes.width, notesBefore.width), + ) { "the notes changed width: ${notesBefore.width} -> ${notes.width}" } + check( + fixture.incarnationsOf(TREE) == 1 && + fixture.incarnationsOf(TOC) == 1 && + fixture.incarnationsOf(NOTES) == 1, + ) { + "a reorder rebuilt a panel: ${fixture.incarnations.value}" + } + check(workspace.publishesNoDragFeedback()) { "feedback left behind after the session ended" } + }, + ) + } + + private fun screenApiRefusedTransferSessionStarts(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "native Wayland: the screen-space drag is refused and the transfer session starts instead", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + check(window.isNativeWaylandSurface) { "case premise: the owner must be a native Wayland surface" } + check(entry.windowState.offsetFromParent == null) { + "no owner offset can be known on Wayland, yet one was published: " + + "${entry.windowState.offsetFromParent}" + } + + check(workspace.beginDrag(SATELLITE_ID, floatingOrigin(floating), Offset(PROBE_PX, PROBE_PX)) == null) { + "the screen-space drag from a floating window must be refused" + } + check(workspace.beginDrag(SATELLITE_ID, panelOrigin(window), Offset(PROBE_PX, PROBE_PX)) == null) { + "the screen-space drag from a docked panel must be refused" + } + check(workspace.publishesNoDragFeedback()) { "a refused drag must publish nothing" } + check(workspace.dockTargetAt(Offset(PROBE_PX, PROBE_PX)) == null) { + "no dock zone can be hit-tested in screen space without window positions" + } + + val session = + requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) { + "the transfer drag must start where the screen-space one cannot" + } + check(workspace.draggedSatellite === entry) { "a live transfer drag must publish its satellite" } + check(session.title == entry.title) { "the drag card must read the satellite's title" } + val floatingWidthPx = requireNotNull(floating.outerBoundsPx())[RECT_W].toFloat() + check(abs(session.ghostSizePx.width - floatingWidthPx) <= GHOST_TOLERANCE_PX) { + "the card must be as wide as the window it came from: " + + "${session.ghostSizePx.width} vs $floatingWidthPx" + } + check(session.ghostSizePx.height > 0f) { "the card must have a height" } + session.cancel() + check(workspace.publishesNoDragFeedback()) { "a cancelled drag must publish nothing" } + check(!entry.isDocked) { "a cancelled drag must not change the placement" } + }, + ) + } + + private fun recordedZoneDocksAndNoRecordUndocks(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "native Wayland: a recorded zone docks the satellite and no record lifts it back out", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + requireNotNull(fixture.counter.value).value = SAVED_CLICKS + settle() + + // ── a recorded zone docks it, and the window really goes ── + var destroyed = false + floating.onDestroyed { destroyed = true } + workspace.transferDrop(floatingOrigin(floating), DockTarget(window, DockSide.Right)) + awaitUntil("floating window destroyed after docking") { destroyed } + awaitPanelIn(fixture, window) + check(workspace.publishesNoDragFeedback()) { "the finished drag must publish nothing" } + check(workspace.dockedSide() == DockSide.Right) { "not docked right: ${entry.placement}" } + val panel = requireNotNull(fixture.panelBoundsPx.value) + val container = requireNotNull(fixture.hostContentSizePx.value) + check(abs(panel.right - container.width) <= LAYOUT_TOLERANCE_PX) { + "panel does not sit on the right edge: panel=$panel container=$container" + } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "rememberSaveable state lost when docking: ${fixture.counter.value?.value}" + } + + // ── the side it already occupies: left alone ── + val ownSide = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, panelOrigin(window))) + check(ownSide.own == DockTarget(window, DockSide.Right)) { + "a docked panel's drag must know the zone it already occupies: ${ownSide.own}" + } + check(abs(ownSide.ghostSizePx.width - panel.width) <= GHOST_TOLERANCE_PX) { + "the card must be as wide as the panel: ${ownSide.ghostSizePx.width} vs ${panel.width}" + } + ownSide.drop = TransferDrop.Stay + ownSide.end() + settle() + check(workspace.dockedSide() == DockSide.Right) { "a Stay record moved the panel: ${entry.placement}" } + + // ── another side: re-docked, still one panel ── + workspace.transferDrop(panelOrigin(window), DockTarget(window, DockSide.Bottom)) + awaitUntil("re-docked to the bottom") { workspace.dockedSide() == DockSide.Bottom } + awaitPanelIn(fixture, window) + check(fixture.composedHosts.value == 1) { "re-docking left two hosts composing" } + + // ── no record at all: lifted out as a window ── + workspace.transferDrop(panelOrigin(window), target = null) + awaitUntil("floating window recreated") { + val now = fixture.floatingWindow.value + now != null && (now.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(!entry.isDocked && entry.dockHost == null) { "entry still reads as docked after the drop" } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "rememberSaveable state lost when undocking: ${fixture.counter.value?.value}" + } + + // ── no record while already floating: stays put ── + val stillFloating = requireNotNull(fixture.floatingWindow.value) + workspace.transferDrop(floatingOrigin(stillFloating), target = null) + settle() + check(!entry.isDocked) { "a dropless release of a floating satellite docked it: ${entry.placement}" } + check(fixture.floatingWindow.value === stillFloating) { "the floating window was needlessly recreated" } + }, + ) + } + + private fun everyZoneResolvesFromAWindowCoordinate(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "native Wayland: every dock zone resolves from a window coordinate, and maximize still hides", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val workspace = fixture.workspace + awaitFloatingOnWayland(fixture) + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + awaitUntil("the dock layout published its bounds") { + workspace.dockHostGeometry(window)?.layoutBoundsInWindowPx?.isEmpty == false + } + + // ── the four zones, from window coordinates ── + for (side in DockSide.entries) { + check(workspace.zoneProbe(window, side) == side) { + "the strip inside the $side edge did not resolve to $side: " + + "got ${workspace.zoneProbe(window, side)}" + } + } + val geometry = requireNotNull(workspace.dockHostGeometry(window)) + val layout = geometry.layoutBoundsInWindowPx + check(workspace.zoneProbeAt(window, layout.center) == null) { + "the middle of the layout is content, not a zone" + } + check(workspace.zoneProbeAt(window, Offset(layout.center.x, layout.top - 1f)) == null) { + "a point above the layout — the title bar — is no zone" + } + check(workspace.zoneProbeAt(window, Offset(layout.right + 1f, layout.center.y)) == null) { + "a point outside the layout is no zone at all" + } + // The client origin is unknowable, which is what makes the + // window-space path the only one available here. + check(geometry.clientOriginPx() == null) { "a Wayland host must not claim a screen origin" } + check(geometry.layoutScreenRectPx() == null) { "a Wayland host must not claim a screen rect" } + + // ── ownership is untouched by any of this ── + window.setMaximized(true) + awaitUntil("satellite hidden while the owner is maximized") { entry.windowState.isHiddenByParent } + window.setMaximized(false) + awaitUntil("satellite back once the owner is restored") { !entry.windowState.isHiddenByParent } + awaitUntil("restored satellite is mapped with a real size") { + val rect = fixture.floatingWindow.value?.outerBoundsPx() ?: return@awaitUntil false + rect[RECT_W] > 0 && rect[RECT_H] > 0 + } + check(entry.windowState.offsetFromParent == null) { + "a maximize round trip must not invent an owner offset" + } + }, + ) + } + + private fun tabTransferDragTearsOffAndMergesBack(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture() + return TaoWindowTestCase( + name = "native Wayland: a tab transfer drag tears a tab off and merges it back", + skip = ::waylandSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val workspace = fixture.workspace + val first = awaitTabWindowsOnWayland(fixture, "Alpha", "Beta") + val beta = fixture.tabId("Beta") + requireNotNull(fixture.counters.value[beta]).value = TAB_SAVED_CLICKS + check(workspace.beginDrag(beta, stripOrigin(first), Offset(PROBE_PX, PROBE_PX)) == null) { + "the screen-space tab drag must be refused" + } + + // ── no record: torn into a window of its own ── + val tearOff = + requireNotNull(workspace.beginTransferDrag(beta, first)) { "the transfer drag must start" } + check(workspace.draggedTab?.id == beta) { "a live transfer drag must publish its tab" } + check(tearOff.title == "Beta") { "the drag card must read the tab's title" } + tearOff.end() + val torn = awaitTornOff(fixture, first, "Beta") + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "Beta lost its saveable state when torn off" + } + + // ── a record: merged back, at the index it names ── + val tornWindow = requireNotNull(torn.window) + val merge = requireNotNull(workspace.beginTransferDrag(beta, tornWindow)) + val firstGroup = requireNotNull(fixture.groupOf("Alpha")) + merge.drop = TabDropTarget(firstGroup, 0) + merge.end() + awaitUntil("Beta merged back, first in the strip") { + workspace.groups.size == 1 && firstGroup.ids.firstOrNull() == beta + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "the finished tab drag left feedback behind" + } + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "Beta lost its saveable state when merged back" + } + + // ── close out: the last tab takes the window with it ── + var lastDestroyed = false + requireNotNull(firstGroup.window).onDestroyed { lastDestroyed = true } + workspace.close(beta) + awaitUntil("one tab left, still one window") { workspace.tabs.size == 1 && workspace.groups.size == 1 } + workspace.close(fixture.tabId("Alpha")) + awaitUntil("the last window was destroyed") { lastDestroyed && workspace.groups.isEmpty() } + awaitUntil("onLastWindowClosed fired") { fixture.lastWindowClosed.value } + }, + ) + } + + /** Any finite point: neither the refusal nor a zone probe may depend on where it is. */ + private const val PROBE_PX = 100f + + private const val TREE = "tree" + private const val TOC = "toc" + private const val NOTES = "notes" + + /** Well inside the outer half of a layer: the rank ahead of it. */ + private const val OUTER_HALF = 0.8f + + /** A point past the left strip and well short of the 310 dp of layers on the right, in a 520 dp layout. */ + private const val CONTENT_PROBE_DP = 100f + + /** Past Compose's touch slop, so the gesture is a drag and not a click. */ + private const val SLOP_PX = 24f + + /** Far enough along the strip for the carried tab's edge to cross its neighbour's centre. */ + private const val CARRY_FRACTION = 0.8f + + /** Below the strip: the window's body, where a released tab is out of the strip's hands. */ + private const val OUT_OF_STRIP_PX = 120f +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceStressHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceStressHeadfulCases.kt new file mode 100644 index 000000000..59efd561a --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceStressHeadfulCases.kt @@ -0,0 +1,743 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.TabDropTarget +import dev.nucleusframework.window.tao.TaoApplication +import dev.nucleusframework.window.tao.TransferDrop + +/** + * The native-Wayland transfer drag under abuse: everything that happens + * between a clean grab and a clean drop when the gesture is a platform + * drag-and-drop session rather than a pointer the workspace can follow. + * + * Grouped by what is being stressed: + * + * - **session identity** — superseded sessions, cancel, a double release, a + * record written after the release, a stale session acting late; + * - **lifecycle** — the owner window closing mid-session, the dock host + * closing, the satellite closed or the whole workspace hidden while a + * session is live, a maximize in the middle of one; + * - **concurrency** — two satellites of one workspace with sessions in + * flight at once, and a satellite session interleaved with a tab session; + * - **bursts** — dozens of begin/release pairs with no frame in between, + * which is what an abrupt gesture and a synthetic replay both look like + * from this side; + * - **churn** — repeated dock / undock and tear-off / merge, each of which + * creates and destroys a real OS window, checked against the live window + * count so a leak cannot hide; + * - **edge cases** — a panel with no published bounds, a drop naming a + * foreign host, an index past the end of a strip, a minimized host. + * + * Runs only on native Wayland; the pointer-driven counterparts of these are + * [SatelliteWorkspaceStressHeadfulCases] and [TabWorkspaceStressHeadfulCases]. + */ +@Suppress("LargeClass") // one method per real-window case, by design +internal object WaylandWorkspaceStressHeadfulCases { + fun all(): List = + listOf( + supersededSessionIsInert(), + cancelledSessionNeverActs(), + doubleReleaseActsOnce(), + recordWrittenAfterReleaseIsIgnored(), + ownerClosingMidSessionStaysSane(), + dockHostClosingMidSessionRehosts(), + satelliteClosedMidSessionIsNotResurrected(), + workspaceHiddenMidSessionStaysSane(), + maximizeMidSessionStillDocks(), + twoSatellitesInFlightAtOnce(), + burstOfSessionsLeavesOneOutcome(), + dockChurnLeaksNoWindows(), + foreignHostRecordDocksThere(), + panelWithoutBoundsStillCarriesACard(), + minimizedHostTakesNoDrop(), + tabSupersededAndCancelledSessions(), + tabSourceWindowClosingMidSessionStaysSane(), + tabClosedMidSessionIsNotResurrected(), + tabOnlyTabWithoutRecordStaysPut(), + tabIndexPastTheStripIsClamped(), + tabTearOffChurnLeaksNoWindows(), + satelliteAndTabSessionsInterleaved(), + ) + + // ── Session identity ───────────────────────────────────────────────── + + private fun supersededSessionIsInert(): TaoWindowTestCase = + satelliteCase("native Wayland: a superseded transfer session is inert and the last one wins") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val first = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + val second = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + check(workspace.transferDrag === second) { "the workspace must publish the newest session" } + + // The first one still holds a record; releasing it must do nothing. + first.drop = TransferDrop.Dock(DockTarget(window, DockSide.Left)) + first.end() + settle() + check(!requireNotNull(workspace.satellite(SATELLITE_ID)).isDocked) { + "the superseded session docked the satellite" + } + check(workspace.transferDrag === second) { "the superseded session stole the live one" } + + second.drop = TransferDrop.Dock(DockTarget(window, DockSide.Right)) + second.end() + awaitUntil("docked right by the surviving session") { workspace.dockedSide() == DockSide.Right } + check(workspace.publishesNoDragFeedback()) { "the finished session left feedback behind" } + } + + private fun cancelledSessionNeverActs(): TaoWindowTestCase = + satelliteCase("native Wayland: a cancelled transfer session never acts, even with a record") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + session.drop = TransferDrop.Dock(DockTarget(window, DockSide.Right)) + session.cancel() + check(workspace.publishesNoDragFeedback()) { "a cancelled session left feedback behind" } + session.end() + settle() + check(!requireNotNull(workspace.satellite(SATELLITE_ID)).isDocked) { + "a cancelled session acted on its record after the fact" + } + // Cancelling twice, and after the end: all no-ops. + session.cancel() + session.cancel() + check(workspace.publishesNoDragFeedback()) { "repeated cancels published something" } + } + + private fun doubleReleaseActsOnce(): TaoWindowTestCase = + satelliteCase("native Wayland: releasing a transfer session twice docks it once") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + session.drop = TransferDrop.Dock(DockTarget(window, DockSide.Bottom)) + session.end() + awaitUntil("docked bottom") { workspace.dockedSide() == DockSide.Bottom } + awaitPanelIn(fixture, window) + val hosts = fixture.composedHosts.value + + // A second release, and a third with a different record: both inert. + session.end() + session.drop = TransferDrop.Dock(DockTarget(window, DockSide.Left)) + session.end() + settle() + check(workspace.dockedSide() == DockSide.Bottom) { "a repeated release moved the panel" } + check(fixture.composedHosts.value == hosts) { "a repeated release duplicated the host" } + } + + private fun recordWrittenAfterReleaseIsIgnored(): TaoWindowTestCase = + satelliteCase("native Wayland: a record written after the release is ignored") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + session.end() + settle() + check(!requireNotNull(workspace.satellite(SATELLITE_ID)).isDocked) { "a dropless release docked it" } + session.drop = TransferDrop.Dock(DockTarget(window, DockSide.Right)) + settle() + check(!requireNotNull(workspace.satellite(SATELLITE_ID)).isDocked) { + "a late record docked the satellite without a release" + } + } + + // ── Lifecycle ──────────────────────────────────────────────────────── + + private fun ownerClosingMidSessionStaysSane(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val dialogVisible = mutableStateOf(true) + return TaoWindowTestCase( + name = "native Wayland: the owner closing mid-session leaves the workspace consistent", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { secondMemberBody(fixture) }, + dialogVisible = dialogVisible, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val workspace = fixture.workspace + awaitFloatingOnWayland(fixture) + val dialog = requireNotNull(dialogWindow) + awaitUntil("both members joined") { workspace.members.size == 2 } + // Pinned rather than focused: keyboard focus is the + // compositor's to give on Wayland, and a client asking for it + // is within its rights to be refused — so a case that needs a + // particular owner names it instead of racing activation. + workspace.pinTo(dialog) + awaitUntil("the dialog is the owner") { workspace.owner === dialog } + val owned = awaitFloatingOnWayland(fixture) + + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(owned))) + var dialogDestroyed = false + dialog.onDestroyed { dialogDestroyed = true } + dialogVisible.value = false + awaitUntil("the owner was destroyed mid-session") { dialogDestroyed } + // The record names the window that is gone: acting on it must + // not resurrect it, and must not take the satellite with it. + session.drop = TransferDrop.Dock(DockTarget(dialog, DockSide.Right)) + session.end() + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.publishesNoDragFeedback()) { "a session across a closing owner left feedback" } + check(workspace.owner === window) { "the owner did not fall back to the surviving member" } + awaitUntil("the satellite is still hosted somewhere") { fixture.isComposed } + }, + ) + } + + private fun dockHostClosingMidSessionRehosts(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val dialogVisible = mutableStateOf(true) + return TaoWindowTestCase( + name = "native Wayland: a panel whose host closes mid-session moves to the surviving member", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { secondMemberBody(fixture) }, + dialogVisible = dialogVisible, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val dialog = requireNotNull(dialogWindow) + awaitUntil("both members joined") { workspace.members.size == 2 } + requireNotNull(fixture.counter.value).value = SAVED_CLICKS + + workspace.transferDrop(floatingOrigin(floating), DockTarget(dialog, DockSide.Bottom)) + awaitPanelIn(fixture, dialog) + + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, panelOrigin(dialog))) + var dialogDestroyed = false + dialog.onDestroyed { dialogDestroyed = true } + dialogVisible.value = false + awaitUntil("the host was destroyed mid-session") { dialogDestroyed } + session.end() + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.publishesNoDragFeedback()) { "a session across a closing host left feedback" } + awaitUntil("the satellite is hosted by the surviving member") { fixture.isComposed } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "the satellite lost its state when its host closed mid-session" + } + }, + ) + } + + private fun satelliteClosedMidSessionIsNotResurrected(): TaoWindowTestCase = + satelliteCase("native Wayland: a satellite closed mid-session is not resurrected by the release") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + workspace.close(SATELLITE_ID) + awaitUntil("the closed satellite left composition") { !fixture.isComposed } + session.drop = TransferDrop.Dock(DockTarget(window, DockSide.Right)) + session.end() + settle(SETTLE_AFTER_MAP_MILLIS) + check(!fixture.isComposed) { "the release brought a closed satellite back on screen" } + check(requireNotNull(workspace.satellite(SATELLITE_ID)).isOpen.not()) { "the release reopened it" } + // Reopening honours the placement the release recorded. + workspace.open(SATELLITE_ID) + awaitUntil("reopened as the docked panel the drop asked for") { + fixture.panelHost.value === window && workspace.dockedSide() == DockSide.Right + } + } + + private fun workspaceHiddenMidSessionStaysSane(): TaoWindowTestCase = + satelliteCase("native Wayland: a session across a workspace visibility toggle leaves no feedback") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + workspace.visible = false + awaitUntil("everything left composition") { !fixture.isComposed } + session.drop = TransferDrop.Dock(DockTarget(window, DockSide.Left)) + session.end() + workspace.visible = true + awaitUntil("composed again") { fixture.isComposed } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.publishesNoDragFeedback()) { "a session across a visibility toggle left feedback" } + check(workspace.dockedSide() == DockSide.Left) { "the recorded dock was lost across the toggle" } + } + + private fun maximizeMidSessionStillDocks(): TaoWindowTestCase = + satelliteCase("native Wayland: a maximize mid-session still docks on release") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + window.setMaximized(true) + awaitUntil("the satellite hid itself under the maximized owner") { entry.windowState.isHiddenByParent } + session.drop = TransferDrop.Dock(DockTarget(window, DockSide.Top)) + session.end() + awaitUntil("docked into the maximized owner") { workspace.dockedSide() == DockSide.Top } + awaitPanelIn(fixture, window) + window.setMaximized(false) + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.dockedSide() == DockSide.Top) { "the restore undid the dock" } + check(fixture.isComposed) { "the panel left composition across the restore" } + } + + // ── Concurrency ────────────────────────────────────────────────────── + + private fun twoSatellitesInFlightAtOnce(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val second = SecondSatellite() + return TaoWindowTestCase( + name = "native Wayland: two satellites of one workspace, sessions in flight at once", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { + with(fixture) { ToolsSatellite() } + with(second) { Declare(fixture.workspace) } + }, + driver = { + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + awaitUntil("the second satellite was declared") { workspace.satellite(SECOND_SATELLITE_ID) != null } + + // One workspace publishes one drag: the second begin supersedes + // the first even though it is a different satellite. + val firstSession = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + val secondSession = + requireNotNull(workspace.beginTransferDrag(SECOND_SATELLITE_ID, floatingOrigin(floating))) + check(workspace.draggedSatellite?.id == SECOND_SATELLITE_ID) { + "the workspace must publish the newest satellite: ${workspace.draggedSatellite?.id}" + } + firstSession.drop = TransferDrop.Dock(DockTarget(window, DockSide.Left)) + firstSession.end() + settle() + check(!requireNotNull(workspace.satellite(SATELLITE_ID)).isDocked) { + "the superseded satellite's session still docked it" + } + + secondSession.drop = TransferDrop.Dock(DockTarget(window, DockSide.Right)) + secondSession.end() + awaitUntil("the second satellite docked right") { + requireNotNull(workspace.satellite(SECOND_SATELLITE_ID)).isDocked + } + settle(SETTLE_AFTER_MAP_MILLIS) + + // Then the first one, cleanly, into the other side: two panels. + workspace.transferDrop( + floatingOrigin(requireNotNull(fixture.floatingWindow.value)), + DockTarget(window, DockSide.Left), + ) + awaitPanelIn(fixture, window) + check(workspace.dockedSide() == DockSide.Left) { "the first satellite is not docked left" } + check(workspace.satellites.count { it.isDocked } == 2) { "both satellites should be docked now" } + check(workspace.publishesNoDragFeedback()) { "two finished sessions left feedback behind" } + }, + ) + } + + private fun satelliteAndTabSessionsInterleaved(): TaoWindowTestCase { + val satellites = SatelliteWorkspaceFixture() + val tabs = TabWorkspaceFixture() + return TaoWindowTestCase( + name = "native Wayland: a satellite session and a tab session interleave without interfering", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { satellites.Body() }, + applicationContent = { + with(satellites) { ToolsSatellite() } + with(tabs) { Windows() } + }, + driver = { + val floating = awaitFloatingOnWayland(satellites) + val tabWindow = awaitTabWindowsOnWayland(tabs, "Alpha", "Beta") + val beta = tabs.tabId("Beta") + + // Both live at once: two workspaces, two independent sessions. + val satelliteSession = + requireNotNull(satellites.workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + val tabSession = requireNotNull(tabs.workspace.beginTransferDrag(beta, tabWindow)) + check(satellites.workspace.draggedSatellite != null) { "the satellite workspace dropped its drag" } + check(tabs.workspace.draggedTab?.id == beta) { "the tab workspace dropped its drag" } + + // Released in the opposite order to the one they started in. + tabSession.end() + awaitTornOff(tabs, tabWindow, "Beta") + check(satellites.workspace.draggedSatellite != null) { "the tab release cleared the satellite drag" } + + satelliteSession.drop = TransferDrop.Dock(DockTarget(window, DockSide.Right)) + satelliteSession.end() + awaitUntil("the satellite docked right") { satellites.workspace.dockedSide() == DockSide.Right } + check(satellites.workspace.publishesNoDragFeedback()) { "the satellite workspace kept feedback" } + check(tabs.workspace.draggedTab == null && tabs.workspace.dragGhost == null) { + "the tab workspace kept feedback" + } + check(tabs.workspace.groups.size == 2) { "the torn-off tab window went away" } + }, + ) + } + + // ── Bursts and churn ───────────────────────────────────────────────── + + private fun burstOfSessionsLeavesOneOutcome(): TaoWindowTestCase = + satelliteCase("native Wayland: a burst of sessions with no frame in between leaves one outcome") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val sides = DockSide.entries + + // No settle anywhere in here: every begin, record and release lands + // in the same frame, which is what an abrupt gesture looks like + // from this side of the session. + repeat(BURST_SESSIONS) { i -> + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + session.drop = TransferDrop.Dock(DockTarget(window, sides[i % sides.size])) + if (i % 3 == 0) session.cancel() else session.end() + } + val last = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(floating))) + last.drop = TransferDrop.Dock(DockTarget(window, DockSide.Right)) + last.end() + awaitUntil("the last release of the burst is the one that stuck") { + workspace.dockedSide() == DockSide.Right + } + awaitPanelIn(fixture, window) + check(workspace.publishesNoDragFeedback()) { "the burst left feedback behind" } + check(fixture.composedHosts.value == 1) { "the burst left more than one host composing" } + } + + private fun dockChurnLeaksNoWindows(): TaoWindowTestCase = + satelliteCase("native Wayland: dock and undock churn leaks no windows and keeps the state") { fixture -> + val workspace = fixture.workspace + awaitFloatingOnWayland(fixture) + requireNotNull(fixture.counter.value).value = SAVED_CLICKS + settle() + val baseline = TaoApplication.liveWindowCount() + + repeat(CHURN_CYCLES) { cycle -> + val floating = requireNotNull(fixture.floatingWindow.value) { "no floating window in cycle $cycle" } + workspace.transferDrop( + floatingOrigin(floating), + DockTarget(window, DockSide.entries[cycle % DockSide.entries.size]), + ) + awaitPanelIn(fixture, window) + workspace.transferDrop(panelOrigin(window), target = null) + awaitUntil("floating again in cycle $cycle") { + val now = fixture.floatingWindow.value + now != null && (now.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + val now = TaoApplication.liveWindowCount() + check(now <= baseline) { "$CHURN_CYCLES churn cycles leaked windows: $baseline → $now" } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "the churn lost the saveable state: ${fixture.counter.value?.value}" + } + check(workspace.publishesNoDragFeedback()) { "the churn left feedback behind" } + } + + // ── Edge cases ─────────────────────────────────────────────────────── + + private fun foreignHostRecordDocksThere(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "native Wayland: a record naming another member docks the satellite into that window", + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { secondMemberBody(fixture) }, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val dialog = requireNotNull(dialogWindow) + awaitUntil("both members joined") { workspace.members.size == 2 } + check(workspace.owner === window) { "the case window should own the satellite to start with" } + + // The owner is one window, the drop names the other: the record + // decides, since it is the window the pointer was actually over. + workspace.transferDrop(floatingOrigin(floating), DockTarget(dialog, DockSide.Left)) + awaitUntil("the entry records the foreign host") { + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + entry.isDocked && entry.dockHost === dialog + } + awaitPanelIn(fixture, dialog) + check(workspace.zoneProbe(dialog, DockSide.Left) == DockSide.Left) { + "the foreign host published no usable layout" + } + + // And back into the first window, from the foreign panel — a + // dock-to-dock host change, with no floating window in between. + workspace.transferDrop(panelOrigin(dialog), DockTarget(window, DockSide.Right)) + awaitUntil("the entry records the case window as its host") { + requireNotNull(workspace.satellite(SATELLITE_ID)).dockHost === window + } + awaitPanelIn(fixture, window) + check(workspace.dockedSide() == DockSide.Right) { "the panel did not move to the other window" } + check(fixture.composedHosts.value == 1) { "the host change left two panels composing" } + }, + ) + } + + private fun panelWithoutBoundsStillCarriesACard(): TaoWindowTestCase = + satelliteCase("native Wayland: a panel with no published bounds still carries a sized card") { fixture -> + val workspace = fixture.workspace + val floating = awaitFloatingOnWayland(fixture) + val entry = requireNotNull(workspace.satellite(SATELLITE_ID)) + + // Docked through the API, and the session started in the same frame: + // the panel has not been laid out yet, so its bounds are unknown. + workspace.dock(SATELLITE_ID, DockSide.Right) + entry.dockedBoundsInWindowPx = null + val session = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, panelOrigin(window))) + check(session.ghostSizePx.width > 0f && session.ghostSizePx.height > 0f) { + "the card fell back to an empty size: ${session.ghostSizePx}" + } + session.cancel() + + // The same for a floating window that is not mapped yet. + workspace.transferDrop(panelOrigin(window), target = null) + awaitUntil("floating again") { fixture.floatingWindow.value != null } + val fresh = requireNotNull(fixture.floatingWindow.value) + + @Suppress("UNUSED_VARIABLE") + val floatingSession = requireNotNull(workspace.beginTransferDrag(SATELLITE_ID, floatingOrigin(fresh))) + check(floatingSession.ghostSizePx.width > 0f) { "the card has no width: ${floatingSession.ghostSizePx}" } + check(floatingSession.ghostSizePx.height > 0f) { "the card has no height: ${floatingSession.ghostSizePx}" } + floatingSession.cancel() + check(workspace.publishesNoDragFeedback()) { "the cancelled sessions left feedback behind" } + } + + private fun minimizedHostTakesNoDrop(): TaoWindowTestCase = + satelliteCase("native Wayland: a minimized host publishes no layout to drop onto") { fixture -> + val workspace = fixture.workspace + awaitFloatingOnWayland(fixture) + awaitUntil("the dock layout published its bounds") { + workspace.dockHostGeometry(window)?.layoutBoundsInWindowPx?.isEmpty == false + } + val geometry = requireNotNull(workspace.dockHostGeometry(window)) + check(!geometry.minimized()) { "the host should start un-minimized" } + + window.setMinimized(true) + awaitUntil("the host reports itself minimized") { geometry.minimized() } + // A minimized window is off screen: the compositor sends it no drag + // events at all, which is what makes it an impossible target. + check(workspace.dockTargetAt(Offset.Zero) == null) { "a minimized host was offered as a screen target" } + window.setMinimized(false) + awaitUntil("the host is back") { !geometry.minimized() } + check(workspace.zoneProbe(window, DockSide.Right) == DockSide.Right) { + "the restored host publishes no usable layout" + } + } + + // ── Tabs ───────────────────────────────────────────────────────────── + + private fun tabSupersededAndCancelledSessions(): TaoWindowTestCase = + tabCase("native Wayland: superseded and cancelled tab sessions never act") { fixture -> + val workspace = fixture.workspace + val first = awaitTabWindowsOnWayland(fixture, "Alpha", "Beta") + val beta = fixture.tabId("Beta") + val alpha = fixture.tabId("Alpha") + val group = requireNotNull(fixture.groupOf("Beta")) + + val superseded = requireNotNull(workspace.beginTransferDrag(beta, first)) + val live = requireNotNull(workspace.beginTransferDrag(alpha, first)) + check(workspace.draggedTab?.id == alpha) { "the workspace must publish the newest tab" } + superseded.drop = TabDropTarget(group, 0) + superseded.end() + settle() + check(workspace.groups.size == 1) { "the superseded session tore a window off" } + + live.cancel() + live.end() + settle() + check(workspace.groups.size == 1) { "the cancelled session tore a window off" } + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "the cancelled session left feedback behind" + } + check(group.ids == listOf(alpha, beta)) { "the strip order changed: ${group.ids}" } + } + + private fun tabSourceWindowClosingMidSessionStaysSane(): TaoWindowTestCase = + tabCase("native Wayland: a tab session whose source window closes mid-flight stays sane") { fixture -> + val workspace = fixture.workspace + val first = awaitTabWindowsOnWayland(fixture, "Alpha", "Beta") + val beta = fixture.tabId("Beta") + + // Tear Beta off, then start a session from its own window and close + // that window under it. + requireNotNull(workspace.beginTransferDrag(beta, first)).end() + val torn = awaitTornOff(fixture, first, "Beta") + val tornWindow = requireNotNull(torn.window) + val session = requireNotNull(workspace.beginTransferDrag(beta, tornWindow)) + var destroyed = false + tornWindow.onDestroyed { destroyed = true } + workspace.close(beta) + awaitUntil("the source window went with its last tab") { destroyed && workspace.groups.size == 1 } + session.drop = TabDropTarget(requireNotNull(fixture.groupOf("Alpha")), 0) + session.end() + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.tab(beta) == null || workspace.tab(beta)?.group == null) { + "the release resurrected a closed tab: ${workspace.tab(beta)?.group}" + } + check(workspace.groups.size == 1) { "the release opened a window for a closed tab" } + check(workspace.draggedTab == null && workspace.dragGhost == null) { "feedback survived the close" } + } + + private fun tabClosedMidSessionIsNotResurrected(): TaoWindowTestCase = + tabCase("native Wayland: a tab closed mid-session is not resurrected by the release") { fixture -> + val workspace = fixture.workspace + val first = awaitTabWindowsOnWayland(fixture, "Alpha", "Beta") + val beta = fixture.tabId("Beta") + val group = requireNotNull(fixture.groupOf("Beta")) + val session = requireNotNull(workspace.beginTransferDrag(beta, first)) + workspace.close(beta) + awaitUntil("one tab left") { workspace.tabs.size == 1 } + session.end() + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.size == 1) { "the release tore a window off for a closed tab" } + check(group.ids == listOf(fixture.tabId("Alpha"))) { "the closed tab came back: ${group.ids}" } + } + + private fun tabOnlyTabWithoutRecordStaysPut(): TaoWindowTestCase = + tabCase( + name = "native Wayland: the only tab of a window, released with no record, stays put", + titles = listOf("Solo"), + ) { fixture -> + val workspace = fixture.workspace + val window = awaitTabWindowsOnWayland(fixture, "Solo") + val solo = fixture.tabId("Solo") + val sizeBefore = requireNotNull(window.outerBoundsPx()).toList() + + repeat(SOLO_RELEASES) { + requireNotNull(workspace.beginTransferDrag(solo, window)).end() + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.size == 1) { "a dropless release of the only tab opened a window" } + check(requireNotNull(fixture.groupOf("Solo")).window === window) { "the window was recreated" } + val sizeAfter = requireNotNull(window.outerBoundsPx()).toList() + check(sizeAfter[RECT_W] == sizeBefore[RECT_W] && sizeAfter[RECT_H] == sizeBefore[RECT_H]) { + "the window was resized by a dropless release: $sizeBefore → $sizeAfter" + } + check(workspace.draggedTab == null && workspace.dragGhost == null) { "feedback survived the releases" } + } + + private fun tabIndexPastTheStripIsClamped(): TaoWindowTestCase = + tabCase( + name = "native Wayland: a drop index past the end of a strip is clamped", + titles = listOf("Alpha", "Beta", "Gamma"), + ) { fixture -> + val workspace = fixture.workspace + val first = awaitTabWindowsOnWayland(fixture, "Alpha", "Beta", "Gamma") + val alpha = fixture.tabId("Alpha") + val group = requireNotNull(fixture.groupOf("Alpha")) + + val session = requireNotNull(workspace.beginTransferDrag(alpha, first)) + session.drop = TabDropTarget(group, index = ABSURD_INDEX) + session.end() + awaitUntil("Alpha moved to the end rather than out of range") { group.ids.lastOrNull() == alpha } + check(group.ids.size == 3) { "a clamped drop lost a tab: ${group.ids}" } + + // And a negative one, the other way. + val back = requireNotNull(workspace.beginTransferDrag(alpha, first)) + back.drop = TabDropTarget(group, index = -ABSURD_INDEX) + back.end() + awaitUntil("Alpha moved to the front") { group.ids.firstOrNull() == alpha } + check(group.ids.size == 3) { "a clamped drop lost a tab: ${group.ids}" } + } + + private fun tabTearOffChurnLeaksNoWindows(): TaoWindowTestCase = + tabCase("native Wayland: tear-off and merge churn leaks no windows and keeps the state") { fixture -> + val workspace = fixture.workspace + val first = awaitTabWindowsOnWayland(fixture, "Alpha", "Beta") + val beta = fixture.tabId("Beta") + requireNotNull(fixture.counters.value[beta]).value = TAB_SAVED_CLICKS + settle() + val baseline = TaoApplication.liveWindowCount() + + repeat(CHURN_CYCLES) { cycle -> + val source = requireNotNull(fixture.groupOf("Beta")?.window) { "no source window in cycle $cycle" } + requireNotNull(workspace.beginTransferDrag(beta, source)).end() + val torn = awaitTornOff(fixture, first, "Beta") + val merge = requireNotNull(workspace.beginTransferDrag(beta, requireNotNull(torn.window))) + merge.drop = TabDropTarget(requireNotNull(fixture.groupOf("Alpha")), 1) + merge.end() + awaitUntil("merged back in cycle $cycle") { workspace.groups.size == 1 } + settle(JUMP_SETTLE_MILLIS) + } + settle(SETTLE_AFTER_MAP_MILLIS) + val now = TaoApplication.liveWindowCount() + check(now <= baseline) { "$CHURN_CYCLES tear-off cycles leaked windows: $baseline → $now" } + check(requireNotNull(fixture.counters.value[beta]).value == TAB_SAVED_CLICKS) { + "the churn lost Beta's saveable state: ${fixture.counters.value[beta]?.value}" + } + } + + // ── Case scaffolding ───────────────────────────────────────────────── + + /** A one-window satellite case: the fixture's dock layout plus its satellite. */ + private fun satelliteCase( + name: String, + driver: suspend TaoWindowTestScope.(SatelliteWorkspaceFixture) -> Unit, + ): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = name, + skip = ::waylandSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { driver(fixture) }, + ) + } + + /** A tab-workspace case: the workspace's own windows, next to an idle case window. */ + private fun tabCase( + name: String, + titles: List = listOf("Alpha", "Beta"), + driver: suspend TaoWindowTestScope.(TabWorkspaceFixture) -> Unit, + ): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = name, + skip = ::waylandSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { driver(fixture) }, + ) + } + + /** A second workspace member: joins, and hosts a dock layout of its own. */ + @androidx.compose.runtime.Composable + private fun secondMemberBody(fixture: SatelliteWorkspaceFixture) { + JoinSatelliteWorkspace(fixture.workspace) + DockLayout(fixture.workspace, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color(0xFF3C8D5A))) + } + } + + /** Enough sessions in one frame to expose a stale one, few enough to stay quick. */ + private const val BURST_SESSIONS = 24 + + /** Releases of the only tab of a window: each one must be a no-op. */ + private const val SOLO_RELEASES = 8 + + /** Far past any strip's length, and its negative twin. */ + private const val ABSURD_INDEX = 99 +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceSupport.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceSupport.kt new file mode 100644 index 000000000..e42ab655c --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WaylandWorkspaceSupport.kt @@ -0,0 +1,217 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.geometry.Offset +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.DockTarget +import dev.nucleusframework.window.tao.Satellite +import dev.nucleusframework.window.tao.SatelliteDragOrigin +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteTransferDrag +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TabWindowGroup +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.TransferDrop +import dev.nucleusframework.window.tao.dockSideAt + +// Shared scaffolding for the native-Wayland workspace cases. +// +// The pointer path is not driveable there — the compositor owns the pointer for +// the whole drag-and-drop session, and no test harness on this platform can +// inject into it (see the suite's notes on input injection) — so the cases +// drive the *session* the gesture starts and assert what each end of it does. +// Everything else is a real window: two toplevels, a real dock layout, real +// creation and destruction on every dock. + +/** Runs only where [workspaceSkipReason] skips: native Wayland, no forced X11. */ +internal fun waylandSkipReason(): String? = + if (workspaceSkipReason() == null) "requires native Wayland (WAYLAND_DISPLAY, no forced X11)" else null + +/** + * Waits until the workspace's floating satellite window is mapped with a real + * size, and returns it. + * + * The Wayland counterpart of [awaitFloating], which additionally waits for the + * owner offset — a value that stays `null` here on purpose, since no client + * can know where its windows are. + */ +internal suspend fun TaoWindowTestScope.awaitFloatingOnWayland(fixture: SatelliteWorkspaceFixture): TaoWindow { + awaitUntil("owner window mapped") { bounds() != null } + awaitUntil("floating satellite mapped with a real size") { + val rect = fixture.floatingWindow.value?.outerBoundsPx() ?: return@awaitUntil false + rect[RECT_W] > 0 && rect[RECT_H] > 0 + } + settle(SETTLE_AFTER_MAP_MILLIS) + val floating = requireNotNull(fixture.floatingWindow.value) + check(floating.isNativeWaylandSurface) { "case premise: the satellite must be a native Wayland surface" } + return floating +} + +/** Waits until the satellite is composed as a panel in [host]. */ +internal suspend fun TaoWindowTestScope.awaitPanelIn( + fixture: SatelliteWorkspaceFixture, + host: TaoWindow, +) { + awaitUntil("panel composed in the expected host") { + fixture.panelHost.value === host && fixture.panelBoundsPx.value != null + } + settle() +} + +/** + * Starts a transfer drag of the workspace's satellite from [origin] and + * releases it on [target] — the whole gesture as the two ends of the session + * see it, with no pointer in between. + */ +internal fun SatelliteWorkspace.transferDrop( + origin: SatelliteDragOrigin, + target: DockTarget?, +): SatelliteTransferDrag { + val session = requireNotNull(beginTransferDrag(SATELLITE_ID, origin)) { "the transfer drag must start" } + session.drop = target?.let { TransferDrop.Dock(it) } + session.end() + return session +} + +/** The floating window's own drag origin. */ +internal fun floatingOrigin(window: TaoWindow) = SatelliteDragOrigin.FloatingWindow(window) + +/** A docked panel's drag origin in [host]. */ +internal fun panelOrigin(host: TaoWindow) = SatelliteDragOrigin.DockedPanel(host) + +/** + * The dock zone [side] of [host]'s layout resolved the way the layout itself + * does it — from a point in *window* coordinates, the only space an inbound + * drag event speaks. `null` when the host published no layout yet. + */ +internal fun SatelliteWorkspace.zoneProbe( + host: TaoWindow, + side: DockSide, +): DockSide? { + val geometry = dockHostGeometry(host) ?: return null + val layout = geometry.layoutBoundsInWindowPx + val inset = 1f + val point = + when (side) { + DockSide.Left -> Offset(layout.left + inset, layout.center.y) + DockSide.Right -> Offset(layout.right - inset, layout.center.y) + DockSide.Top -> Offset(layout.center.x, layout.top + inset) + DockSide.Bottom -> Offset(layout.center.x, layout.bottom - inset) + } + return dockSideAt(layout, point, SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne()) +} + +/** `true` while the workspace publishes no drag feedback of any kind. */ +internal fun SatelliteWorkspace.publishesNoDragFeedback(): Boolean = + draggedSatellite == null && dockPreview == null && dragGhost == null && transferDrag == null + +/** The side the satellite is docked on, or `null` while it floats. */ +internal fun SatelliteWorkspace.dockedSide(): DockSide? = + (satellite(SATELLITE_ID)?.placement as? SatellitePlacement.Docked)?.side + +/** + * A second satellite of [fixture]'s workspace, so a case can put two sessions + * in flight over one workspace. Publishes its host and its saveable counter + * the same way the fixture's own satellite does. + */ +internal class SecondSatellite { + val counter = mutableStateOf?>(null) + val isDocked = mutableStateOf(false) + + @Composable + fun ApplicationScope.Declare(workspace: SatelliteWorkspace) { + Satellite( + workspace = workspace, + id = SECOND_SATELLITE_ID, + title = "Palette", + initialPlacement = + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ) { + val clicks = rememberSaveable { mutableStateOf(0) } + val hosted = isDocked + SideEffect { + counter.value = clicks + this@SecondSatellite.isDocked.value = hosted + } + } + } +} + +internal const val SECOND_SATELLITE_ID = "palette" + +/** Index of the width / height components of an `outerBoundsPx()` rect. */ +internal const val RECT_W = 2 +internal const val RECT_H = 3 + +/** [zoneProbe] at an explicit point rather than at a side's own strip. */ +internal fun SatelliteWorkspace.zoneProbeAt( + host: TaoWindow, + pointInWindowPx: Offset, +): DockSide? { + val geometry = dockHostGeometry(host) ?: return null + val zonePx = SatelliteWorkspace.DockZoneWidth.value * geometry.scaleOrOne() + return dockSideAt(geometry.layoutBoundsInWindowPx, pointInWindowPx, zonePx) +} + +/** The Wayland counterpart of [awaitTabWindows]: no strip screen rect to wait for. */ +internal suspend fun TaoWindowTestScope.awaitTabWindowsOnWayland( + fixture: TabWorkspaceFixture, + vararg titles: String, +): TaoWindow { + awaitUntil("case window mapped") { bounds() != null } + awaitUntil("every tab declared") { titles.all { fixture.workspace.tab(fixture.tabId(it)) != null } } + awaitUntil("a tab window is mapped with a real size") { + val tabWindow = + fixture.workspace.groups + .firstOrNull() + ?.window ?: return@awaitUntil false + val rect = tabWindow.outerBoundsPx() ?: return@awaitUntil false + rect[RECT_W] > 0 && rect[RECT_H] > 0 + } + awaitUntil("the selected tab's body is composed") { fixture.composedBodies.value > 0 } + awaitUntil("the strip published its slots") { + val group = fixture.workspace.groups.firstOrNull() ?: return@awaitUntil false + group.slotsInWindowPx.size >= group.ids.size + } + settle(SETTLE_AFTER_MAP_MILLIS) + val window = + requireNotNull( + fixture.workspace.groups + .first() + .window, + ) + check(window.isNativeWaylandSurface) { "case premise: the tab window must be a native Wayland surface" } + return window +} + +/** Waits until [title] holds a window of its own, distinct from [from], and returns its group. */ +internal suspend fun TaoWindowTestScope.awaitTornOff( + fixture: TabWorkspaceFixture, + from: TaoWindow, + title: String, +): TabWindowGroup { + val id = fixture.tabId(title) + awaitUntil("a second window holds $title on its own") { + fixture.workspace.groups.size >= 2 && fixture.groupOf(title)?.ids == listOf(id) + } + val torn = requireNotNull(fixture.groupOf(title)) + awaitUntil("the torn-off window is mapped and composing $title") { + val tornWindow = torn.window ?: return@awaitUntil false + tornWindow !== from && + (tornWindow.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L && + fixture.windowOf(title) != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + return torn +} + +/** The card is sized off a live frame; one rounding step on each side. */ +internal const val GHOST_TOLERANCE_PX = 4f diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WindowApiV2HeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WindowApiV2HeadfulCases.kt new file mode 100644 index 000000000..4fd2201af --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WindowApiV2HeadfulCases.kt @@ -0,0 +1,506 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPlacement +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoMonitor +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.v2.WindowBoundsProvider +import dev.nucleusframework.window.tao.v2.WindowPositionProvider +import dev.nucleusframework.window.tao.v2.WindowScreenProvider +import dev.nucleusframework.window.tao.v2.WindowSizeProvider +import dev.nucleusframework.window.tao.v2.WindowState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.abs + +/** + * End-to-end coverage for the AWT-free window API v2 clone + * ([dev.nucleusframework.window.tao.v2]): every request shape that is inert on + * Compose's own v2 types — because its `WindowGeometryProviderScope` needs a + * displayable `java.awt.Window` — has to reach a real native window here, and + * the observed state has to come back from that window. + */ +internal object WindowApiV2HeadfulCases { + fun all(): List = + listOf( + initialBoundsCentreOnScreen(), + requestSizeAndPosition(), + scopedBoundsProviderReadsLiveMetrics(), + requestScreenMovesTheWindow(), + observedScreenIdTracksTheHostingMonitor(), + burstOfPositionRequestsLandsOnTheLast(), + interleavedRequestsInOneTickAllApply(), + boundsRequestWhileMaximizedGoesFloating(), + rapidPlacementTogglingThenBoundsConverges(), + requestsFromABackgroundThreadApply(), + animatedMoveTracksTheLastFrame(), + ) + + private fun initialBoundsCentreOnScreen(): TaoWindowTestCase { + val state = + WindowState( + initialBoundsProvider = + WindowBoundsProvider( + sizeProvider = WindowSizeProvider.Fixed(INITIAL_SIZE), + positionProvider = WindowPositionProvider.CenteredOnScreen, + ), + ) + return TaoWindowTestCase( + name = "window v2 clone: initial provider centres a fixed size on the screen", + nucleusWindowState = state, + ) { + awaitMapped() + // Poll rather than snapshot: a freshly mapped window sits at the + // platform's placeholder position (32767 on Windows) until the + // initial geometry effect applies, so a single read right after + // mapping races the very thing under test. + var polls = 0 + awaitUntil("initial provider sized the window") { + val outer = outerDp() + // Once a second, so a CI timeout leaves the trajectory in the log. + if (polls++ % DIAG_EVERY_POLLS == 0) { + System.err.println( + "[v2-e2e] sizing outer=$outer " + + "scale=${window.scaleFactor} initialized=${state.isInitialized}", + ) + } + closeEnough(INITIAL_SIZE.width.value, outer.width) && + closeEnough(INITIAL_SIZE.height.value, outer.height) + } + // The requested position is a *request*: an X11 window manager + // applies its own placement policy to a client's initial position + // (openbox on CI does), which is why the v1 path retries its + // Aligned centring. Assert the strict centre where the platform + // honours the request, and containment in the target work area + // everywhere — that is what the provider genuinely controls. + val available = hostMonitor().workAreaDp(window.scaleFactor) + val outer = outerDp() + System.err.println("[v2-e2e] outer=$outer available=$available scale=${window.scaleFactor}") + if (!isLinux) { + awaitUntil("initial provider centred the window on its screen") { + val rect = outerDp() + closeEnough(available.left.value + (available.width - rect.width) / 2f, rect.left) && + closeEnough(available.top.value + (available.height - rect.height) / 2f, rect.top) + } + } else { + check(outer.left >= available.left.value - TOLERANCE_DP) { + "window placed left of the work area: $outer vs $available" + } + check(outer.top >= available.top.value - TOLERANCE_DP) { + "window placed above the work area: $outer vs $available" + } + } + awaitUntil("the state observed the window being shown") { state.isInitialized } + // Observed bounds must be the window's own, not the requested ones. + // Polled, not snapshotted: the native geometry and its publication + // settle independently, so two separate reads can straddle a frame. + awaitUntil("observed bounds converge on the native outer rectangle") { + val outer = outerDp() + val bounds = state.bounds + closeEnough(outer.left, bounds.left.value) && closeEnough(outer.width, bounds.width) + } + } + } + + private fun requestSizeAndPosition(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: requestSize / requestPosition reach the native window", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + + state.requestSize(RESIZED) + awaitUntil("outer size follows requestSize(${RESIZED.width.value}x${RESIZED.height.value})") { + val outer = outerDp() + closeEnough(RESIZED.width.value, outer.width) && closeEnough(RESIZED.height.value, outer.height) + } + + val available = hostMonitor().workAreaDp(window.scaleFactor) + val target = DpOffset(available.left + MOVE_INSET, available.top + MOVE_INSET) + state.requestPosition(target) + awaitUntil("outer position follows requestPosition(${target.x.value}, ${target.y.value})") { + val outer = outerDp() + closeEnough(target.x.value, outer.left) && closeEnough(target.y.value, outer.top) + } + // Moving must not resize. + val outer = outerDp() + assertClose(RESIZED.width.value, outer.width, "width after the move") + + // And the state must have observed the result, not just requested it. + awaitUntil("state.bounds reflects the applied geometry") { + closeEnough(RESIZED.width.value, state.bounds.right.value - state.bounds.left.value) + } + } + } + + private fun scopedBoundsProviderReadsLiveMetrics(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: scoped bounds provider reads live window metrics", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + // The shape the Compose v2 path logs and drops: the lambda + // dereferences the geometry scope. + state.requestBounds { + // A real measure pass against the live scene: the suite's chrome + // is a `Box(fillMaxSize())`, which takes whatever finite maximum + // it is given (and, correctly, 0×0 under infinite constraints — + // the macOS run proved the hook was live by returning exactly + // that before this assertion was made deterministic). + val measured = measureWindowContent(maxWidth = MEASURE_BOX.width, maxHeight = MEASURE_BOX.height) + check( + closeEnough(MEASURE_BOX.width.value, measured.width.value) && + closeEnough(MEASURE_BOX.height.value, measured.height.value), + ) { "measureWindowContent(max=$MEASURE_BOX) returned $measured" } + val screen = windowMetrics.screen.availableBounds + DpRect( + left = screen.left + SCOPED_INSET, + top = screen.top + SCOPED_INSET, + right = screen.left + SCOPED_INSET + SCOPED_SIZE.width, + bottom = screen.top + SCOPED_INSET + SCOPED_SIZE.height, + ) + } + val available = hostMonitor().workAreaDp(window.scaleFactor) + awaitUntil("scoped provider applied") { + val outer = outerDp() + closeEnough(available.left.value + SCOPED_INSET.value, outer.left) && + closeEnough(SCOPED_SIZE.width.value, outer.width) + } + } + } + + private fun requestScreenMovesTheWindow(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: requestScreen lands the window on the target monitor", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + val monitors = TaoMonitors.all(window) + // Deterministic target: the last monitor in platform order. On a + // single-monitor box that is the current one, which still exercises + // the whole path (evaluate → clamp into the work area → apply). + val target = monitors.last() + state.requestScreen(WindowScreenProvider.ById(target.id)) + awaitUntil("window centre lands on '${target.id}'") { + val centre = outerCentrePx() + target.containsPx(centre.first, centre.second) + } + awaitUntil("state.screenId reports '${target.id}'") { state.screenId == target.id } + val outer = outerDp() + val available = target.workAreaDp(window.scaleFactor) + check(outer.left >= available.left.value - TOLERANCE_DP) { + "the window was not clamped into the target work area: $outer vs $available" + } + } + } + + private fun observedScreenIdTracksTheHostingMonitor(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: observed screenId matches the monitor hosting the window", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + awaitUntil("state.isInitialized") { state.isInitialized } + val hosting = hostMonitor() + check(state.screenId == hosting.id) { + "state.screenId='${state.screenId}' but the window sits on '${hosting.id}'" + } + val centre = outerCentrePx() + check(hosting.containsPx(centre.first, centre.second)) { + "TaoMonitors.forWindow returned '${hosting.id}', which does not contain the window centre $centre" + } + // The enumeration must agree with itself. + check(TaoMonitors.byId(hosting.id, window) != null) { + "the hosting monitor '${hosting.id}' is missing from the enumeration" + } + } + } + + // ── Edge cases: bursts, interleaving, placement, threads ────────────── + + private fun burstOfPositionRequestsLandsOnTheLast(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: a burst of position requests lands on the last one", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + val available = hostMonitor().workAreaDp(window.scaleFactor) + // No suspension between sends: everything queues before the bridge + // gets a turn, so it must drain to the newest request without + // applying stale ones after it. + var last = DpOffset.Zero + repeat(BURST_COUNT) { i -> + last = DpOffset(available.left + (STEP_DP * (i + 1)).dp, available.top + (STEP_DP * (i + 1)).dp) + state.requestPosition(last) + } + awaitUntil("outer position landed on the last of the burst") { + val outer = outerDp() + closeEnough(last.x.value, outer.left) && closeEnough(last.y.value, outer.top) + } + // ...and stays there: a stale request applied late would move it back. + settle() + assertClose(last.x.value, outerDp().left, "position after settling") + awaitUntil("observed bounds caught up") { closeEnough(last.x.value, state.bounds.left.value) } + } + } + + private fun interleavedRequestsInOneTickAllApply(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: size, position and screen requested in one tick all apply", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + val target = hostMonitor() + val available = target.workAreaDp(window.scaleFactor) + val position = DpOffset(available.left + MOVE_INSET, available.top + MOVE_INSET) + // Three different channels, no suspension in between: the bridge + // consumes them concurrently and each must land. + state.requestSize(RESIZED) + state.requestPosition(position) + state.requestScreen(WindowScreenProvider.ById(target.id)) + awaitUntil("size and position both applied") { + val outer = outerDp() + closeEnough(RESIZED.width.value, outer.width) && + closeEnough(RESIZED.height.value, outer.height) && + outer.left >= available.left.value - TOLERANCE_DP && + outer.top >= available.top.value - TOLERANCE_DP + } + awaitUntil("state.screenId reports the target") { state.screenId == target.id } + val centre = outerCentrePx() + check(target.containsPx(centre.first, centre.second)) { "window left its screen: $centre" } + } + } + + private fun boundsRequestWhileMaximizedGoesFloating(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: a bounds request on a maximized window restores it floating", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + val before = outerDp() + state.requestPlacement(WindowPlacement.Maximized) + awaitUntil("window maximized") { + outerDp().width > before.width && state.isInitialized && state.placement == WindowPlacement.Maximized + } + val available = hostMonitor().workAreaDp(window.scaleFactor) + val rect = + DpRect( + left = available.left + MOVE_INSET, + top = available.top + MOVE_INSET, + right = available.left + MOVE_INSET + SCOPED_SIZE.width, + bottom = available.top + MOVE_INSET + SCOPED_SIZE.height, + ) + // The v2 contract: bounds on a non-floating window make it floating. + state.requestBounds(rect) + awaitUntil("placement observed Floating") { state.placement == WindowPlacement.Floating } + awaitUntil("requested bounds applied after leaving Maximized") { + val outer = outerDp() + closeEnough(SCOPED_SIZE.width.value, outer.width) && closeEnough(SCOPED_SIZE.height.value, outer.height) + } + } + } + + private fun rapidPlacementTogglingThenBoundsConverges(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: rapid maximize/restore toggling then a bounds request converges", + // Six zoom animations plus the restore-and-confirm loop legitimately + // take a while on macOS; the default budget is sized for one. + timeoutMillis = LONG_CASE_MS, + nucleusWindowState = state, + ) { + awaitMapped() + settle() + // Faster than the OS zoom animation on macOS / the WM configure round + // trip on X11: requests pile up while the previous one is in flight. + repeat(TOGGLE_COUNT) { i -> + state.requestPlacement(if (i % 2 == 0) WindowPlacement.Maximized else WindowPlacement.Floating) + settle(TOGGLE_GAP_MS) + } + val available = hostMonitor().workAreaDp(window.scaleFactor) + val rect = + DpRect( + left = available.left + SCOPED_INSET, + top = available.top + SCOPED_INSET, + right = available.left + SCOPED_INSET + SCOPED_SIZE.width, + bottom = available.top + SCOPED_INSET + SCOPED_SIZE.height, + ) + state.requestBounds(rect) + awaitUntil( + "final bounds applied after the toggling storm", + timeoutMillis = LONG_AWAIT_MS, + detail = { + "placement=${state.placement} outer=${outerDp()} wanted=$SCOPED_SIZE " + + "maximized=${window.isMaximized} fullscreen=${window.isFullscreen}" + }, + ) { + val outer = outerDp() + state.placement == WindowPlacement.Floating && + closeEnough(SCOPED_SIZE.width.value, outer.width) && + closeEnough(SCOPED_SIZE.height.value, outer.height) + } + // Nothing queued behind it may undo it. + settle() + assertClose(SCOPED_SIZE.width.value, outerDp().width, "width after settling") + } + } + + private fun requestsFromABackgroundThreadApply(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: requests sent from a background thread are applied", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + val available = hostMonitor().workAreaDp(window.scaleFactor) + val position = DpOffset(available.left + MOVE_INSET, available.top + MOVE_INSET) + // The request channels are the only thing crossing threads here; the + // bridge must pick them up on the dispatcher and never touch native + // state from the sender's thread. + withContext(Dispatchers.Default) { + repeat(BACKGROUND_BURST) { state.requestSize(RESIZED) } + state.requestPosition(position) + } + awaitUntil("background-thread requests applied") { + val outer = outerDp() + closeEnough(RESIZED.width.value, outer.width) && + closeEnough(position.x.value, outer.left) && + closeEnough(position.y.value, outer.top) + } + } + } + + private fun animatedMoveTracksTheLastFrame(): TaoWindowTestCase { + val state = WindowState() + return TaoWindowTestCase( + name = "window v2 clone: a frame-paced move animation ends on its last frame", + nucleusWindowState = state, + ) { + awaitMapped() + settle() + val available = hostMonitor().workAreaDp(window.scaleFactor) + val start = DpOffset(available.left + MOVE_INSET, available.top + MOVE_INSET) + state.requestPosition(start) + awaitUntil("at the start position") { + val outer = outerDp() + closeEnough(start.x.value, outer.left) && closeEnough(start.y.value, outer.top) + } + // Drag-like: one request per ~frame, each a few dp further. Every + // request must resolve against the live window, not against the + // position of the request before it; otherwise the window either + // lags a frame for good or overshoots. + var last = start + repeat(ANIMATION_FRAMES) { i -> + last = DpOffset(start.x + (STEP_DP * (i + 1)).dp, start.y + (STEP_DP * (i + 1)).dp) + state.requestPosition(last) + settle(FRAME_GAP_MS) + } + awaitUntil("ended on the last frame") { + val outer = outerDp() + closeEnough(last.x.value, outer.left) && closeEnough(last.y.value, outer.top) + } + awaitUntil("observed bounds match the last frame") { + closeEnough(last.x.value, state.bounds.left.value) && closeEnough(last.y.value, state.bounds.top.value) + } + } + } + + // ── Driver helpers ────────────────────────────────────────────────────── + + private suspend fun TaoWindowTestScope.awaitMapped() = + awaitUntil("window mapped with non-zero outer bounds") { + val b = bounds() + b != null && b[RECT_W] > 0 && b[RECT_H] > 0 + } + + private fun TaoWindowTestScope.hostMonitor(): TaoMonitor = TaoMonitors.forWindow(window) + + private fun TaoWindowTestScope.outerCentrePx(): Pair { + val b = checkNotNull(bounds()) { "window is not mapped" } + return (b[RECT_X] + b[RECT_W] / 2).toInt() to (b[RECT_Y] + b[RECT_H] / 2).toInt() + } + + /** Outer rectangle in the window's own Dp space — what the v2 API reports. */ + private fun TaoWindowTestScope.outerDp(): OuterDp { + val b = checkNotNull(bounds()) { "window is not mapped" } + val scale = window.scaleFactor.takeIf { it > 0f } ?: 1f + return OuterDp( + left = b[RECT_X] / scale, + top = b[RECT_Y] / scale, + width = b[RECT_W] / scale, + height = b[RECT_H] / scale, + ) + } + + private class OuterDp( + val left: Float, + val top: Float, + val width: Float, + val height: Float, + ) { + override fun toString(): String = "OuterDp(${left}x$top ${width}x$height)" + } + + private val DpRect.width: Float get() = (right - left).value + + private val DpRect.height: Float get() = (bottom - top).value + + private fun closeEnough( + expected: Float, + actual: Float, + ): Boolean = abs(expected - actual) <= TOLERANCE_DP + + private fun assertClose( + expected: Float, + actual: Float, + what: String, + ) = check(closeEnough(expected, actual)) { "$what: expected ~${expected}dp, the window reported ${actual}dp" } + + private val isLinux: Boolean get() = Platform.Current == Platform.Linux + + private const val DIAG_EVERY_POLLS = 40 + + private const val RECT_X = 0 + private const val RECT_Y = 1 + private const val RECT_W = 2 + private const val RECT_H = 3 + + /** Native frames round to whole pixels, and a WM may nudge a window. */ + private const val TOLERANCE_DP = 24f + + private val INITIAL_SIZE = DpSize(900.dp, 640.dp) + private val RESIZED = DpSize(1000.dp, 700.dp) + private val SCOPED_SIZE = DpSize(820.dp, 560.dp) + private val MEASURE_BOX = DpSize(400.dp, 300.dp) + + private const val BURST_COUNT = 40 + private const val BACKGROUND_BURST = 10 + private const val TOGGLE_COUNT = 6 + private const val TOGGLE_GAP_MS = 40L + private const val ANIMATION_FRAMES = 30 + private const val FRAME_GAP_MS = 16L + private const val STEP_DP = 4f + private const val LONG_AWAIT_MS = 30_000L + private const val LONG_CASE_MS = 60_000L + private val MOVE_INSET = 120.dp + private val SCOPED_INSET = 60.dp +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WindowExtremesHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WindowExtremesHeadfulCases.kt new file mode 100644 index 000000000..d15d5b5d8 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WindowExtremesHeadfulCases.kt @@ -0,0 +1,901 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.NativeView +import dev.nucleusframework.window.tao.NucleusPlatformView +import dev.nucleusframework.window.tao.TextureView +import dev.nucleusframework.window.tao.nucleusGtkPlatformView +import dev.nucleusframework.window.tao.nucleusHwndPlatformView +import dev.nucleusframework.window.tao.nucleusNsPlatformView +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.abs + +/** + * Windows pushed to the shapes an application only reaches by accident: fully + * transparent, one pixel across, resized faster than the compositor can + * answer, and carrying an embedded native view or an external texture while it + * all happens. + * + * These are the conditions every layer disagrees about. The scene has a size, + * the native window has another, the platform reports a third for a frame; an + * embedded child is placed in physical pixels against a rect that may already + * be stale; a texture is imported for a surface that is about to be destroyed. + * The invariants asserted here are the ones that hold whatever the sizes are: + * + * 1. the scene ends up agreeing with the window, however many sizes were + * asked for in between; + * 2. the render loop is still ticking afterwards — a window that survives a + * resize storm but stops painting is not a survivor; + * 3. an embedded native view is never handed a rect the platform would refuse + * (negative, or outside the window), and is placed where the composable + * ended up; + * 4. nothing above leaks when the content is added and removed over and over. + */ +@Suppress("LargeClass") // one method per real-window case, by design +internal object WindowExtremesHeadfulCases { + fun all(): List = + listOf( + aResizeStormEndsWithTheSceneMatchingTheWindow(), + aResizeStormLeavesTheRenderLoopTicking(), + aWindowSqueezedToOnePixelComesBack(), + aTinyWindowStillLaysOutAndGrowsBack(), + aTransparentWindowSurvivesAResizeStorm(), + aTransparentWindowSqueezedToNothingKeepsPainting(), + anAnimationKeepsRunningThroughAResizeStorm(), + alternatingSizesNeverLeaveTheSceneBehind(), + aNativeViewIsPlacedWhereItsComposableEndedUp(), + aNativeViewNeverGetsANegativeRect(), + aNativeViewAddedAndRemovedRepeatedlyIsBalanced(), + aNativeViewSurvivesAResizeStormAndKeepsItsRect(), + aNativeViewInATransparentWindowIsStillPlaced(), + aTextureViewWithoutASourceIsHarmless(), + aTextureViewAppearingAndDisappearingDuringAStorm(), + aTextureViewSignalledFasterThanTheLoopDoesNotStarveIt(), + aTabStripInAWindowTooSmallForItStaysConsistent(), + aSatelliteKeepsItsOffsetThroughAResizeStorm(), + ) + + // ── 1. resize storms ───────────────────────────────────────────────── + + /** + * Sizes asked for faster than the platform answers. Only the last one + * matters, and what must hold at the end is that the scene Compose lays + * out in is the size the window really has — a scene left behind means + * content drawn for a window that is not there any more. + */ + private fun aResizeStormEndsWithTheSceneMatchingTheWindow(): TaoWindowTestCase { + val probe = ExtremeProbe() + return TaoWindowTestCase( + name = "window extremes a resize storm ends with the scene matching the window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + stormResize(window, ROUNDS) + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + }, + ) + } + + /** A window that survives a resize storm but stops painting has not survived it. */ + private fun aResizeStormLeavesTheRenderLoopTicking(): TaoWindowTestCase { + val probe = ExtremeProbe(animate = true) + return TaoWindowTestCase( + name = "window extremes a resize storm leaves the render loop ticking", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + awaitUntil("the loop is ticking to begin with") { probe.frames.get() > MIN_FRAMES } + stormResize(window, ROUNDS) + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + + val before = probe.frames.get() + settle(FRAME_WINDOW_MILLIS) + val after = probe.frames.get() + check(after - before >= MIN_FRAMES) { + "only ${after - before} frames in ${FRAME_WINDOW_MILLIS}ms after the storm" + } + }, + ) + } + + /** + * One pixel across. Every layer has a lower bound it clamps to — the WM's, + * GTK's, the swapchain's — and the interesting part is coming back: a + * surface destroyed at 1×1 has to be rebuilt at the size that follows. + */ + private fun aWindowSqueezedToOnePixelComesBack(): TaoWindowTestCase { + val probe = ExtremeProbe(animate = true) + return TaoWindowTestCase( + name = "window extremes a window squeezed to one pixel comes back", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + for (size in listOf(1.0, 2.0, 1.0, 4.0)) { + window.setInnerSize(size, size) + settle(SQUEEZE_SETTLE_MILLIS) + check(bounds() != null) { "the window was lost at ${size}dp" } + } + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + + val before = probe.frames.get() + settle(FRAME_WINDOW_MILLIS) + check(probe.frames.get() - before >= MIN_FRAMES) { + "the render loop did not come back after the squeeze" + } + }, + ) + } + + /** + * A window too small for its content: the layout is asked for sizes that do + * not fit, which is where a negative measurement turns into a crash. It has + * to lay out anyway, and be usable again once there is room. + */ + private fun aTinyWindowStillLaysOutAndGrowsBack(): TaoWindowTestCase { + val probe = ExtremeProbe() + return TaoWindowTestCase( + name = "window extremes a window too small for its content still lays out", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle, fixedChild = DpSize(BIG_CHILD_DP.dp, BIG_CHILD_DP.dp)) }, + driver = { + awaitProbe(probe) + window.setInnerSize(TINY_DP, TINY_DP) + settle(SQUEEZE_SETTLE_MILLIS) + check(bounds() != null) { "the window was lost when squeezed" } + val child = probe.childBounds.value + if (child != null) { + check(child.width >= 0f && child.height >= 0f) { + "the oversized child measured negative in a tiny window: $child" + } + } + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + awaitUntil("the child is laid out again") { + (probe.childBounds.value?.width ?: 0f) > 0f + } + }, + ) + } + + // ── 2. transparency ────────────────────────────────────────────────── + + /** + * The same storm on a fully transparent window. The clear is alpha 0 and + * the surface is recreated on every size change, which is the combination + * that has produced protocol errors on Wayland before. + */ + private fun aTransparentWindowSurvivesAResizeStorm(): TaoWindowTestCase { + val probe = ExtremeProbe(animate = true) + return TaoWindowTestCase( + name = "window extremes a transparent window survives a resize storm", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + transparent = true, + paintDefaultBackground = false, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + content = { probe.Content(window.nativeHandle, opaque = false) }, + driver = { + awaitProbe(probe) + stormResize(window, ROUNDS) + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + val before = probe.frames.get() + settle(FRAME_WINDOW_MILLIS) + check(probe.frames.get() - before >= MIN_FRAMES) { + "a transparent window stopped painting after the storm" + } + }, + ) + } + + /** Transparent *and* squeezed to nothing: the two together, then back. */ + private fun aTransparentWindowSqueezedToNothingKeepsPainting(): TaoWindowTestCase { + val probe = ExtremeProbe(animate = true) + return TaoWindowTestCase( + name = "window extremes a transparent window squeezed to nothing keeps painting", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + transparent = true, + paintDefaultBackground = false, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + content = { probe.Content(window.nativeHandle, opaque = false) }, + driver = { + awaitProbe(probe) + repeat(SQUEEZE_ROUNDS) { round -> + window.setInnerSize(1.0 + round % 2, 1.0) + settle(SQUEEZE_SETTLE_MILLIS) + window.setInnerSize(END_W_DP, END_H_DP) + settle(SQUEEZE_SETTLE_MILLIS) + } + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + val before = probe.frames.get() + settle(FRAME_WINDOW_MILLIS) + check(probe.frames.get() - before >= MIN_FRAMES) { + "the transparent window stopped painting after the squeezes" + } + }, + ) + } + + /** + * An animation running while the window is resized under it. The frame + * clock drives the animation and the resize drives the surface; a resize + * that parks the clock stops the animation for good. + */ + private fun anAnimationKeepsRunningThroughAResizeStorm(): TaoWindowTestCase { + val probe = ExtremeProbe(animate = true) + return TaoWindowTestCase( + name = "window extremes an animation keeps running through a resize storm", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + awaitUntil("the animation started") { probe.frames.get() > MIN_FRAMES } + val duringStart = probe.frames.get() + stormResize(window, ROUNDS, settleMillis = STORM_STEP_MILLIS) + val duringEnd = probe.frames.get() + check(duringEnd - duringStart >= MIN_FRAMES) { + "the animation stalled during the storm: ${duringEnd - duringStart} frames" + } + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + }, + ) + } + + /** + * Two sizes alternating as fast as they can be asked for. Each one arrives + * while the previous is still being applied, so this is where the scene and + * the window drift apart and stay apart. + */ + private fun alternatingSizesNeverLeaveTheSceneBehind(): TaoWindowTestCase { + val probe = ExtremeProbe() + return TaoWindowTestCase( + name = "window extremes alternating sizes never leave the scene behind the window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + repeat(ALTERNATIONS) { round -> + window.setInnerSize(if (round % 2 == 0) SMALL_W_DP else END_W_DP, END_H_DP) + } + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + }, + ) + } + + // ── 3. embedded native views ───────────────────────────────────────── + + /** + * The rect an embedded view is given has to be the one its composable ended + * up with — the whole point of the embed is that the platform child sits + * exactly where Compose put the hole. + */ + private fun aNativeViewIsPlacedWhereItsComposableEndedUp(): TaoWindowTestCase { + val probe = ExtremeProbe(nativeView = true) + return TaoWindowTestCase( + name = "window extremes an embedded native view is placed where its composable ended up", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::embedGeometrySkipReason, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + awaitUntil("the embed was given a rect") { probe.view.bounds() != null } + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + awaitUntil("the embed followed the composable") { + val given = probe.view.bounds() ?: return@awaitUntil false + val laid = probe.childBounds.value ?: return@awaitUntil false + abs(given.width - laid.width) <= EMBED_TOLERANCE_PX && + abs(given.height - laid.height) <= EMBED_TOLERANCE_PX + } + }, + ) + } + + /** + * A window with no room left for the embed. Negative or absurd rects are + * exactly what platform APIs reject or, worse, accept and misdraw, so they + * must never leave the host. + */ + private fun aNativeViewNeverGetsANegativeRect(): TaoWindowTestCase { + val probe = ExtremeProbe(nativeView = true) + return TaoWindowTestCase( + name = "window extremes an embedded native view is never handed a negative rect", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::embedGeometrySkipReason, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + awaitUntil("the embed was given a rect") { probe.view.bounds() != null } + for (size in listOf(TINY_DP, 1.0, 2.0, TINY_DP)) { + window.setInnerSize(size, size) + settle(SQUEEZE_SETTLE_MILLIS) + } + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + val worst = probe.view.worstRect() + check(worst == null) { "the embed was handed $worst" } + }, + ) + } + + /** + * Added and removed over and over — a tab switching between a document and + * a preview. Every attach has to be matched by a detach, and the last state + * has to be the one the composition asks for. + */ + private fun aNativeViewAddedAndRemovedRepeatedlyIsBalanced(): TaoWindowTestCase { + val probe = ExtremeProbe(nativeView = true) + return TaoWindowTestCase( + name = "window extremes an embedded native view added and removed repeatedly is balanced", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + awaitUntil("the first embed exists") { probe.view.created.get() == 1 } + repeat(TOGGLES) { round -> + probe.showNativeView.value = false + awaitUntil("round $round: the embed left") { probe.view.disposed.get() == round + 1 } + probe.showNativeView.value = true + awaitUntil("round $round: a new embed arrived") { probe.view.created.get() == round + 2 } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(probe.view.created.get() - probe.view.disposed.get() == 1) { + "created ${probe.view.created.get()} embeds, disposed ${probe.view.disposed.get()}" + } + check(bounds() != null) { "the window did not survive the toggling" } + }, + ) + } + + /** The embed's rect through a storm: never negative, and correct at the end. */ + private fun aNativeViewSurvivesAResizeStormAndKeepsItsRect(): TaoWindowTestCase { + val probe = ExtremeProbe(nativeView = true, animate = true) + return TaoWindowTestCase( + name = "window extremes an embedded native view survives a resize storm", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::embedGeometrySkipReason, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + awaitUntil("the embed was given a rect") { probe.view.bounds() != null } + stormResize(window, ROUNDS) + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + check(probe.view.worstRect() == null) { "the storm handed the embed ${probe.view.worstRect()}" } + awaitUntil( + "the embed caught up with the composable", + detail = { + "embed=${probe.view.bounds()} laid out=${probe.childBounds.value} " + + "scene=${probe.sceneSize.value} outer=${window.outerBoundsPx()?.toList()} " + + "frames=${probe.frames.get()} content=${probe.rootBounds.value}" + }, + ) { + val given = probe.view.bounds() ?: return@awaitUntil false + val laid = probe.childBounds.value ?: return@awaitUntil false + abs(given.width - laid.width) <= EMBED_TOLERANCE_PX + } + val before = probe.frames.get() + settle(FRAME_WINDOW_MILLIS) + check(probe.frames.get() - before >= MIN_FRAMES) { "the loop stopped with an embed on screen" } + }, + ) + } + + /** An embed inside a transparent window: the hole-punch and the alpha clear at once. */ + private fun aNativeViewInATransparentWindowIsStillPlaced(): TaoWindowTestCase { + val probe = ExtremeProbe(nativeView = true) + return TaoWindowTestCase( + name = "window extremes an embedded native view in a transparent window is still placed", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::embedGeometrySkipReason, + transparent = true, + paintDefaultBackground = false, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + content = { probe.Content(window.nativeHandle, opaque = false) }, + driver = { + awaitProbe(probe) + awaitUntil("the embed was given a rect") { probe.view.bounds() != null } + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + awaitUntil("the embed followed") { + val given = probe.view.bounds() ?: return@awaitUntil false + val laid = probe.childBounds.value ?: return@awaitUntil false + abs(given.width - laid.width) <= EMBED_TOLERANCE_PX + } + check(probe.view.worstRect() == null) { "the embed was handed ${probe.view.worstRect()}" } + }, + ) + } + + // ── 4. external textures ───────────────────────────────────────────── + + /** + * A `TextureView` with nothing behind it — the state every app is in before + * its producer is ready. It has to be an ordinary empty box, through + * resizes and all. + */ + private fun aTextureViewWithoutASourceIsHarmless(): TaoWindowTestCase { + val probe = ExtremeProbe(textureView = true, animate = true) + return TaoWindowTestCase( + name = "window extremes a texture view with no source is an ordinary empty box", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + stormResize(window, ROUNDS) + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + val before = probe.frames.get() + settle(FRAME_WINDOW_MILLIS) + check(probe.frames.get() - before >= MIN_FRAMES) { + "a source-less texture view stopped the loop" + } + }, + ) + } + + /** The texture view coming and going while the window resizes under it. */ + private fun aTextureViewAppearingAndDisappearingDuringAStorm(): TaoWindowTestCase { + val probe = ExtremeProbe(textureView = true, animate = true) + return TaoWindowTestCase( + name = "window extremes a texture view appearing and disappearing during a resize storm", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + repeat(TOGGLES) { round -> + probe.showTextureView.value = round % 2 == 0 + window.setInnerSize(if (round % 2 == 0) SMALL_W_DP else END_W_DP, END_H_DP) + settle(STORM_STEP_MILLIS) + } + probe.showTextureView.value = true + window.setInnerSize(END_W_DP, END_H_DP) + awaitSettledAt(probe, window, END_W_DP, END_H_DP) + val before = probe.frames.get() + settle(FRAME_WINDOW_MILLIS) + check(probe.frames.get() - before >= MIN_FRAMES) { "the loop stopped after the toggling" } + }, + ) + } + + /** + * A producer signalling frames far faster than the display: the signal is + * meant to invalidate the draw pass, not to queue work without bound. The + * loop has to stay responsive and the window has to stay usable. + */ + private fun aTextureViewSignalledFasterThanTheLoopDoesNotStarveIt(): TaoWindowTestCase { + val probe = ExtremeProbe(textureView = true, animate = true) + return TaoWindowTestCase( + name = "window extremes a texture signalled faster than the loop does not starve it", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(START_W_DP.dp, START_H_DP.dp), + paintDefaultBackground = false, + content = { probe.Content(window.nativeHandle) }, + driver = { + awaitProbe(probe) + awaitUntil("the loop is ticking") { probe.frames.get() > MIN_FRAMES } + val before = probe.frames.get() + repeat(SIGNAL_STORM) { probe.controller.value?.markFrameAvailable() } + settle(FRAME_WINDOW_MILLIS) + val after = probe.frames.get() + check(after - before >= MIN_FRAMES) { + "the signal storm starved the loop: ${after - before} frames" + } + check(bounds() != null) { "the window did not survive the signal storm" } + }, + ) + } + + // ── 5. the workspaces at extreme sizes ─────────────────────────────── + + /** + * A tab window shrunk below the width of its own strip. The slots the strip + * publishes are what turn a pointer position into an insertion index, so + * they have to stay describable — never wider than the window, never + * crossing — and come back when there is room again. + */ + private fun aTabStripInAWindowTooSmallForItStaysConsistent(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "window extremes a tab strip in a window too small for it stays consistent", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabSlots(fixture, *titles.toTypedArray()) + val group = requireNotNull(fixture.groupOf("Alpha")) + + for (width in listOf(SMALL_W_DP, TINY_DP, 1.0, SMALL_W_DP)) { + tabWindow.setInnerSize(width, STRIP_H_DP) + settle(SQUEEZE_SETTLE_MILLIS) + val slots = group.slotsInWindowPx + check(slots.size <= group.ids.size) { + "the strip published ${slots.size} slots for ${group.ids.size} tabs at ${width}dp" + } + check(slots.all { it.width >= 0f }) { "a slot measured negative at ${width}dp: $slots" } + check( + slots.zipWithNext().all { (left, right) -> left.left <= right.left }, + ) { "slots crossed at ${width}dp: $slots" } + } + + tabWindow.setInnerSize(WIDE_W_DP, STRIP_H_DP) + awaitUntil("the strip is usable again") { + val slots = group.slotsInWindowPx + slots.size == group.ids.size && slots.all { it.width > 1f } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.groups.size == 1) { "squeezing the window moved a tab" } + check(group.ids.size == titles.size) { "squeezing the window lost a tab: ${group.ids}" } + }, + ) + } + + /** + * The parent resized under a satellite as fast as it can be asked for. The + * satellite holds an offset from the parent's *top-left*, so a resize that + * does not move the origin must not move it — and one that does must. + */ + private fun aSatelliteKeepsItsOffsetThroughAResizeStorm(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "window extremes a satellite keeps its offset through a resize storm", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + val satellite = awaitFloating(fixture) + val parentBefore = requireNotNull(bounds()) + val satelliteBefore = requireNotNull(satellite.outerBoundsPx()) + val offsetX = satelliteBefore[0] - parentBefore[0] + val offsetY = satelliteBefore[1] - parentBefore[1] + + repeat(ROUNDS) { round -> + val w = PARENT_W_DP - (round % STORM_SPAN) * STORM_STEP_DP + window.setInnerSize(w.toDouble(), PARENT_H_DP.toDouble()) + } + window.setInnerSize(PARENT_W_DP.toDouble(), PARENT_H_DP.toDouble()) + settle(SETTLE_AFTER_MAP_MILLIS) + + awaitUntil("the satellite is still at its offset from the parent") { + val parentNow = bounds() ?: return@awaitUntil false + val satelliteNow = satellite.outerBoundsPx() ?: return@awaitUntil false + abs((satelliteNow[0] - parentNow[0]) - offsetX) <= STORM_FOLLOW_TOLERANCE_PX && + abs((satelliteNow[1] - parentNow[1]) - offsetY) <= STORM_FOLLOW_TOLERANCE_PX + } + check(requireNotNull(satellite.outerBoundsPx())[RECT_W] > 0L) { + "the satellite lost its size in the storm" + } + }, + ) + } + + // ── the probe ──────────────────────────────────────────────────────── + + /** + * The content every case above composes: the scene size it is laid out in, + * a frame counter, and — on demand — an embedded native view or a texture + * view to put under the same pressure. + */ + private class ExtremeProbe( + private val animate: Boolean = false, + private val nativeView: Boolean = false, + private val textureView: Boolean = false, + ) { + /** The scene's container size, as Compose lays the content out in it. */ + val sceneSize = mutableStateOf(IntSize.Zero) + + /** Bounds of the probe's child, in window px. */ + val childBounds = mutableStateOf(null) + + /** Size of the probe's own root, to compare against the scene it sits in. */ + val rootBounds = mutableStateOf(null) + + /** Frame-clock ticks since the content was composed. */ + val frames = AtomicLong() + + val showNativeView = mutableStateOf(true) + val showTextureView = mutableStateOf(true) + val controller = mutableStateOf(null) + val view = EmbedRecorder() + + @Composable + fun Content( + hostHandle: Long, + opaque: Boolean = true, + fixedChild: DpSize? = null, + ) { + val container = LocalWindowInfo.current.containerSize + SideEffect { sceneSize.value = container } + if (animate) FrameTicker(frames) + val childModifier = + (if (fixedChild != null) Modifier.size(fixedChild) else Modifier.fillMaxSize()) + .onGloballyPositioned { childBounds.value = it.boundsInWindow().size } + Box( + Modifier + .fillMaxSize() + .onGloballyPositioned { rootBounds.value = Size(it.size.width.toFloat(), it.size.height.toFloat()) } + .background(if (opaque) Color.DarkGray else Color.Transparent), + ) { + when { + nativeView && showNativeView.value -> + NativeView(factory = { view.create(hostHandle) }, modifier = childModifier) + textureView && showTextureView.value -> { + val live = + dev.nucleusframework.window.tao + .rememberTextureViewController() + SideEffect { controller.value = live } + TextureView(source = null, modifier = childModifier, controller = live) + } + else -> Box(childModifier.background(Color(0xFF2D6CDF))) + } + } + } + } + + /** + * A frame-clock loop whose phase is read in `drawBehind`, so each tick + * invalidates the draw layer and the host schedules the next frame. Without + * the read the clock parks — the host only ticks it when it renders. + */ + @Composable + private fun FrameTicker(frames: AtomicLong) { + val phase = remember { mutableFloatStateOf(0f) } + // Deliberately the smallest node that can draw: the ticker is a + // sibling of the probe's content in the window's scene column, and a + // `fillMaxSize` here takes the whole height with it — leaving the + // content the case is about measured at zero and every geometry + // assertion comparing two stale rects. + Box( + Modifier.size(TICKER_DP.dp).drawBehind { + @Suppress("UNUSED_EXPRESSION") + phase.value + }, + ) + LaunchedEffect(Unit) { + while (true) { + withFrameNanos { + frames.incrementAndGet() + phase.value = (phase.value + 1f) % PHASE_WRAP + } + } + } + } + + /** + * A platform view of whatever kind this OS embeds, with no real native + * handle behind it: every host guards a zero handle, so nothing is mounted + * and what is exercised is the host's own geometry, region and lifecycle + * bookkeeping — which is where the resize storms bite. + */ + private class EmbedRecorder { + val created = AtomicInteger() + val disposed = AtomicInteger() + + private val lastBounds = mutableStateOf(null) + private val worst = mutableStateOf(null) + private var nsChild: Long? = null + + fun bounds(): Size? = lastBounds.value + + /** The first rect that no platform would accept, or `null` when every one was sane. */ + fun worstRect(): String? = worst.value + + fun create(parentHandle: Long): NucleusPlatformView { + created.incrementAndGet() + val onBounds: (Int, Int, Int, Int) -> Unit = { x, y, w, h -> + if (w < 0 || h < 0 || x < MIN_EMBED_COORD_PX || y < MIN_EMBED_COORD_PX) { + if (worst.value == null) worst.value = "rect(x=$x, y=$y, w=$w, h=$h)" + } + lastBounds.value = Size(w.toFloat(), h.toFloat()) + } + val onResize: (Int, Int) -> Unit = { w, h -> + if (w < 0 || h < 0) { + if (worst.value == null) worst.value = "size(w=$w, h=$h)" + } + } + val onDispose: () -> Unit = { disposed.incrementAndGet() } + return when (Platform.Current) { + Platform.MacOS -> + nucleusNsPlatformView( + // A real child NSView: macOS disables the embed for a + // zero handle, and the geometry path is the point. + handle = { + nsChild ?: dev.nucleusframework.window.tao.ffi.NativeTaoMacOsNativeViewBridge + .nativeCreateOverlay(parentHandle) + .also { nsChild = it } + }, + onResize = onResize, + onSetBounds = onBounds, + onDispose = onDispose, + ) + Platform.Windows -> + nucleusHwndPlatformView( + handle = { 0L }, + onResize = onResize, + onSetBounds = onBounds, + onDispose = onDispose, + ) + else -> + nucleusGtkPlatformView( + handle = { 0L }, + onResize = onResize, + onSetBounds = onBounds, + onDispose = onDispose, + ) + } + } + } + + // ── driving ────────────────────────────────────────────────────────── + + /** + * Why an embed's geometry cannot be exercised here, or `null` when it can. + * + * The host disables the embed entirely for a handle it cannot use, so a + * case about *where the child is put* needs a real one. macOS and Windows + * can fabricate a bare child view from their own bridges; Linux has no + * equivalent, and inventing a `GtkWidget*` would hand GTK a wild pointer. + */ + private fun embedGeometrySkipReason(): String? = + if (Platform.Current == Platform.Linux) { + "no way to fabricate a GtkWidget from the test module" + } else { + null + } + + private suspend fun TaoWindowTestScope.awaitProbe(probe: ExtremeProbe) { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("the scene has a size") { probe.sceneSize.value.width > 0 } + settle(SETTLE_AFTER_MAP_MILLIS) + } + + /** Asks for [rounds] sizes in a row, cycling through a span of widths and heights. */ + private suspend fun TaoWindowTestScope.stormResize( + window: dev.nucleusframework.window.tao.TaoWindow, + rounds: Int, + settleMillis: Long = 0, + ) { + repeat(rounds) { round -> + val w = START_W_DP - (round % STORM_SPAN) * STORM_STEP_DP + val h = START_H_DP - (round % STORM_SPAN) * STORM_STEP_DP + window.setInnerSize(w.toDouble(), h.toDouble()) + if (settleMillis > 0) settle(settleMillis) + } + } + + /** + * Waits until the window really is [wDp]×[hDp] and the scene agrees with + * it: the two are measured independently, and the whole point of a storm is + * to find out whether they can end up disagreeing. + */ + private suspend fun TaoWindowTestScope.awaitSettledAt( + probe: ExtremeProbe, + window: dev.nucleusframework.window.tao.TaoWindow, + wDp: Double, + hDp: Double, + ) { + val scale = window.scaleFactor + // The scene is the inner size in physical pixels, which is what + // `setInnerSize` asks for. The outer frame carries the chrome and, on + // a CSD desktop, a shadow margin the WM owns — comparing against it + // would measure the decoration, not the resize. + awaitUntil("the scene settled at ${wDp}x${hDp}dp") { + val scene = probe.sceneSize.value + abs(scene.width - (wDp * scale).toInt()) <= SIZE_TOLERANCE_PX + } + // The scene having the right size does not mean the content was laid + // out in it: a sibling that eats the window's height leaves every + // geometry assertion below comparing two stale rects, and passing. + awaitUntil( + "the content filled the scene", + detail = { "content=${probe.rootBounds.value} scene=${probe.sceneSize.value}" }, + ) { + val root = probe.rootBounds.value ?: return@awaitUntil false + abs(root.height.toInt() - probe.sceneSize.value.height) <= SIZE_TOLERANCE_PX + } + awaitUntil("the window is still mapped with a real frame") { + val rect = window.outerBoundsPx() ?: return@awaitUntil false + rect[RECT_W] >= probe.sceneSize.value.width - SIZE_TOLERANCE_PX && rect[RECT_H] > 0L + } + settle(SETTLE_AFTER_MAP_MILLIS) + } + + /** The frame ticker's own size — big enough to draw, small enough to ignore. */ + private const val TICKER_DP = 1 + + private const val START_W_DP = 520.0 + private const val START_H_DP = 380.0 + private const val END_W_DP = 600.0 + private const val END_H_DP = 420.0 + private const val SMALL_W_DP = 200.0 + private const val TINY_DP = 20.0 + private const val WIDE_W_DP = 720.0 + private const val STRIP_H_DP = 200.0 + private const val BIG_CHILD_DP = 1200 + + /** Widths the storm cycles through, in steps of [STORM_STEP_DP]. */ + private const val STORM_SPAN = 8 + private const val STORM_STEP_DP = 24 + + private const val ROUNDS = 120 + private const val ALTERNATIONS = 80 + private const val TOGGLES = 8 + private const val SQUEEZE_ROUNDS = 4 + private const val SIGNAL_STORM = 500 + private const val STORM_STEP_MILLIS = 8L + private const val SQUEEZE_SETTLE_MILLIS = 120L + private const val FRAME_WINDOW_MILLIS = 400L + private const val MIN_FRAMES = 4L + private const val PHASE_WRAP = 1000f + + /** dp↔px rounding on both sides of a size round trip. */ + private const val SIZE_TOLERANCE_PX = 8 + + private const val EMBED_TOLERANCE_PX = 8f + + /** A rect further off-window than this is a bug, not a scroll offset. */ + private const val MIN_EMBED_COORD_PX = -10_000 + + private const val STORM_FOLLOW_TOLERANCE_PX = 24L + private const val LONG_CASE_TIMEOUT_MILLIS = 120_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceChaosSupport.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceChaosSupport.kt new file mode 100644 index 000000000..9637ea87b --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceChaosSupport.kt @@ -0,0 +1,625 @@ +@file:OptIn( + androidx.compose.ui.InternalComposeUiApi::class, + androidx.compose.ui.ExperimentalComposeUiApi::class, +) + +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.draganddrop.dragAndDropTarget +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draganddrop.DragAndDropEvent +import androidx.compose.ui.draganddrop.DragAndDropTarget +import androidx.compose.ui.draganddrop.awtTransferable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.ApplicationScope +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.Satellite +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.Tab +import dev.nucleusframework.window.tao.TabScope +import dev.nucleusframework.window.tao.TabWindowGroup +import dev.nucleusframework.window.tao.TabWindows +import dev.nucleusframework.window.tao.TabWorkspace +import dev.nucleusframework.window.tao.TaoEventCode +import dev.nucleusframework.window.tao.TaoMouseButton +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.dnd.TaoSceneDnD +import java.awt.datatransfer.DataFlavor +import java.io.File + +// ── Inbound file drags ─────────────────────────────────────────────────────── +// +// The OS hands an inbound drag to a window through the platform bridge +// callbacks, which resolve the scene's drop target through +// `TaoWindow.inboundDragAndDropNode` and pass it to `TaoSceneDnD`. These +// helpers enter the same funnel from inside the process: everything above the +// JNI boundary — the synthetic AWT transferable, the Compose drag-and-drop +// tree, the app's `dragAndDropTarget` — runs exactly as it does for a real +// drop from the file manager. Coordinates are physical pixels in the window's +// own content space, the space the native callbacks speak. + +/** `null` when the window's scene has published no drop target (not attached yet). */ +private fun TaoWindow.dropTargetNode() = inboundDragAndDropNode?.invoke() + +/** `true` once this window's scene is attached and can answer an inbound drag. */ +internal fun TaoWindow.hasSceneDropTarget(): Boolean = dropTargetNode() != null + +/** A file drag entering [this] window at a content-space point; `true` when the scene took it. */ +internal fun TaoWindow.fileDragEnter(pointInContentPx: Offset): Boolean = + TaoSceneDnD.onDragEnter(dropTargetNode(), pointInContentPx.x.toInt(), pointInContentPx.y.toInt()) + +/** A file drag moving over [this] window; `true` while a drop target is eligible. */ +internal fun TaoWindow.fileDragOver(pointInContentPx: Offset): Boolean = + TaoSceneDnD.onDragOver(dropTargetNode(), pointInContentPx.x.toInt(), pointInContentPx.y.toInt()) + +/** The drag left [this] window without dropping. */ +internal fun TaoWindow.fileDragLeave() { + TaoSceneDnD.onDragLeave(dropTargetNode()) +} + +/** A file drop on [this] window; `true` when a target accepted it. */ +internal fun TaoWindow.fileDrop( + pointInContentPx: Offset, + files: List, +): Boolean = + TaoSceneDnD.onDrop( + dropTargetNode(), + pointInContentPx.x.toInt(), + pointInContentPx.y.toInt(), + files.toTypedArray(), + ) + +/** Enter, move and drop in one go — the shape of a drag the user completes. */ +internal fun TaoWindow.fileDragAndDrop( + pointInContentPx: Offset, + files: List, +): Boolean { + fileDragEnter(pointInContentPx) + fileDragOver(pointInContentPx) + return fileDrop(pointInContentPx, files) +} + +// ── In-process pointer input ───────────────────────────────────────────────── +// +// The native loop turns every mouse event into a `TaoWindow.dispatch` of a +// `TaoEventCode`, which the scene host translates into a Compose pointer event. +// These helpers post the same events, so the pointer pipeline under test is the +// real one — the deadband, the resize-edge band, the gesture detectors, the +// drag handles — with only the OS left out. That matters beyond convenience: +// `java.awt.Robot` cannot inject at all on a Wayland session (the compositor +// refuses the portal session, see [HeadfulRobot]), so this is the only way to +// exercise a click on that platform. +// +// Positions are physical pixels in the window's own content space — the space +// `HostGeometry.layoutBoundsInWindowPx` and `TabWindowGroup.slotsInWindowPx` +// are published in, so no screen placement is needed to aim at a tab. + +/** Tao ships cursor positions as 1/1024 px fixed point; [TaoWindow.dispatch] expects that wire form. */ +private const val POINTER_FIXED_POINT = 1024f + +/** Moves the pointer to [pointInContentPx]. Sub-1-dp moves are swallowed by the deadband, as for a real mouse. */ +internal fun TaoWindow.pointerMove(pointInContentPx: Offset) { + dispatch( + TaoEventCode.CURSOR_MOVED, + (pointInContentPx.x * POINTER_FIXED_POINT).toInt(), + (pointInContentPx.y * POINTER_FIXED_POINT).toInt(), + ) +} + +/** Presses a mouse button at wherever the pointer last moved to. */ +internal fun TaoWindow.pointerPress(button: Int = TaoMouseButton.LEFT) { + dispatch(TaoEventCode.MOUSE_DOWN, button, 0) +} + +/** Releases a mouse button. */ +internal fun TaoWindow.pointerRelease(button: Int = TaoMouseButton.LEFT) { + dispatch(TaoEventCode.MOUSE_UP, button, 0) +} + +/** The pointer left the window. */ +internal fun TaoWindow.pointerExit() { + dispatch(TaoEventCode.CURSOR_LEFT, 0, 0) +} + +/** Move, press, release — one click, with no motion in between. */ +internal fun TaoWindow.pointerClick( + pointInContentPx: Offset, + button: Int = TaoMouseButton.LEFT, +) { + pointerMove(pointInContentPx) + pointerPress(button) + pointerRelease(button) +} + +/** + * Presses at [from] and drags to [to] in [steps] samples, leaving the button + * **down** so the caller can assert the in-flight state before + * [TaoWindow.pointerRelease] ends it. + * + * Settles between samples: a gesture detector consumes events from a coroutine + * on the scene's dispatcher, and a drag whose whole path arrives inside one + * tick is not the gesture a user makes. + */ +internal suspend fun TaoWindowTestScope.pointerDragFrom( + window: TaoWindow, + from: Offset, + to: Offset, + steps: Int = POINTER_DRAG_STEPS, + stepMillis: Long = POINTER_DRAG_STEP_MILLIS, +) { + window.pointerMove(from) + settle(stepMillis) + window.pointerPress() + settle(stepMillis) + for (step in 1..steps) { + window.pointerMove(from + (to - from) * (step / steps.toFloat())) + settle(stepMillis) + } +} + +/** Enough samples to cross the touch slop and be a drag rather than a twitch. */ +internal const val POINTER_DRAG_STEPS = 8 + +internal const val POINTER_DRAG_STEP_MILLIS = 16L + +/** + * What one drop target saw, published so a case can assert on it. + * + * [files] is read back through Compose's own `awtTransferable` accessor — the + * route an application uses — so a case that finds the paths here has proven + * the whole chain, not just that a callback fired. + */ +internal class FileDropLog { + val entered = mutableIntStateOf(0) + val moved = mutableIntStateOf(0) + val exited = mutableIntStateOf(0) + val ended = mutableIntStateOf(0) + val drops = mutableIntStateOf(0) + + /** Paths of the last drop, in the order the transferable listed them. */ + val files = mutableStateOf>(emptyList()) + + /** Every path this target ever received, across drops. */ + val allFiles = mutableStateListOf() + + /** What the target threw while reading a drop, if anything. */ + val failure = mutableStateOf(null) + + fun reset() { + entered.value = 0 + moved.value = 0 + exited.value = 0 + ended.value = 0 + drops.value = 0 + files.value = emptyList() + allFiles.clear() + failure.value = null + } +} + +/** + * Records every inbound file drag event on this node into [log]. + * + * [accept] gates `shouldStartDragAndDrop`, so a case can put a target that + * refuses the drag next to one that takes it — which is how a scene with + * several targets decides where a drop lands. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun Modifier.fileDropRecorder( + log: FileDropLog, + accept: Boolean = true, +): Modifier { + val target = + remember(log) { + object : DragAndDropTarget { + override fun onEntered(event: DragAndDropEvent) { + log.entered.value++ + } + + override fun onMoved(event: DragAndDropEvent) { + log.moved.value++ + } + + override fun onExited(event: DragAndDropEvent) { + log.exited.value++ + } + + override fun onEnded(event: DragAndDropEvent) { + log.ended.value++ + } + + override fun onDrop(event: DragAndDropEvent): Boolean { + log.drops.value++ + val paths = readPaths(event) + log.files.value = paths + log.allFiles += paths + return true + } + + private fun readPaths(event: DragAndDropEvent): List = + try { + @Suppress("UNCHECKED_CAST") + ( + event.awtTransferable.getTransferData(DataFlavor.javaFileListFlavor) + as? List + ).orEmpty().map { it.absolutePath } + } catch ( + @Suppress("TooGenericExceptionCaught") t: Throwable, + ) { + log.failure.value = "${t::class.simpleName}: ${t.message}" + emptyList() + } + } + } + return dragAndDropTarget(shouldStartDragAndDrop = { accept }, target = target) +} + +// ── The two archetypes composed: tabs, each window with its own palettes ───── + +/** + * The `tab-satellites` archetype on real windows: one [TabWorkspace] owning + * the windows, and one [SatelliteWorkspace] **per tab window** whose palette + * draws whichever tab that window is showing. + * + * The wiring mirrors `examples/tab-satellites-demo` down to where each piece + * lives — the window joins its workspace from the window wrapper, the + * [DockLayout] is inside the tab body, and the satellites are declared at + * application scope per group — because that placement is the whole design: + * anything else churns a native palette window on every tab change. + * + * Everything a case asserts on is published from composition, keyed by group + * id for the palettes and by tab id for the bodies. + */ +internal class TabSatellitesFixture( + initialTitles: List = listOf("Alpha", "Beta"), + windowSize: DpSize = DpSize(TAB_WINDOW_W_DP.dp, TAB_WINDOW_H_DP.dp), + /** + * Extra content composed inside every tab body, given the group of the + * window it is composed in — a per-window animation, typically, so a case + * can tell which windows the shared loop is actually painting. + */ + private val bodyExtra: (@Composable (TabWindowGroup) -> Unit)? = null, +) { + val tabs = TabWorkspace(defaultWindowSize = windowSize) + + /** Ids in declaration order; a case may add to this to open a tab mid-run. */ + val titles = mutableStateListOf(*initialTitles.toTypedArray()) + + // Plain map, not snapshot state: it is read from composition and must not + // invalidate anything when a window's workspace is created on demand. + private val workspaces = HashMap() + + /** The satellite workspace of the tab window [groupId], created on first use. */ + fun palettesOf(groupId: String): SatelliteWorkspace = workspaces.getOrPut(groupId) { SatelliteWorkspace() } + + /** Whether [groupId] still has a workspace — a window's workspace is forgotten with the window. */ + fun hasPalettes(groupId: String): Boolean = groupId in workspaces + + /** How many satellite workspaces are alive; one per live tab window, no more. */ + val liveWorkspaces: Int get() = workspaces.size + + fun tabId(title: String): String = "tab-${title.lowercase()}" + + fun paletteId(groupId: String): String = "$groupId-palette" + + /** The group of the tab titled [title], or `null` while it has none. */ + fun groupOf(title: String): TabWindowGroup? = tabs.tab(tabId(title))?.group + + /** The window showing the tab titled [title], or `null` while it is not composed. */ + fun windowOf(title: String): TaoWindow? = composedIn.value[tabId(title)]?.lastOrNull() + + /** + * The windows each tab's body is composed in, by tab id, oldest host + * first — see the same field on [TabWorkspaceFixture] for why a tab can + * legitimately have two hosts at once. + */ + val composedIn = mutableStateOf>>(emptyMap()) + + /** The `rememberSaveable` counter of each tab's current composition, by tab id. */ + val counters = mutableStateOf>>(emptyMap()) + + /** How many tab bodies are composing right now. */ + val composedBodies = mutableIntStateOf(0) + + /** Times [TabWindows] reported the last window gone. */ + val lastWindowClosedCount = mutableIntStateOf(0) + + /** The host window of each group's docked palette, by group id. */ + val panelHost = mutableStateOf>(emptyMap()) + + /** The floating window of each group's palette, by group id. */ + val floatingPalette = mutableStateOf>(emptyMap()) + + /** Which palette body wrote [panelHost] / [floatingPalette] last, so only it may clear the entry. */ + private val publishedPanel = HashMap() + private val publishedFloating = HashMap() + + /** The tab title each group's palette is currently drawing, by group id. */ + val paletteShows = mutableStateOf>(emptyMap()) + + /** The `rememberSaveable` counter of each group's palette body, by group id. */ + val paletteCounters = mutableStateOf>>(emptyMap()) + + /** + * How many times each group's palette body was built from scratch. A dock + * or an undock rebuilds it once — two hosts, two compositions — but a tab + * change inside the window must not. + */ + val paletteIncarnations = mutableStateOf>(emptyMap()) + + /** How many palette bodies are composing right now. */ + val composedPalettes = mutableIntStateOf(0) + + @Composable + fun ApplicationScope.Windows() { + TabWindows( + workspace = tabs, + windowContentWrapper = { content -> + // The window joins its own workspace once, for as long as it + // lives: tying membership to the tab body would destroy and + // recreate a native palette on every tab change. + val group = tabs.groupOf(window) + if (group != null) JoinSatelliteWorkspace(palettesOf(group.id)) + content() + }, + onLastWindowClosed = { lastWindowClosedCount.value++ }, + ) + for (title in titles) { + key(title) { + Tab(workspace = tabs, id = tabId(title), title = title) { TabBody(title) } + } + } + for (group in rememberLiveGroups(tabs)) { + key(group.id) { WindowPalette(group) } + } + } + + /** One tab's body: the dock layout its window's panels live in, plus a saveable value. */ + @Composable + private fun TabScope.TabBody(title: String) { + val id = tabId(title) + val clicks = rememberSaveable { mutableStateOf(0) } + val window = LocalTaoWindow.current + val palettes = tab.group?.let { palettesOf(it.id) } + + SideEffect { + counters.value = counters.value + (id to clicks) + } + DisposableEffect(Unit) { + composedBodies.value++ + if (window != null) composedIn.value = composedIn.value.plusHost(id, window) + onDispose { + composedBodies.value-- + if (window != null) composedIn.value = composedIn.value.minusHost(id, window) + } + } + val group = tab.group + if (palettes == null || group == null) { + Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) + } else { + DockLayout(palettes, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color(0xFF2D6CDF))) { + bodyExtra?.invoke(group) + } + } + } + } + + /** The palette of one tab window, drawing whichever tab that window shows. */ + @Composable + private fun ApplicationScope.WindowPalette(group: TabWindowGroup) { + val workspace = palettesOf(group.id) + DisposableEffect(group.id) { + onDispose { + workspaces.remove(group.id) + panelHost.value = panelHost.value - group.id + floatingPalette.value = floatingPalette.value - group.id + paletteShows.value = paletteShows.value - group.id + } + } + val shown = tabs.selectedTab(group)?.title + Satellite( + workspace = workspace, + id = paletteId(group.id), + title = "Palette ${shown ?: "—"}", + initialPlacement = + SatellitePlacement.Floating( + positioner = workspaceRightEdgePositioner(), + size = workspaceSatelliteSize(), + ), + ) { + val clicks = rememberSaveable { mutableStateOf(0) } + val window = LocalTaoWindow.current + val docked = isDocked + // A plain `remember`: back at a fresh identity whenever this + // subtree is rebuilt rather than moved. + val incarnation = remember { Any() } + SideEffect { + paletteCounters.value = paletteCounters.value + (group.id to clicks) + paletteShows.value = paletteShows.value + (group.id to shown) + if (docked) { + if (window != null) { + panelHost.value = panelHost.value + (group.id to window) + publishedPanel[group.id] = incarnation + } + } else if (window != null) { + floatingPalette.value = floatingPalette.value + (group.id to window) + publishedFloating[group.id] = incarnation + } + } + DisposableEffect(incarnation) { + composedPalettes.value++ + paletteIncarnations.value = + paletteIncarnations.value + (group.id to (paletteIncarnations.value[group.id] ?: 0) + 1) + onDispose { + composedPalettes.value-- + // Only the body that published the entry may withdraw it. + // A panel moving from one tab body's DockLayout to the + // next is disposed *after* its successor composed — movable + // content is released at the end of the frame — so the + // leaving body must not erase what the arriving one wrote. + if (docked) { + if (publishedPanel[group.id] === incarnation) { + panelHost.value = panelHost.value - group.id + publishedPanel.remove(group.id) + } + } else if (publishedFloating[group.id] === incarnation) { + floatingPalette.value = floatingPalette.value - group.id + publishedFloating.remove(group.id) + } + } + } + Box(Modifier.fillMaxSize().background(Color(0xFF7A5CD6))) + } + } +} + +/** + * The tab workspace's groups, mirrored out through an effect. + * + * The tabs are declared above the call site, so the write that creates the + * first group lands during a composition that has already read the list — + * and Compose drops an invalidation aimed at a scope it has just composed. + * Read directly, the first window's palettes would never be declared. + */ +@Composable +internal fun rememberLiveGroups(workspace: TabWorkspace): List { + var groups by remember(workspace) { mutableStateOf(workspace.groups.toList()) } + LaunchedEffect(workspace) { + snapshotFlow { workspace.groups.toList() }.collect { groups = it } + } + return groups +} + +/** Waits until every named tab is declared, its window mapped and its palettes alive. */ +internal suspend fun TaoWindowTestScope.awaitTabSatellites( + fixture: TabSatellitesFixture, + vararg titles: String, +): TaoWindow { + awaitUntil("case window mapped") { bounds() != null } + awaitUntil("every tab declared") { titles.all { fixture.tabs.tab(fixture.tabId(it)) != null } } + awaitUntil("a tab window is mapped with a real size") { + val rect = + fixture.tabs.groups + .firstOrNull() + ?.window + ?.outerBoundsPx() ?: return@awaitUntil false + rect[RECT_W] > 0 && rect[RECT_H] > 0 + } + awaitUntil("the selected tab's body is composed") { fixture.composedBodies.value > 0 } + val group = requireNotNull(fixture.tabs.groups.firstOrNull()) + awaitUntil("the window joined its own satellite workspace") { + fixture.palettesOf(group.id).members.isNotEmpty() + } + awaitUntil("its palette is declared") { + fixture.palettesOf(group.id).satellite(fixture.paletteId(group.id)) != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull(group.window) +} + +/** Waits until the palette of [group] is composed as a floating window, and returns it. */ +internal suspend fun TaoWindowTestScope.awaitFloatingPalette( + fixture: TabSatellitesFixture, + group: TabWindowGroup, +): TaoWindow { + awaitUntil("the palette of ${group.id} floats with a real size") { + val rect = fixture.floatingPalette.value[group.id]?.outerBoundsPx() ?: return@awaitUntil false + rect[RECT_W] > 0 && rect[RECT_H] > 0 + } + settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull(fixture.floatingPalette.value[group.id]) +} + +/** A point in [window]'s content space, [fx]/[fy] of the way across it. */ +internal fun contentPointPx( + window: TaoWindow, + fx: Float, + fy: Float, +): Offset { + val outer = requireNotNull(window.outerBoundsPx()) { "the window is not mapped" } + return Offset(outer[RECT_W] * fx, outer[RECT_H] * fy) +} + +/** Temp files a drop can name, deleted when the JVM exits. */ +internal fun dropFiles( + count: Int, + prefix: String = "nucleus-drop", +): List = + (1..count).map { index -> + File + .createTempFile("$prefix-$index-", ".txt") + .apply { + deleteOnExit() + writeText("drop $index") + }.absolutePath + } + +/** Window size for the tab-satellites cases: wide enough for a strip of several tabs. */ +internal const val CHAOS_WINDOW_W_DP = 720 + +internal const val CHAOS_WINDOW_H_DP = 460 + +/** + * Tears the tab titled [title] out of [from] into a window of its own, and + * waits until that window is mapped with a laid-out strip and a satellite + * workspace of its own. + */ +internal suspend fun TaoWindowTestScope.tearOffTabWindow( + fixture: TabSatellitesFixture, + title: String, + from: TaoWindow, +): TabWindowGroup { + val group = + requireNotNull( + fixture.tabs.tearOff(fixture.tabId(title), tearOffRectPx(from), from.scaleFactor), + ) { "tearing $title off produced no window" } + awaitUntil("the torn-off window is mapped with a strip") { + val window = group.window ?: return@awaitUntil false + (window.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L && + fixture.tabs.stripGeometry(group)?.layoutScreenRectPx() != null && + group.slotsInWindowPx.size >= group.ids.size + } + awaitUntil("it joined a satellite workspace of its own") { + fixture.hasPalettes(group.id) && fixture.palettesOf(group.id).owner === group.window + } + settle(SETTLE_AFTER_MAP_MILLIS) + return group +} + +/** Screen centre (physical px) of the tab titled [title] in its strip. */ +internal fun tabCenterOnScreenPx( + fixture: TabSatellitesFixture, + title: String, +): Offset? { + val group = fixture.groupOf(title) ?: return null + val index = group.ids.indexOf(fixture.tabId(title)).takeIf { it >= 0 } ?: return null + val slot = group.slotsInWindowPx.getOrNull(index) ?: return null + val client = fixture.tabs.stripGeometry(group)?.clientOriginPx() ?: return null + return client + slot.center +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceFileDropHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceFileDropHeadfulCases.kt new file mode 100644 index 000000000..529e8060f --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceFileDropHeadfulCases.kt @@ -0,0 +1,626 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import java.io.File + +/** + * Files dragged in from outside the application, on real windows. + * + * The OS delivers an inbound drag through the platform bridge callbacks, which + * hand the window's scene root to `TaoSceneDnD`; these cases enter the same + * funnel from inside the process (see [fileDropRecorder] and the helpers next + * to it), so everything above the JNI boundary is the real thing: the synthetic + * AWT transferable, Compose's drag-and-drop node tree, the application's own + * `dragAndDropTarget`, and the paths read back through `awtTransferable`. + * + * 1. **the happy path** — enter, move, drop, and the paths arrive intact; + * 2. **where a drop lands** — outside every target, between two targets, and + * past a target that refuses the drag; + * 3. **drags that end badly** — one that leaves without dropping, an empty + * payload, paths that do not exist, hundreds of samples, drops back to back; + * 4. **against the workspaces** — a drop into the tab a window is showing, + * a drop while a tab drag is live, and a drop aimed at a window that is + * being torn down under it. + */ +internal object WorkspaceFileDropHeadfulCases { + fun all(): List = + listOf( + filesDroppedOnAWindowReachTheTarget(), + aDropOutsideEveryTargetIsRefused(), + aDragThatLeavesWithoutDroppingLeavesNoState(), + twoTargetsAndOnlyTheOneUnderThePointerTakesIt(), + aTargetThatRefusesTheDragLetsTheOneBelowHaveIt(), + anEmptyPayloadStillReachesTheTarget(), + pathsThatDoNotExistArriveVerbatim(), + hundredsOfSamplesInOneFileDragStayConsistent(), + dropsBackToBackEachDeliverTheirOwnFiles(), + filesDroppedOnATabWindowLandInTheSelectedTab(), + filesFollowTheSelectionAndTheTabToItsNewWindow(), + aFileDragWhileATabDragIsLiveDisturbsNeither(), + aDropAimedAtAClosingWindowIsSurvivable(), + aDropOnEveryWindowOfASpreadReachesEachOne(), + ) + + // ── 1. the happy path ──────────────────────────────────────────────── + + /** + * One drag from the file manager, start to finish. What has to hold is not + * that a callback fired but that the *paths* came out of the transferable + * on the other side, in order — that is the whole contract an application + * writes against. + */ + private fun filesDroppedOnAWindowReachTheTarget(): TaoWindowTestCase { + val log = FileDropLog() + val files = dropFiles(count = 3) + return TaoWindowTestCase( + name = "file drop delivers every path to the target under the pointer", + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Box(Modifier.fillMaxSize().background(Color.DarkGray).fileDropRecorder(log)) + }, + driver = { + awaitDropTarget() + val point = contentPointPx(window, HALF, HALF) + + check(window.fileDragEnter(point)) { "the scene refused a file drag over a target" } + awaitUntil("the target was entered") { log.entered.value == 1 } + check(window.fileDragOver(point)) { "no eligible drop target while hovering one" } + check(window.fileDrop(point, files)) { "the drop was refused" } + + awaitUntil("the drop was recorded") { log.drops.value == 1 } + check(log.failure.value == null) { "reading the drop failed: ${log.failure.value}" } + check(log.files.value == files) { "arrived as ${log.files.value}, dropped $files" } + check(log.ended.value >= 1) { "the target was never told the drag ended" } + settle() + check(bounds() != null) { "the window did not survive a file drop" } + }, + ) + } + + // ── 2. where a drop lands ──────────────────────────────────────────── + + /** + * A drop on the window but clear of every target: the scene has to say no, + * so the OS can tell the user the drag was not taken rather than swallow + * the files. + */ + private fun aDropOutsideEveryTargetIsRefused(): TaoWindowTestCase { + val log = FileDropLog() + return TaoWindowTestCase( + name = "file drop clear of every target is refused", + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Column(Modifier.fillMaxSize().background(Color.DarkGray)) { + Box(Modifier.fillMaxWidth().weight(1f).fileDropRecorder(log)) + Box(Modifier.fillMaxWidth().weight(1f).background(Color(0xFF303030))) + } + }, + driver = { + awaitDropTarget() + val onTarget = contentPointPx(window, HALF, TOP_QUARTER) + val offTarget = contentPointPx(window, HALF, BOTTOM_QUARTER) + + check(window.fileDragEnter(onTarget)) { "the top half must take the drag" } + awaitUntil("entered on the target") { log.entered.value == 1 } + check(!window.fileDragOver(offTarget)) { "the bottom half offered a drop target" } + check(!window.fileDrop(offTarget, dropFiles(1))) { "a drop clear of every target was accepted" } + settle() + check(log.drops.value == 0) { "the target took a drop aimed elsewhere" } + }, + ) + } + + /** + * The user changes their mind: the drag leaves the window without a drop. + * The target has to be told, and the window has to be ready for the next + * one — a stuck "drag in progress" is what makes the second drop silently + * do nothing. + */ + private fun aDragThatLeavesWithoutDroppingLeavesNoState(): TaoWindowTestCase { + val log = FileDropLog() + val files = dropFiles(count = 1, prefix = "nucleus-after-leave") + return TaoWindowTestCase( + name = "file drag that leaves without dropping leaves the window ready for the next", + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Box(Modifier.fillMaxSize().background(Color.DarkGray).fileDropRecorder(log)) + }, + driver = { + awaitDropTarget() + val point = contentPointPx(window, HALF, HALF) + + window.fileDragEnter(point) + window.fileDragOver(point) + window.fileDragLeave() + awaitUntil("the target was told the drag left") { log.exited.value >= 1 } + settle() + check(log.drops.value == 0) { "a drag that left dropped anyway" } + + // And the very next drag still works, all the way through. + check(window.fileDragEnter(point)) { "the second drag was refused" } + check(window.fileDrop(point, files)) { "the second drop was refused" } + awaitUntil("the second drop arrived") { log.drops.value == 1 } + check(log.files.value == files) { "the second drop arrived as ${log.files.value}" } + }, + ) + } + + /** + * Two targets side by side: the drop belongs to the one under the pointer + * and to no other. This is the shape of a real window — a document area + * and a palette, each taking its own files. + */ + private fun twoTargetsAndOnlyTheOneUnderThePointerTakesIt(): TaoWindowTestCase { + val top = FileDropLog() + val bottom = FileDropLog() + val toTop = dropFiles(count = 1, prefix = "nucleus-top") + val toBottom = dropFiles(count = 2, prefix = "nucleus-bottom") + return TaoWindowTestCase( + name = "file drop with two targets reaches only the one under the pointer", + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Column(Modifier.fillMaxSize().background(Color.DarkGray)) { + Box(Modifier.fillMaxWidth().weight(1f).fileDropRecorder(top)) + Box(Modifier.fillMaxWidth().weight(1f).fileDropRecorder(bottom)) + } + }, + driver = { + awaitDropTarget() + val onTop = contentPointPx(window, HALF, TOP_QUARTER) + val onBottom = contentPointPx(window, HALF, BOTTOM_QUARTER) + + check(window.fileDragAndDrop(onTop, toTop)) { "the top target refused its drop" } + awaitUntil("the top target got it") { top.drops.value == 1 } + check(bottom.drops.value == 0) { "the bottom target took the top's drop" } + check(top.files.value == toTop) { "the top target got ${top.files.value}" } + + check(window.fileDragAndDrop(onBottom, toBottom)) { "the bottom target refused its drop" } + awaitUntil("the bottom target got it") { bottom.drops.value == 1 } + check(top.drops.value == 1) { "the top target took a second drop" } + check(bottom.files.value == toBottom) { "the bottom target got ${bottom.files.value}" } + }, + ) + } + + /** + * A target that refuses the drag altogether — an area that takes text but + * not files, say. The drop has to fall through to whatever is behind it + * rather than be eaten by the refusal. + */ + private fun aTargetThatRefusesTheDragLetsTheOneBelowHaveIt(): TaoWindowTestCase { + val refusing = FileDropLog() + val accepting = FileDropLog() + val files = dropFiles(count = 2, prefix = "nucleus-fallthrough") + return TaoWindowTestCase( + name = "file drop falls through a target that refuses the drag", + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Box(Modifier.fillMaxSize().background(Color.DarkGray).fileDropRecorder(accepting)) { + Box(Modifier.fillMaxSize().fileDropRecorder(refusing, accept = false)) + } + }, + driver = { + awaitDropTarget() + val point = contentPointPx(window, HALF, HALF) + + check(window.fileDragAndDrop(point, files)) { "no target took the drop" } + awaitUntil("the accepting target got it") { accepting.drops.value == 1 } + check(refusing.drops.value == 0) { "the refusing target took the drop" } + check(refusing.entered.value == 0) { "the refusing target was entered" } + check(accepting.files.value == files) { "arrived as ${accepting.files.value}" } + }, + ) + } + + // ── 3. drags that end badly ────────────────────────────────────────── + + /** + * A drop the OS reports with nothing in it — a drag of a kind we do not + * carry, or a source that withdrew its data. The target still runs; it + * just gets an empty list, and reading it must not throw. + */ + private fun anEmptyPayloadStillReachesTheTarget(): TaoWindowTestCase { + val log = FileDropLog() + return TaoWindowTestCase( + name = "file drop with an empty payload reaches the target without throwing", + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Box(Modifier.fillMaxSize().background(Color.DarkGray).fileDropRecorder(log)) + }, + driver = { + awaitDropTarget() + val point = contentPointPx(window, HALF, HALF) + check(window.fileDragAndDrop(point, emptyList())) { "an empty drop was refused" } + awaitUntil("the empty drop arrived") { log.drops.value == 1 } + check(log.failure.value == null) { "reading an empty payload threw: ${log.failure.value}" } + check(log.files.value.isEmpty()) { "an empty drop produced ${log.files.value}" } + + // And a real one right after it still works. + val files = dropFiles(count = 1, prefix = "nucleus-after-empty") + check(window.fileDragAndDrop(point, files)) + awaitUntil("the real drop arrived") { log.drops.value == 2 } + check(log.files.value == files) + }, + ) + } + + /** + * Paths the drag names that are not on this machine — a stale drag from a + * removed volume, a path only the source can see. Nothing in the chain may + * touch the filesystem, so they have to arrive exactly as sent and let the + * application decide. + */ + private fun pathsThatDoNotExistArriveVerbatim(): TaoWindowTestCase { + val log = FileDropLog() + val ghosts = + listOf( + File("/nucleus/does/not/exist/one.txt").absolutePath, + File("/nucleus/does/not/exist/two with spaces.txt").absolutePath, + File("/nucleus/does/not/exist/three-é-ü.txt").absolutePath, + ) + return TaoWindowTestCase( + name = "file drop of paths that do not exist arrives verbatim", + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Box(Modifier.fillMaxSize().background(Color.DarkGray).fileDropRecorder(log)) + }, + driver = { + awaitDropTarget() + val point = contentPointPx(window, HALF, HALF) + check(window.fileDragAndDrop(point, ghosts)) { "the drop was refused" } + awaitUntil("the drop arrived") { log.drops.value == 1 } + check(log.failure.value == null) { "reading unreachable paths threw: ${log.failure.value}" } + check(log.files.value == ghosts) { "arrived as ${log.files.value}" } + }, + ) + } + + /** + * A slow drag across the window: hundreds of move samples before the drop. + * Enter has to happen once and only once, and the target must still be the + * one that gets the files at the end. + */ + private fun hundredsOfSamplesInOneFileDragStayConsistent(): TaoWindowTestCase { + val log = FileDropLog() + val files = dropFiles(count = 1, prefix = "nucleus-storm") + return TaoWindowTestCase( + name = "file drag with hundreds of samples enters once and drops once", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Box(Modifier.fillMaxSize().background(Color.DarkGray).fileDropRecorder(log)) + }, + driver = { + awaitDropTarget() + val outer = requireNotNull(bounds()) + val start = contentPointPx(window, EDGE_INSET, HALF) + check(window.fileDragEnter(start)) { "the drag was refused" } + awaitUntil("entered once") { log.entered.value == 1 } + + repeat(SAMPLE_STORM) { step -> + val t = step / SAMPLE_STORM.toFloat() + val x = outer[RECT_W] * (EDGE_INSET + t * (1f - 2 * EDGE_INSET)) + check(window.fileDragOver(Offset(x, outer[RECT_H] * HALF))) { + "sample $step found no drop target inside a full-window one" + } + } + settle() + check(log.entered.value == 1) { + "the storm entered the target ${log.entered.value}× for one drag" + } + check(log.drops.value == 0) { "a move sample dropped" } + + val end = contentPointPx(window, 1f - EDGE_INSET, HALF) + check(window.fileDrop(end, files)) { "the drop after the storm was refused" } + awaitUntil("the storm ended in a drop") { log.drops.value == 1 } + check(log.files.value == files) { "arrived as ${log.files.value}" } + }, + ) + } + + /** + * Drop after drop with no frame in between — the shape of a script feeding + * a window, and of a user who drops a batch impatiently. Each drop carries + * its own payload and none may leak into the next. + */ + private fun dropsBackToBackEachDeliverTheirOwnFiles(): TaoWindowTestCase { + val log = FileDropLog() + return TaoWindowTestCase( + name = "file drops back to back each deliver their own payload", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + size = DpSize(DROP_WINDOW_W_DP.dp, DROP_WINDOW_H_DP.dp), + paintDefaultBackground = false, + content = { + Box(Modifier.fillMaxSize().background(Color.DarkGray).fileDropRecorder(log)) + }, + driver = { + awaitDropTarget() + val point = contentPointPx(window, HALF, HALF) + val batches = (1..DROP_BURST).map { listOf(File("/nucleus/burst/$it.txt").absolutePath) } + for ((index, batch) in batches.withIndex()) { + check(window.fileDragAndDrop(point, batch)) { "drop $index was refused" } + } + awaitUntil("every drop arrived") { log.drops.value == DROP_BURST } + settle() + check(log.failure.value == null) { "a drop in the burst threw: ${log.failure.value}" } + check(log.allFiles.toList() == batches.flatten()) { + "the burst arrived as ${log.allFiles.toList()}" + } + check(bounds() != null) { "the window did not survive the burst" } + }, + ) + } + + // ── 4. against the workspaces ──────────────────────────────────────── + + /** + * The everyday case in a tabbed application: files dropped on the window + * belong to the document it is showing, and to no other tab. + */ + private fun filesDroppedOnATabWindowLandInTheSelectedTab(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta"), fileDropTargets = true) + val toAlpha = dropFiles(count = 1, prefix = "nucleus-alpha") + val toBeta = dropFiles(count = 2, prefix = "nucleus-beta") + return TaoWindowTestCase( + name = "file drop on a tab window lands in the tab it is showing", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val tabWindow = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + workspace.select(fixture.tabId("Alpha")) + awaitUntil("Alpha is composed") { fixture.windowOf("Alpha") === tabWindow } + settle(SETTLE_AFTER_MAP_MILLIS) + + val point = contentPointPx(tabWindow, HALF, BOTTOM_QUARTER) + check(tabWindow.fileDragAndDrop(point, toAlpha)) { "the drop on Alpha was refused" } + awaitUntil("Alpha took the files") { fixture.dropLog("Alpha").drops.value == 1 } + check(fixture.dropLog("Alpha").files.value == toAlpha) + check(fixture.dropLog("Beta").drops.value == 0) { "the hidden tab took the drop" } + + workspace.select(fixture.tabId("Beta")) + awaitUntil("Beta is composed") { fixture.windowOf("Beta") === tabWindow } + settle(SETTLE_AFTER_MAP_MILLIS) + check(tabWindow.fileDragAndDrop(point, toBeta)) { "the drop on Beta was refused" } + awaitUntil("Beta took the files") { fixture.dropLog("Beta").drops.value == 1 } + check(fixture.dropLog("Beta").files.value == toBeta) + check(fixture.dropLog("Alpha").drops.value == 1) { "Alpha took a second drop while hidden" } + }, + ) + } + + /** + * A tab torn into a window of its own keeps its drop target: the body + * moved, so the files dropped on the *new* window have to reach it there, + * and the window it left must not answer for it any more. + */ + private fun filesFollowTheSelectionAndTheTabToItsNewWindow(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta"), fileDropTargets = true) + val files = dropFiles(count = 1, prefix = "nucleus-torn") + return TaoWindowTestCase( + name = "file drop follows a tab into the window it was torn into", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + + val torn = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + val tornWindow = awaitMappedStrip(fixture, torn) + awaitUntil("Beta composes in its own window") { fixture.windowOf("Beta") === tornWindow } + settle(SETTLE_AFTER_MAP_MILLIS) + + val onTorn = contentPointPx(tornWindow, HALF, BOTTOM_QUARTER) + check(tornWindow.fileDragAndDrop(onTorn, files)) { "the torn-off window refused the drop" } + awaitUntil("Beta took the files in its new window") { fixture.dropLog("Beta").drops.value == 1 } + check(fixture.dropLog("Beta").files.value == files) + check(fixture.dropLog("Alpha").drops.value == 0) { "the window Beta left took the drop" } + + // The window it came from is still a target of its own. + val onFirst = contentPointPx(first, HALF, BOTTOM_QUARTER) + val other = dropFiles(count = 1, prefix = "nucleus-home") + check(first.fileDragAndDrop(onFirst, other)) { "the source window stopped taking drops" } + awaitUntil("Alpha took its own files") { fixture.dropLog("Alpha").drops.value == 1 } + check(fixture.dropLog("Alpha").files.value == other) + }, + ) + } + + /** + * Two drag mechanisms live at once: the user is holding a tab with the + * mouse while a file drag from another application crosses the window. + * They share nothing, so neither may disturb the other — and the tab drag + * has to be exactly where it was when the files land. + */ + private fun aFileDragWhileATabDragIsLiveDisturbsNeither(): TaoWindowTestCase { + val fixture = + TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta", "Gamma"), fileDropTargets = true) + val files = dropFiles(count = 1, prefix = "nucleus-during-drag") + return TaoWindowTestCase( + name = "file drag crossing a live tab drag disturbs neither", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta", "Gamma") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val group = requireNotNull(fixture.groupOf("Beta")) + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val away = requireNotNull(fixture.farFromStripPx(group)) + + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + session.update(away) + check(workspace.dragGhost != null) { "the tab tear-out must be previewed" } + + // Lifting a tab selects it, so the body under the pointer is the + // dragged tab's from the grab onwards. Waited for rather than + // assumed: the files would otherwise land in whichever body was + // still composed a frame ago. + val selected = requireNotNull(workspace.selectedTab(group)).title + awaitUntil("the lifted tab's body is the one composed") { fixture.windowOf(selected) === first } + val point = contentPointPx(first, HALF, BOTTOM_QUARTER) + check(first.fileDragAndDrop(point, files)) { "the file drop was refused mid tab drag" } + awaitUntil("the files reached the selected tab") { fixture.dropLog(selected).drops.value == 1 } + + check(workspace.draggedTab?.id == beta) { "the file drag ended the tab drag" } + check(workspace.dragGhost != null) { "the file drag cleared the tab ghost" } + check(workspace.groups.size == 1) { "the file drag moved a tab" } + + session.end(away) + awaitUntil("the tab drag still lands") { + workspace.groups.size == 2 && fixture.groupOf("Beta")?.ids == listOf(beta) + } + check(fixture.dropLog(selected).files.value == files) { "the files were lost by the tab drag" } + }, + ) + } + + /** + * A drop aimed at a window the application is closing in the same frame — + * the drag was accepted by a scene that no longer exists by the time the + * files arrive. Nothing may throw, and the surviving window has to keep + * taking drops. + */ + private fun aDropAimedAtAClosingWindowIsSurvivable(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha", "Beta"), fileDropTargets = true) + val files = dropFiles(count = 1, prefix = "nucleus-closing") + return TaoWindowTestCase( + name = "file drop aimed at a window closing under it is survivable", + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, "Alpha", "Beta") + val workspace = fixture.workspace + val beta = fixture.tabId("Beta") + val torn = requireNotNull(workspace.tearOff(beta, tearOffRectPx(first), first.scaleFactor)) + val tornWindow = awaitMappedStrip(fixture, torn) + awaitUntil("Beta composes in its own window") { fixture.windowOf("Beta") === tornWindow } + settle(SETTLE_AFTER_MAP_MILLIS) + + val point = contentPointPx(tornWindow, HALF, BOTTOM_QUARTER) + check(tornWindow.fileDragEnter(point)) { "the torn-off window refused the drag" } + + var destroyed = false + tornWindow.onDestroyed { destroyed = true } + workspace.close(beta) + awaitUntil("the window went away under the drag") { destroyed } + settle() + + // The OS has no way of knowing; it delivers the drop anyway. + check(!tornWindow.fileDrop(point, files)) { "a destroyed window accepted a drop" } + tornWindow.fileDragLeave() + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.dropLog("Beta").drops.value == 0) { "a closed tab took a drop" } + + // The survivor is untouched. + val onFirst = contentPointPx(first, HALF, BOTTOM_QUARTER) + check(first.fileDragAndDrop(onFirst, files)) { "the surviving window stopped taking drops" } + awaitUntil("the surviving window took the files") { fixture.dropLog("Alpha").drops.value == 1 } + }, + ) + } + + /** + * Files dropped on each of several windows in turn. Every window owns its + * own scene and its own drop target; a single shared one would send every + * drop to whichever window happened to be focused. + */ + private fun aDropOnEveryWindowOfASpreadReachesEachOne(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles, fileDropTargets = true) + return TaoWindowTestCase( + name = "file drops on a spread of windows each reach their own tab", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabWindows(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + for (title in titles.drop(1)) { + val from = requireNotNull(fixture.groupOf(title)?.window) + val group = + requireNotNull( + workspace.tearOff(fixture.tabId(title), tearOffRectPx(from), from.scaleFactor), + ) + awaitMappedStrip(fixture, group) + } + awaitUntil("every tab composes in a window of its own") { + titles.mapNotNull { fixture.windowOf(it) }.distinct().size == titles.size + } + settle(SETTLE_AFTER_MAP_MILLIS) + + val payloads = + titles.associateWith { title -> + listOf(File("/nucleus/spread/${title.lowercase()}.txt").absolutePath) + } + for (title in titles) { + val host = requireNotNull(fixture.windowOf(title)) { "$title has no window" } + val point = contentPointPx(host, HALF, BOTTOM_QUARTER) + check(host.fileDragAndDrop(point, requireNotNull(payloads[title]))) { + "$title's window refused its drop" + } + } + awaitUntil("every window took exactly one drop") { + titles.all { fixture.dropLog(it).drops.value == 1 } + } + settle() + for (title in titles) { + check(fixture.dropLog(title).files.value == payloads[title]) { + "$title got ${fixture.dropLog(title).files.value}" + } + } + }, + ) + } + + /** Waits until this case's window has attached a scene that can answer a drag. */ + private suspend fun TaoWindowTestScope.awaitDropTarget() { + awaitUntil("window mapped") { bounds() != null } + awaitUntil("the scene published a drop target") { window.hasSceneDropTarget() } + settle(SETTLE_AFTER_MAP_MILLIS) + } + + private const val DROP_WINDOW_W_DP = 480 + private const val DROP_WINDOW_H_DP = 320 + private const val HALF = 0.5f + private const val TOP_QUARTER = 0.25f + private const val BOTTOM_QUARTER = 0.75f + private const val EDGE_INSET = 0.1f + private const val SAMPLE_STORM = 300 + private const val DROP_BURST = 20 + private const val LONG_CASE_TIMEOUT_MILLIS = 90_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceLoadHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceLoadHeadfulCases.kt new file mode 100644 index 000000000..d4e74a576 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceLoadHeadfulCases.kt @@ -0,0 +1,593 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.TabWindowGroup +import dev.nucleusframework.window.tao.TaoWindow +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import kotlin.math.abs + +/** + * The whole archetype under sustained load: several document windows open at + * once, each hosting its own palettes, each animating, while tabs are switched, + * torn off and merged as fast as the loop will take it. + * + * One event loop drives every window, so load is where the archetype's costs + * become visible: a window that stops being scheduled, a palette whose follow + * falls behind the window it belongs to, an anchoring that never catches up + * because the parent moves again before it lands. None of that shows in a case + * that drives one window at a time. + * + * What is asserted is **fairness and convergence**, never an absolute frame + * rate: CI runners paint through software GL, and a hard fps threshold there + * measures the runner. Every window has to keep getting frames while the + * others do, and every gesture has to converge once the storm stops. + */ +internal object WorkspaceLoadHeadfulCases { + fun all(): List = + listOf( + everyWindowKeepsGettingFramesWhileTheOthersAnimate(), + palettesKeepUpWithABurstOfOwnerMoves(), + aTabStormAcrossFourAnimatingWindowsConverges(), + tearOffAndMergeUnderAnimationLoadLoseNoTabs(), + aPaletteDockedAndUndockedRepeatedlyUnderLoad(), + everyWindowStillPaintsAfterHalfOfThemClose(), + aSelectionStormWhilePalettesAnimateKeepsOneBodyPerWindow(), + anchoringConvergesWhenTheOwnerNeverStopsMoving(), + ) + + /** + * Four windows, all animating. The loop is shared, so the question is + * whether it is shared *fairly*: every window has to keep painting while + * the others do. A window that stops being scheduled looks alive — its + * state is right, its size is right — and is frozen on screen. + */ + private fun everyWindowKeepsGettingFramesWhileTheOthersAnimate(): TaoWindowTestCase { + val titles = (1..WINDOW_CROWD).map { "W$it" } + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load every window keeps getting frames while the others animate", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + val groups = fixture.spread(this, first, titles.drop(1)) + check(groups.size + 1 == WINDOW_CROWD) { "expected $WINDOW_CROWD windows" } + awaitUntil("every window is animating", detail = { fixture.frameReport() }) { + fixture.workspace.groups.all { fixture.frames(it.id) > MIN_FRAMES } + } + + val before = fixture.workspace.groups.associate { it.id to fixture.frames(it.id) } + settle(FRAME_WINDOW_MILLIS) + val after = fixture.workspace.groups.associate { it.id to fixture.frames(it.id) } + val painted = after.mapValues { (id, n) -> n - (before[id] ?: 0L) } + check(painted.values.all { it >= MIN_FRAMES }) { + "a window was starved over ${FRAME_WINDOW_MILLIS}ms: $painted" + } + // Fairness, not a rate: the busiest window may get several + // times the frames of the quietest, but not all of them. + val most = painted.values.max() + val least = painted.values.min() + check(least * STARVATION_RATIO >= most) { + "one window got $most frames while another got $least" + } + }, + ) + } + + /** + * The owner window dragged in a burst while its palette follows. Every + * move is a native command for the satellite, and a follow that queues them + * instead of converging leaves the palette trailing across the desktop + * after the drag ends. + */ + private fun palettesKeepUpWithABurstOfOwnerMoves(): TaoWindowTestCase { + val titles = listOf("W1", "W2") + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load palettes keep up with a burst of owner moves", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + val group = requireNotNull(fixture.workspace.groups.first()) + val palette = fixture.awaitPalette(this, group) + awaitUntil("the palette captured its offset") { + fixture + .satellites(group.id) + .satellite(fixture.paletteId(group.id)) + ?.windowState + ?.offsetFromParent != null + } + settle(SETTLE_AFTER_MAP_MILLIS) + + val ownerStart = requireNotNull(first.outerBoundsPx()) + val paletteStart = requireNotNull(palette.outerBoundsPx()) + val offsetX = paletteStart[0] - ownerStart[0] + val offsetY = paletteStart[1] - ownerStart[1] + val scale = first.scaleFactor.toDouble() + + // A drag's worth of moves, faster than the platform answers. + repeat(MOVE_BURST) { round -> + val delta = (round % MOVE_SPAN) * MOVE_STEP_DP + first.setOuterPosition(ownerStart[0] / scale + delta, ownerStart[1] / scale + delta) + } + first.setOuterPosition(ownerStart[0] / scale, ownerStart[1] / scale) + + awaitUntil("the palette converged back onto its offset") { + val owner = first.outerBoundsPx() ?: return@awaitUntil false + val follower = palette.outerBoundsPx() ?: return@awaitUntil false + abs((follower[0] - owner[0]) - offsetX) <= FOLLOW_SLOP_PX && + abs((follower[1] - owner[1]) - offsetY) <= FOLLOW_SLOP_PX + } + check(requireNotNull(palette.outerBoundsPx())[RECT_W] > 0L) { + "the palette lost its size in the burst" + } + }, + ) + } + + /** + * Selections and reorders fired across four animating windows at once. + * Every window is repainting while its strip is rewritten, which is the + * frame where a stale slot list turns into a drop landing in the wrong + * place. Afterwards every strip has to describe itself again. + */ + private fun aTabStormAcrossFourAnimatingWindowsConverges(): TaoWindowTestCase { + val titles = (1..TAB_CROWD).map { "W$it" } + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load a tab storm across four animating windows converges", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + val leads = titles.filterIndexed { index, _ -> index % TABS_PER_WINDOW == 0 }.drop(1) + val homes = fixture.spread(this, first, leads) + for ((index, title) in titles.withIndex()) { + val home = homes.getOrNull(index / TABS_PER_WINDOW - 1) ?: continue + if (index % TABS_PER_WINDOW != 0) fixture.workspace.move(fixture.archetype.tabId(title), home) + } + awaitUntil("the tabs are spread") { + fixture.workspace.groups.size == WINDOW_CROWD && + fixture.workspace.groups.sumOf { it.ids.size } == TAB_CROWD + } + settle(SETTLE_AFTER_MAP_MILLIS) + + repeat(STORM_ROUNDS) { round -> + val title = titles[round % titles.size] + fixture.workspace.select(fixture.archetype.tabId(title)) + fixture.workspace.reorder(fixture.archetype.tabId(title), round % TABS_PER_WINDOW) + } + + awaitUntil( + "every strip republished a slot per tab, in order", + detail = { fixture.stripReport() }, + ) { + fixture.workspace.groups.all { group -> + val slots = group.slotsInWindowPx + slots.size >= group.ids.size && + slots.take(group.ids.size).zipWithNext().all { (l, r) -> l.left <= r.left } + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.workspace.groups.sumOf { it.ids.size } == TAB_CROWD) { + "the storm lost a tab: ${fixture.workspace.groups.map { it.ids }}" + } + awaitUntil("one body per window composes") { + fixture.archetype.composedBodies.value == fixture.workspace.groups.size + } + // And the windows are still painting. + val before = fixture.workspace.groups.associate { it.id to fixture.frames(it.id) } + settle(FRAME_WINDOW_MILLIS) + check( + fixture.workspace.groups.all { + fixture.frames(it.id) - (before[it.id] ?: 0L) >= MIN_FRAMES + }, + ) { "a window stopped painting after the storm" } + }, + ) + } + + /** + * Tear-offs and merges while every window animates. Windows are created and + * destroyed under a running frame clock, which is where a scene outlives + * the window it belonged to. + */ + private fun tearOffAndMergeUnderAnimationLoadLoseNoTabs(): TaoWindowTestCase { + val titles = listOf("W1", "W2", "W3") + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load tear-offs and merges under animation load lose no tabs", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + val workspace = fixture.workspace + val home = requireNotNull(fixture.archetype.groupOf("W1")) + + repeat(CHURN_ROUNDS) { round -> + val title = titles[1 + round % (titles.size - 1)] + val id = fixture.archetype.tabId(title) + val from = fixture.archetype.groupOf(title)?.window ?: first + val torn = workspace.tearOff(id, tearOffRectPx(from), from.scaleFactor) + if (torn != null) { + awaitUntil("round $round: $title is in a window of its own") { + fixture.archetype.groupOf(title)?.ids == listOf(id) + } + awaitUntil("round $round: that window is mapped") { + (torn.window?.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L + } + } + workspace.move(id, home) + awaitUntil("round $round: $title is back home") { fixture.archetype.groupOf(title) === home } + } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.groups.size == 1) { "the churn left ${workspace.groups.size} windows" } + check(home.ids.size == titles.size) { "the churn lost a tab: ${home.ids}" } + awaitUntil("one body composes") { fixture.archetype.composedBodies.value == 1 } + val before = fixture.frames(home.id) + settle(FRAME_WINDOW_MILLIS) + check(fixture.frames(home.id) - before >= MIN_FRAMES) { + "the surviving window stopped painting after the churn" + } + }, + ) + } + + /** + * Docking and undocking a palette over and over while its window animates. + * Each round destroys a native window and builds a panel, or the reverse, + * under a live frame clock — and the palette's own state has to ride + * through every one of them. + */ + private fun aPaletteDockedAndUndockedRepeatedlyUnderLoad(): TaoWindowTestCase { + val titles = listOf("W1", "W2") + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load a palette docked and undocked repeatedly under animation load", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + val group = requireNotNull(fixture.workspace.groups.first()) + val host = requireNotNull(group.window) + fixture.awaitPalette(this, group) + val workspace = fixture.satellites(group.id) + val id = fixture.paletteId(group.id) + requireNotNull(fixture.paletteCounters[group.id]).value = SAVED_CLICKS + + repeat(DOCK_ROUNDS) { round -> + workspace.dock(id, if (round % 2 == 0) DockSide.Right else DockSide.Bottom) + awaitUntil("round $round: docked") { fixture.panelHosts[group.id] === host } + check(requireNotNull(fixture.paletteCounters[group.id]).value == SAVED_CLICKS) { + "round $round: the palette lost its state docking" + } + workspace.undock(id) + fixture.awaitPalette(this, group) + check(requireNotNull(fixture.paletteCounters[group.id]).value == SAVED_CLICKS) { + "round $round: the palette lost its state undocking" + } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.composedPalettes() == 1) { + "${fixture.composedPalettes()} palette bodies after the churn" + } + val before = fixture.frames(group.id) + settle(FRAME_WINDOW_MILLIS) + check(fixture.frames(group.id) - before >= MIN_FRAMES) { + "the host window stopped painting after the dock churn" + } + }, + ) + } + + /** + * Half the windows closed while every one of them is animating. The loop + * keeps running, and the survivors must keep being scheduled — a frame + * clock left holding a destroyed window's scene stops the whole loop, not + * just that window. + */ + private fun everyWindowStillPaintsAfterHalfOfThemClose(): TaoWindowTestCase { + val titles = (1..WINDOW_CROWD).map { "W$it" } + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load the survivors still paint after half the windows close", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + fixture.spread(this, first, titles.drop(1)) + awaitUntil("every window is animating", detail = { fixture.frameReport() }) { + fixture.workspace.groups.all { fixture.frames(it.id) > MIN_FRAMES } + } + + val doomed = titles.filterIndexed { index, _ -> index % 2 == 1 } + for (title in doomed) fixture.workspace.close(fixture.archetype.tabId(title)) + awaitUntil("the closed windows went") { + fixture.workspace.groups.size == WINDOW_CROWD - doomed.size + } + settle(SETTLE_AFTER_MAP_MILLIS) + + val before = fixture.workspace.groups.associate { it.id to fixture.frames(it.id) } + settle(FRAME_WINDOW_MILLIS) + val painted = + fixture.workspace.groups.associate { it.id to fixture.frames(it.id) - (before[it.id] ?: 0L) } + check(painted.values.all { it >= MIN_FRAMES }) { + "a survivor stopped painting after the others closed: $painted" + } + check(fixture.workspace.groups.all { (it.window?.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L }) { + "a survivor lost its frame" + } + }, + ) + } + + /** + * Selection changed hundreds of times across windows whose palettes are all + * animating. Every change swaps a body in and out under a running clock, + * which is where a body is left composing in a window that has moved on. + */ + private fun aSelectionStormWhilePalettesAnimateKeepsOneBodyPerWindow(): TaoWindowTestCase { + val titles = (1..TAB_CROWD).map { "W$it" } + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load a selection storm while palettes animate keeps one body per window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + val group = requireNotNull(fixture.workspace.groups.first()) + fixture.awaitPalette(this, group) + check(first.outerBoundsPx() != null) + + repeat(STORM_ROUNDS) { round -> + fixture.workspace.select(fixture.archetype.tabId(titles[round % titles.size])) + } + val last = titles[(STORM_ROUNDS - 1) % titles.size] + awaitUntil("the storm settled on $last") { + group.selectedId == fixture.archetype.tabId(last) + } + awaitUntil("one body composes") { fixture.archetype.composedBodies.value == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.composedPalettes() == 1) { + "${fixture.composedPalettes()} palette bodies after the storm" + } + val before = fixture.frames(group.id) + settle(FRAME_WINDOW_MILLIS) + check(fixture.frames(group.id) - before >= MIN_FRAMES) { + "the window stopped painting after the selection storm" + } + }, + ) + } + + /** + * The owner moved again before its palette has finished being placed — + * over and over. The anchoring is a command the platform answers + * asynchronously, so this is the case where it can chase its own tail and + * never settle. It has to converge the moment the moves stop. + */ + private fun anchoringConvergesWhenTheOwnerNeverStopsMoving(): TaoWindowTestCase { + val titles = listOf("W1") + val fixture = LoadFixture(titles) + return TaoWindowTestCase( + name = "workspace load anchoring converges when the owner never stops moving", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val host = awaitTabSatellites(fixture.archetype, *titles.toTypedArray()) + val group = requireNotNull(fixture.workspace.groups.first()) + val palette = fixture.awaitPalette(this, group) + val state = + requireNotNull(fixture.satellites(group.id).satellite(fixture.paletteId(group.id))).windowState + awaitUntil("the offset was captured") { state.offsetFromParent != null } + settle(SETTLE_AFTER_MAP_MILLIS) + + val start = requireNotNull(host.outerBoundsPx()) + val scale = host.scaleFactor.toDouble() + // Re-anchor requested in the middle of a move burst, repeatedly. + repeat(ANCHOR_ROUNDS) { round -> + host.setOuterPosition( + start[0] / scale + (round % MOVE_SPAN) * MOVE_STEP_DP, + start[1] / scale, + ) + state.reanchor() + } + host.setOuterPosition(start[0] / scale, start[1] / scale) + state.reanchor() + + awaitUntil("the palette settled off the owner's right edge") { + val owner = host.outerBoundsPx() ?: return@awaitUntil false + val follower = palette.outerBoundsPx() ?: return@awaitUntil false + follower[0] >= owner[0] + owner[RECT_W] - ANCHOR_SLOP_PX + } + settle(SETTLE_AFTER_MAP_MILLIS) + val owner = requireNotNull(host.outerBoundsPx()) + val follower = requireNotNull(palette.outerBoundsPx()) + check(follower[RECT_W] > 0L && follower[RECT_H] > 0L) { "the palette lost its size" } + check(follower[0] >= owner[0]) { "the palette ended up left of its owner" } + }, + ) + } + + // ── the fixture ────────────────────────────────────────────────────── + + /** + * Tab windows that animate, each with a palette of its own. + * + * Built on [TabSatellitesFixture] — the same wiring the composed archetype + * uses — with a frame-clock driver per window so a case can tell which + * windows are actually being painted. + */ + private class LoadFixture( + titles: List, + ) { + val archetype: TabSatellitesFixture = + TabSatellitesFixture( + initialTitles = titles, + windowSize = DpSize(LOAD_WINDOW_W_DP.dp, LOAD_WINDOW_H_DP.dp), + bodyExtra = { group -> WindowAnimation(group.id) }, + ) + + /** The tab workspace the windows come from. */ + val workspace: dev.nucleusframework.window.tao.TabWorkspace get() = archetype.tabs + + private val frameCounts = ConcurrentHashMap() + + /** Frames the window of [groupId] has painted since it opened. */ + fun frames(groupId: String): Long = frameCounts[groupId]?.get() ?: 0L + + /** Frames per group, so a starved window names itself in a failure. */ + fun frameReport(): String = + workspace.groups.joinToString { "${it.id}=${frames(it.id)}" } + + " | counted=" + frameCounts.entries.joinToString { "${it.key}=${it.value.get()}" } + + /** Tabs and published slots per group, for a strip that never converges. */ + fun stripReport(): String = + workspace.groups.joinToString { group -> + "${group.id}: tabs=${group.ids.size} slots=${group.slotsInWindowPx.map { it.left.toInt() }}" + } + + fun satellites(groupId: String) = archetype.palettesOf(groupId) + + fun paletteId(groupId: String) = archetype.paletteId(groupId) + + val panelHosts: Map get() = archetype.panelHost.value + + val paletteCounters get() = archetype.paletteCounters.value + + fun composedPalettes(): Int = archetype.composedPalettes.value + + @Composable + fun dev.nucleusframework.window.tao.ApplicationScope.Windows() { + with(archetype) { Windows() } + } + + /** Tears each of [titles] into a window of its own and waits for it. */ + suspend fun spread( + scope: TaoWindowTestScope, + source: TaoWindow, + titles: List, + ): List { + val groups = ArrayList(titles.size) + for (title in titles) { + val from = archetype.groupOf(title)?.window ?: source + val group = + workspace.tearOff(archetype.tabId(title), tearOffRectPx(from), from.scaleFactor) ?: continue + scope.awaitUntil("the window for $title is mapped with a strip") { + (group.window?.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L && + group.slotsInWindowPx.size >= group.ids.size + } + groups += group + } + scope.settle(SETTLE_AFTER_MAP_MILLIS) + return groups + } + + /** Waits for the palette of [group] to be floating with a real size. */ + suspend fun awaitPalette( + scope: TaoWindowTestScope, + group: TabWindowGroup, + ): TaoWindow { + scope.awaitUntil("the palette of ${group.id} is up") { + val rect = archetype.floatingPalette.value[group.id]?.outerBoundsPx() ?: return@awaitUntil false + rect[RECT_W] > 0L && rect[RECT_H] > 0L + } + scope.settle(SETTLE_AFTER_MAP_MILLIS) + return requireNotNull(archetype.floatingPalette.value[group.id]) + } + + /** + * A frame-clock loop for one window, counted per group so a case can + * see which windows the shared loop is actually painting. The phase is + * read in `drawBehind`, which is what keeps the clock ticking. + */ + @Composable + fun WindowAnimation(groupId: String) { + val counter = remember(groupId) { frameCounts.getOrPut(groupId) { AtomicLong() } } + val phase = remember { mutableFloatStateOf(0f) } + Box( + Modifier.fillMaxSize().drawBehind { + @Suppress("UNUSED_EXPRESSION") + phase.value + }, + ) + LaunchedEffect(groupId) { + while (true) { + withFrameNanos { + counter.incrementAndGet() + phase.value = (phase.value + 1f) % PHASE_WRAP + } + } + } + } + } + + private const val WINDOW_CROWD = 4 + private const val TAB_CROWD = 8 + private const val TABS_PER_WINDOW = 2 + private const val LOAD_WINDOW_W_DP = 420 + private const val LOAD_WINDOW_H_DP = 260 + private const val STORM_ROUNDS = 120 + private const val CHURN_ROUNDS = 4 + private const val DOCK_ROUNDS = 4 + private const val MOVE_BURST = 60 + private const val MOVE_SPAN = 6 + private const val MOVE_STEP_DP = 8.0 + private const val ANCHOR_ROUNDS = 40 + private const val FRAME_WINDOW_MILLIS = 500L + private const val MIN_FRAMES = 3L + private const val PHASE_WRAP = 1000f + + /** How much more one window may paint than another before it is starvation. */ + private const val STARVATION_RATIO = 12 + + private const val FOLLOW_SLOP_PX = 24L + private const val ANCHOR_SLOP_PX = 48L + private const val LONG_CASE_TIMEOUT_MILLIS = 150_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceRaceHeadfulCases.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceRaceHeadfulCases.kt new file mode 100644 index 000000000..20181c51c --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/headful/WorkspaceRaceHeadfulCases.kt @@ -0,0 +1,616 @@ +package dev.nucleusframework.window.tao.headful + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread + +/** + * The workspaces asked to do several things at the same instant, from several + * places at once. + * + * Every member of a workspace is documented as belonging to the Tao event-loop + * thread, which is also the Compose dispatcher — so the interesting failures + * are not data races on fields but *ordering* races between things that each + * look atomic: a background thread posting work while a gesture runs, two + * coroutines mutating the same group in one frame, a restore landing between a + * tear-off and its window being mapped, a close arriving while a drop is being + * resolved. + * + * The invariant behind all of them is the same and is asserted every time: when + * the dust settles, no tab is in two groups or none, no group is empty, one + * body composes per window, and nothing is left publishing drag feedback. + */ +internal object WorkspaceRaceHeadfulCases { + fun all(): List = + listOf( + workAndPostedFromBackgroundThreadsAllLands(), + twoCoroutinesMutatingTheSameGroupInOneFrame(), + aRestoreLandingBetweenATearOffAndItsWindow(), + everyWindowAskedToCloseAtTheSameInstant(), + aDropResolvedWhileTheTargetGroupIsBeingEmptied(), + visibilityTogglesRacingDockChanges(), + pinChurnWhileTheOwnerCloses(), + fileDropsArrivingThroughoutAWorkspaceStorm(), + declarationsAndClosuresInterleavedFromCoroutines(), + aGestureStartedInOneFrameAndEndedManyLater(), + ) + + /** + * Work posted from real background threads. The workspace is the event + * loop's, so an application thread has to hand its change over — and a + * hundred of them arriving at once must all land, in some order, with none + * lost and none applied twice. + */ + private fun workAndPostedFromBackgroundThreadsAllLands(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "workspace race work posted from background threads all lands", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val applied = AtomicInteger() + val start = CountDownLatch(1) + val done = CountDownLatch(POSTER_THREADS) + + repeat(POSTER_THREADS) { index -> + thread(isDaemon = true, name = "workspace-poster-$index") { + start.await() + repeat(POSTS_PER_THREAD) { round -> + val title = titles[(index + round) % titles.size] + // The only correct way in: hand it to the loop. + kotlinx.coroutines.runBlocking(Dispatchers.Main) { + workspace.select(fixture.tabId(title)) + applied.incrementAndGet() + } + } + done.countDown() + } + } + start.countDown() + awaitUntil("every posted change landed") { + done.await(0, TimeUnit.MILLISECONDS) || + applied.get() == POSTER_THREADS * POSTS_PER_THREAD + } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(applied.get() == POSTER_THREADS * POSTS_PER_THREAD) { + "only ${applied.get()} of ${POSTER_THREADS * POSTS_PER_THREAD} changes landed" + } + assertCoherent(fixture, titles.size) + }, + ) + } + + /** + * Two coroutines writing the same group inside one frame: one reorders + * while the other moves a tab out. Both are legitimate, and the group has + * to end up describing itself either way. + */ + private fun twoCoroutinesMutatingTheSameGroupInOneFrame(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "workspace race two coroutines mutating one group in the same frame", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val home = requireNotNull(fixture.groupOf("Alpha")) + + coroutineScope { + val reorders = + launch { + repeat(RACE_ROUNDS) { round -> + workspace.reorder(fixture.tabId(titles[round % titles.size]), round % titles.size) + if (round % YIELD_EVERY == 0) delay(1) + } + } + val moves = + launch { + repeat(RACE_ROUNDS / 4) { round -> + val title = titles[1 + round % (titles.size - 1)] + val id = fixture.tabId(title) + val from = fixture.groupOf(title)?.window ?: first + workspace.tearOff(id, tearOffRectPx(from), from.scaleFactor) + delay(1) + workspace.move(id, home) + delay(1) + } + } + reorders.join() + moves.join() + } + awaitUntil("everything is back in one window") { workspace.groups.size == 1 } + awaitUntil("the strip republished its slots in order") { + val slots = home.slotsInWindowPx + slots.size >= home.ids.size && + slots.take(home.ids.size).zipWithNext().all { (l, r) -> l.left <= r.left } + } + settle(SETTLE_AFTER_MAP_MILLIS) + assertCoherent(fixture, titles.size) + }, + ) + } + + /** + * A saved layout applied in the window between a tear-off and the window it + * asked for being mapped. The group exists, its window does not yet, and + * the restore has an opinion about both. + */ + private fun aRestoreLandingBetweenATearOffAndItsWindow(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "workspace race a restore landing between a tear-off and its window", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val snapshot = workspace.snapshot() + + repeat(RESTORE_ROUNDS) { round -> + val title = titles[1 + round % (titles.size - 1)] + // No await in between: the restore lands while the window + // the tear-off asked for is still being created. + workspace.tearOff(fixture.tabId(title), tearOffRectPx(first), first.scaleFactor) + workspace.restore(snapshot) + } + awaitUntil("the layout is back to one window") { workspace.groups.size == 1 } + awaitUntil("its window is mapped") { + ( + workspace.groups + .first() + .window + ?.outerBoundsPx() + ?.get(RECT_W) ?: 0L + ) > 0L + } + settle(SETTLE_AFTER_MAP_MILLIS) + assertCoherent(fixture, titles.size) + check( + workspace.groups + .first() + .ids + .toSet() == titles.map(fixture::tabId).toSet(), + ) { + "the restore lost a tab: ${workspace.groups.first().ids}" + } + }, + ) + } + + /** + * Every window asked to close in the same instant — the shape of a quit. + * Each close empties its own group, and the group list is being rewritten + * by all of them at once. + */ + private fun everyWindowAskedToCloseAtTheSameInstant(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "workspace race every window asked to close at the same instant", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + for (title in titles.drop(1)) { + val from = fixture.groupOf(title)?.window ?: first + val group = + workspace.tearOff(fixture.tabId(title), tearOffRectPx(from), from.scaleFactor) ?: continue + awaitMappedStrip(fixture, group) + } + check(workspace.groups.size == titles.size) { "expected one window per tab" } + + // Every window's own close request, in one pass. + val windows = workspace.groups.mapNotNull { it.window } + for (w in windows) w.requestUserClose() + + awaitUntil("the workspace emptied") { workspace.groups.isEmpty() && workspace.tabs.isEmpty() } + awaitUntil("nothing is composing") { fixture.composedBodies.value == 0 } + awaitUntil("the last window was reported once") { fixture.lastWindowClosedCount.value == 1 } + settle(SETTLE_AFTER_MAP_MILLIS) + check(fixture.lastWindowClosedCount.value == 1) { + "reported ${fixture.lastWindowClosedCount.value}× for one shutdown" + } + check(workspace.draggedTab == null && workspace.dragGhost == null) { + "drag feedback outlived the shutdown" + } + }, + ) + } + + /** + * A drop being resolved into a group that the application is emptying in + * the same frame. The release has to act on the world it finds, not the one + * it was aimed at, and must not resurrect the group it was heading for. + */ + private fun aDropResolvedWhileTheTargetGroupIsBeingEmptied(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "workspace race a drop resolved while its target group is emptied", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val gamma = fixture.tabId("Gamma") + val target = + requireNotNull(workspace.tearOff(gamma, tearOffRectPx(first), first.scaleFactor)) + awaitMappedStrip(fixture, target) + settle(SETTLE_AFTER_MAP_MILLIS) + + val beta = fixture.tabId("Beta") + val grab = requireNotNull(fixture.tabCenterPx("Beta")) + val onTarget = requireNotNull(fixture.stripRectPx(target)).center + val session = requireNotNull(workspace.beginDrag(beta, stripOrigin(first), grab)) + session.update(grab) + session.update(onTarget) + check(workspace.dropPreview?.group === target) { "the target strip did not preview the drop" } + + // The target's only tab is closed in the same frame the drop + // is released onto it. + workspace.close(gamma) + session.end(onTarget) + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.tab(gamma) == null) { "the drop resurrected the closed tab" } + check(workspace.groups.none { it.ids.isEmpty() }) { "an empty group survived the drop" } + check(workspace.tab(beta)?.group != null) { "Beta ended up in no group at all" } + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "drag feedback outlived the race" + } + awaitUntil("one body per window composes") { + fixture.composedBodies.value == workspace.groups.size + } + }, + ) + } + + /** + * The workspace-wide visibility switch flipped while satellites are being + * docked and undocked. Each flip destroys or builds every floating window, + * and each dock change decides where a satellite lives — in the same frames. + */ + private fun visibilityTogglesRacingDockChanges(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + return TaoWindowTestCase( + name = "workspace race visibility toggles racing dock changes", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + awaitFloating(fixture) + val workspace = fixture.workspace + requireNotNull(fixture.counter.value).value = SAVED_CLICKS + + repeat(TOGGLE_ROUNDS) { round -> + workspace.visible = false + workspace.dock(SATELLITE_ID, if (round % 2 == 0) DockSide.Left else DockSide.Right) + workspace.visible = true + settle(RACE_SETTLE_MILLIS) + workspace.undock(SATELLITE_ID) + settle(RACE_SETTLE_MILLIS) + } + workspace.visible = true + awaitUntil("the satellite is composed again") { fixture.isComposed } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(fixture.composedHosts.value == 1) { + "${fixture.composedHosts.value} hosts composing after the race" + } + check(requireNotNull(fixture.counter.value).value == SAVED_CLICKS) { + "the satellite lost its state in the race" + } + check(workspace.draggedSatellite == null && workspace.dockPreview == null) { + "drag feedback appeared out of a visibility race" + } + }, + ) + } + + /** + * The pinned owner changed repeatedly while the window it points at is + * closing. A pin that outlives its window would leave every floating + * satellite anchored to a frame that no longer exists. + */ + private fun pinChurnWhileTheOwnerCloses(): TaoWindowTestCase { + val fixture = SatelliteWorkspaceFixture() + val dialogVisible = mutableStateOf(true) + return TaoWindowTestCase( + name = "workspace race pin churn while the pinned owner closes", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = workspaceParentWindowState(), + size = DpSize(PARENT_W_DP.dp, PARENT_H_DP.dp), + paintDefaultBackground = false, + dialogSize = DpSize(DIALOG_W_DP.dp, DIALOG_H_DP.dp), + dialogContent = { + JoinSatelliteWorkspace(fixture.workspace) + DockLayout(fixture.workspace, Modifier.fillMaxSize()) { + Box(Modifier.fillMaxSize().background(Color(0xFF3C8D5A))) + } + }, + dialogVisible = dialogVisible, + content = { fixture.Body() }, + applicationContent = { with(fixture) { ToolsSatellite() } }, + driver = { + awaitFloating(fixture) + val workspace = fixture.workspace + val dialog = requireNotNull(dialogWindow) + awaitUntil("both members joined") { workspace.members.size == 2 } + + repeat(PIN_ROUNDS) { round -> + workspace.pinTo(if (round % 2 == 0) dialog else window) + settle(RACE_SETTLE_MILLIS) + } + workspace.pinTo(dialog) + awaitUntil("the dialog owns the satellites") { workspace.owner === dialog } + + var destroyed = false + dialog.onDestroyed { destroyed = true } + dialogVisible.value = false + awaitUntil("the pinned owner went") { destroyed } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.pinnedOwner == null) { "the pin outlived the window it named" } + check(workspace.owner === window) { "the owner did not fall back to the survivor" } + check(workspace.members == listOf(window)) { "the closed window is still a member" } + awaitFloating(fixture) + }, + ) + } + + /** + * Files arriving from outside the application throughout a workspace storm. + * The two paths share the window and nothing else, so what this pins down + * is that neither can leave the other in a state it cannot recover from. + */ + private fun fileDropsArrivingThroughoutAWorkspaceStorm(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma") + val fixture = TabWorkspaceFixture(initialTitles = titles, fileDropTargets = true) + return TaoWindowTestCase( + name = "workspace race file drops arriving throughout a workspace storm", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + var delivered = 0 + + repeat(DROP_STORM_ROUNDS) { round -> + workspace.select(fixture.tabId(titles[round % titles.size])) + val selected = requireNotNull(workspace.selectedTab(requireNotNull(fixture.groupOf("Alpha")))) + settle(RACE_SETTLE_MILLIS) + val host = fixture.windowOf(selected.title) ?: first + val point = contentPointPx(host, HALF, DEEP) + if (host.fileDragAndDrop(point, listOf("/nucleus/storm/$round.txt"))) delivered++ + workspace.reorder(fixture.tabId(titles[round % titles.size]), round % titles.size) + } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(delivered >= DROP_STORM_ROUNDS / 2) { + "only $delivered of $DROP_STORM_ROUNDS drops were taken during the storm" + } + val taken = titles.sumOf { fixture.dropLog(it).drops.value } + check(taken == delivered) { "$delivered drops were accepted but $taken were recorded" } + check(titles.none { fixture.dropLog(it).failure.value != null }) { + "a drop failed to read its payload: " + + "${titles.mapNotNull { fixture.dropLog(it).failure.value }}" + } + assertCoherent(fixture, titles.size) + }, + ) + } + + /** + * Tabs declared and closed from coroutines that interleave. Registration + * places a tab in the active window and a close can drop that very window, + * so the two racing is how a tab ends up in a group that is already gone. + */ + private fun declarationsAndClosuresInterleavedFromCoroutines(): TaoWindowTestCase { + val fixture = TabWorkspaceFixture(initialTitles = listOf("Alpha")) + return TaoWindowTestCase( + name = "workspace race declarations and closures interleaved from coroutines", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + awaitTabSlots(fixture, "Alpha") + val workspace = fixture.workspace + + coroutineScope { + val opening = + async { + repeat(OPEN_ROUNDS) { round -> + fixture.titles += "New$round" + delay(RACE_SETTLE_MILLIS) + } + } + val closing = + async { + repeat(OPEN_ROUNDS) { round -> + delay(RACE_SETTLE_MILLIS * 2) + val victim = "New${round / 2}" + if (workspace.tab(fixture.tabId(victim)) != null) { + workspace.close(fixture.tabId(victim)) + fixture.titles -= victim + } + } + } + listOf(opening, closing).awaitAll() + } + awaitUntil("every declared tab found a group") { + workspace.tabs.all { it.group != null } + } + awaitUntil("every group has a mapped window") { + workspace.groups.all { (it.window?.outerBoundsPx()?.get(RECT_W) ?: 0L) > 0L } + } + settle(SETTLE_AFTER_MAP_MILLIS) + check(workspace.groups.none { it.ids.isEmpty() }) { "an empty group survived" } + check(workspace.tabs.isNotEmpty()) { "the race closed everything" } + awaitUntil("one body per window composes") { + fixture.composedBodies.value == workspace.groups.size + } + }, + ) + } + + /** + * A gesture held across everything else: started, then left running while + * tabs are closed, declared, reordered and torn off around it, and only + * then released. The session has to act on the world as it is at the + * release — or do nothing at all — but never on the one it started in. + */ + private fun aGestureStartedInOneFrameAndEndedManyLater(): TaoWindowTestCase { + val titles = listOf("Alpha", "Beta", "Gamma", "Delta") + val fixture = TabWorkspaceFixture(initialTitles = titles) + return TaoWindowTestCase( + name = "workspace race a gesture started in one frame and ended many frames later", + timeoutMillis = LONG_CASE_TIMEOUT_MILLIS, + skip = ::workspaceSkipReason, + windowState = idleCaseWindowState(), + size = idleCaseWindowSize(), + paintDefaultBackground = false, + applicationContent = { with(fixture) { Windows() } }, + driver = { + val first = awaitTabSlots(fixture, *titles.toTypedArray()) + val workspace = fixture.workspace + val alpha = fixture.tabId("Alpha") + val group = requireNotNull(fixture.groupOf("Alpha")) + val grab = requireNotNull(fixture.tabCenterPx("Alpha")) + val away = requireNotNull(fixture.farFromStripPx(group)) + + val session = requireNotNull(workspace.beginDrag(alpha, stripOrigin(first), grab)) + session.update(grab) + session.update(away) + + // The world moves on around the held gesture. + withContext(Dispatchers.Main) { + workspace.close(fixture.tabId("Delta")) + fixture.titles -= "Delta" + fixture.titles += "Epsilon" + } + awaitUntil("the new tab was declared") { workspace.tab(fixture.tabId("Epsilon")) != null } + val torn = + workspace.tearOff(fixture.tabId("Gamma"), tearOffRectPx(first), first.scaleFactor) + if (torn != null) awaitMappedStrip(fixture, torn) + workspace.reorder(fixture.tabId("Beta"), 0) + settle(SETTLE_AFTER_MAP_MILLIS) + + // Only now is it released, far from every strip. + session.update(away) + session.end(away) + awaitUntil("the held gesture landed") { workspace.draggedTab == null } + settle(SETTLE_AFTER_MAP_MILLIS) + + check(workspace.tab(fixture.tabId("Delta")) == null) { "the release resurrected a closed tab" } + check(workspace.tab(alpha)?.group != null) { "the dragged tab ended up in no group" } + check(workspace.groups.none { it.ids.isEmpty() }) { "an empty group survived" } + check(workspace.dragGhost == null && workspace.dropPreview == null) { + "drag feedback outlived the gesture" + } + awaitUntil("one body per window composes") { + fixture.composedBodies.value == workspace.groups.size + } + }, + ) + } + + /** + * The invariant every case in this file ends on: the workspace still + * describes a possible world — every tab in exactly one group, no empty + * group, one body per window, and no drag feedback left over. + */ + private fun assertCoherent( + fixture: TabWorkspaceFixture, + expectedTabs: Int, + ) { + val workspace = fixture.workspace + check(workspace.tabs.size == expectedTabs) { + "expected $expectedTabs tabs, got ${workspace.tabs.map { it.id }}" + } + val placed = workspace.groups.flatMap { it.ids } + check(placed.size == placed.toSet().size) { "a tab is in two groups: $placed" } + check(placed.toSet() == workspace.tabs.map { it.id }.toSet()) { + "the groups hold $placed but the workspace knows ${workspace.tabs.map { it.id }}" + } + check(workspace.groups.none { it.ids.isEmpty() }) { "an empty group survived" } + check(workspace.groups.all { it.selectedId in it.ids }) { + "a group selects a tab it does not hold: ${workspace.groups.map { it.id to it.selectedId }}" + } + check(workspace.draggedTab == null && workspace.dragGhost == null && workspace.dropPreview == null) { + "drag feedback outlived the race" + } + } + + private const val POSTER_THREADS = 4 + private const val POSTS_PER_THREAD = 25 + private const val RACE_ROUNDS = 60 + private const val YIELD_EVERY = 8 + private const val RESTORE_ROUNDS = 5 + private const val TOGGLE_ROUNDS = 4 + private const val PIN_ROUNDS = 6 + private const val OPEN_ROUNDS = 6 + private const val DROP_STORM_ROUNDS = 8 + private const val RACE_SETTLE_MILLIS = 16L + private const val HALF = 0.5f + private const val DEEP = 0.8f + private const val LONG_CASE_TIMEOUT_MILLIS = 120_000L +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/MacPopupPictureCullTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/MacPopupPictureCullTest.kt new file mode 100644 index 000000000..84b525aa2 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/MacPopupPictureCullTest.kt @@ -0,0 +1,220 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.scene.replayPicture +import dev.nucleusframework.window.tao.scene.runTaoSceneTest +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Picture +import org.jetbrains.skia.Rect +import org.jetbrains.skia.Surface +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * The macOS popup layer's frame, run end to end against a **real Compose + * scene**: record through the production `recordSceneToPicture`, replay through + * the production [replayPicture], read the pixels back. + * + * `TaoPopupSceneLayer` lays its inner scene out in owner-window coordinates + * (`calculateLocalPosition` is the identity) and defers the translation into the + * popup's own surface to replay time (`pictureOffset = -drawBounds.topLeft`), so + * the recorded content sits at the popup's position inside a work-area-sized + * scene rather than at the picture's origin. The picture's cull rect has to say + * so, because `SkCanvas::drawPicture` quick-rejects against that rect mapped + * through the current matrix — and the replay matrix moves an origin-rooted rect + * clean off the drawable. + * + * Whether that is fatal depends on the op count, which is the subtlety this + * class pins. Skia unrolls a picture of at most one op straight into the target + * canvas and never consults the rect, and a Compose scene records as exactly one + * op (a skiko `RenderNode` drawable — see [POPUP_DRAW_MARGIN_DP]'s note). A bare + * popup therefore survived the mismatch by accident. A popup **dimmed by a + * dialog stacked above it** does not: the layer paints those scrims into the + * same picture ([PopupScrimRegistry.paintAbove], through + * `TaoSceneBundle.renderOverlay`), the picture stops being unrollable, and the + * quick-reject drops the whole frame — popup and scrim alike. + */ +class MacPopupPictureCullTest { + /** The layer's inner scene: work-area sized, as the layer builds it. */ + private val sceneWidth = 1600 + private val sceneHeight = 1200 + + /** Where Compose placed the popup inside that scene, and its inflated surface. */ + private val contentBounds = IntRect(left = 420, top = 340, right = 660, bottom = 520) + private val drawBounds = popupDrawBounds(contentBounds, density = 1f) + + private val fill = Color.Magenta + + // ── The regression ──────────────────────────────────────────────────── + + /** + * A popup under an open dialog: two ops, so the cull rect is consulted, and + * an origin-rooted one takes the entire frame with it. + */ + @Test + fun `a dimmed popup keeps its content`() { + val pixels = renderPopupSurface(popupPictureCullRect(drawBounds), dimmed = true) + assertNotEquals( + CLEAR_ARGB, + pixels.center, + "a popup dimmed by a dialog above it must still render its content", + ) + assertNotEquals(CLEAR_ARGB, pixels.contentTopLeft) + } + + /** The failure mode itself, kept so the reason the rect must follow the content is documented. */ + @Test + fun `an origin-rooted cull rect drops a dimmed popup's whole frame`() { + val pixels = renderPopupSurface(originRootedCullRect(), dimmed = true) + assertEquals( + CLEAR_ARGB, + pixels.center, + "Skia is expected to quick-reject a cull rect the replay matrix moves off the drawable", + ) + } + + /** Two ops is what takes the picture off Skia's unroll path. */ + @Test + fun `a dimmed popup records more than one op`() { + recordPopupScene(popupPictureCullRect(drawBounds), dimmed = true).use { picture -> + assertTrue( + picture.approximateOpCount > 1, + "the scrim must be recorded into the same picture as the scene, got ${picture.approximateOpCount}", + ) + } + } + + // ── The undimmed case, and why it hid the bug ───────────────────────── + + @Test + fun `an undimmed popup keeps its content`() { + val pixels = renderPopupSurface(popupPictureCullRect(drawBounds), dimmed = false) + assertEquals( + fill.toArgb(), + pixels.center, + "the popup's surface must show what the scene drew, not an empty rectangle", + ) + assertEquals( + fill.toArgb(), + pixels.contentTopLeft, + "the content must land at the draw margin, not at the surface origin", + ) + } + + /** + * A Compose scene on its own is a single `RenderNode` drawable, which Skia + * unrolls without ever looking at the cull rect. Pinned because it is the + * only reason the mismatch was invisible for a plain menu — change it and + * the undimmed case starts failing the way the dimmed one did. + */ + @Test + fun `a bare Compose scene records as one op and is unrolled`() { + recordPopupScene(popupPictureCullRect(drawBounds), dimmed = false).use { picture -> + assertEquals(1, picture.approximateOpCount) + } + val pixels = renderPopupSurface(originRootedCullRect(), dimmed = false) + assertEquals(fill.toArgb(), pixels.center) + } + + // ── Harness ─────────────────────────────────────────────────────────── + + private class Pixels( + val center: Int, + val contentTopLeft: Int, + ) + + /** The rect the layer recorded with before the fix: the surface size at the picture origin. */ + private fun originRootedCullRect(): Rect = Rect.makeWH(drawBounds.width.toFloat(), drawBounds.height.toFloat()) + + /** Records the layer's scene with [cullRect] and replays it into its surface. */ + private fun renderPopupSurface( + cullRect: Rect, + dimmed: Boolean, + ): Pixels { + recordPopupScene(cullRect, dimmed).use { picture -> + val surface = Surface.makeRasterN32Premul(drawBounds.width, drawBounds.height) + try { + surface.canvas.clear(CLEAR_ARGB) + surface.canvas.replayPicture(picture, IntOffset(-drawBounds.left, -drawBounds.top)) + val image = surface.makeImageSnapshot() + val bitmap = + Bitmap().apply { + allocPixels(image.imageInfo) + image.readPixels(this) + } + val margin = popupDrawMarginPx(1f) + return Pixels( + center = bitmap.getColor(drawBounds.width / 2, drawBounds.height / 2), + contentTopLeft = bitmap.getColor(margin + PROBE_INSET_PX, margin + PROBE_INSET_PX), + ) + } finally { + surface.close() + } + } + } + + /** + * A scene shaped like the layer's: work-area sized, transparent everywhere + * except the popup, which sits at [contentBounds] — where Compose's `Popup` + * places it once `calculateLocalPosition` stops moving it. When [dimmed], + * the layer's real overlay pass runs too, painting the scrim of a dialog + * registered above this popup. + */ + private fun recordPopupScene( + cullRect: Rect, + dimmed: Boolean, + ): Picture { + var picture: Picture? = null + runTaoSceneTest(width = sceneWidth, height = sceneHeight) { + if (dimmed) { + val scrims = PopupScrimRegistry(onChanged = { }) + val popupToken = Any() + scrims.register(popupToken) { null } + scrims.register(Any()) { Color.Black.copy(alpha = SCRIM_ALPHA) } + renderOverlay = { canvas -> + scrims.paintAbove( + popupToken, + canvas, + Rect.makeXYWH( + drawBounds.left.toFloat(), + drawBounds.top.toFloat(), + drawBounds.width.toFloat(), + drawBounds.height.toFloat(), + ), + ) + } + } + setContent { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .offset { IntOffset(contentBounds.left, contentBounds.top) } + .size(contentBounds.width.dp, contentBounds.height.dp) + .background(fill), + ) + } + } + frameUntilIdle() + picture = frame(cullRect = cullRect) + } + return requireNotNull(picture) + } + + private companion object { + private const val CLEAR_ARGB = 0x00000000 + private const val PROBE_INSET_PX = 4 + private const val SCRIM_ALPHA = 0.4f + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflateTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflateTest.kt new file mode 100644 index 000000000..3b1274500 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupDrawInflateTest.kt @@ -0,0 +1,59 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntRect +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Unit cases for the draw margin of native popup layers: the surface must + * cover what Compose draws around the layout bounds, on every side. + */ +class PopupDrawInflateTest { + private val bounds = IntRect(100, 200, 300, 400) + + @Test + fun `the margin is 32 dp in physical pixels`() { + assertEquals(32, popupDrawMarginPx(1f)) + assertEquals(64, popupDrawMarginPx(2f)) + assertEquals(40, popupDrawMarginPx(1.25f)) + } + + @Test + fun `a fractional margin rounds up`() { + assertEquals(48, popupDrawMarginPx(1.5f)) + assertEquals(36, popupDrawMarginPx(1.1f)) + } + + @Test + fun `a density below one is treated as one`() { + assertEquals(32, popupDrawMarginPx(0.5f)) + } + + @Test + fun `the surface is inflated on every side`() { + assertEquals(IntRect(68, 168, 332, 432), popupDrawBounds(bounds, 1f)) + assertEquals(IntRect(36, 136, 364, 464), popupDrawBounds(bounds, 2f)) + } + + /** + * The cull rect lives in the space the scene draws in, not the surface's — + * `MacPopupPictureCullTest` shows what an origin-rooted one costs. + */ + @Test + fun `the cull rect is the draw bounds in scene coordinates`() { + val draw = popupDrawBounds(bounds, 1f) + val rect = popupPictureCullRect(draw) + assertEquals(draw.left.toFloat(), rect.left) + assertEquals(draw.top.toFloat(), rect.top) + assertEquals(draw.right.toFloat(), rect.right) + assertEquals(draw.bottom.toFloat(), rect.bottom) + } + + @Test + fun `the content keeps its size and offset inside the surface`() { + val draw = popupDrawBounds(bounds, 2f) + assertEquals(bounds.size.width + 2 * 64, draw.size.width) + assertEquals(64, bounds.left - draw.left) + assertEquals(64, bounds.top - draw.top) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClampTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClampTest.kt new file mode 100644 index 000000000..2f4a51699 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScreenClampTest.kt @@ -0,0 +1,243 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Unit cases for the #569 screen clamp: the geometry decision the headful + * suite then verifies against real windows. + * + * Coordinates are physical pixels. The fixtures use a 1920×1080 primary + * display with a 40 px taskbar (work area `0,0 → 1920,1040`) and, where + * relevant, a second display to its right. + */ +class PopupScreenClampTest { + // ── No geometry / degenerate input: the pre-#569 behaviour ───────────── + + @Test + fun `no geometry leaves the frame untouched`() { + assertEquals( + IntOffset.Zero, + popupScreenClampOffset(rect(0, 0, 200, 300), geometry = null), + ) + } + + @Test + fun `an empty frame is never moved`() { + // A layer pushes frames before Compose has measured the content. + assertEquals(IntOffset.Zero, clampAt(windowAt(1900, 1000), rect(0, 0, 0, 0))) + assertEquals(IntOffset.Zero, clampAt(windowAt(1900, 1000), rect(0, 0, 200, 0))) + } + + @Test + fun `no usable work area leaves the frame untouched`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(1900, 1000), + workAreasPx = listOf(IntRect(0, 0, 0, 0)), + ) + assertEquals(IntOffset.Zero, popupScreenClampOffset(rect(0, 0, 200, 300), geometry)) + } + + // ── The regression the issue reports ────────────────────────────────── + + @Test + fun `a popup already inside the work area is not moved`() { + // Window at the middle of the screen, dropdown just below its anchor. + assertEquals(IntOffset.Zero, clampAt(windowAt(400, 300), rect(50, 120, 200, 180))) + } + + @Test + fun `a dropdown past the bottom edge slides up instead of landing offscreen`() { + // Window content origin 100 px above the taskbar; Compose believes it + // has `workAreaHeight` of room below the anchor, so it does not flip. + val clamp = clampAt(windowAt(400, 940), rect(0, 20, 200, 300)) + // 940 + 20 + 300 = 1260, work area bottom is 1040 → back by 220. + assertEquals(IntOffset(0, -220), clamp) + assertTrue(screenRect(windowAt(400, 940), rect(0, 20, 200, 300), clamp) in PRIMARY_WORK) + } + + @Test + fun `a menu past the right edge slides left`() { + val clamp = clampAt(windowAt(1700, 200), rect(100, 0, 300, 200)) + // 1700 + 100 + 300 = 2100, work area right is 1920 → back by 180. + assertEquals(IntOffset(-180, 0), clamp) + } + + @Test + fun `both axes clamp independently`() { + val clamp = clampAt(windowAt(1800, 1000), rect(60, 60, 400, 400)) + assertEquals(IntOffset(1920 - 400 - 1860, 1040 - 400 - 1060), clamp) + assertTrue(screenRect(windowAt(1800, 1000), rect(60, 60, 400, 400), clamp) in PRIMARY_WORK) + } + + @Test + fun `a popup extending above the window origin is not pinned at zero`() { + // The other half of #569: Compose clamps to 0 in *window* coordinates, + // so a popup that should open upward gets stuck at the window's top + // edge. In screen space there is room, so the clamp must not move it. + assertEquals(IntOffset.Zero, clampAt(windowAt(600, 500), rect(0, -220, 200, 180))) + } + + @Test + fun `a popup above the screen top slides down`() { + // Same shape, but the window itself is at the top: now it really is + // offscreen and must come back in. + val clamp = clampAt(windowAt(600, 30), rect(0, -220, 200, 180)) + assertEquals(IntOffset(0, 190), clamp) + assertEquals(0, screenRect(windowAt(600, 30), rect(0, -220, 200, 180), clamp).top) + } + + @Test + fun `the taskbar is respected, not just the screen bounds`() { + // 1040..1080 is the taskbar. A frame ending at 1060 must come back to + // 1040 even though it is inside the monitor's full bounds. + val clamp = clampAt(windowAt(0, 900), rect(0, 0, 100, 160)) + assertEquals(IntOffset(0, -20), clamp) + } + + // ── Oversized popups keep their top-left ────────────────────────────── + + @Test + fun `a popup taller than the work area is aligned to the top`() { + val clamp = clampAt(windowAt(100, 200), rect(0, 0, 200, 1400)) + // Top-left wins: the menu's first items stay reachable. + assertEquals(IntOffset(0, -200), clamp) + assertEquals(0, screenRect(windowAt(100, 200), rect(0, 0, 200, 1400), clamp).top) + } + + @Test + fun `a popup wider than the work area is aligned to the left`() { + val clamp = clampAt(windowAt(300, 100), rect(0, 0, 2400, 200)) + assertEquals(IntOffset(-300, 0), clamp) + assertEquals(0, screenRect(windowAt(300, 100), rect(0, 0, 2400, 200), clamp).left) + } + + // ── The window's own coordinate space is never used as a screen ─────── + + @Test + fun `the clamp is independent of the owner window size`() { + // A 1×1 tray anchor and a full-screen window at the same origin must + // clamp identically — the whole point of #569 is that the *window* is + // not the reference rect. + val fromTinyWindow = clampAt(windowAt(1850, 1010), rect(0, 0, 240, 200)) + val fromBigWindow = clampAt(windowAt(1850, 1010), rect(0, 0, 240, 200)) + assertEquals(fromTinyWindow, fromBigWindow) + assertEquals(IntOffset(1920 - 240 - 1850, 1040 - 200 - 1010), fromTinyWindow) + } + + // ── Multi-display ───────────────────────────────────────────────────── + + @Test + fun `a popup on the secondary display clamps to that display's work area`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(2400, 100), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // 2400 + 1000 = 3400 → past the secondary's right edge (3200). + val clamp = popupScreenClampOffset(rect(1000, 0, 300, 200), geometry) + assertEquals(IntOffset(3200 - 300 - 3400, 0), clamp) + } + + @Test + fun `a popup on the secondary display is not yanked onto the primary`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(2400, 200), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // Well inside the secondary display: clamping against the primary + // work area (the bug a primary-monitor-only lookup would have) would + // have dragged it back to x < 1920. + assertEquals(IntOffset.Zero, popupScreenClampOffset(rect(100, 100, 300, 200), geometry)) + } + + @Test + fun `a popup that overlaps two displays clamps to the one it covers most`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(1800, 300), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // 1800 + 40 = 1840 → 80 px on the primary, 220 px on the secondary. + // The secondary wins, and the frame is already inside it after the + // left clamp to 1920. + val frame = rect(40, 0, 300, 200) + val clamp = popupScreenClampOffset(frame, geometry) + assertEquals(IntOffset(1920 - 1840, 0), clamp) + } + + @Test + fun `a fully offscreen popup returns to the owner's display`() { + val geometry = + PopupScreenGeometry( + parentContentOriginPx = IntOffset(2400, 300), + workAreasPx = listOf(PRIMARY_WORK, SECONDARY_WORK), + ) + // Below every work area — overlaps nothing, so the display hosting the + // owner (the secondary) decides. + val clamp = popupScreenClampOffset(rect(0, 900, 200, 200), geometry) + val landed = screenRect(geometry.parentContentOriginPx, rect(0, 900, 200, 200), clamp) + assertTrue(landed in SECONDARY_WORK, "landed on the wrong display: $landed") + } + + // ── Idempotence: the layers re-clamp on every owner move ────────────── + + @Test + fun `clamping an already-clamped frame is a no-op`() { + val origin = windowAt(1800, 1000) + val frame = rect(60, 60, 400, 400) + val first = clampAt(origin, frame) + val moved = IntRect(frame.left + first.x, frame.top + first.y, frame.right + first.x, frame.bottom + first.y) + assertEquals(IntOffset.Zero, clampAt(origin, moved)) + } + + // ── Fixtures ────────────────────────────────────────────────────────── + + private companion object { + val PRIMARY_WORK = IntRect(0, 0, 1920, 1040) + val SECONDARY_WORK = IntRect(1920, 0, 3200, 1024) + + fun rect( + x: Int, + y: Int, + w: Int, + h: Int, + ) = IntRect(x, y, x + w, y + h) + + fun windowAt( + x: Int, + y: Int, + ) = IntOffset(x, y) + + /** Clamp against the single-display fixture. */ + fun clampAt( + parentOrigin: IntOffset, + frameInParent: IntRect, + ): IntOffset = + popupScreenClampOffset( + frameInParent, + PopupScreenGeometry(parentOrigin, listOf(PRIMARY_WORK)), + ) + + /** Where [frameInParent] lands on screen once [clamp] is applied. */ + fun screenRect( + parentOrigin: IntOffset, + frameInParent: IntRect, + clamp: IntOffset, + ): IntRect = + IntRect( + left = parentOrigin.x + frameInParent.left + clamp.x, + top = parentOrigin.y + frameInParent.top + clamp.y, + right = parentOrigin.x + frameInParent.right + clamp.x, + bottom = parentOrigin.y + frameInParent.bottom + clamp.y, + ) + + operator fun IntRect.contains(inner: IntRect): Boolean = + inner.left >= left && inner.top >= top && inner.right <= right && inner.bottom <= bottom + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistryTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistryTest.kt new file mode 100644 index 000000000..d8ee2065b --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/popup/PopupScrimRegistryTest.kt @@ -0,0 +1,175 @@ +package dev.nucleusframework.window.tao.popup + +import androidx.compose.ui.graphics.Color +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.Canvas +import org.jetbrains.skia.ColorAlphaType +import org.jetbrains.skia.ColorType +import org.jetbrains.skia.ImageInfo +import org.jetbrains.skia.Paint +import org.jetbrains.skia.Rect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Unit cases for the dialog-scrim bookkeeping of native popup layers: which + * scrims each surface paints, and how they blend. + */ +class PopupScrimRegistryTest { + private val bottom = Any() + private val middle = Any() + private val top = Any() + + private fun stack(vararg colors: Pair): PopupScrimRegistry = + PopupScrimRegistry(onChanged = {}).apply { + for ((token, color) in colors) register(token) { color } + } + + // ── Bookkeeping ──────────────────────────────────────────────────────── + + @Test + fun `popups without a scrim contribute nothing`() { + val registry = stack(bottom to null, middle to null) + assertEquals(emptyList(), registry.all()) + assertEquals(emptyList(), registry.above(bottom)) + } + + @Test + fun `the owner window paints every scrim bottom-up`() { + val registry = stack(bottom to null, middle to Color.Red, top to Color.Blue) + assertEquals(listOf(Color.Red, Color.Blue), registry.all()) + } + + @Test + fun `a layer paints only the scrims of the layers above it`() { + val registry = stack(bottom to Color.Red, middle to Color.Green, top to Color.Blue) + assertEquals(listOf(Color.Green, Color.Blue), registry.above(bottom)) + assertEquals(listOf(Color.Blue), registry.above(middle)) + assertEquals(emptyList(), registry.above(top)) + } + + @Test + fun `an unknown layer sees no scrim above it`() { + val registry = stack(bottom to Color.Red) + assertEquals(emptyList(), registry.above(Any())) + } + + @Test + fun `a scrim written after registration is read at paint time`() { + var color: Color? = null + val registry = PopupScrimRegistry(onChanged = {}).apply { register(top) { color } } + assertEquals(emptyList(), registry.all()) + color = Color.Black + assertEquals(listOf(Color.Black), registry.all()) + } + + @Test + fun `a scrim change is reported to the host`() { + var changes = 0 + val registry = PopupScrimRegistry(onChanged = { changes++ }) + registry.notifyChanged() + assertEquals(1, changes) + } + + @Test + fun `unregistering removes the layer from every view`() { + val registry = stack(bottom to Color.Red, top to Color.Blue) + registry.unregister(top) + assertEquals(listOf(Color.Red), registry.all()) + assertEquals(emptyList(), registry.above(bottom)) + } + + /** + * A layer torn down while its scrim was still opaque — a dialog removed + * from composition rather than faded out by `DialogAppearanceController` — + * takes its dimming with it, and nothing under it observes that. Without a + * repaint the owner window stays dark until an unrelated invalidation + * happens to produce a non-clean frame. + */ + @Test + fun `unregistering a dimming layer repaints the host`() { + var changes = 0 + val registry = PopupScrimRegistry(onChanged = { changes++ }) + registry.register(top) { Color.Black } + registry.unregister(top) + assertEquals(1, changes) + } + + /** The common case — a plain popup — must not cost a repaint on the way out. */ + @Test + fun `unregistering a layer that dimmed nothing is silent`() { + var changes = 0 + val registry = PopupScrimRegistry(onChanged = { changes++ }) + registry.register(top) { null } + registry.unregister(top) + registry.unregister(Any()) + assertEquals(0, changes) + } + + @Test + fun `re-registering moves a layer to the top of the stack`() { + val registry = stack(bottom to Color.Red, top to Color.Blue) + registry.register(bottom) { Color.Red } + assertEquals(listOf(Color.Blue, Color.Red), registry.all()) + assertEquals(listOf(Color.Red), registry.above(top)) + } + + // ── Painting ─────────────────────────────────────────────────────────── + + private fun paintOnto( + opaqueLeftHalf: Boolean, + paint: (Canvas) -> Unit, + ): Bitmap { + val bitmap = Bitmap() + bitmap.allocPixels(ImageInfo(4, 2, ColorType.BGRA_8888, ColorAlphaType.PREMUL)) + Canvas(bitmap).use { canvas -> + canvas.clear(0x00000000) + if (opaqueLeftHalf) { + val white = Paint().apply { color = 0xFFFFFFFF.toInt() } + canvas.drawRect(Rect.makeWH(2f, 2f), white) + } + paint(canvas) + } + return bitmap + } + + private fun alphaAt( + bitmap: Bitmap, + x: Int, + y: Int, + ): Int = (bitmap.getColor(x, y) ushr 24) and 0xFF + + @Test + fun `an opaque owner window is dimmed everywhere`() { + val registry = stack(top to Color(0x80000000)) + val bitmap = + paintOnto(opaqueLeftHalf = true) { + registry.paintAll(it, Rect.makeWH(4f, 2f), transparent = false) + } + assertTrue(alphaAt(bitmap, 0, 0) == 0xFF, "drawn pixels stay opaque") + assertTrue(alphaAt(bitmap, 3, 1) > 0, "the scrim lands on undrawn pixels of an opaque window") + } + + @Test + fun `a per-pixel-transparent surface is dimmed only where it drew`() { + val registry = stack(bottom to null, top to Color(0x80000000)) + val bitmap = + paintOnto(opaqueLeftHalf = true) { + registry.paintAbove(bottom, it, Rect.makeWH(4f, 2f)) + } + assertTrue(alphaAt(bitmap, 0, 0) == 0xFF, "drawn pixels stay opaque") + assertEquals(0, alphaAt(bitmap, 3, 1), "SrcAtop leaves undrawn pixels transparent") + assertTrue((bitmap.getColor(0, 0) and 0xFF) < 0xFF, "drawn pixels are darkened") + } + + @Test + fun `no scrim leaves the surface untouched`() { + val registry = stack(bottom to null) + val bitmap = + paintOnto(opaqueLeftHalf = false) { + registry.paintAll(it, Rect.makeWH(4f, 2f), transparent = false) + } + assertEquals(0, alphaAt(bitmap, 0, 0)) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTestTextStyle.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTestTextStyle.kt new file mode 100644 index 000000000..b59aff4b9 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTestTextStyle.kt @@ -0,0 +1,35 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.FontHinting +import androidx.compose.ui.text.FontRasterizationSettings +import androidx.compose.ui.text.FontSmoothing +import androidx.compose.ui.text.PlatformParagraphStyle +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle + +/** + * Test twin of the ClearType default the Nucleus Gradle plugin bakes into + * `FontRasterizationSettings.PlatformDefault` (`LcdTextDefaultTransform`). + * Library tests run against the unpatched Compose artifact, so LCD-rendering + * tests request subpixel rasterization explicitly through this style. + */ +@OptIn(ExperimentalTextApi::class) +internal fun taoLcdTextStyle(): TextStyle = + TextStyle( + color = Color.Unspecified, + platformStyle = + PlatformTextStyle( + spanStyle = null, + paragraphStyle = + PlatformParagraphStyle( + FontRasterizationSettings( + smoothing = FontSmoothing.SubpixelAntiAlias, + hinting = FontHinting.Normal, + subpixelPositioning = true, + autoHintingForced = false, + ), + ), + ), + ) diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTextCaptureTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTextCaptureTest.kt new file mode 100644 index 000000000..1f5750a59 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTextCaptureTest.kt @@ -0,0 +1,179 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.PixelGeometry +import org.jetbrains.skia.SurfaceProps +import java.awt.Font +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.nio.file.Files +import java.nio.file.Path +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Writes a GitHub-issue-style side-by-side zoom of grayscale vs ClearType text. + * Output: `decorated-window-tao/build/lcd-text-comparison.png` + */ +class LcdTextCaptureTest { + @Test + fun `write zoomed grayscale vs LCD comparison png`() { + val gray = renderText(lcd = false) + val lcd = renderText(lcd = true) + val out = writeComparison(gray, lcd) + assertTrue(Files.exists(out) && Files.size(out) > 0L, "missing $out") + println("LCD comparison written to $out") + } +} + +private fun renderText(lcd: Boolean): BufferedImage { + lateinit var bitmap: Bitmap + runTaoSceneTest(width = SAMPLE_WIDTH, height = SAMPLE_HEIGHT) { + setContent { + SampleLines(lcd) + } + bitmap = + renderToBitmap( + surfaceProps = + if (lcd) { + SurfaceProps(isDeviceIndependentFonts = false, pixelGeometry = PixelGeometry.RGB_H) + } else { + SurfaceProps() + }, + ) + } + return bitmap.toBufferedImage() +} + +@Composable +private fun SampleLines(lcd: Boolean) { + Box(Modifier.fillMaxSize().background(Color.White).padding(12.dp)) { + Column { + Text("File Edit View Help", style = sampleStyle(lcd, 13.sp, FontWeight.Normal)) + Text("The five boxing wizards jump", style = sampleStyle(lcd, 14.sp, FontWeight.Normal)) + Text("fun main() { println(\"Hello\") }", style = sampleStyle(lcd, 13.sp, FontWeight.Normal)) + } + } +} + +private fun sampleStyle( + lcd: Boolean, + size: androidx.compose.ui.unit.TextUnit, + weight: FontWeight, +): TextStyle { + val base = TextStyle(color = Color.Black, fontSize = size, fontWeight = weight) + return if (lcd) taoLcdTextStyle().merge(base) else base +} + +private fun writeComparison( + gray: BufferedImage, + lcd: BufferedImage, +): Path { + val crop = cropToContent(gray).union(cropToContent(lcd)) + val grayCrop = gray.getSubimage(crop.x, crop.y, crop.w, crop.h) + val lcdCrop = lcd.getSubimage(crop.x, crop.y, crop.w, crop.h) + val zoomedGray = nearestZoom(grayCrop, ZOOM) + val zoomedLcd = nearestZoom(lcdCrop, ZOOM) + + val labelH = 36 + val gap = 16 + val panelW = zoomedGray.width + val panelH = zoomedGray.height + val outW = panelW * 2 + gap + 32 + val outH = labelH + panelH + 24 + val out = BufferedImage(outW, outH, BufferedImage.TYPE_INT_RGB) + val g = out.createGraphics() + g.color = java.awt.Color(0xF3, 0xF3, 0xF3) + g.fillRect(0, 0, outW, outH) + g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON) + g.font = Font("Segoe UI", Font.BOLD, 16) + g.color = java.awt.Color(0x33, 0x33, 0x33) + g.drawString("Grayscale (avant — #875)", 16, 24) + g.drawString("ClearType LCD (Tao)", 16 + panelW + gap, 24) + g.drawImage(zoomedGray, 16, labelH, null) + g.drawImage(zoomedLcd, 16 + panelW + gap, labelH, null) + g.dispose() + + val path = Path.of(System.getProperty("user.dir"), "build", "lcd-text-comparison.png") + Files.createDirectories(path.parent) + ImageIO.write(out, "png", path.toFile()) + return path +} + +private fun Bitmap.toBufferedImage(): BufferedImage { + val img = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + for (y in 0 until height) { + for (x in 0 until width) { + img.setRGB(x, y, getColor(x, y)) + } + } + return img +} + +private data class Crop( + val x: Int, + val y: Int, + val w: Int, + val h: Int, +) { + fun union(other: Crop): Crop { + val left = minOf(x, other.x) + val top = minOf(y, other.y) + val right = maxOf(x + w, other.x + other.w) + val bottom = maxOf(y + h, other.y + other.h) + return Crop(left, top, right - left, bottom - top) + } +} + +private fun cropToContent(img: BufferedImage): Crop { + var minX = img.width + var minY = img.height + var maxX = 0 + var maxY = 0 + for (y in 0 until img.height) { + for (x in 0 until img.width) { + if (img.getRGB(x, y) and 0x00FFFFFF != 0x00FFFFFF) { + if (x < minX) minX = x + if (y < minY) minY = y + if (x > maxX) maxX = x + if (y > maxY) maxY = y + } + } + } + val pad = 4 + val x = (minX - pad).coerceAtLeast(0) + val y = (minY - pad).coerceAtLeast(0) + val w = (maxX + pad + 1 - x).coerceAtMost(img.width - x) + val h = (maxY + pad + 1 - y).coerceAtMost(img.height - y) + return Crop(x, y, w, h) +} + +private fun nearestZoom( + src: BufferedImage, + zoom: Int, +): BufferedImage { + val dst = BufferedImage(src.width * zoom, src.height * zoom, BufferedImage.TYPE_INT_RGB) + val g = dst.createGraphics() + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR) + g.drawImage(src, 0, 0, dst.width, dst.height, null) + g.dispose() + return dst +} + +private const val SAMPLE_WIDTH = 420 +private const val SAMPLE_HEIGHT = 110 +private const val ZOOM = 8 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTextTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTextTest.kt new file mode 100644 index 000000000..7598a2e28 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/LcdTextTest.kt @@ -0,0 +1,144 @@ +package dev.nucleusframework.window.tao.scene + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material.Text +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.core.runtime.Platform +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.PixelGeometry +import org.jetbrains.skia.SurfaceProps +import kotlin.test.Test +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class LcdTextTest { + @Test + fun `transparent windows disable LCD surface props`() { + assertNull( + lcdSurfaceProps( + windowTransparent = true, + platform = Platform.Windows, + windowsLcdGeometry = { PixelGeometry.RGB_H }, + ), + ) + assertNull( + lcdSurfaceProps( + windowTransparent = true, + platform = Platform.Windows, + windowsLcdGeometry = { PixelGeometry.BGR_H }, + ), + ) + } + + @Test + fun `opaque windows on Windows keep RGB or BGR geometry`() { + assertNotNull( + lcdSurfaceProps( + windowTransparent = false, + platform = Platform.Windows, + windowsLcdGeometry = { PixelGeometry.RGB_H }, + ), + ) + assertNotNull( + lcdSurfaceProps( + windowTransparent = false, + platform = Platform.Windows, + windowsLcdGeometry = { PixelGeometry.BGR_H }, + ), + ) + } + + @Test + fun `macOS and Linux stay grayscale`() { + assertNull( + lcdSurfaceProps( + windowTransparent = false, + platform = Platform.MacOS, + windowsLcdGeometry = { PixelGeometry.RGB_H }, + ), + ) + assertNull( + lcdSurfaceProps( + windowTransparent = false, + platform = Platform.Linux, + windowsLcdGeometry = { PixelGeometry.RGB_H }, + ), + ) + } + + @Test + fun `ClearType off means no LCD surface props`() { + assertNull( + lcdSurfaceProps( + windowTransparent = false, + platform = Platform.Windows, + windowsLcdGeometry = { null }, + ), + ) + } + + @Test + fun `Compose LCD text on an RGB surface has chromatic edges`() { + // 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 on macOS lcdScore + // equals grayScore, which is the documented behaviour of this feature + // (`macOS and Linux stay grayscale` asserts the same thing on the + // surface-props side), not a regression to catch here. + if (Platform.Current == Platform.MacOS) { + println("SKIPPED: CoreText has no subpixel glyph masks; LCD text is a Windows/Linux capability") + return + } + runTaoSceneTest(width = 240, height = 64) { + setContent { + Box(Modifier.fillMaxSize().background(Color.White).padding(8.dp)) { + Text( + "Hamburg", + style = + taoLcdTextStyle().merge( + TextStyle(color = Color.Black, fontSize = 22.sp), + ), + ) + } + } + val lcd = + renderToBitmap( + surfaceProps = + SurfaceProps(isDeviceIndependentFonts = false, pixelGeometry = PixelGeometry.RGB_H), + ) + val gray = renderToBitmap(surfaceProps = SurfaceProps()) + val lcdScore = chromaticScore(lcd) + val grayScore = chromaticScore(gray) + assertTrue( + lcdScore > grayScore, + "Tao LCD text should fringe on RGB_H (lcd=$lcdScore gray=$grayScore)", + ) + } + } +} + +private fun chromaticScore(bitmap: Bitmap): Int { + var score = 0 + for (y in 0 until bitmap.height) { + for (x in 0 until bitmap.width) { + val color = bitmap.getColor(x, y) + val r = (color ushr 16) and 0xFF + val g = (color ushr 8) and 0xFF + val b = color and 0xFF + val spread = maxOf(r, g, b) - minOf(r, g, b) + if (spread > CHROMA_THRESHOLD) score++ + } + } + return score +} + +private const val CHROMA_THRESHOLD = 12 diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt index 78a0349c8..e7168fee1 100644 --- a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTestHarness.kt @@ -27,6 +27,7 @@ import dev.nucleusframework.window.tao.TaoPointerScrollEvent import dev.nucleusframework.window.tao.event.TaoSyntheticMouseWheelEvent import dev.nucleusframework.window.tao.event.dispatchNativeKeyEvent import dev.nucleusframework.window.tao.event.dispatchTrackpadPan +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale import dev.nucleusframework.window.tao.event.taoKeyboardModifiers import dev.nucleusframework.window.tao.ffi.TaoNativeWireFormat import kotlinx.coroutines.CoroutineDispatcher @@ -34,6 +35,7 @@ import kotlinx.coroutines.awaitCancellation import org.jetbrains.skia.Bitmap import org.jetbrains.skia.ImageInfo import org.jetbrains.skia.Picture +import org.jetbrains.skia.Rect import org.jetbrains.skia.Surface import kotlin.coroutines.CoroutineContext @@ -259,6 +261,18 @@ internal class TaoSceneTestScope( val scene: ComposeScene get() = sceneBundle.scene + /** + * Mirrors [TaoSceneBundle.renderOverlay] — what a popup layer paints into + * the same picture *after* its scene (the scrims of the layers stacked + * above it). Recorded inside the frame, so it counts towards the picture's + * op count exactly as it does in production. + */ + var renderOverlay: ((org.jetbrains.skia.Canvas) -> Unit)? + get() = sceneBundle.renderOverlay + set(value) { + sceneBundle.renderOverlay = value + } + /** * Mirrors the scene host's `exceptionHandler` field (#621): installed on the * bundle, so frames go through the production guard in @@ -343,7 +357,16 @@ internal class TaoSceneTestScope( * render pass: pump continuations, deliver the frame clock, then record * the scene through the production CPU record path. */ - fun frame(deltaMillis: Long = FRAME_DELTA_MILLIS): Picture { + fun frame( + deltaMillis: Long = FRAME_DELTA_MILLIS, + /** + * Cull rect handed to the picture recorder. Defaults to the scene size, + * as a window host records; a popup layer records the same scene with a + * rect rooted at its draw bounds, which is what + * `MacPopupPictureCullTest` exercises. + */ + cullRect: Rect? = null, + ): Picture { timeNanos += deltaMillis * NANOS_PER_MILLI // Release virtual-clock timers (delay / withTimeout) due at the new // time BEFORE pumping, so their continuations run in this frame. @@ -358,7 +381,13 @@ internal class TaoSceneTestScope( // dispatchers around the tick), so the recompose triggered by this // frame's `withFrameNanos` continuations is part of the recorded picture // — same guarantee the explicit sendFrame + pump used to give. - return recordSceneToPicture(sceneBundle, width, height, timeNanos).also { lastPicture = it } + return recordSceneToPicture( + bundle = sceneBundle, + widthPx = width, + heightPx = height, + nanoTime = timeNanos, + cullRect = cullRect ?: Rect.makeWH(width.toFloat(), height.toFloat()), + ).also { lastPicture = it } } /** @@ -527,6 +556,25 @@ internal class TaoSceneTestScope( frame() } + /** + * Mirrors the scene host's trackpad pinch dispatch (`dispatchTrackpadScale`, + * #660): [scaleFactor] is a multiplicative per-event ratio (`1f` = no + * change). The pointer sits at the last cursor position. + */ + fun scale( + type: PointerEventType, + scaleFactor: Float = 1f, + ) { + scene.dispatchTrackpadScale( + x = pointerDeadband.x, + y = pointerDeadband.y, + type = type, + scaleFactor = scaleFactor, + keyboardModifiers = taoKeyboardModifiers(modifierState), + ) + frame() + } + /** Mirrors `TaoComposeSceneHost.onPointerScroll` (AWT-shaped native event attached). */ fun scroll(event: TaoPointerScrollEvent) { val modifiers = taoKeyboardModifiers(modifierState) @@ -680,9 +728,17 @@ internal class TaoSceneTestScope( // ── Pixels ────────────────────────────────────────────────────────────── /** Rasterizes the last recorded frame (CPU) and returns it as a Skia bitmap. */ - fun renderToBitmap(clearColor: Int = COLOR_WHITE): Bitmap { + fun renderToBitmap( + clearColor: Int = COLOR_WHITE, + surfaceProps: org.jetbrains.skia.SurfaceProps? = null, + ): Bitmap { val picture = lastPicture ?: frame() - val surface = Surface.makeRasterN32Premul(width, height) + val surface = + Surface.makeRaster( + ImageInfo.makeN32Premul(width, height), + 0, + surfaceProps, + ) surface.canvas.clear(clearColor) surface.canvas.drawPicture(picture) val bitmap = Bitmap() diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadScaleTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadScaleTest.kt new file mode 100644 index 000000000..3c60a828b --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/scene/TaoSceneTrackpadScaleTest.kt @@ -0,0 +1,435 @@ +@file:OptIn(InternalComposeUiApi::class, androidx.compose.ui.ExperimentalComposeUiApi::class) + +package dev.nucleusframework.window.tao.scene + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.InternalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerId +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.scene.ComposeScenePointer +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.event.TaoTrackpadScaleSession +import dev.nucleusframework.window.tao.event.dispatchTrackpadScale +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * #660: a platform-recognized pinch must reach Compose as `ScaleStart` / + * `ScaleChange` / `ScaleEnd` at the cursor, not as two synthetic Touch + * contacts 120 px off it. + * + * The first tests replay the pre-#660 synthesis so the bug stays measurable + * (dual-hit at a map edge, touch-slop delay on a 1 % pinch). The rest drive + * [dispatchTrackpadScale] — the production path after the fix. + */ +class TaoSceneTrackpadScaleTest { + // ── Reproduction of the pre-#660 two-touch synthesis ─────────────────── + + @Test + fun `legacy two-touch pinch plants contacts 120 px off the cursor`() = + runTaoSceneTest(width = 400, height = 200) { + val contacts = mutableListOf() + setContent { + Box(Modifier.fillMaxSize().recordingPositions(contacts)) + } + moveMouse(CURSOR_X, CURSOR_Y) + frameUntilIdle() + contacts.clear() + sendLegacyPinch(PointerEventType.Press, scale = 1f, CURSOR_X, CURSOR_Y) + frameUntilIdle() + + val unique = contacts.distinct() + assertEquals(2, unique.size, "legacy pinch must plant two Touch contacts, got $contacts") + val distances = unique.map { hypot(it.x - CURSOR_X, it.y - CURSOR_Y) } + distances.forEach { distance -> + assertEquals( + LEGACY_RADIUS_PX.toDouble(), + distance.toDouble(), + absoluteTolerance = 0.01, + message = "legacy contact $distance px from cursor; expected $LEGACY_RADIUS_PX px", + ) + } + println( + "REPRO #660 geometry: cursor=($CURSOR_X, $CURSOR_Y) contacts=$unique " + + "distances=$distances span=${hypot(unique[0].x - unique[1].x, unique[0].y - unique[1].y)}", + ) + } + + @Test + fun `legacy two-touch pinch at a map edge hits the neighbouring chrome`() = + runTaoSceneTest(width = 400, height = 200) { + val mapHits = mutableListOf() + val chromeHits = mutableListOf() + setContent { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxHeight() + .width(MAP_WIDTH_DP.dp) + .background(Color.Blue) + .recordingPositions(mapHits), + ) + Box( + Modifier + .offset(x = MAP_WIDTH_DP.dp) + .fillMaxHeight() + .width(CHROME_WIDTH_DP.dp) + .background(Color.Red) + .recordingPositions(chromeHits), + ) + } + } + // Cursor 10 px inside the map, next to the chrome. The 120 px + // synthetic pair straddles the boundary: one contact in the map, + // the other in the chrome — the edge interruption MapLibre saw. + moveMouse(NEAR_EDGE_X, CURSOR_Y) + frameUntilIdle() + mapHits.clear() + chromeHits.clear() + sendLegacyPinch(PointerEventType.Press, scale = 1f, NEAR_EDGE_X, CURSOR_Y) + frameUntilIdle() + + println( + "REPRO #660 dual-hit: cursor=$NEAR_EDGE_X (map is 0..$MAP_WIDTH_PX) " + + "mapHits=$mapHits chromeHits=$chromeHits", + ) + assertTrue(mapHits.isNotEmpty(), "one synthetic contact must land in the map, got mapHits=$mapHits") + assertTrue( + chromeHits.isNotEmpty(), + "the other synthetic contact must land in the neighbouring chrome " + + "(the #660 edge interruption); chromeHits=$chromeHits", + ) + } + + @Test + fun `legacy two-touch pinch delays a 1 percent zoom behind touch slop`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + val callbacks = mutableStateOf(0) + setContent { + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + detectTransformGestures { _, _, zoomChange, _ -> + callbacks.value++ + zoom.value *= zoomChange + } + }, + ) + } + moveMouse(CURSOR_X, CURSOR_Y) + sendLegacyPinch(PointerEventType.Press, scale = 1f, CURSOR_X, CURSOR_Y) + sendLegacyPinch(PointerEventType.Move, scale = ONE_PERCENT, CURSOR_X, CURSOR_Y) + frameUntilIdle() + + println( + "REPRO #660 slop: 1% pinch through two-touch synthesis → " + + "callbacks=${callbacks.value} zoom=${zoom.value} " + + "(zoomMotion = |1-$ONE_PERCENT| × $LEGACY_RADIUS_PX = " + + "${abs(1f - ONE_PERCENT) * LEGACY_RADIUS_PX} px vs ~18 px touchSlop)", + ) + assertEquals(0, callbacks.value, "a 1% pinch must not cross detectTransformGestures touch slop") + assertEquals(1f, zoom.value) + } + + @Test + fun `legacy two-touch pinch needs about 15 percent before detectTransformGestures zooms`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + val callbacks = mutableStateOf(0) + setContent { + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + detectTransformGestures { _, _, zoomChange, _ -> + callbacks.value++ + zoom.value *= zoomChange + } + }, + ) + } + moveMouse(CURSOR_X, CURSOR_Y) + sendLegacyPinch(PointerEventType.Press, scale = 1f, CURSOR_X, CURSOR_Y) + var steps = 0 + var scale = 1f + while (callbacks.value == 0 && steps < MAX_SLOP_STEPS) { + scale *= ONE_PERCENT + steps++ + sendLegacyPinch(PointerEventType.Move, scale = scale, CURSOR_X, CURSOR_Y) + frameUntilIdle() + } + println( + "REPRO #660 hesitation: $steps steps of +1% (cumulative scale=$scale, " + + "${((scale - 1f) * 100f).toInt()}%) before detectTransformGestures fired " + + "(callbacks=${callbacks.value} zoom=${zoom.value})", + ) + assertTrue(callbacks.value > 0, "eventually the slop must be crossed") + assertTrue( + steps >= MIN_SLOP_STEPS, + "expected a long slop delay, got a callback after $steps × 1% steps", + ) + } + + // ── Production Scale path (#660) ─────────────────────────────────────── + + @Test + fun `magnify is dispatched as ScaleStart ScaleChange ScaleEnd at the cursor`() = + runTaoSceneTest(width = 400, height = 200) { + val seen = mutableListOf() + setContent { Box(Modifier.fillMaxSize().recordingScale(seen)) } + moveMouse(CURSOR_X, CURSOR_Y) + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + + assertEquals( + listOf( + PointerEventType.ScaleStart, + PointerEventType.ScaleChange, + PointerEventType.ScaleEnd, + ), + seen.map { it.type }, + "pinch must reach Compose as Scale events, got $seen", + ) + assertEquals(ONE_PERCENT, seen[1].scaleFactor) + seen.forEach { record -> + assertEquals(1, record.pointerCount, "Scale events must carry one pointer, got $record") + assertEquals(PointerType.Mouse, record.pointerType) + assertEquals(CURSOR_X, record.position.x) + assertEquals(CURSOR_Y, record.position.y) + } + println("FIX #660 events: $seen") + } + + @Test + fun `scale events at a map edge hit only the map under the cursor`() = + runTaoSceneTest(width = 400, height = 200) { + val mapHits = mutableListOf() + val chromeHits = mutableListOf() + setContent { + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxHeight() + .width(MAP_WIDTH_DP.dp) + .background(Color.Blue) + .recordingPositions(mapHits), + ) + Box( + Modifier + .offset(x = MAP_WIDTH_DP.dp) + .fillMaxHeight() + .width(CHROME_WIDTH_DP.dp) + .background(Color.Red) + .recordingPositions(chromeHits), + ) + } + } + moveMouse(NEAR_EDGE_X, CURSOR_Y) + frameUntilIdle() + mapHits.clear() + chromeHits.clear() + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + + println("FIX #660 hit-test: mapHits=$mapHits chromeHits=$chromeHits") + assertTrue(mapHits.isNotEmpty(), "the Scale event must hit the map under the cursor") + assertTrue( + chromeHits.isEmpty(), + "Scale events must not hit neighbouring chrome, got chromeHits=$chromeHits", + ) + } + + @Test + fun `a 1 percent scale change zooms transformable immediately`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + setContent { + val state = + @Suppress("DEPRECATION") + rememberTransformableState { zoomChange, _, _ -> + zoom.value *= zoomChange + } + Box(Modifier.fillMaxSize().transformable(state)) + } + moveMouse(CURSOR_X, CURSOR_Y) + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + + println("FIX #660 transformable: 1% ScaleChange → zoom=${zoom.value}") + assertEquals( + ONE_PERCENT.toDouble(), + zoom.value.toDouble(), + absoluteTolerance = 0.0001, + message = "transformable must apply the ScaleChange ratio with no slop, got ${zoom.value}", + ) + } + + @Test + fun `detectTransformGestures is not the Scale path and stays quiet on a 1 percent pinch`() = + runTaoSceneTest(width = 400, height = 200) { + val callbacks = mutableStateOf(0) + setContent { + Box( + Modifier.fillMaxSize().pointerInput(Unit) { + detectTransformGestures { _, _, _, _ -> callbacks.value++ } + }, + ) + } + moveMouse(CURSOR_X, CURSOR_Y) + scale(PointerEventType.ScaleStart) + scale(PointerEventType.ScaleChange, ONE_PERCENT) + scale(PointerEventType.ScaleEnd) + frameUntilIdle() + assertEquals( + 0, + callbacks.value, + "detectTransformGestures must not re-interpret Scale events as a two-finger pinch", + ) + } + + @Test + fun `host-shaped magnify stream zooms transformable without slop`() = + runTaoSceneTest(width = 400, height = 200) { + val zoom = mutableStateOf(1f) + setContent { + val state = + @Suppress("DEPRECATION") + rememberTransformableState { zoomChange, _, _ -> + zoom.value *= zoomChange + } + Box(Modifier.fillMaxSize().transformable(state)) + } + moveMouse(CURSOR_X, CURSOR_Y) + val session = + TaoTrackpadScaleSession { type, factor -> + scene.dispatchTrackpadScale(CURSOR_X, CURSOR_Y, type, factor) + frame() + } + // macOS: Began, then a 1% Changed, then Ended — the AppKit stream. + session.start() + session.magnifyBy(0.01f) + session.end() + frameUntilIdle() + println("FIX #660 host stream: Began + 1% Changed + Ended → zoom=${zoom.value}") + assertEquals( + ONE_PERCENT.toDouble(), + zoom.value.toDouble(), + absoluteTolerance = 0.0001, + message = "the host magnify stream must zoom immediately, got ${zoom.value}", + ) + } + + /** + * Pre-#660 host synthesis: two Touch pointers [LEGACY_RADIUS_PX] either + * side of [centerX]/[centerY], distance scaled by [scale]. + */ + private fun TaoSceneTestScope.sendLegacyPinch( + eventType: PointerEventType, + scale: Float, + centerX: Float, + centerY: Float, + ) { + val radius = LEGACY_RADIUS_PX * scale + val pressed = eventType != PointerEventType.Release + scene.sendPointerEvent( + eventType = eventType, + pointers = + listOf( + ComposeScenePointer( + id = PointerId(LEGACY_POINTER_ID_A), + position = Offset(centerX - radius, centerY), + pressed = pressed, + type = PointerType.Touch, + ), + ComposeScenePointer( + id = PointerId(LEGACY_POINTER_ID_B), + position = Offset(centerX + radius, centerY), + pressed = pressed, + type = PointerType.Touch, + ), + ), + ) + frame() + } + + private fun Modifier.recordingPositions(into: MutableList): Modifier = + pointerInput(into) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + event.changes.forEach { into += it.position } + } + } + } + + private fun Modifier.recordingScale(into: MutableList): Modifier = + pointerInput(into) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + when (event.type) { + PointerEventType.ScaleStart, + PointerEventType.ScaleChange, + PointerEventType.ScaleEnd, + -> { + val change = event.changes.first() + into += + ScaleRecord( + type = event.type, + scaleFactor = change.scaleFactor, + position = change.position, + pointerCount = event.changes.size, + pointerType = change.type, + ) + } + else -> Unit + } + } + } + } + + private data class ScaleRecord( + val type: PointerEventType, + val scaleFactor: Float, + val position: Offset, + val pointerCount: Int, + val pointerType: PointerType, + ) + + private companion object { + const val CURSOR_X = 200f + const val CURSOR_Y = 100f + const val LEGACY_RADIUS_PX = 120f + const val LEGACY_POINTER_ID_A = 0xA001L + const val LEGACY_POINTER_ID_B = 0xA002L + const val ONE_PERCENT = 1.01f + const val MAP_WIDTH_DP = 150 + const val CHROME_WIDTH_DP = 250 + const val MAP_WIDTH_PX = 150f + const val NEAR_EDGE_X = 140f + const val MAX_SLOP_STEPS = 40 + const val MIN_SLOP_STEPS = 10 + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/DragControllerTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/DragControllerTest.kt new file mode 100644 index 000000000..aeb6a49df --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/DragControllerTest.kt @@ -0,0 +1,63 @@ +package dev.nucleusframework.window.tao.workspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** One live drag at a time, and feedback cleared exactly when a drag ends. */ +class DragControllerTest { + private class Session + + @Test + fun `begin supersedes the live session and clears the feedback once`() { + var cleared = 0 + val controller = DragController { cleared++ } + val first = Session() + val second = Session() + + controller.begin(first) + assertEquals(0, cleared, "nothing to clear before the first drag") + assertTrue(controller.isLive(first)) + + controller.begin(second) + assertEquals(1, cleared, "the superseded drag's feedback is gone") + assertFalse(controller.isLive(first)) + assertTrue(controller.isLive(second)) + assertSame(second, controller.active) + } + + @Test + fun `release ignores a session that is not live and is idempotent for the live one`() { + var cleared = 0 + val controller = DragController { cleared++ } + val live = Session() + val stale = Session() + controller.begin(live) + + controller.release(stale) + assertEquals(0, cleared) + assertTrue(controller.isLive(live), "a stale release cannot end the live drag") + + controller.release(live) + controller.release(live) + assertEquals(1, cleared, "the second release finds nothing live and clears again harmlessly") + assertNull(controller.active) + } + + @Test + fun `release of null ends whichever session is live`() { + var cleared = 0 + val controller = DragController { cleared++ } + val live = Session() + controller.begin(live) + + controller.release(null) + + assertNull(controller.active) + assertFalse(controller.isLive(live)) + assertEquals(1, cleared) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometryTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometryTest.kt new file mode 100644 index 000000000..758310357 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/HostGeometryTest.kt @@ -0,0 +1,85 @@ +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.unit.IntSize +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** Screen placement of a published drop target and the registry that keeps one per window. */ +class HostGeometryTest { + private val a = TaoWindow(handle = 1L) + private val b = TaoWindow(handle = 2L) + + @Test + fun `client origin splits the side borders evenly and matches them at the bottom`() { + // A 820×660 frame around 800×600 of content: 10 px borders left and + // right, 10 px assumed below, the remaining 50 px title bar and top + // border. + val origin = clientOriginPx(longArrayOf(100L, 200L, 820L, 660L), IntSize(800, 600)) + + assertEquals(Offset(110f, 250f), origin) + // A plain resize frame — Win32's invisible borders — adds the same + // 8 px on every side, so the content starts 8 px in on both axes. + assertEquals(Offset(108f, 208f), clientOriginPx(longArrayOf(100L, 200L, 816L, 616L), IntSize(800, 600))) + // Client-side decorated: frame == content, origin == frame origin. + assertEquals(Offset(100f, 200f), clientOriginPx(longArrayOf(100L, 200L, 800L, 600L), IntSize(800, 600))) + } + + @Test + fun `screen rect is unknown until both the container size and the outer frame are`() { + var outer: LongArray? = null + val geometry = HostGeometry(a, outerBoundsPx = { outer }, scaleFactor = { 1f }) + geometry.layoutBoundsInWindowPx = Rect(0f, 40f, 800f, 600f) + + assertNull(geometry.clientOriginPx(), "no container size yet") + geometry.containerSizePx = IntSize(800, 600) + assertNull(geometry.layoutScreenRectPx(), "unmapped window has no frame") + + outer = longArrayOf(100L, 100L, 800L, 600L) + assertEquals(Rect(100f, 140f, 900f, 700f), geometry.layoutScreenRectPx()) + } + + @Test + fun `scale falls back to one while the window reports none`() { + val geometry = HostGeometry(a, scaleFactor = { 0f }) + assertEquals(1f, geometry.scaleOrOne()) + assertEquals(2f, HostGeometry(a, scaleFactor = { 2f }).scaleOrOne()) + } + + @Test + fun `the registry keeps one geometry per window and only that one can unregister`() { + val registry = HostGeometryRegistry() + val first = HostGeometry(a) + val second = HostGeometry(a) + registry.register(first) + registry.register(second) + assertSame(second, registry[a], "the latest publisher wins") + + // The layout that was replaced disposes later: it must not take the + // live one down with it. + registry.unregister(first) + assertSame(second, registry[a]) + registry.unregister(second) + assertNull(registry[a]) + assertNull(registry[null]) + } + + @Test + fun `ordered lists the given hosts first and the rest in registration order`() { + val registry = HostGeometryRegistry() + val geometryA = HostGeometry(a) + val geometryB = HostGeometry(b) + registry.register(geometryA) + registry.register(geometryB) + + assertEquals(listOf(geometryB, geometryA), registry.ordered(listOf(b, a))) + assertEquals(listOf(geometryB, geometryA), registry.ordered(listOf(b)), "unnamed hosts follow") + assertEquals(listOf(geometryA, geometryB), registry.ordered(emptyList())) + // A host without a geometry (no layout composed) is simply skipped. + assertEquals(listOf(geometryA, geometryB), registry.ordered(listOf(TaoWindow(handle = 9L), a))) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/RelocatingSaveableStateRegistryTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/RelocatingSaveableStateRegistryTest.kt new file mode 100644 index 000000000..2634aa98c --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/RelocatingSaveableStateRegistryTest.kt @@ -0,0 +1,108 @@ +package dev.nucleusframework.window.tao.workspace + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +/** + * Key relocation and value ordering of [RelocatingSaveableStateRegistry] — the + * part of a host change that needs no window and no composition. The headful + * suite covers the real `rememberSaveable` round trips. + */ +class RelocatingSaveableStateRegistryTest { + @Test + fun `keys relocate across hosts by rotation of the anchor delta`() { + val anchorA = 0x1234_5678_9ABC_DEF0L + val anchorB = -0x0FED_CBA9_8765_4322L + val delta = anchorA xor anchorB + // Two call sites at depths 2 and 7 below the anchor: their hashes differ + // between hosts by the delta rotated by the accumulated shifts. + val siteA1 = 0x0000_00AB_CDEF_0123L + val siteA2 = -0x7777_0000_1111_2222L + val siteB1 = siteA1 xor delta.rotateLeft(6) + val siteB2 = siteA2 xor delta.rotateLeft(21) + val saved = + RelocatedSavedState( + anchor = anchorA, + values = + mapOf( + siteA1.toString(36) to listOf("first"), + siteA2.toString(36) to listOf(42), + "explicit" to listOf("named"), + ), + ) + + val registry = RelocatingSaveableStateRegistry(saved, anchorB) + + assertEquals("first", registry.consumeRestored(siteB1.toString(36))) + assertEquals(42, registry.consumeRestored(siteB2.toString(36))) + assertEquals("named", registry.consumeRestored("explicit")) + assertNull(registry.consumeRestored(siteB1.toString(36))) + assertNull(registry.consumeRestored(0x5555L.toString(36))) + } + + @Test + fun `values keep their order when providers unregister in reverse`() { + val registry = RelocatingSaveableStateRegistry(saved = null, anchor = 1L) + // Three call sites sharing one key — what Compose does with sibling + // rememberSaveable / rememberScrollState calls in the same group. + val entries = + listOf("tool", 33f, 0).map { value -> + registry.registerProvider("shared") { value } + } + + // Compose forgets in reverse composition order, before the host's own + // disposable effect gets to save. + entries.asReversed().forEach { it.unregister() } + + assertEquals(mapOf("shared" to listOf("tool", 33f, 0)), registry.performSave()) + } + + @Test + fun `a re-registering provider keeps its place among the values`() { + val registry = RelocatingSaveableStateRegistry(saved = null, anchor = 1L) + registry.registerProvider("shared") { "first" } + val second = registry.registerProvider("shared") { "second" } + registry.registerProvider("shared") { "third" } + + // A recomposing rememberSaveable: unregisters, then registers again. + second.unregister() + registry.registerProvider("shared") { "second-again" } + + assertEquals(mapOf("shared" to listOf("first", "second-again", "third")), registry.performSave()) + } + + @Test + fun `restored values never consumed survive another host change`() { + val saved = RelocatedSavedState(anchor = 1L, values = mapOf("kept" to listOf("value"))) + val registry = RelocatingSaveableStateRegistry(saved, anchor = 2L) + registry.registerProvider("other") { "live" } + + assertEquals( + mapOf("kept" to listOf("value"), "other" to listOf("live")), + registry.performSave(), + ) + } + + @Test + fun `a slot snapshot prefers the live registry over the last save`() { + val slot = RelocatableSlot() + assertNull(slot.snapshot(), "nothing known before any host composed") + + slot.savedState = RelocatedSavedState(anchor = 1L, values = mapOf("k" to listOf("old"))) + assertEquals(listOf("old"), slot.snapshot()?.values?.get("k")) + + // The next host mounts while the previous one is still composed: the + // live values win over the stale save. + val live = RelocatingSaveableStateRegistry(saved = null, anchor = 2L) + live.registerProvider("k") { "new" } + slot.activeRegistry = live + val snapshot = slot.snapshot() + assertEquals(2L, snapshot?.anchor) + assertEquals(listOf("new"), snapshot?.values?.get("k")) + + slot.activeRegistry = null + assertSame(slot.savedState, slot.snapshot()) + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/TransferDragTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/TransferDragTest.kt new file mode 100644 index 000000000..aad2d34c3 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/TransferDragTest.kt @@ -0,0 +1,188 @@ +package dev.nucleusframework.window.tao.workspace + +import androidx.compose.ui.draganddrop.DragAndDropTransferAction +import androidx.compose.ui.draganddrop.TaoTransferableAccess +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.unit.IntRect +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.dnd.TaoPrivateTransfer +import dev.nucleusframework.window.tao.dockSideAt +import java.awt.datatransfer.DataFlavor +import java.awt.datatransfer.StringSelection +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull + +/** + * The coordinate-space and payload rules the DnD-carried cross-window drag + * rests on. Both are pure functions of what an inbound drag event carries, so + * they are checked here rather than against a compositor. + */ +@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) +class TransferDragTest { + private val layout = Rect(0f, 40f, 720f, 760f) + private val zone = 64f + + @Test + fun `nearest edge within the zone wins`() { + assertEquals(DockSide.Left, dockSideAt(layout, Offset(layout.left + 1f, layout.center.y), zone)) + assertEquals(DockSide.Right, dockSideAt(layout, Offset(layout.right - 1f, layout.center.y), zone)) + assertEquals(DockSide.Top, dockSideAt(layout, Offset(layout.center.x, layout.top + 1f), zone)) + assertEquals(DockSide.Bottom, dockSideAt(layout, Offset(layout.center.x, layout.bottom - 1f), zone)) + } + + @Test + fun `a corner resolves to the closer of its two edges`() { + // 10 px from the top, 30 from the left: the top edge is nearer. + assertEquals(DockSide.Top, dockSideAt(layout, Offset(layout.left + 30f, layout.top + 10f), zone)) + assertEquals(DockSide.Left, dockSideAt(layout, Offset(layout.left + 10f, layout.top + 30f), zone)) + } + + @Test + fun `content and points outside the layout are no zone`() { + assertNull(dockSideAt(layout, layout.center, zone)) + assertNull(dockSideAt(layout, Offset(layout.right + 1f, layout.center.y), zone)) + // Inside the *window* but above the layout — a title bar, say. + assertNull(dockSideAt(layout, Offset(layout.center.x, layout.top - 1f), zone)) + } + + @Test + fun `a zone wider than the layout still resolves to exactly one side`() { + // A dock zone deeper than the layout it is measured in: every point is + // within range of all four edges, and the nearest must still win + // outright rather than the sides overlapping. + val wide = Rect(0f, 0f, 400f, 200f) + assertEquals(DockSide.Top, dockSideAt(wide, Offset(200f, 40f), zonePx = 1000f)) + assertEquals(DockSide.Left, dockSideAt(wide, Offset(30f, 100f), zonePx = 1000f)) + assertEquals(DockSide.Bottom, dockSideAt(wide, Offset(200f, 160f), zonePx = 1000f)) + } + + @Test + fun `the private payload round-trips under its own flavor only`() { + val transferable = TaoPrivateTransfer.transferable("workspace-drag") + assertEquals("workspace-drag", TaoPrivateTransfer.tokenOf(transferable)) + assertEquals(listOf(TaoPrivateTransfer.FLAVOR), transferable.transferDataFlavors.toList()) + assertFalse( + transferable.isDataFlavorSupported(DataFlavor.stringFlavor), + "a private payload must not masquerade as text a foreign target could take", + ) + } + + @Test + fun `an ordinary transferable carries no token`() { + assertNull(TaoPrivateTransfer.tokenOf(StringSelection("hello"))) + } + + /** + * The transfer's completion callback is the only signal that the platform + * session is over, and therefore the only thing that ends the workspace's + * drag: without it the drop record is never acted on and the drop-zone + * highlights never clear, with nothing logged. Asserted directly, because + * a gesture stranded this way looks exactly like one that never started. + */ + @Test + fun `the transfer ends the drag when the platform reports the session over`() { + val drag = RecordingDrag() + val data = transferDragData(drag, drag.ghostSizePx, hotspotPx = Offset(4f, 6f)) + assertEquals(0, drag.ended, "the drag must not end before the platform says so") + requireNotNull(data.onTransferCompleted) { "a transfer with no completion callback strands the gesture" } + .invoke(DragAndDropTransferAction.Move) + assertEquals(1, drag.ended) + assertEquals(0, drag.cancelled) + } + + @Test + fun `the transfer carries the private token and a Move action only`() { + val drag = RecordingDrag() + val data = transferDragData(drag, drag.ghostSizePx, Offset.Zero) + assertEquals(listOf(DragAndDropTransferAction.Move), data.supportedActions.toList()) + val awt = requireNotNull(TaoTransferableAccess.toAwt(data.transferable)) + assertEquals(TRANSFER_DRAG_TOKEN, TaoPrivateTransfer.tokenOf(awt)) + } + + @Test + fun `the decoration offset puts the hotspot under the pointer, clamped to the icon`() { + val drag = RecordingDrag() + val size = drag.ghostSizePx + // Inside the icon: the offset is the hotspot, negated. + assertEquals(Offset(-4f, -6f), transferDragData(drag, size, Offset(4f, 6f)).dragDecorationOffset) + // Past the icon — clamps to its edge rather than pushing it off the pointer. + assertEquals( + Offset(-size.width, -size.height), + transferDragData(drag, size, Offset(9_999f, 9_999f)).dragDecorationOffset, + ) + // Behind the origin clamps to it. Compared by distance: negating a + // clamped zero yields -0.0f, which `Offset.Zero` does not equal even + // though it is the same point. + assertEquals(0f, transferDragData(drag, size, Offset(-50f, -50f)).dragDecorationOffset.getDistance()) + } + + @Test + fun `without a picture the icon is the title card, one to one`() { + val drag = RecordingDrag() + val ghost = transferGhost(drag, picture = null) + assertEquals(drag.ghostSizePx, ghost.sizePx) + assertEquals(1f, ghost.scale) + // A grab in the strip maps straight into the card. + assertEquals(Offset(30f, 10f), ghost.hotspotPx(Offset(30f, 10f))) + } + + @Test + fun `a picture is shown reduced and capped on its longer edge`() { + val palette = RecordingDrag(source = TransferGhostSource.WholeWindow) + val small = ImageBitmap(300, 400) + val reduced = transferGhost(palette, small) + // Float products: compared within a hundredth of a pixel. + assertEquals(180f, reduced.sizePx.width, PX_TOLERANCE, "a small palette is shown at the reduction scale") + assertEquals(240f, reduced.sizePx.height, PX_TOLERANCE) + + val tall = ImageBitmap(400, 2000) + val capped = transferGhost(palette, tall) + assertEquals(480f, capped.sizePx.height, PX_TOLERANCE, "the longer edge stops at the cap") + assertEquals(96f, capped.sizePx.width, PX_TOLERANCE) + assertEquals(capped.sizePx.height / 2000f, capped.scale, SCALE_TOLERANCE) + } + + @Test + fun `the hotspot follows the grab point into the reduced picture of a region`() { + val panel = RecordingDrag(source = TransferGhostSource.Region(IntRect(420, 40, 720, 760))) + val ghost = transferGhost(panel, ImageBitmap(300, 720)) + // Grabbed 70 px into the panel's header: the same point, reduced. + val hotspot = ghost.hotspotPx(Offset(490f, 55f)) + assertEquals(70f * 0.6f, hotspot.x, PX_TOLERANCE) + assertEquals(15f * 0.6f, hotspot.y, PX_TOLERANCE) + // A grab outside the region clamps to the icon's edge. + assertEquals(0f, ghost.hotspotPx(Offset(0f, 0f)).getDistance(), PX_TOLERANCE) + assertEquals(ghost.sizePx.width, ghost.hotspotPx(Offset(5_000f, 55f)).x, PX_TOLERANCE) + // No grab position at all: hung from the top edge, centred. + assertEquals(ghost.sizePx.width / 2f, ghost.hotspotPx(null).x, PX_TOLERANCE) + } + + private companion object { + const val PX_TOLERANCE = 0.01f + const val SCALE_TOLERANCE = 0.0001f + } + + private class RecordingDrag( + val source: TransferGhostSource = TransferGhostSource.None, + ) : TransferDrag { + var ended = 0 + var cancelled = 0 + + override val title = "Tools" + override val ghostSizePx = Size(220f, 30f) + override val ghostSource: TransferGhostSource get() = source + + override fun end() { + ended++ + } + + override fun cancel() { + cancelled++ + } + } +} diff --git a/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/WindowGroupTest.kt b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/WindowGroupTest.kt new file mode 100644 index 000000000..2d1563534 --- /dev/null +++ b/decorated-window-tao/src/test/kotlin/dev/nucleusframework/window/tao/workspace/WindowGroupTest.kt @@ -0,0 +1,127 @@ +package dev.nucleusframework.window.tao.workspace + +import dev.nucleusframework.window.tao.TaoWindow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** + * Membership, focus recency and pinning of [WindowGroup], driven without any + * native window: members are bare [TaoWindow] handles and focus is fed through + * [WindowGroup.noteFocus]. + */ +class WindowGroupTest { + private val a = TaoWindow(handle = 1L) + private val b = TaoWindow(handle = 2L) + private val c = TaoWindow(handle = 3L) + + @Test + fun `the owner is the pinned member, else the last focused, else the first joined`() { + val group = WindowGroup(followFocus = true) + assertNull(group.owner) + + group.join(a) + group.join(b) + assertSame(a, group.owner, "first joined") + + group.noteFocus(b) + assertSame(b, group.owner, "last focused") + + group.pinTo(a) + assertSame(a, group.owner, "pinned") + + group.pinTo(null) + assertSame(b, group.owner, "back to focus") + } + + @Test + fun `a leaving owner hands over to the member focused before it`() { + val group = WindowGroup(followFocus = true) + group.join(a) + group.join(b) + group.join(c) + group.noteFocus(b) + group.noteFocus(c) + + group.leave(c) + + // Not the last joined (b happens to be both here), not the first: the + // one the user was in before — so three members cannot fool it. + assertSame(b, group.owner) + group.leave(b) + assertSame(a, group.owner, "no focus history left: the first member") + } + + @Test + fun `members by recency put the owner first and never-focused members last in join order`() { + val group = WindowGroup(followFocus = true) + group.join(a) + group.join(b) + group.join(c) + assertEquals(listOf(a, b, c), group.membersByRecency, "no focus yet: join order") + + group.noteFocus(c) + group.noteFocus(b) + assertEquals(listOf(b, c, a), group.membersByRecency) + + // A pin puts its window first and leaves the recency of the rest alone. + group.pinTo(a) + assertEquals(listOf(a, b, c), group.membersByRecency) + } + + @Test + fun `a pin to a non-member is kept but ignored until it joins`() { + val group = WindowGroup(followFocus = true) + group.join(a) + group.pinTo(b) + + assertSame(b, group.pinned) + assertSame(a, group.owner, "a stranger cannot own the group") + + group.join(b) + assertSame(b, group.owner) + + group.leave(b) + assertNull(group.pinned, "a leaving member takes its pin with it") + assertSame(a, group.owner) + } + + @Test + fun `join is idempotent, leaving a stranger is a no-op, and the hooks see both`() { + val joined = mutableListOf() + val left = mutableListOf>() + val group = WindowGroup(followFocus = true, onJoined = joined::add, onLeft = { w, o -> left += w to o }) + + group.join(a) + group.join(a) + group.join(b) + assertEquals(listOf(a, b), group.members) + assertEquals(listOf(a, b), joined) + + group.leave(c) + assertTrue(left.isEmpty(), "a stranger leaving is nothing") + + group.noteFocus(b) + group.leave(b) + assertEquals(listOf>(b to a), left, "the hook sees the owner that remains") + group.leave(a) + assertEquals(listOf>(b to a, a to null), left) + assertNull(group.owner) + } + + @Test + fun `without follow focus the owner ignores focus and takes the pin or the first member`() { + val group = WindowGroup(followFocus = false) + group.join(a) + group.join(b) + group.noteFocus(b) + assertSame(a, group.owner) + // Recency is still tracked for hit-testing, just not for ownership. + assertEquals(listOf(a, b), group.membersByRecency) + + group.pinTo(b) + assertSame(b, group.owner) + } +} diff --git a/examples/avfoundation-demo/src/main/kotlin/dev/nucleusframework/sampleavf/Main.kt b/examples/avfoundation-demo/src/main/kotlin/dev/nucleusframework/sampleavf/Main.kt index c8071a6a9..a339f1e1e 100644 --- a/examples/avfoundation-demo/src/main/kotlin/dev/nucleusframework/sampleavf/Main.kt +++ b/examples/avfoundation-demo/src/main/kotlin/dev/nucleusframework/sampleavf/Main.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar @@ -72,7 +71,7 @@ import java.util.concurrent.atomic.AtomicInteger fun main(args: Array) { // No GraalVmInitializer call: nucleusApplication runs it first thing. val url = resolveUrl(args.firstOrNull() ?: System.getenv("NUCLEUS_AVF_URL")) - nucleusApplication(backend = NucleusBackend.Tao) { + nucleusApplication { NucleusDecoratedWindowTheme(isDark = true) { DecoratedWindow( onCloseRequest = ::exitApplication, diff --git a/examples/benchmark-demo/src/main/kotlin/benchmarkdemo/Main.kt b/examples/benchmark-demo/src/main/kotlin/benchmarkdemo/Main.kt index 1af336c89..30f1c5747 100644 --- a/examples/benchmark-demo/src/main/kotlin/benchmarkdemo/Main.kt +++ b/examples/benchmark-demo/src/main/kotlin/benchmarkdemo/Main.kt @@ -43,7 +43,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar @@ -59,7 +58,7 @@ fun main(args: Array) { printSuite() return } - nucleusApplication(args = args, backend = NucleusBackend.Tao) { + nucleusApplication(args = args) { val titleBarStyle = TitleBarStyle( colors = diff --git a/examples/cmp-demo/build.gradle.kts b/examples/cmp-demo/build.gradle.kts index 4a904ae3e..db674d3dd 100644 --- a/examples/cmp-demo/build.gradle.kts +++ b/examples/cmp-demo/build.gradle.kts @@ -36,7 +36,7 @@ kotlin { androidMain.dependencies { implementation("androidx.activity:activity-compose:1.10.1") } - val desktopMain by getting { + getByName("desktopMain") { dependencies { implementation(compose.desktop.currentOs) implementation(project(":nucleus-application")) diff --git a/examples/compose-demo/src/main/kotlin/demo/shim/DemoDragAndDrop.kt b/examples/compose-demo/src/main/kotlin/demo/shim/DemoDragAndDrop.kt index 00aa84c39..86b93c429 100644 --- a/examples/compose-demo/src/main/kotlin/demo/shim/DemoDragAndDrop.kt +++ b/examples/compose-demo/src/main/kotlin/demo/shim/DemoDragAndDrop.kt @@ -7,7 +7,7 @@ import java.awt.datatransfer.DataFlavor import java.io.File // DragAndDropEvent payload helpers. On the Tao backend (as on standard Compose -// Desktop / decorated-window-jni) drops surface through the same AWT transfer +// Desktop / the legacy AWT backend) drops surface through the same AWT transfer // path exercised by tao-demo: DragAndDropEvent.awtTransferable exposes the // payload via the AWT flavor system. diff --git a/examples/gstreamer-demo/src/main/kotlin/dev/nucleusframework/samplegst/Main.kt b/examples/gstreamer-demo/src/main/kotlin/dev/nucleusframework/samplegst/Main.kt index df7ebe476..907d750ae 100644 --- a/examples/gstreamer-demo/src/main/kotlin/dev/nucleusframework/samplegst/Main.kt +++ b/examples/gstreamer-demo/src/main/kotlin/dev/nucleusframework/samplegst/Main.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar @@ -70,7 +69,7 @@ import java.util.concurrent.atomic.AtomicInteger fun main(args: Array) { // No GraalVmInitializer call: nucleusApplication runs it first thing. val uri = resolveUri(args.firstOrNull() ?: System.getenv("NUCLEUS_GST_URI")) - nucleusApplication(backend = NucleusBackend.Tao) { + nucleusApplication { NucleusDecoratedWindowTheme(isDark = true) { DecoratedWindow( onCloseRequest = ::exitApplication, diff --git a/examples/hot-update-demo/build.gradle.kts b/examples/hot-update-demo/build.gradle.kts new file mode 100644 index 000000000..7537f0d80 --- /dev/null +++ b/examples/hot-update-demo/build.gradle.kts @@ -0,0 +1,57 @@ +import dev.nucleusframework.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// Fixture for the Windows hot-update E2E (scripts/e2e/windows-hot-update.ps1): the app shows its +// version on a version-coloured background and, when HOT_UPDATE_DEMO_FEED points at a loopback +// update feed, downloads the update and calls installAndRestart on its own. +// +// Build two versions with: ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.1.0 + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(project(":core-runtime")) + implementation(project(":updater-runtime")) + implementation(project(":decorated-window-tao")) + implementation(project(":nucleus-application")) + implementation(libs.coroutines.core) +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + } +} + +val demoVersion = providers.gradleProperty("hotUpdateDemoVersion").getOrElse("1.0.0") + +nucleus.application { + mainClass = "hotupdatedemo.MainKt" + + nativeDistributions { + packageName = "HotUpdateDemo" + packageVersion = demoVersion + targetFormats(TargetFormat.Nsis) + + windows { + nsis { + oneClick = true + perMachine = false + createDesktopShortcut = false + createStartMenuShortcut = false + runAfterFinish = false + } + } + } +} diff --git a/examples/hot-update-demo/src/main/kotlin/hotupdatedemo/Main.kt b/examples/hot-update-demo/src/main/kotlin/hotupdatedemo/Main.kt new file mode 100644 index 000000000..1c1d319e4 --- /dev/null +++ b/examples/hot-update-demo/src/main/kotlin/hotupdatedemo/Main.kt @@ -0,0 +1,154 @@ +package hotupdatedemo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.updater.NucleusUpdater +import dev.nucleusframework.updater.UpdateResult +import dev.nucleusframework.updater.exception.UpdateException +import dev.nucleusframework.updater.provider.GenericProvider +import dev.nucleusframework.updater.provider.GitHubProvider +import dev.nucleusframework.window.NucleusDecoratedWindowTheme +import dev.nucleusframework.window.TitleBar +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.onEach +import java.io.File +import java.time.LocalTime +import kotlin.time.Duration.Companion.seconds + +private val feed: String? = System.getenv("HOT_UPDATE_DEMO_FEED") + +// Keeps the updater when a redirect is requested but refused, so the E2E sees the refusal. +private val redirectRequested = System.getenv("NUCLEUS_UPDATER_FEED_URL") != null + +// The E2E samples the screen at the window: keep it above whatever else is open there. +private val topmost = System.getenv("HOT_UPDATE_DEMO_TOPMOST") == "1" + +// Multi-instance E2E: every launch is its own instance, holding the "document" passed as argument. +private val multiInstance = System.getenv("HOT_UPDATE_DEMO_MULTI") == "1" + +// An instance that never checks the feed learns about an update another one installed. +private val checksForUpdates = System.getenv("HOT_UPDATE_DEMO_CHECK") != "0" +private val logFile = File(System.getProperty("java.io.tmpdir"), "hot-update-demo.log") + +private fun log(message: String) { + val line = "${LocalTime.now()} pid=${ProcessHandle.current().pid()} $message" + runCatching { logFile.appendText("$line\n") } +} + +fun main(args: Array) = + nucleusApplication(args, enableSingleInstance = !multiInstance) { + // HOT_UPDATE_DEMO_FEED configures the feed in code; without it the app ships a production + // provider that the dev-testing E2E (scripts/updater-dev-testing-e2e.ps1) redirects at launch + // with NUCLEUS_UPDATER_FEED_URL, or replaces with NUCLEUS_UPDATER_SIMULATE. + val updater = + remember { + NucleusUpdater { + provider = feed?.let(::GenericProvider) ?: GitHubProvider("NucleusFramework", "hot-update-demo-e2e") + allowLaunchOverrides = System.getenv("HOT_UPDATE_DEMO_ALLOW_OVERRIDES") != "0" + }.takeIf { feed != null || it.feedOverride != null || it.simulation != null || redirectRequested } + } + val version = updater?.currentVersion ?: "dev" + var status by remember { mutableStateOf(if (updater == null) "No update feed" else "Checking…") } + + LaunchedEffect(Unit) { + val command = + ProcessHandle + .current() + .info() + .command() + .orElse("?") + log( + "started version=$version args=${args.toList()} command=$command " + + "java.home=${System.getProperty("java.home")}", + ) + updater?.let { + log( + "updater feedOverride=${it.feedOverride} simulation=${it.simulation} supported=${it.isUpdateSupported()}", + ) + } + updater?.consumeUpdateEvent()?.let { log("updated from ${it.previousVersion} to ${it.newVersion}") } + if (updater == null || !checksForUpdates) return@LaunchedEffect + // Poll, so that a chained E2E can publish the next version once this one is running. + var result = updater.checkForUpdates() + while (result !is UpdateResult.Available) { + status = "Up to date" + log("no update ($result)") + delay(3.seconds) + result = updater.checkForUpdates() + } + status = "Downloading ${result.info.version}…" + var reports = 0 + val last = + try { + updater.downloadUpdate(result.info).onEach { reports++ }.last() + } catch (e: UpdateException) { + status = "Download failed" + log("download failed: $e") + return@LaunchedEffect + } + val file = last.file ?: return@LaunchedEffect + log( + "downloaded ${file.name} bytes=${last.bytesDownloaded} differential=${last.isDifferential} reports=$reports", + ) + status = "Installing ${result.info.version}…" + log("installAndRestart ${file.name}") + updater.installAndRestart(file, relaunchArguments = args.toList()) + // Returns at once for a hot update (the handoff follows), a simulated one and an unpackaged + // run (both skip the install). + log("installAndRestart returned") + } + + // Another instance installed an update: restart onto it, keeping this instance's document. + // A real app would offer "Restart to update" instead of restarting on its own. + LaunchedEffect(Unit) { + val pending = updater?.pendingRestartVersion?.first { it != null } ?: return@LaunchedEffect + log("pendingRestart $pending") + status = "Restarting to $pending…" + updater.restartToInstalledVersion(relaunchArguments = args.toList()) + } + + NucleusDecoratedWindowTheme(isDark = true) { + DecoratedWindow( + onCloseRequest = ::exitApplication, + title = "Hot Update Demo $version", + alwaysOnTop = topmost, + state = rememberWindowState(size = DpSize(640.dp, 400.dp), position = WindowPosition(200.dp, 200.dp)), + ) { + TitleBar { BasicText("Hot Update Demo $version", style = TextStyle(color = Color.White)) } + Column( + modifier = Modifier.fillMaxSize().background(versionColor(version)), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + BasicText(version, style = TextStyle(color = Color.White, fontSize = 72.sp)) + BasicText(status, style = TextStyle(color = Color.White, fontSize = 20.sp)) + } + } + } + } + +private fun versionColor(version: String): Color = + listOf(Color(0xFF1565C0), Color(0xFF2E7D32), Color(0xFF6A1B9A), Color(0xFFC62828))[ + Math.floorMod(version.hashCode(), 4), + ] diff --git a/examples/jewel-demo/build.gradle.kts b/examples/jewel-demo/build.gradle.kts index 1300064c1..d9a20abe3 100644 --- a/examples/jewel-demo/build.gradle.kts +++ b/examples/jewel-demo/build.gradle.kts @@ -95,8 +95,16 @@ tasks.withType().configureEach { ) } +// Compiled to class file 69, so the app has to *run* on a 25 JVM too — and the Gradle JVM +// (the packaging default) is often older. Resolved through a toolchain, not a hard-coded path. +val jvm25 = + javaToolchains + .launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } + .map { it.metadata.installationPath.asFile.absolutePath } + nucleus.application { mainClass = "jewelsample.MainKt" + javaHome = jvm25.get() buildTypes { release { proguard { diff --git a/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt b/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt index 249985d51..49737ca71 100644 --- a/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt +++ b/examples/jewel-demo/src/main/kotlin/jewelsample/Main.kt @@ -3,7 +3,6 @@ package jewelsample import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent @@ -16,20 +15,14 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState -import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.darkmodedetector.isSystemInDarkMode -import dev.nucleusframework.window.NucleusDecoratedWindowTheme -import dev.nucleusframework.window.jewel.ProvideJewelSpellcheckMenu -import dev.nucleusframework.window.jewel.rememberJewelTitleBarStyle -import dev.nucleusframework.window.jewel.rememberJewelWindowStyle +import dev.nucleusframework.window.jewel.JewelDecoratedWindow import jewelsample.view.TitleBarView import jewelsample.viewmodel.MainViewModel import jewelsample.viewmodel.MainViewModel.currentView import org.jetbrains.compose.resources.ExperimentalResourceApi import org.jetbrains.compose.resources.decodeToSvgPainter -import org.jetbrains.jewel.foundation.ExperimentalJewelApi import org.jetbrains.jewel.foundation.theme.JewelTheme import org.jetbrains.jewel.foundation.util.JewelLogger import org.jetbrains.jewel.intui.markdown.standalone.ProvideMarkdownStyling @@ -44,7 +37,7 @@ import org.jetbrains.jewel.ui.ComponentStyling @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @ExperimentalLayoutApi fun main() = - nucleusApplication(backend = NucleusBackend.Tao) { + nucleusApplication { remember { JewelLogger.getInstance("StandaloneSample").info("Starting Jewel Standalone sample") true @@ -63,57 +56,53 @@ fun main() = val contentTheme = if (isDark) darkTheme else lightTheme val titleBarTheme = if (isTitleBarDark) darkTheme else lightTheme - DecoratedWindow( - onCloseRequest = { exitApplication() }, - title = "Jewel standalone sample", - icon = icon, - state = - rememberWindowState( - position = WindowPosition.Aligned(Alignment.Center), - ), - minimumSize = DpSize(800.dp, 400.dp), - onKeyEvent = { keyEvent -> - processKeyShortcuts(keyEvent = keyEvent, onNavigateTo = MainViewModel::onNavigateTo) - }, - content = { + // The title-bar theme wraps the window: JewelDecoratedWindow reads it at + // the call site for the native deco + TitleBar styling, and the Tao + // scene bridge re-exposes it to the content inside the window. + IntUiTheme( + theme = titleBarTheme, + styling = ComponentStyling.default(), + swingCompatMode = MainViewModel.swingCompat, + ) { + JewelDecoratedWindow( + onCloseRequest = { exitApplication() }, + title = "Jewel standalone sample", + icon = icon, + state = + rememberWindowState( + position = WindowPosition.Aligned(Alignment.Center), + ), + minimumSize = DpSize(800.dp, 400.dp), + // Jewel's `LocalPopupRenderer` default delegates to + // `androidx.compose.ui.window.Popup`, so every ListComboBox / + // PopupMenu / tooltip in the showcase becomes a real OS window + // and is placed against the display rather than against this + // window (#569). Park the window at the bottom of the screen + // and open a combo box to see it. + nativePopupLayers = true, + onKeyEvent = { keyEvent -> + processKeyShortcuts(keyEvent = keyEvent, onNavigateTo = MainViewModel::onNavigateTo) + }, + ) { + // JewelDecoratedWindow already installs the Jewel spellcheck + // text-context menu; capture it before the content theme below + // re-provides Jewel's stock one, and restore it inside. @Suppress("DEPRECATION") - val defaultTextContextMenu = androidx.compose.foundation.text.LocalTextContextMenu.current - IntUiTheme( - theme = titleBarTheme, - styling = ComponentStyling.default(), - swingCompatMode = MainViewModel.swingCompat, - ) { - val jewelTitleBarStyle = rememberJewelTitleBarStyle() - val jewelWindowStyle = rememberJewelWindowStyle() - val titleBarIsDark = jewelTitleBarStyle.colors.background.luminance() < 0.5f - NucleusDecoratedWindowTheme( - isDark = titleBarIsDark, - windowStyle = jewelWindowStyle, - titleBarStyle = jewelTitleBarStyle, - ) { - androidx.compose.runtime.CompositionLocalProvider( - androidx.compose.foundation.text.LocalTextContextMenu provides defaultTextContextMenu, - ) { - TitleBarView() - } - } - } + val windowTextContextMenu = androidx.compose.foundation.text.LocalTextContextMenu.current + TitleBarView() IntUiTheme( theme = contentTheme, styling = ComponentStyling.default(), swingCompatMode = MainViewModel.swingCompat, ) { - @OptIn(ExperimentalJewelApi::class) androidx.compose.runtime.CompositionLocalProvider( - androidx.compose.foundation.text.LocalTextContextMenu provides defaultTextContextMenu, + androidx.compose.foundation.text.LocalTextContextMenu provides windowTextContextMenu, ) { - ProvideJewelSpellcheckMenu { - ProvideMarkdownStyling { currentView.content() } - } + ProvideMarkdownStyling { currentView.content() } } } - }, - ) + } + } } /* diff --git a/examples/jewel-tabs-demo/build.gradle.kts b/examples/jewel-tabs-demo/build.gradle.kts new file mode 100644 index 000000000..e38ceb9b7 --- /dev/null +++ b/examples/jewel-tabs-demo/build.gradle.kts @@ -0,0 +1,64 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// The tab workspace wearing IntelliJ's own tab chrome: Jewel's `TabStrip` and +// `TabData.Editor` render the strip, the Nucleus `TabWorkspace` owns what the +// tabs are and which window holds each of them. + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(project(":core-runtime")) + implementation(project(":darkmode-detector")) + implementation(project(":decorated-window-tao")) + implementation(project(":decorated-window-jewel")) + implementation(project(":nucleus-application")) + val jewelExclusions = + Action { + exclude(group = "org.jetbrains.skiko", module = "skiko-awt-runtime-all") + } + implementation(libs.jewel.int.ui.standalone, jewelExclusions) + // Jewel 0.39+ IntUiTheme needs IconManager/DefaultIconManager from these. + implementation(libs.intellij.icons) + implementation(libs.intellij.icons.api) + implementation(libs.intellij.icons.impl) + // Jewel's StandalonePlatformCursorController uses JNA at runtime. + implementation(libs.jna.jpms) +} + +// decorated-window-jewel is a JVM 25 module (Jewel's own target), so anything +// linking against it follows. +java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_25) + optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") + } +} + +// Those classes come out as class file 69, so the app has to *run* on a 25 JVM +// too — and the Gradle JVM is often older. Resolved through a toolchain rather +// than a hard-coded path. +val jvm25 = + javaToolchains + .launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } + .map { it.metadata.installationPath.asFile.absolutePath } + +nucleus.application { + mainClass = "dev.nucleusframework.jeweltabsdemo.MainKt" + javaHome = jvm25.get() + + nativeDistributions { + packageName = "jewel-tabs-demo" + packageVersion = "1.0.0" + } +} diff --git a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/DemoState.kt b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/DemoState.kt new file mode 100644 index 000000000..8f5f64163 --- /dev/null +++ b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/DemoState.kt @@ -0,0 +1,81 @@ +package dev.nucleusframework.jeweltabsdemo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TabLayoutSnapshot +import dev.nucleusframework.window.tao.TabWorkspace + +/** + * One open file of the demo — one tab of the workspace. + * + * @property id the tab's identity, stable while the file is open. + * @property title shown on the tab and, while it is selected, as the window title. + * @property draft what its editor starts with. + */ +class Document( + val id: String, + val title: String, + val draft: String, +) + +/** + * Everything the demo drives: the [workspace] and the files declared against it. + * + * The file list is the app's own — the workspace owns *where* each tab is, never + * whether it exists — so opening a file means adding to this list, and a tab the + * user closes has to be dropped from it ([forget]) or it would be declared again. + */ +class DemoState { + // `captureThumbnails` keeps a reduced picture of each tab's editor for the + // hover card to draw. Off by default — a layer and a readback per tab. + val workspace = + TabWorkspace( + defaultWindowSize = DpSize(WINDOW_WIDTH_DP.dp, WINDOW_HEIGHT_DP.dp), + captureThumbnails = true, + ) + + /** The file behind a tab id, for chrome that draws more than a title. */ + fun document(id: String): Document? = documents.firstOrNull { it.id == id } + + /** The open files, in declaration order. One tab each. */ + val documents = + mutableStateListOf( + Document("main", "Main.kt", "fun main() = nucleusApplication { }"), + Document("strip", "JewelTabStrip.kt", "TabStrip(tabs, style = JewelTheme.editorTabStyle)"), + Document("build", "build.gradle.kts", "implementation(libs.jewel.int.ui.standalone)"), + ) + + /** The layout captured by "Save layout", ready for "Restore layout". */ + var savedLayout: TabLayoutSnapshot? by mutableStateOf(null) + private set + + private var opened = 0 + + /** Opens a new file; the `Tab` declaration puts it in the window focused last. */ + fun open() { + opened++ + documents += Document("scratch-$opened", "scratch$opened.kt", "") + } + + /** Drops the file [id] once its tab is gone from the workspace. */ + fun forget(id: String) { + documents.removeAll { it.id == id } + } + + fun saveLayout() { + savedLayout = workspace.snapshot() + } + + fun restoreLayout() { + savedLayout?.let(workspace::restore) + } + + private companion object { + const val WINDOW_WIDTH_DP = 900 + const val WINDOW_HEIGHT_DP = 600 + } +} diff --git a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/EditorContent.kt b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/EditorContent.kt new file mode 100644 index 000000000..e85285bf5 --- /dev/null +++ b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/EditorContent.kt @@ -0,0 +1,145 @@ +package dev.nucleusframework.jeweltabsdemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.tao.TabScope +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.DefaultButton +import org.jetbrains.jewel.ui.component.GroupHeader +import org.jetbrains.jewel.ui.component.OutlinedButton +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.component.TextArea + +/** + * The body of one tab: a small editor whose state has to survive being dragged + * to another window, plus the workspace controls and a live read-out. + * + * Composed by `TabWindows` in whichever window holds the tab — the same call + * site in every window, which is what lets the saveable values below travel. + */ +@Composable +fun TabScope.EditorContent( + demo: DemoState, + document: Document, +) { + val workspace = demo.workspace + val group = tab.group + + // `rememberTextFieldState` is saveable, so the draft crosses windows with + // the tab for free — as does the scroll position below. + val draft = rememberTextFieldState(document.draft) + var savedClicks by rememberSaveable { mutableIntStateOf(0) } + val scroll = rememberScrollState() + // Not saveable, on purpose: the counterexample. A move rebuilds this + // subtree in the other window's composition and a plain `remember` starts + // over there. + var plainClicks by remember { mutableIntStateOf(0) } + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Jewel 0.39's `Typography` object is deprecated in favour of a + // `JewelTheme.typography` that this version does not ship yet, so the + // heading is derived from the theme's own text style instead. + Text( + document.title, + style = JewelTheme.defaultTextStyle.copy(fontSize = TITLE_SP.sp, fontWeight = FontWeight.SemiBold), + ) + Text( + "The tabs above are Jewel's own — IntelliJ's editor-tab chrome, styled by " + + "JewelTheme.editorTabStyle — driven by the Nucleus TabWorkspace. Drag one out " + + "of the strip and drop it on the desktop: it lands in a window of its own. " + + "Drag it back onto the other window's strip and it is inserted where you drop " + + "it. Drag the only tab of a window and the window itself follows the pointer, " + + "then merges into the strip it lands on.", + ) + + GroupHeader("This tab") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DefaultButton(onClick = { demo.open() }) { Text("New tab") } + OutlinedButton(onClick = { select() }, enabled = !tab.isSelected) { Text("Select") } + OutlinedButton(onClick = { close() }) { Text("Close this tab") } + } + + GroupHeader("State that follows the tab") + // A bounded height, and it has to be: Jewel's TextArea scrolls + // internally, which an enclosing Column(verticalScroll) would measure + // with an unbounded height. + TextArea(state = draft, modifier = Modifier.fillMaxWidth().height(EDITOR_HEIGHT_DP.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DefaultButton(onClick = { savedClicks++ }) { Text("saveable: $savedClicks") } + OutlinedButton(onClick = { plainClicks++ }) { Text("plain remember: $plainClicks") } + } + Text( + "Type in the editor, click both counters, scroll down a little, then drag this tab " + + "into another window. The draft, the saveable counter and the scroll position " + + "come back; the plain one restarts at 0 — the two windows are two compositions, " + + "and only saveable state crosses.", + color = JewelTheme.globalColors.text.info, + ) + + GroupHeader("Layout") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { demo.saveLayout() }) { Text("Save layout") } + OutlinedButton(onClick = { demo.restoreLayout() }, enabled = demo.savedLayout != null) { + Text("Restore layout") + } + } + + GroupHeader("Live state") + StateLine("windows", workspace.groups.size.toString()) + StateLine("tabs, all windows", workspace.tabs.size.toString()) + StateLine("this window's group", group?.id ?: "—") + StateLine("its tabs", group?.ids?.joinToString(", ") ?: "—") + StateLine("dragging", workspace.draggedTab?.title ?: "—") + StateLine("drop preview", workspace.dropPreview?.let { "${it.group.id} @ ${it.index}" } ?: "—") + + // Something to scroll past, so the saved scroll position shows. + GroupHeader("Notes") + for (line in 1..NOTE_LINES) { + Text("$line. ${document.title} — line $line", color = JewelTheme.globalColors.text.info) + } + } +} + +@Composable +private fun StateLine( + name: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(name, fontFamily = FontFamily.Monospace, color = JewelTheme.globalColors.text.info) + Text(value, fontFamily = FontFamily.Monospace) + } +} + +private const val TITLE_SP = 18 +private const val EDITOR_HEIGHT_DP = 110 +private const val NOTE_LINES = 24 diff --git a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/JewelTabStrip.kt b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/JewelTabStrip.kt new file mode 100644 index 000000000..d0b5f62d5 --- /dev/null +++ b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/JewelTabStrip.kt @@ -0,0 +1,201 @@ +package dev.nucleusframework.jeweltabsdemo + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TabDropGhost +import dev.nucleusframework.window.tao.TabDropGhostCard +import dev.nucleusframework.window.tao.TabEntry +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabHoverPreviewPopup +import dev.nucleusframework.window.tao.TabHoverPreviewScope +import dev.nucleusframework.window.tao.TabStripScope +import dev.nucleusframework.window.tao.dropGhost +import dev.nucleusframework.window.tao.tabDragHandle +import dev.nucleusframework.window.tao.tabSlot +import dev.nucleusframework.window.tao.tabStripGeometry +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.ui.component.IconButton +import org.jetbrains.jewel.ui.component.TabData +import org.jetbrains.jewel.ui.component.TabStrip +import org.jetbrains.jewel.ui.component.Text +import org.jetbrains.jewel.ui.theme.editorTabStyle + +/** + * The tab strip of one window, drawn by Jewel: [TabStrip] with one + * [TabData.Editor] per tab of the group, in IntelliJ's editor-tab style. + * + * The split of responsibilities is the whole point of this demo — Jewel owns + * how a tab looks (shape, hover, selection underline, close button, the + * scrollbar once the tabs overflow), and the workspace owns what the tabs are: + * + * - [tabStripGeometry] on the strip itself publishes the drop target, so a tab + * dragged out of another window can be released here; + * - [tabSlot] on each tab's content is what turns a pointer position into an + * insertion index; + * - [tabDragHandle] on the same element is the grip that drags the tab between + * windows; + * - `onClick` / `onClose` are workspace calls. + * + * Jewel's [TabData] carries no `Modifier`, so the three per-tab modifiers go + * on the tab's *content* — which is therefore made to fill the tab, and to + * carry the click that selects as well. Anything less and the tab has two + * different active areas: Jewel's own `onClick` covering the whole tab, and a + * drag grip covering only the label, which claims the press wherever it sits. + * What is left over is a sliver of padding that selects but does not drag. + * + * A `Modifier` on [TabData] would remove the need for any of this: the slot, + * the grip and the click would go on the tab itself. + * + * The hover card is the other half of that contract: [TabHoverPreviewPopup] + * needs nothing but the slots this strip already marks, and the card itself is + * drawn here, in Jewel's own colours ([JewelTabHoverCard]) — the workspace + * neither knows nor imposes what a preview looks like. + */ +@Composable +fun TabStripScope.JewelEditorTabStrip( + demo: DemoState, + onNewTab: () -> Unit, +) { + val entries = tabs + // A tab dragged over this strip from another window is shown taking its + // place: the same card it travels under, as wide as it is, opened among + // the tabs where the release would put it. + val ghost = dropGhost + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + val tabData = entries.mapIndexed { index, entry -> editorTab(index, entry) }.toMutableList() + if (ghost != null) tabData.add(ghost.index, ghostTab(ghost)) + TabStrip( + tabs = tabData, + style = JewelTheme.editorTabStyle, + modifier = Modifier.weight(1f).tabStripGeometry(workspace, group), + ) + NewTabButton(onNewTab) + } + // Anchored on the slots marked above, so it follows the pointer from tab to + // tab without this strip tracking anything itself. + val preview = remember(demo) { TabHoverPreview { JewelTabHoverCard(demo) } } + TabHoverPreviewPopup(preview) +} + +/** + * The hover card of one tab, drawn by the demo from end to end: the file name, + * its first line, and the picture the workspace kept of its editor. + * + * Nothing of the stock card is used — [TabHoverPreview] takes the whole + * composable, so an app's preview looks like the rest of its design system + * rather than like the window chrome. + */ +@Composable +private fun TabHoverPreviewScope.JewelTabHoverCard(demo: DemoState) { + val document = demo.document(tab.id) + Column( + modifier = + Modifier + .width(CARD_WIDTH_DP.dp) + .background(JewelTheme.globalColors.panelBackground) + .border(1.dp, JewelTheme.globalColors.borders.normal) + .padding(CARD_PADDING_DP.dp), + ) { + Text(tab.title, maxLines = 1, overflow = TextOverflow.Ellipsis) + val draft = document?.draft ?: "" + val firstLine = draft.substringBefore('\n') + if (firstLine.isNotBlank()) { + Spacer(Modifier.height(CARD_GAP_DP.dp)) + Text( + text = firstLine, + color = JewelTheme.globalColors.text.info, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + thumbnail?.let { picture -> + Spacer(Modifier.height(CARD_GAP_DP.dp)) + Image( + bitmap = picture, + contentDescription = null, + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(picture.width.toFloat() / picture.height.toFloat()), + contentScale = ContentScale.Crop, + ) + } + } +} + +/** The slot a tab from another window would take, as a Jewel tab that is nothing but the card. */ +private fun ghostTab(ghost: TabDropGhost): TabData = + TabData.Editor(selected = false, closable = false, content = { TabDropGhostCard(ghost) }) + +/** One document as a Jewel editor tab, its whole surface the slot, the grip and the click. */ +private fun TabStripScope.editorTab( + index: Int, + entry: TabEntry, +): TabData = + TabData.Editor( + selected = entry.id == group.selectedId, + closable = true, + onClose = { workspace.close(entry.id) }, + onClick = { workspace.select(entry.id) }, + content = { tabState -> + // One element for the whole gesture surface, filling + // the tab: the slot the strip publishes, the grip a + // drag starts from and the click that selects are + // the same box, so there is no part of a tab that + // reacts to one and not the others. Putting them on + // the label alone leaves selection to the padding + // around it — a sliver at the edges — while the + // label drags, which is exactly as odd as it sounds. + Box( + modifier = + Modifier + .fillMaxSize() + .tabSlot(group, index) + .tabDragHandle(workspace, entry) + .clickable { workspace.select(entry.id) }, + contentAlignment = Alignment.CenterStart, + ) { + // `tabContentAlpha` is Jewel's own: the label + // dims exactly as it does in the IDE when the + // tab is unselected or its window loses focus. + Text(entry.title, modifier = Modifier.tabContentAlpha(state = tabState)) + } + }, + ) + +/** The "+" of a browser, as an IntelliJ icon button. */ +@Composable +private fun NewTabButton(onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.padding(horizontal = 4.dp).size(24.dp)) { + Text("+") + } +} + +private const val CARD_WIDTH_DP = 260 +private const val CARD_PADDING_DP = 8 +private const val CARD_GAP_DP = 6 diff --git a/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/Main.kt b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/Main.kt new file mode 100644 index 000000000..3502aff9e --- /dev/null +++ b/examples/jewel-tabs-demo/src/main/kotlin/dev/nucleusframework/jeweltabsdemo/Main.kt @@ -0,0 +1,103 @@ +package dev.nucleusframework.jeweltabsdemo + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import dev.nucleusframework.application.Tab +import dev.nucleusframework.application.TabWindows +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import dev.nucleusframework.window.NucleusDecoratedWindowTheme +import dev.nucleusframework.window.WindowAppearance +import dev.nucleusframework.window.WindowAppearanceMode +import dev.nucleusframework.window.WindowBackground +import dev.nucleusframework.window.jewel.rememberJewelTitleBarStyle +import dev.nucleusframework.window.jewel.rememberJewelWindowStyle +import org.jetbrains.jewel.foundation.theme.JewelTheme +import org.jetbrains.jewel.intui.standalone.theme.IntUiTheme +import org.jetbrains.jewel.intui.standalone.theme.createDefaultTextStyle +import org.jetbrains.jewel.intui.standalone.theme.createEditorTextStyle +import org.jetbrains.jewel.intui.standalone.theme.darkThemeDefinition +import org.jetbrains.jewel.intui.standalone.theme.default +import org.jetbrains.jewel.intui.standalone.theme.lightThemeDefinition +import org.jetbrains.jewel.ui.ComponentStyling + +/** + * The Chrome-like tab workspace wearing IntelliJ's tab chrome. + * + * Same archetype as `examples/tabs-demo` — files declared once as tabs of one + * `TabWorkspace`, windows that follow the tabs — with Jewel's `TabStrip` in + * place of the stock strip. Everything a tab archetype needs from its chrome is + * a modifier contract, so swapping the whole design system is one composable: + * see [JewelEditorTabStrip]. + */ +fun main() = + nucleusApplication { + val demo = remember { DemoState() } + val dark = isSystemInDarkMode() + + val textStyle = JewelTheme.createDefaultTextStyle() + val editorTextStyle = JewelTheme.createEditorTextStyle() + val theme = + if (dark) { + JewelTheme.darkThemeDefinition(defaultTextStyle = textStyle, editorTextStyle = editorTextStyle) + } else { + JewelTheme.lightThemeDefinition(defaultTextStyle = textStyle, editorTextStyle = editorTextStyle) + } + + // Both themes sit *above* the windows: the workspace opens and closes + // them, so there is no window call site for `JewelDecoratedWindow` to + // install the Jewel window and title-bar styles at. Established here, + // they are bridged into every scene the workspace creates — which is + // where the strip in each title bar reads its colours from. + IntUiTheme(theme = theme, styling = ComponentStyling.default()) { + NucleusDecoratedWindowTheme( + isDark = dark, + windowStyle = rememberJewelWindowStyle(), + titleBarStyle = rememberJewelTitleBarStyle(), + ) { + val panel = JewelTheme.globalColors.panelBackground + TabWindows( + workspace = demo.workspace, + strip = { JewelEditorTabStrip(demo, onNewTab = demo::open) }, + // Per-window chrome, since the app opens no window itself. + windowWrapper = { content -> + WindowBackground(panel) + WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) + Box(Modifier.fillMaxSize().background(panel)) { content() } + }, + onLastWindowClosed = ::exitApplication, + ) + + for (document in demo.documents) { + key(document.id) { + Tab(demo.workspace, id = document.id, title = document.title) { + EditorContent(demo, document) + } + DropClosedTab(demo, document.id) + } + } + } + } + } + +/** + * Keeps the file list in step with the workspace: closing a tab is a workspace + * call, and a file still declared once its tab is gone would be registered + * again and hosted nowhere. + */ +@Composable +private fun DropClosedTab( + demo: DemoState, + id: String, +) { + val closed = demo.workspace.tab(id) == null + LaunchedEffect(closed) { + if (closed) demo.forget(id) + } +} diff --git a/examples/jni-demo/build.gradle.kts b/examples/jni-demo/build.gradle.kts deleted file mode 100644 index c41025cca..000000000 --- a/examples/jni-demo/build.gradle.kts +++ /dev/null @@ -1,40 +0,0 @@ -import dev.nucleusframework.desktop.application.dsl.TargetFormat -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - kotlin("jvm") - alias(libs.plugins.kotlinComposePlugin) - alias(libs.plugins.jetbrainsCompose) - id("dev.nucleusframework") -} - -dependencies { - implementation(project(":decorated-window-jni")) - implementation(project(":decorated-window-core")) - implementation(project(":nucleus-application")) - implementation(project(":examples:shared")) - implementation(project(":core-runtime")) - implementation(compose.desktop.currentOs) -} - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -kotlin { - compilerOptions { - jvmTarget.set(JvmTarget.JVM_17) - } -} - -nucleus.application { - mainClass = "dev.nucleusframework.samplejni.MainKt" - - nativeDistributions { - targetFormats(TargetFormat.Dmg) - appName = "Sample JNI" - packageName = "SampleJni" - packageVersion = "1.0.0" - } -} diff --git a/examples/jni-demo/src/main/kotlin/dev/nucleusframework/samplejni/ActionsTab.kt b/examples/jni-demo/src/main/kotlin/dev/nucleusframework/samplejni/ActionsTab.kt deleted file mode 100644 index 0a5313e0d..000000000 --- a/examples/jni-demo/src/main/kotlin/dev/nucleusframework/samplejni/ActionsTab.kt +++ /dev/null @@ -1,132 +0,0 @@ -package dev.nucleusframework.samplejni - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import dev.nucleusframework.application.NucleusWindow - -@Composable -fun ActionsTab( - modifier: Modifier = Modifier, - window: NucleusWindow, - currentTitle: String, - onTitleChange: (String) -> Unit, - onLog: (String) -> Unit, -) { - Column( - modifier = modifier.padding(24.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - SectionTitle("Title") - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - BasicTextField( - value = currentTitle, - onValueChange = onTitleChange, - singleLine = true, - textStyle = TextStyle(color = Color.White, fontSize = 14.sp), - cursorBrush = SolidColor(Color(0xFF8AB4FF)), - modifier = - Modifier - .clip(RoundedCornerShape(6.dp)) - .background(Color.White.copy(alpha = 0.06f)) - .border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(6.dp)) - .padding(horizontal = 12.dp, vertical = 8.dp) - .width(320.dp), - ) - ActionButton("Apply") { - onLog("setTitle(\"$currentTitle\")") - } - } - - SectionTitle("Window state") - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ActionButton("Minimize") { - window.setMinimized(true) - onLog("setMinimized(true)") - } - ActionButton("Toggle Maximize") { - val next = !window.isMaximized - window.setMaximized(next) - onLog("setMaximized($next)") - } - ActionButton("Hide 2 s") { - window.hide() - onLog("hide()") - Thread { - Thread.sleep(2_000) - window.show() - onLog("show() (auto)") - }.start() - } - } - - SectionTitle("Close") - ActionButton("requestClose()", accent = Color(0xFFFF7777)) { - window.close() - onLog("window.close()") - } - - Spacer(Modifier.height(8.dp)) - BasicText( - "Backend-agnostic window controls via NucleusWindow. Drag the title bar to move; double-click to maximize.", - style = TextStyle(color = Color(0xFF7A8088), fontSize = 11.sp), - ) - } -} - -@Composable -private fun SectionTitle(text: String) { - BasicText( - text = text.uppercase(), - style = - TextStyle( - color = Color(0xFF7A8088), - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - letterSpacing = 0.8.sp, - ), - ) -} - -@Composable -private fun ActionButton( - label: String, - accent: Color = Color(0xFF8AB4FF), - onClick: () -> Unit, -) { - Box( - modifier = - Modifier - .clip(RoundedCornerShape(8.dp)) - .background(accent.copy(alpha = 0.12f)) - .border(1.dp, accent.copy(alpha = 0.4f), RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 8.dp), - ) { - BasicText( - text = label, - style = TextStyle(color = accent, fontSize = 13.sp, fontWeight = FontWeight.SemiBold), - ) - } -} diff --git a/examples/jni-demo/src/main/kotlin/dev/nucleusframework/samplejni/Main.kt b/examples/jni-demo/src/main/kotlin/dev/nucleusframework/samplejni/Main.kt deleted file mode 100644 index 94d2495d1..000000000 --- a/examples/jni-demo/src/main/kotlin/dev/nucleusframework/samplejni/Main.kt +++ /dev/null @@ -1,183 +0,0 @@ -package dev.nucleusframework.samplejni - -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicText -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.rememberWindowState -import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend -import dev.nucleusframework.application.nucleusApplication -import dev.nucleusframework.sampleshared.EventsTab -import dev.nucleusframework.sampleshared.FancyDemo -import dev.nucleusframework.sampleshared.PALETTE -import dev.nucleusframework.sampleshared.ScrollTab -import dev.nucleusframework.sampleshared.Tab -import dev.nucleusframework.sampleshared.TabBar -import dev.nucleusframework.sampleshared.logEvent -import dev.nucleusframework.window.NucleusDecoratedWindowTheme -import dev.nucleusframework.window.TitleBar -import dev.nucleusframework.window.macOSLargeCornerRadius -import dev.nucleusframework.window.styling.TitleBarColors -import dev.nucleusframework.window.styling.TitleBarMetrics -import dev.nucleusframework.window.styling.TitleBarStyle - -fun main() = - nucleusApplication(backend = NucleusBackend.Awt) { - val state = rememberWindowState(width = 1024.dp, height = 720.dp) - - val titleBarStyle = - TitleBarStyle( - colors = - TitleBarColors( - background = Color(0xFF1A1D24), - inactiveBackground = Color(0xFF15181D), - content = Color(0xFFE6E6E6), - border = Color.Transparent, - ), - metrics = TitleBarMetrics(height = 36.dp), - ) - - var title by remember { mutableStateOf("JNI Backend Demo") } - - NucleusDecoratedWindowTheme(isDark = true, titleBarStyle = titleBarStyle) { - DecoratedWindow( - onCloseRequest = ::exitApplication, - state = state, - title = title, - minimumSize = DpSize(640.dp, 400.dp), - ) { - var clicks by remember { mutableStateOf(0) } - val enabledBlobs = remember { mutableStateListOf(true, true, true, true) } - var selectedTab by remember { mutableStateOf(Tab.Demo) } - val events = remember { mutableStateListOf() } - - TitleBar(modifier = Modifier.macOSLargeCornerRadius()) { state -> - Row( - modifier = Modifier.align(Alignment.Start).padding(start = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - Box( - modifier = - Modifier - .size(8.dp) - .clip(CircleShape) - .background(if (state.isActive) Color(0xFF34D399) else Color(0xFF6B7280)), - ) - BasicText( - text = if (state.isActive) "Live" else "Inactive", - style = - TextStyle( - color = Color(0xFFA0A4B0), - fontSize = 11.sp, - fontWeight = FontWeight.Medium, - ), - ) - } - - BasicText( - text = title, - modifier = Modifier.align(Alignment.CenterHorizontally), - style = - TextStyle( - color = if (state.isActive) Color(0xFFE6E6E6) else Color(0xFFE6E6E6).copy(alpha = 0.5f), - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - ), - ) - - Row( - modifier = Modifier.align(Alignment.End).padding(end = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - PALETTE.forEachIndexed { idx, color -> - Box( - modifier = - Modifier - .size(14.dp) - .clip(CircleShape) - .background(if (enabledBlobs[idx]) color else color.copy(alpha = 0.18f)) - .border( - 1.dp, - if (enabledBlobs[idx]) color.copy(alpha = 0.4f) else Color.Transparent, - CircleShape, - ).clickable { enabledBlobs[idx] = !enabledBlobs[idx] }, - ) - } - Box(modifier = Modifier.size(width = 8.dp, height = 16.dp)) - BasicText( - text = "Clear", - style = - TextStyle( - color = Color(0xFF8AB4FF), - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - ), - modifier = - Modifier - .clip(RoundedCornerShape(6.dp)) - .background(Color.White.copy(alpha = 0.06f)) - .clickable { - clicks = 0 - events.clear() - }.padding(horizontal = 8.dp, vertical = 4.dp), - ) - } - } - - Column(modifier = Modifier.fillMaxSize().background(Color(0xFF0F1115))) { - TabBar(selectedTab, onSelect = { selectedTab = it }) - Box(modifier = Modifier.weight(1f).fillMaxSize()) { - when (selectedTab) { - Tab.Demo -> - FancyDemo( - modifier = Modifier.fillMaxSize(), - clicks = clicks, - onClick = { - clicks++ - logEvent(events, "click @ demo (#$clicks)") - }, - enabledBlobs = enabledBlobs, - ) - Tab.Scroll -> ScrollTab(modifier = Modifier.fillMaxSize()) - Tab.Actions -> - ActionsTab( - modifier = Modifier.fillMaxSize(), - window = nucleusWindow, - currentTitle = title, - onTitleChange = { title = it }, - onLog = { logEvent(events, it) }, - ) - Tab.Events -> EventsTab(modifier = Modifier.fillMaxSize(), events = events) - else -> {} - } - } - } - } - } - } diff --git a/examples/macos-appex-demo/README.md b/examples/macos-appex-demo/README.md new file mode 100644 index 000000000..31e511d27 --- /dev/null +++ b/examples/macos-appex-demo/README.md @@ -0,0 +1,94 @@ +# macOS Network Extension (`.appex`) demo + +Reproduces the scenario from [issue #394](https://github.com/NucleusFramework/Nucleus/issues/394): +shipping a macOS **Network Extension** (`.appex`) inside a Nucleus JVM app, embedded under +`Contents/PlugIns/`, **signed with its own entitlements** (distinct from the host app). + +Nucleus embeds and signs the extension for you via the `appExtensions {}` DSL: + +```kotlin +macOS { + entitlementsFile.set(file("packaging/app.entitlements")) // host-app entitlements + appExtensions { + extension("NetworkFilter") { + appex(file("build/appex/NetworkFilter.appex")) // prebuilt .appex + entitlements(file("packaging/extension/NetworkExtension.entitlements")) // ITS OWN + // provisioningProfile(file("packaging/NetworkFilter.provisionprofile")) + } + } +} +``` + +Under the hood the plugin copies the `.appex` into `Contents/PlugIns/`, embeds its provisioning +profile (as `Contents/embedded.provisionprofile` inside the extension), signs the extension with +its own entitlements, then seals the outer app **without `--deep`** — so the extension keeps its +distinct signature. It does the same on the DMG/PKG re-seal path. + +> Nucleus does not build the `.appex` — that stays Xcode / Kotlin/Native territory. Here a small +> `build.sh` compiles a minimal `NEFilterDataProvider` into a universal `.appex`. + +## Layout + +``` +packaging/ + app.entitlements host-app entitlements (App Group + networkextension) + extension/ + FilterDataProvider.m minimal NEFilterDataProvider (allows all traffic) + Info.plist NSExtension declaration (principal class, point id) + NetworkExtension.entitlements the EXTENSION's own entitlements + build.sh compiles the universal .appex +src/main/kotlin/.../Main.kt Compose app; inspects its own Contents/PlugIns at runtime +``` + +## Run it + +```bash +# Build the .app with the extension embedded & signed (ad-hoc, no certificate needed): +./gradlew :examples:macos-appex-demo:createDistributable + +# Launch it — the window lists the embedded extension and shows that the .appex +# carries its own signature/entitlements, separate from the app: +open "build/compose/binaries/main/app/Network Extension Demo.app" +``` + +Inspect manually: + +```bash +APP="build/compose/binaries/main/app/Network Extension Demo.app" +codesign --verify --deep --strict --verbose=2 "$APP" +codesign -d --entitlements :- "$APP/Contents/PlugIns/NetworkFilter.appex" +``` + +## Real distribution (Developer ID / App Store) + +1. Request the Network Extension capability for your App ID, create App IDs + provisioning + profiles for both the app and the extension (they need the same App Group). +2. Enable `signing { sign.set(true); identity.set("Developer ID Application: You (TEAMID)") }`. +3. Add each extension's `provisioningProfile(...)` and the app's `provisioningProfile.set(...)`. + +Build the GraalVM native variant (the `.appex` is embedded & ad-hoc signed there too): + +```bash +GRAALVM_HOME=/path/to/graalvm ./gradlew :examples:macos-appex-demo:packageGraalvmNative +# → build/compose/tmp/main/graalvm/output/NetworkExtensionDemo.app/Contents/PlugIns/NetworkFilter.appex +``` + +### Caveats + +- **Host entitlements**: Nucleus' default entitlements grant + `com.apple.security.cs.allow-unsigned-executable-memory` and + `com.apple.security.cs.disable-library-validation`; a host app shipping a Network Extension has + been reported not to launch with them (#394). Point `entitlementsFile` at a plist without those + two keys, as `packaging/app.entitlements` does — `allow-jit` is all the JVM needs. The plugin + warns when it embeds an extension into an app whose entitlements still carry them. +- **Dev loop**: the `.appex` only exists inside the signed `.app`, so `run` (IDE launch) cannot + exercise it. Use `runDistributable` — it builds the app image with the extension and launches it. +- **GraalVM native images are always ad-hoc signed**, so the embedded extension is ad-hoc too. + For a Developer-ID/notarized GraalVM DMG, configure `signing {}` (the GraalVM DMG re-seal goes + through the same electron-builder path as the JVM one). +- Actually *installing/enabling* the extension uses the NetworkExtension management APIs + (`NEFilterManager` / `NETunnelProviderManager`), called from the JVM via a native bridge — + see https://nucleusframework.dev/en/docs/performance/native-code/. This example is about + signing/bundling/shipping the `.appex`. +- Testing the extension at runtime without a paid account requires disabling SIP + AMFI on a + dev VM / victim machine (`csrutil disable` + `nvram boot-args="amfi_get_out_of_my_way=0x1"`). diff --git a/examples/macos-appex-demo/build.gradle.kts b/examples/macos-appex-demo/build.gradle.kts new file mode 100644 index 000000000..9361c8f5a --- /dev/null +++ b/examples/macos-appex-demo/build.gradle.kts @@ -0,0 +1,83 @@ +import dev.nucleusframework.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.kotlin) + alias(libs.plugins.kotlinComposePlugin) + id("dev.nucleusframework") +} + +dependencies { + implementation(nucleus.desktop.currentOs) + implementation(project(":nucleus-application")) + implementation(libs.compose.material3) +} + +val macAppName = "NetworkExtensionDemo" +val isMac = System.getProperty("os.name").startsWith("Mac") +val extensionDir = layout.projectDirectory.dir("packaging/extension") +val appexOutputDir = layout.buildDirectory.dir("appex") + +// Compile the Network Extension .appex (Nucleus does not build .appex itself). +// Nucleus signs it via the appExtensions {} DSL below. +val buildAppex = tasks.register("buildAppex") { + group = "distribution" + description = "Compile the Network Extension .appex." + onlyIf { isMac } + inputs.dir(extensionDir) + outputs.dir(appexOutputDir) + commandLine( + "bash", + extensionDir.file("build.sh").asFile.absolutePath, + appexOutputDir.get().asFile.absolutePath, + ) +} + +nucleus.application { + mainClass = "dev.nucleusframework.appexdemo.MainKt" + + // The .appex is embedded & signed on the GraalVM native path too (ad-hoc). + graalvm { + isEnabled = true + imageName = "network-extension-demo" + } + + nativeDistributions { + targetFormats(TargetFormat.Dmg) + appName = "Network Extension Demo" + packageName = macAppName + packageVersion = "1.0.0" + + macOS { + bundleID = "dev.nucleusframework.appexdemo" + appCategory = "public.app-category.utilities" + entitlementsFile.set(layout.projectDirectory.file("packaging/app.entitlements")) + + // First-class embedding: Nucleus copies the .appex into Contents/PlugIns, + // signs it with its OWN entitlements, then seals the app without --deep. + appExtensions { + extension("NetworkFilter") { + appex(appexOutputDir.get().file("NetworkFilter.appex").asFile) + entitlements(extensionDir.file("NetworkExtension.entitlements").asFile) + // provisioningProfile(file("packaging/NetworkFilter.provisionprofile")) // real distribution + } + } + + // For a real, notarizable / App Store build, enable signing so the DMG re-seal + // keeps the nested extension signature: + // signing { + // sign.set(true) + // identity.set("Developer ID Application: You (TEAMID)") + // } + } + } +} + +// The .appex must exist before the app image is assembled (JVM and GraalVM paths). +val appImageTasks = + setOf( + "createDistributable", + "createReleaseDistributable", + "embedGraalvmAppExtensions", + "embedReleaseGraalvmAppExtensions", + ) +tasks.matching { it.name in appImageTasks }.configureEach { dependsOn(buildAppex) } diff --git a/examples/macos-appex-demo/packaging/app.entitlements b/examples/macos-appex-demo/packaging/app.entitlements new file mode 100644 index 000000000..4b3ad2312 --- /dev/null +++ b/examples/macos-appex-demo/packaging/app.entitlements @@ -0,0 +1,29 @@ + + + + + + com.apple.developer.networking.networkextension + + content-filter-provider + + com.apple.security.application-groups + + group.dev.nucleusframework.appexdemo + + + com.apple.security.cs.allow-jit + + + diff --git a/examples/macos-appex-demo/packaging/extension/FilterDataProvider.m b/examples/macos-appex-demo/packaging/extension/FilterDataProvider.m new file mode 100644 index 000000000..1b27e42c2 --- /dev/null +++ b/examples/macos-appex-demo/packaging/extension/FilterDataProvider.m @@ -0,0 +1,34 @@ +// Minimal macOS Network Extension provider used only to demonstrate packaging. +// +// This is a content-filter data provider (NEFilterDataProvider) that allows all +// traffic. It is intentionally trivial: the point of this example is the *build, +// sign, bundle and re-seal* pipeline around the .appex, not the filtering logic. +// +// The executable has no main() of its own — an app extension's entry point is +// NSExtensionMain (provided by Foundation). build.sh links it via `-e _NSExtensionMain`. +// The principal class is declared in Info.plist (NSExtensionPrincipalClass). + +#import +#import + +@interface FilterDataProvider : NEFilterDataProvider +@end + +@implementation FilterDataProvider + +- (void)startFilterWithCompletionHandler:(void (^)(NSError *_Nullable))completionHandler { + // No filtering rules — start successfully. + completionHandler(nil); +} + +- (void)stopFilterWithReason:(NEProviderStopReason)reason + completionHandler:(void (^)(void))completionHandler { + completionHandler(); +} + +- (NEFilterNewFlowVerdict *)handleNewFlow:(NEFilterFlow *)flow { + // Allow every new flow. + return [NEFilterNewFlowVerdict allowVerdict]; +} + +@end diff --git a/examples/macos-appex-demo/packaging/extension/Info.plist b/examples/macos-appex-demo/packaging/extension/Info.plist new file mode 100644 index 000000000..f1fed4ff1 --- /dev/null +++ b/examples/macos-appex-demo/packaging/extension/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Network Filter + CFBundleExecutable + NetworkFilter + CFBundleIdentifier + dev.nucleusframework.appexdemo.networkfilter + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + NetworkFilter + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.networkextension.filter-data + NSExtensionPrincipalClass + FilterDataProvider + + + diff --git a/examples/macos-appex-demo/packaging/extension/NetworkExtension.entitlements b/examples/macos-appex-demo/packaging/extension/NetworkExtension.entitlements new file mode 100644 index 000000000..317e18315 --- /dev/null +++ b/examples/macos-appex-demo/packaging/extension/NetworkExtension.entitlements @@ -0,0 +1,20 @@ + + + + + + com.apple.developer.networking.networkextension + + content-filter-provider + + + com.apple.security.application-groups + + group.dev.nucleusframework.appexdemo + + + diff --git a/examples/macos-appex-demo/packaging/extension/build.sh b/examples/macos-appex-demo/packaging/extension/build.sh new file mode 100755 index 000000000..482c0cca0 --- /dev/null +++ b/examples/macos-appex-demo/packaging/extension/build.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# +# Compiles the Network Extension .appex bundle. Signing is handled by Nucleus: +# the appExtensions {} DSL signs the extension with its own entitlements and seals +# the app. This script only produces the (unsigned) .appex. +# +# Usage: build.sh → /NetworkFilter.appex +set -euo pipefail + +OUT_DIR="${1:?usage: build.sh }" +HERE="$(cd "$(dirname "$0")" && pwd)" + +APPEX="$OUT_DIR/NetworkFilter.appex" +MACOS_DIR="$APPEX/Contents/MacOS" + +echo "==> Assembling $APPEX" +rm -rf "$APPEX" +mkdir -p "$MACOS_DIR" +cp "$HERE/Info.plist" "$APPEX/Contents/Info.plist" + +# An app extension's executable entry point is NSExtensionMain (from Foundation), +# so there is no main() in our source; we override the entry symbol with -e. +echo "==> Compiling universal (arm64 + x86_64) executable" +clang \ + -arch arm64 -arch x86_64 \ + -mmacosx-version-min=11.0 \ + -fobjc-arc \ + -fvisibility=hidden \ + -framework Foundation \ + -framework NetworkExtension \ + -e _NSExtensionMain \ + -o "$MACOS_DIR/NetworkFilter" \ + "$HERE/FilterDataProvider.m" + +echo "==> Done: $APPEX" diff --git a/examples/macos-appex-demo/src/main/kotlin/dev/nucleusframework/appexdemo/Main.kt b/examples/macos-appex-demo/src/main/kotlin/dev/nucleusframework/appexdemo/Main.kt new file mode 100644 index 000000000..ab766beab --- /dev/null +++ b/examples/macos-appex-demo/src/main/kotlin/dev/nucleusframework/appexdemo/Main.kt @@ -0,0 +1,118 @@ +package dev.nucleusframework.appexdemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.window.NucleusDecoratedWindowTheme +import dev.nucleusframework.window.TitleBar +import java.io.File + +/** + * Demonstrates a Nucleus JVM app shipping a macOS Network Extension `.appex` + * embedded under `Contents/PlugIns/`. + * + * When launched from the packaged `.app`, this window locates its own bundle and + * lists the embedded extensions, proving that the `.appex` was bundled and that + * it carries its OWN code signature / entitlements (distinct from the app). + * + * Note: this only *inspects* the bundled extension. Actually installing/enabling + * a Network Extension requires the NetworkExtension management APIs + * (NEFilterManager / NETunnelProviderManager), reached from the JVM via a native + * bridge (Kotlin/Native + FFM or JNI) — out of scope for this packaging example. + * See https://nucleusframework.dev/en/docs/performance/native-code/ + */ +fun main(args: Array) = + nucleusApplication(args) { + NucleusDecoratedWindowTheme { + DecoratedWindow( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 720.dp, height = 560.dp), + title = "Network Extension Demo", + ) { + TitleBar { Text("Network Extension Demo") } + MaterialTheme { + Surface(Modifier.fillMaxSize()) { + var report by remember { mutableStateOf(inspectBundledExtensions()) } + Column( + modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text("Bundled Network Extensions", style = MaterialTheme.typography.titleLarge) + Button(onClick = { report = inspectBundledExtensions() }) { Text("Refresh") } + Text(report, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + } + } + +/** Walks up from the running executable to the `.app`, then lists the `.appex` bundles in `Contents/PlugIns`. */ +private fun inspectBundledExtensions(): String { + val pluginsDir = + locatePlugInsDir() + ?: return "Not running from a packaged .app bundle.\n" + + "The extension only exists inside the signed .app, so `run` cannot show it:\n" + + " ./gradlew :examples:macos-appex-demo:runDistributable" + + val appexes = pluginsDir.listFiles { f -> f.isDirectory && f.name.endsWith(".appex") }?.toList().orEmpty() + if (appexes.isEmpty()) return "No .appex found under ${pluginsDir.absolutePath}" + + return buildString { + appendLine("PlugIns: ${pluginsDir.absolutePath}\n") + for (appex in appexes) { + appendLine("• ${appex.name}") + appendLine(codesignInfo(appex).prependIndent(" ")) + appendLine() + } + } +} + +private fun locatePlugInsDir(): File? { + // Inside a packaged app the launcher lives at .app/Contents/MacOS/. + val cmd = + ProcessHandle + .current() + .info() + .command() + .orElse(null) ?: return null + val macOsDir = File(cmd).parentFile ?: return null // .../Contents/MacOS + val contents = macOsDir.parentFile ?: return null // .../Contents + if (contents.name != "Contents") return null + return File(contents, "PlugIns").takeIf { it.isDirectory } +} + +/** Reads the extension's real signature + entitlements via the codesign CLI. */ +private fun codesignInfo(appex: File): String = + try { + val proc = + ProcessBuilder( + "/usr/bin/codesign", + "-d", + "--verbose=2", + "--entitlements", + ":-", + appex.absolutePath, + ).redirectErrorStream(true).start() + val out = proc.inputStream.bufferedReader().readText() + proc.waitFor() + out.trim().ifEmpty { "(no signature information)" } + } catch (e: Exception) { + "codesign inspection failed: ${e.message}" + } diff --git a/examples/mediafoundation-demo/src/main/kotlin/dev/nucleusframework/samplemf/Main.kt b/examples/mediafoundation-demo/src/main/kotlin/dev/nucleusframework/samplemf/Main.kt index 5028f6132..1ccc8e62e 100644 --- a/examples/mediafoundation-demo/src/main/kotlin/dev/nucleusframework/samplemf/Main.kt +++ b/examples/mediafoundation-demo/src/main/kotlin/dev/nucleusframework/samplemf/Main.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.window.NucleusDecoratedWindowTheme import dev.nucleusframework.window.TitleBar @@ -72,7 +71,7 @@ import java.util.concurrent.atomic.AtomicInteger fun main(args: Array) { // No GraalVmInitializer call: nucleusApplication runs it first thing. val url = resolveUrl(args.firstOrNull() ?: System.getenv("NUCLEUS_MF_URL")) - nucleusApplication(backend = NucleusBackend.Tao) { + nucleusApplication { NucleusDecoratedWindowTheme(isDark = true) { DecoratedWindow( onCloseRequest = ::exitApplication, diff --git a/examples/nucleus-demo/build.gradle.kts b/examples/nucleus-demo/build.gradle.kts index db669b63a..1efd3ec18 100644 --- a/examples/nucleus-demo/build.gradle.kts +++ b/examples/nucleus-demo/build.gradle.kts @@ -85,6 +85,7 @@ val nativePackageVersion = releaseVersion.substringBefore("-") nucleus.application { mainClass = "com.example.demo.MainKt" + nucleusOptimization = true buildTypes { release { diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt index 672eb053e..d9f5e83ac 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/Main.kt @@ -149,13 +149,24 @@ fun main(args: Array) = title = "Nucleus Demo", minimumSize = DpSize(1300.dp, 480.dp), nativeContextMenu = true, + nativePopupLayers = false, ) { CompositionLocalProvider( LocalLayoutDirection provides if (isRtl) LayoutDirection.Rtl else LayoutDirection.Ltr, ) { val tabs = buildList { - addAll(listOf("Nucleus", "Fill Title", "Gallery", "Taskbar", "Scroll Test", "Trackpad Lab")) + addAll( + listOf( + "Nucleus", + "Fill Title", + "Gallery", + "Taskbar", + "Scroll Test", + "Trackpad Lab", + "Popups", + ), + ) add("Notifications (Common)") add("Notifications") add("Launcher") @@ -285,6 +296,7 @@ fun main(args: Array) = } "Taskbar" -> TaskbarProgressScreen(nucleusWindow) "Scroll Test" -> ScrollTestScreen() + "Popups" -> PopupPlacementScreen(nucleusWindow.unsafe.taoWindow) "Trackpad Lab" -> TrackpadLabScreen(onOpenNativePopupWindow = { isTrackpadLabWindowVisible = diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/PopupPlacementScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/PopupPlacementScreen.kt new file mode 100644 index 000000000..64f3d893a --- /dev/null +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/PopupPlacementScreen.kt @@ -0,0 +1,272 @@ +package com.example.demo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TaoMonitors +import dev.nucleusframework.window.tao.TaoWindow +import kotlinx.coroutines.delay + +/** + * Demo for issue #569 — popups positioned against the **screen**, not the + * owner window. + * + * `nativePopupLayers = true` makes every Compose `Popup` / `DropdownMenu` a + * real OS window, free to extend past the owner. The catch #569 fixed is that + * Compose decided *where* to put it in window-rooted coordinates: it clipped + * and flipped inside a work-area-sized box hanging off the window's content + * origin, which is only the real screen when the window is maximized on the + * primary display. Anywhere else, a menu anchored near the bottom of a window + * sitting near the bottom of the display walked straight off it. + * + * The screen makes that visible with things a demo can actually show: + * - **park the window against a work-area edge** with one click, + * - **open a menu anchored at that same edge** and watch it stay on screen, + * - **open a dialog** and watch it stay centred on the *window* instead — + * a dialog belongs to its window, and only popups follow the display. + * + * Park the window bottom-right and open the bottom-right menu: the live + * readout shows how little room is left below and to the right, and before the + * fix the menu was drawn under the taskbar or off the right edge entirely. + */ +@Composable +fun PopupPlacementScreen(window: TaoWindow?) { + var windowRect by remember { mutableStateOf(null) } + var workArea by remember { mutableStateOf(null) } + + // Poll rather than listen: the point of the screen is to show the geometry + // the popup layer re-reads on every frame push, including while the user + // drags the window by its title bar. + LaunchedEffect(window) { + while (true) { + windowRect = window?.outerRect() + workArea = window?.let { TaoMonitors.forWindow(it).workAreaPx } + delay(POLL_MILLIS) + } + } + + Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Screen-aware popup placement (#569)", style = MaterialTheme.typography.headlineSmall) + Text( + "Every Popup below is a real OS window (nativePopupLayers = true). " + + "Park this window against a work-area edge, then open the menu anchored at " + + "that edge: it slides back inside the display instead of walking off it.", + style = MaterialTheme.typography.bodyMedium, + ) + + GeometryCard(windowRect, workArea) + + Text("1 — park the window", style = MaterialTheme.typography.titleMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ParkButton("↖ top-left", window) { work, _ -> work.left to work.top } + ParkButton("↗ top-right", window) { work, size -> (work.right - size.first) to work.top } + ParkButton("center", window) { work, size -> + (work.left + (work.width - size.first) / 2) to (work.top + (work.height - size.second) / 2) + } + ParkButton("↙ bottom-left", window) { work, size -> work.left to (work.bottom - size.second) } + ParkButton("↘ bottom-right", window) { work, size -> + (work.right - size.first) to (work.bottom - size.second) + } + } + + Text("2 — open a menu anchored at an edge", style = MaterialTheme.typography.titleMedium) + // Anchors pinned to the corners of the *window content*: the geometry + // that used to send a menu offscreen, because Compose measured the room + // below/right of the anchor against a screen rooted at this window. + Box(Modifier.fillMaxWidth().height(ANCHOR_BOX_DP.dp)) { + EdgeMenu("top-left menu", Modifier.align(Alignment.TopStart)) + EdgeMenu("top-right menu", Modifier.align(Alignment.TopEnd)) + EdgeMenu("bottom-left menu", Modifier.align(Alignment.BottomStart)) + EdgeMenu("bottom-right menu", Modifier.align(Alignment.BottomEnd)) + EscapingPopupToggle(Modifier.align(Alignment.Center)) + } + + Text( + "The oversized panel deliberately measures larger than this window — " + + "a popup layer lays out against the work area, so it is not scrolled " + + "down to the window's size, and the clamp keeps it on the display.", + style = MaterialTheme.typography.bodySmall, + ) + + Text("3 — and a dialog is not a popup", style = MaterialTheme.typography.titleMedium) + Text( + "A Dialog goes through the very same native layer, but it belongs to its " + + "window, not to the display: Compose centres it in the container size, so " + + "the layer keeps reporting the window there. Park the window in a corner " + + "and open it — it stays centred on the window, wherever that is.", + style = MaterialTheme.typography.bodyMedium, + ) + CenteredDialogToggle() + } +} + +/** A Material dialog, to show it stays centred on the window (see #569). */ +@Composable +private fun CenteredDialogToggle() { + var shown by remember { mutableStateOf(false) } + Button(onClick = { shown = true }) { Text("open a centred dialog") } + if (shown) { + AlertDialog( + onDismissRequest = { shown = false }, + confirmButton = { Button(onClick = { shown = false }) { Text("close") } }, + title = { Text("Centred on the window") }, + text = { + Text( + "Not on the display — a window-owned dialog that drifted to the " + + "screen centre as you moved the window would be the bug, not the fix.", + ) + }, + ) + } +} + +@Composable +private fun GeometryCard( + windowRect: IntRect?, + workArea: IntRect?, +) { + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text("live geometry (physical px)", style = MaterialTheme.typography.titleSmall) + Text("window outer: ${windowRect?.describe() ?: "—"}") + Text("display work area: ${workArea?.describe() ?: "—"}") + val slack = + if (windowRect != null && workArea != null) { + "${workArea.bottom - windowRect.bottom} px below, " + + "${workArea.right - windowRect.right} px to the right" + } else { + "—" + } + Text("room left on the display: $slack") + Text( + "When that room is smaller than the menu, the clamp is what keeps it visible.", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} + +/** + * Moves the window so [target] — computed from the work area and the window's + * own outer size — becomes its top-left. The dp round-trip is deliberate: + * `setOuterPosition` takes logical units, which is what an app would use. + */ +@Composable +private fun ParkButton( + label: String, + window: TaoWindow?, + target: (work: IntRect, size: Pair) -> Pair, +) { + OutlinedButton( + enabled = window != null, + onClick = { + val w = window ?: return@OutlinedButton + val rect = w.outerRect() ?: return@OutlinedButton + val work = TaoMonitors.forWindow(w).workAreaPx + val (x, y) = target(work, rect.width to rect.height) + val scale = w.scaleFactor.takeIf { it > 0f } ?: 1f + w.setOuterPosition(x / scale.toDouble(), y / scale.toDouble()) + }, + ) { + Text(label) + } +} + +/** A `DropdownMenu` with enough items to be taller than the room at an edge. */ +@Composable +private fun EdgeMenu( + label: String, + modifier: Modifier = Modifier, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier) { + Button(onClick = { expanded = !expanded }) { Text(label) } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + repeat(MENU_ITEMS) { index -> + DropdownMenuItem( + text = { Text("Menu entry ${index + 1}") }, + onClick = { expanded = false }, + ) + } + } + } +} + +/** + * A popup deliberately larger than the owner window, anchored at its centre — + * the "tray anchor" shape. Without native popup layers it would be clipped to + * the window; with them it escapes, and with #569 it still stops at the + * display's work area rather than at some window-rooted phantom edge. + */ +@Composable +private fun EscapingPopupToggle(modifier: Modifier = Modifier) { + var shown by remember { mutableStateOf(false) } + Box(modifier) { + Button(onClick = { shown = !shown }) { + Text(if (shown) "hide oversized panel" else "show oversized panel") + } + if (shown) { + androidx.compose.ui.window.Popup( + alignment = Alignment.TopStart, + onDismissRequest = { shown = false }, + ) { + Card { + Column(Modifier.padding(20.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Oversized popup", style = MaterialTheme.typography.titleMedium) + Text("Measured ${OVERSIZE_W_DP}×$OVERSIZE_H_DP dp — larger than this window.") + AssistChip(onClick = { shown = false }, label = { Text("dismiss") }) + Spacer(Modifier.width(OVERSIZE_W_DP.dp).height(OVERSIZE_H_DP.dp)) + } + } + } + } + } +} + +private fun TaoWindow.outerRect(): IntRect? { + val rect = outerBoundsPx() ?: return null + if (rect.size < RECT_FIELDS) return null + val left = rect[0].toInt() + val top = rect[1].toInt() + return IntRect(left, top, left + rect[2].toInt(), top + rect[3].toInt()) +} + +private fun IntRect.describe(): String = "$left, $top $width×$height" + +private const val POLL_MILLIS = 200L +private const val ANCHOR_BOX_DP = 320 +private const val MENU_ITEMS = 14 +private const val OVERSIZE_W_DP = 520 +private const val OVERSIZE_H_DP = 420 +private const val RECT_FIELDS = 4 diff --git a/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt b/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt index b48db7690..a75f6ebda 100644 --- a/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt +++ b/examples/nucleus-demo/src/main/kotlin/com/example/demo/TrackpadLabScreen.kt @@ -67,16 +67,18 @@ import kotlin.math.max * trackpad issues (#652 sign, #653 magnitude, #654 Pan vs Scroll) side by * side, each with the expected behaviour written next to it: * - * - **Inspector**: every `Scroll` / `PanStart` / `PanMove` / `PanEnd` - * reaching Compose at the root, with the gap since the previous event, + * - **Inspector**: every `Scroll` / `PanStart` / `PanMove` / `PanEnd` / + * `ScaleStart` / `ScaleChange` / `ScaleEnd` reaching Compose at the root, + * with the gap since the previous event, * counters, and one summary per gesture (steps, distance in wheel units, * how long after the last move the `PanEnd` arrived — ~150 ms means the * grace timer closed it, ~0 ms means AppKit's momentum tail did). * - **Sign & magnitude**: a vertical column and a horizontal row; fingers * up / left must make the offsets grow, one wheel notch must move exactly * `10 dp`. - * - **Map canvas**: pans on Pan events, zooms on Scroll — the MapLibre use - * case. A trackpad swipe that zooms means #654 is back. + * - **Map canvas**: pans on Pan events, zooms on Scale (pinch, #660) and + * Scroll (wheel). A trackpad swipe that zooms means #654 is back; a + * pinch that arrives as two Touch contacts means #660 is back. * - **Popup**: a scrollable `DropdownMenu`; inline in the main window, an * NSPanel in the window opened with native popup layers. * - **NativeView**: a WKWebView with a long page and its own HUD (scrollY, @@ -212,6 +214,9 @@ private class PointerLog { var panStarts by mutableIntStateOf(0) var panMoves by mutableIntStateOf(0) var panEnds by mutableIntStateOf(0) + var scaleStarts by mutableIntStateOf(0) + var scaleChanges by mutableIntStateOf(0) + var scaleEnds by mutableIntStateOf(0) var scrolls by mutableIntStateOf(0) private var gestureIndex = 0 @@ -265,6 +270,18 @@ private class PointerLog { if (gestures.size > MAX_GESTURES) gestures.removeAt(gestures.lastIndex) add(gap, "PanEnd (+$endAfter ms after the last move)") } + PointerEventType.ScaleStart -> { + scaleStarts++ + add(gap, "ScaleStart") + } + PointerEventType.ScaleChange -> { + scaleChanges++ + add(gap, "ScaleChange ×${"%.4f".format(change.scaleFactor)}") + } + PointerEventType.ScaleEnd -> { + scaleEnds++ + add(gap, "ScaleEnd") + } PointerEventType.Scroll -> { scrolls++ add( @@ -283,6 +300,9 @@ private class PointerLog { panStarts = 0 panMoves = 0 panEnds = 0 + scaleStarts = 0 + scaleChanges = 0 + scaleEnds = 0 scrolls = 0 lastEventMs = 0L } @@ -306,9 +326,13 @@ private fun InspectorPanel( "PanStart ${log.panStarts} PanMove ${log.panMoves} PanEnd ${log.panEnds} Scroll ${log.scrolls}", bold = true, ) + Mono( + "ScaleStart ${log.scaleStarts} ScaleChange ${log.scaleChanges} ScaleEnd ${log.scaleEnds}", + bold = true, + ) Text( - "Trackpad ⇒ PanStart, PanMove…, ONE PanEnd (end ≈0 ms: momentum closed it, ≈150 ms: grace timer). " + - "Wheel ⇒ Scroll only.", + "Trackpad swipe ⇒ PanStart, PanMove…, ONE PanEnd (end ≈0 ms: momentum closed it, ≈150 ms: grace timer). " + + "Pinch ⇒ ScaleStart, ScaleChange…, ScaleEnd. Wheel ⇒ Scroll only.", style = MaterialTheme.typography.bodySmall, ) Mono("# steps Σ units (x, y) dur gap end", bold = true) @@ -397,9 +421,12 @@ private fun SignAndMagnitudePanel( private fun MapCanvasPanel(modifier: Modifier = Modifier) { var offset by remember { mutableStateOf(Offset.Zero) } var zoom by remember { mutableFloatStateOf(1f) } - Panel("Map canvas — #654: trackpad pans, wheel zooms", modifier) { + Panel("Map canvas — #654 pan / #660 pinch", modifier) { Mono("offset=${offset.fmt()} px zoom=${"%.2f".format(zoom)}", bold = true) - Text("Two fingers move the grid (never zoom); a wheel notch zooms.", style = MaterialTheme.typography.bodySmall) + Text( + "Two fingers pan the grid; pinch zooms (Scale events); a wheel notch zooms.", + style = MaterialTheme.typography.bodySmall, + ) Canvas( modifier = Modifier @@ -420,6 +447,13 @@ private fun MapCanvasPanel(modifier: Modifier = Modifier) { change.consume() } PointerEventType.PanStart, PointerEventType.PanEnd -> change.consume() + PointerEventType.ScaleStart, PointerEventType.ScaleEnd -> change.consume() + PointerEventType.ScaleChange -> { + if (change.scaleFactor != 1f) { + zoom = (zoom * change.scaleFactor).coerceIn(MIN_ZOOM, MAX_ZOOM) + } + change.consume() + } PointerEventType.Scroll -> { zoom = (zoom * (1f - change.scrollDelta.y * ZOOM_PER_NOTCH)).coerceIn( diff --git a/examples/reader-dock-demo/build.gradle.kts b/examples/reader-dock-demo/build.gradle.kts new file mode 100644 index 000000000..2acbc0359 --- /dev/null +++ b/examples/reader-dock-demo/build.gradle.kts @@ -0,0 +1,52 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// A right-to-left book reader whose every pane is a satellite: the navigation +// panels layered on the right, each with its own width and splitter, the +// translation on the left, the commentaries under the text — the pane tree of +// a split-pane reader, drawn by one DockLayout with the reader's own 1 dp +// dividers and hover headers, every pane undockable into its own window. + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(project(":decorated-window-tao")) + implementation(project(":decorated-window-material3")) + implementation(project(":nucleus-application")) + implementation(project(":core-runtime")) + implementation(project(":darkmode-detector")) + implementation(project(":graalvm-runtime")) + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.compose.material3:material3:1.9.0") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") + } +} + +nucleus.application { + mainClass = "dev.nucleusframework.readerdockdemo.MainKt" + + nativeDistributions { + packageName = "reader-dock-demo" + packageVersion = "1.0.0" + } + + graalvm { + isEnabled = true + javaLanguageVersion = 25 + imageName = "reader-dock-demo" + } +} diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt new file mode 100644 index 000000000..23df3225d --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/Main.kt @@ -0,0 +1,487 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.application.Satellite +import dev.nucleusframework.application.Tab +import dev.nucleusframework.application.TabWindows +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import dev.nucleusframework.window.WindowAppearance +import dev.nucleusframework.window.WindowAppearanceMode +import dev.nucleusframework.window.WindowBackground +import dev.nucleusframework.window.material.rememberMaterialTitleBarStyle +import dev.nucleusframework.window.material.rememberMaterialWindowStyle +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.TabWindowGroup +import dev.nucleusframework.window.tao.TabWorkspace + +private val DarkColors = + darkColorScheme( + primary = Color(0xFF8AA4FF), + surface = Color(0xFF1B1D22), + surfaceContainer = Color(0xFF23262D), + surfaceContainerHigh = Color(0xFF2B2F38), + background = Color(0xFF14161A), + outlineVariant = Color(0xFF3A3F4A), + ) + +private val LightColors = + lightColorScheme( + primary = Color(0xFF3F5DDB), + surface = Color(0xFFFFFFFF), + surfaceContainer = Color(0xFFF2F3F7), + surfaceContainerHigh = Color(0xFFE6E8EF), + background = Color(0xFFEDEFF4), + outlineVariant = Color(0xFFD5D8E0), + ) + +/** + * A right-to-left book reader whose seforim are tabs and whose every pane is a + * satellite — the two multi-window archetypes composed. + * + * The pane tree of a classic split-pane reader — books | contents | notes on + * the right, the text in the middle with the translation beside it, the + * commentaries under both — is one `DockLayout`: the right side is *layered*, + * so its three panes are three columns each with its own width and splitter, + * and the side order puts the right side first so the commentaries stop at it + * and run under the translation. The dividers are the reader's own 1 dp lines + * with a 5 dp grip; the headers are the reader's own hover strips; the + * *Islands* style turns every pane into a rounded card. + * + * On top of that, `TabWindows` owns the windows and each sefer is a `Tab`. The + * strip is the top of the window; everything below it — the two activity bars + * and the dock — is the reader's own chrome, hung on `windowBodyWrapper`. The + * **dock belongs to the window** and the tabs change what it holds: the text + * in the middle is the selected sefer, and every pane draws that same sefer. + * Tear a tab out and the new window arrives with a dock of its own, so two + * seforim are read side by side, each with its own pane widths, its own + * commentaries, its own layout to save and restore. Nothing about a tab change + * creates or destroys a panel — see [ReaderState]. + * + * The books pane is the other half of the tie: clicking a sefer there selects + * its tab, and the tab strip's "+" opens another. + */ +fun main() = + nucleusApplication { + val reader = remember { ReaderState() } + val dark = isSystemInDarkMode() + val colors = if (dark) DarkColors else LightColors + + ReaderTheme(colors) { + TabWindows( + workspace = reader.tabs, + // Right to left, like the rest of the reader: the first sefer + // is the rightmost tab and the "+" follows the last one + // leftwards, and the strip animates the same way. + strip = { + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + ReaderTabStrip(reader, onNewBook = reader::openBook) + } + }, + windowWrapper = { content -> + WindowBackground(colors.background) + WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) + // This window joins its own pane workspace, once, for as + // long as it lives: that is what keeps a tab change from + // touching the dock at all. + reader.tabs.groupOf(nucleusWindow.unsafe.taoWindow)?.let { + JoinSatelliteWorkspace(reader.panesOfWindow(it.id)) + } + Surface(Modifier.fillMaxSize(), color = colors.background) { content() } + }, + // Under the tab strip, which stays at the very top of the + // window: the dock and the activity bars belong to the window, + // the text between them is whichever sefer the strip selected. + windowBodyWrapper = { body -> + val group = reader.tabs.groupOf(nucleusWindow.unsafe.taoWindow) + if (group == null) body() else ReaderBody(reader, group) { body() } + }, + onLastWindowClosed = ::exitApplication, + ) + + // Every sefer, declared once: the workspace decides which window + // shows it, and the panes of that window draw it. + for (book in reader.books) { + key(book.id) { + Tab(reader.tabs, id = book.id, title = book.title) { BookText(reader, book) } + DropClosedTab(reader, book.id) + } + } + + // One dock of panes per reader window, declared at application + // scope so they are not tied to whichever sefer is showing. + for (group in rememberTabGroups(reader.tabs)) { + key(group.id) { WindowPanes(reader, group, colors) } + } + } + } + +/** + * The tab windows, mirrored out of the workspace through an effect: the groups + * are created by `Tab`, declared after this list is read, and Compose drops an + * invalidation aimed at a scope it has just composed. + */ +@Composable +private fun rememberTabGroups(workspace: TabWorkspace): List { + var groups by remember(workspace) { mutableStateOf(workspace.groups.toList()) } + LaunchedEffect(workspace) { + snapshotFlow { workspace.groups.toList() }.collect { groups = it } + } + return groups +} + +/** + * The panes of one reader window: one satellite per [Pane], declared against + * that window's workspace and drawing whichever sefer the window is showing. + * + * The entries are per window so a tab change creates and destroys nothing — + * only the content changes, and what the reader remembers per book lives in + * [ReaderState.stateOf]. + */ +@Composable +private fun WindowPanes( + reader: ReaderState, + group: TabWindowGroup, + colors: ColorScheme, +) { + val workspace = reader.panesOfWindow(group.id) + DisposableEffect(reader, group.id) { + onDispose { reader.forgetWindow(group.id) } + } + // The selected tab of *this* window, resolved back to the sefer. The tab id + // is the book id, which is what ties the two archetypes together without + // either knowing about the other. + val book = reader.tabs.selectedTab(group)?.let { reader.book(it.id) } + + ReaderTheme(colors) { + for (pane in Pane.entries) { + Satellite( + workspace = workspace, + id = pane.idIn(group.id), + title = pane.title, + initialPlacement = pane.home, + initiallyOpen = pane.openAtStart, + dockSides = if (pane.fixed) ReaderFixedDockSides else ReaderDockSides, + floatable = !pane.fixed, + reorderable = !pane.fixed, + header = { PaneHeader(reader.style) }, + // Only reserved where the compositor owns the window move; + // elsewhere the whole bar drags the pane and this is not composed. + floatingCaption = { PaneMoveAffordance() }, + ) { + Surface(Modifier.fillMaxSize(), color = colors.surface) { + PaneContent(reader, pane, book) + } + } + } + } +} + +/** + * One reader window: its two activity bars around the dock layout, all + * right-to-left, with the selected sefer's text as the dock's content. + */ +@Composable +private fun ReaderBody( + reader: ReaderState, + group: TabWindowGroup, + text: @Composable () -> Unit, +) { + val workspace = reader.panesOfWindow(group.id) + CompositionLocalProvider(LocalLayoutDirection provides LayoutDirection.Rtl) { + Row(Modifier.fillMaxSize()) { + // Start bar: at the right edge in RTL, toggling the navigation panes. + ActivityBar { + for (pane in listOf(Pane.Tree, Pane.Toc, Pane.Notes)) { + BarButton(pane.title.take(1), selected = reader.isOpen(group.id, pane)) { + reader.toggle(group.id, pane) + } + } + } + VerticalDivider() + DockLayout( + workspace = workspace, + modifier = Modifier.weight(1f).fillMaxHeight(), + // The navigation runs the full height on the right; the + // commentaries run under the text and the translation, not + // under the navigation. + sideOrder = listOf(DockSide.Right, DockSide.Bottom, DockSide.Left, DockSide.Top), + // Books | contents | notes are three columns, not a stack. + layeredSides = setOf(DockSide.Right), + splitter = { ReaderSplitter(reader.style) }, + panel = { body -> PaneCard(reader.style) { body() } }, + ) { + PaneCard(reader.style) { text() } + } + VerticalDivider() + // End bar: the content panes, the style switch, the layout of this window. + ActivityBar { + for (pane in listOf(Pane.Targum, Pane.Comments, Pane.Sources)) { + BarButton(pane.title.take(1), selected = reader.isOpen(group.id, pane)) { + reader.toggle(group.id, pane) + } + } + Spacer(Modifier.height(BAR_GAP_DP.dp)) + BarButton("◫", selected = reader.style == ReaderStyle.Islands) { + reader.style = if (reader.style == ReaderStyle.Islands) ReaderStyle.Classic else ReaderStyle.Islands + } + Spacer(Modifier.weight(1f)) + BarButton("S", selected = false) { reader.saveLayout(group.id) } + BarButton("R", selected = reader.savedLayout(group.id) != null) { reader.restoreLayout(group.id) } + BarButton("⟲", selected = false) { reader.resetLayout(group.id) } + } + } + } +} + +/** + * A sefer's text: the tab's own body, so it is composed in whichever window + * shows the tab and its scroll position follows it there. + */ +@Composable +private fun BookText( + reader: ReaderState, + book: Book, +) { + val state = reader.stateOf(book.id) + val chapter = state.chapter.coerceIn(book.chapters.indices) + Column(Modifier.fillMaxSize()) { + val scroll = rememberScrollState() + Column( + Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(scroll) + .padding(TEXT_PADDING_DP.dp), + verticalArrangement = Arrangement.spacedBy(TEXT_GAP_DP.dp), + ) { + Text("${book.title} · ${book.chapters[chapter]}", fontSize = TITLE_SP.sp, fontWeight = FontWeight.Bold) + repeat(VERSES) { index -> + Text( + "פסוק ${index + 1} — ${SAMPLE_TEXT.repeat(1 + index % 3)}", + fontSize = TEXT_SP.sp, + textAlign = TextAlign.Start, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + HorizontalDivider() + Row( + Modifier.fillMaxWidth().height(BREADCRUMB_H_DP.dp).padding(horizontal = TEXT_PADDING_DP.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "תנ״ך › ${book.title} › ${book.chapters[chapter]}", + fontSize = BREADCRUMB_SP.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * A pane's body, for the sefer its window is showing: the books pane lists + * every open sefer and selects its tab, the contents pane lists the sefer's + * chapters, the rest list what they hold for the chapter in view. + */ +@Composable +private fun PaneContent( + reader: ReaderState, + pane: Pane, + book: Book?, +) { + if (book == null) { + Box(Modifier.fillMaxSize().padding(PANE_PADDING_DP.dp), contentAlignment = Alignment.Center) { + Text("אין ספר פתוח", fontSize = TEXT_SP.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + return + } + val state = reader.stateOf(book.id) + val scroll = rememberScrollState() + Column( + Modifier.fillMaxSize().verticalScroll(scroll).padding(PANE_PADDING_DP.dp), + verticalArrangement = Arrangement.spacedBy(ITEM_GAP_DP.dp), + ) { + when (pane) { + // Every sefer of the app: clicking one brings its tab to the front. + Pane.Tree -> + for (candidate in reader.books) { + PaneItem(candidate.title, selected = candidate.id == book.id) { reader.show(candidate.id) } + } + // The chapters of this sefer; the text follows the choice. + Pane.Toc -> + book.chapters.forEachIndexed { index, name -> + PaneItem(name, selected = index == state.chapter) { state.chapter = index } + } + else -> + repeat(ITEMS) { index -> + val label = "${pane.title} ${book.chapters[ + state.chapter.coerceIn( + book.chapters.indices, + ), + ]}·${index + 1}" + PaneItem(label, selected = state.selected(pane) == index) { state.select(pane, index) } + } + } + } +} + +@Composable +private fun PaneItem( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + Text( + text = label, + fontSize = TEXT_SP.sp, + color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ITEM_CORNER_DP.dp)) + .background(if (selected) MaterialTheme.colorScheme.surfaceContainerHigh else Color.Transparent) + .clickable(onClick = onClick) + .padding(ITEM_PADDING_DP.dp), + ) +} + +/** + * Keeps the sefer list in step with the tab workspace: closing a tab is a + * workspace call, and a book still declared once its tab is gone would be + * registered again and hosted nowhere. + */ +@Composable +private fun DropClosedTab( + reader: ReaderState, + id: String, +) { + val closed = reader.tabs.tab(id) == null + LaunchedEffect(closed) { + if (closed) reader.forget(id) + } +} + +@Composable +private fun ActivityBar(content: @Composable () -> Unit) { + Column( + Modifier + .fillMaxHeight() + .width( + BAR_W_DP.dp, + ).background(MaterialTheme.colorScheme.surfaceContainer) + .padding(vertical = BAR_GAP_DP.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(BAR_GAP_DP.dp), + ) { content() } +} + +@Composable +private fun BarButton( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = MaterialTheme.colorScheme + FilledTonalIconButton( + onClick = onClick, + modifier = Modifier.size(BAR_BUTTON_DP.dp), + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = if (selected) colors.primary.copy(alpha = SELECTED_ALPHA) else Color.Transparent, + contentColor = if (selected) colors.primary else colors.onSurfaceVariant, + ), + ) { + Box( + contentAlignment = Alignment.Center, + ) { Text(label, fontSize = BAR_LABEL_SP.sp, fontWeight = FontWeight.SemiBold) } + } +} + +/** + * Material colours plus the window-chrome styles derived from them. + * + * Established once, above the windows: the workspaces open them, and these + * locals are bridged into every scene they create — the tab strips in the + * title bars and the floating panes' own scenes included. + */ +@Composable +private fun ReaderTheme( + colors: ColorScheme, + content: @Composable () -> Unit, +) { + MaterialTheme(colorScheme = colors) { + CompositionLocalProvider( + LocalTitleBarStyle provides rememberMaterialTitleBarStyle(colors), + LocalDecoratedWindowStyle provides rememberMaterialWindowStyle(colors), + content = content, + ) + } +} + +private const val BAR_W_DP = 48 +private const val BAR_GAP_DP = 8 +private const val BAR_BUTTON_DP = 36 +private const val BAR_LABEL_SP = 14 +private const val SELECTED_ALPHA = 0.18f +private const val TEXT_PADDING_DP = 24 +private const val TEXT_GAP_DP = 12 +private const val TITLE_SP = 26 +private const val TEXT_SP = 17 +private const val VERSES = 40 +private const val BREADCRUMB_H_DP = 28 +private const val BREADCRUMB_SP = 12 +private const val PANE_PADDING_DP = 8 +private const val ITEM_GAP_DP = 2 +private const val ITEM_PADDING_DP = 6 +private const val ITEM_CORNER_DP = 6 +private const val ITEMS = 40 +private const val SAMPLE_TEXT = "בְּרֵאשִׁית בָּרָא אֱלֹהִים אֵת הַשָּׁמַיִם וְאֵת הָאָרֶץ. " diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt new file mode 100644 index 000000000..60fe9fafd --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderChrome.kt @@ -0,0 +1,207 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredHeight +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.tao.DockSplitterScope +import dev.nucleusframework.window.tao.SatelliteScope +import dev.nucleusframework.window.tao.satelliteDragHandle + +/** + * The reader's pane header: a 32 dp strip with the bold title and, on hover, + * the pane's actions — float or dock, and hide. The whole strip is the grip + * that drags the pane between its dock and its own window. + * + * Composed by the satellite in both hosts: above the panel in the dock and in + * the title bar of the floating window, where the bar already is the grip. + */ +@Composable +fun SatelliteScope.PaneHeader(style: ReaderStyle) { + val colors = MaterialTheme.colorScheme + val hover = remember { MutableInteractionSource() } + val hovered by hover.collectIsHoveredAsState() + val background = + if (style == + ReaderStyle.Islands + ) { + colors.surfaceContainerHigh.copy(alpha = ISLANDS_HEADER_ALPHA) + } else { + colors.surfaceContainer + } + Column( + Modifier + .fillMaxWidth() + .background(if (isDocked) background else Color.Transparent) + .hoverable(hover) + .then(if (isDocked) Modifier.satelliteDragHandle(this) else Modifier), + ) { + Row( + Modifier.fillMaxWidth().height(HEADER_HEIGHT_DP.dp).padding(horizontal = HEADER_PADDING_DP.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(satellite.title, fontWeight = FontWeight.Bold, fontSize = HEADER_TEXT_SP.sp, color = colors.onSurface) + AnimatedVisibility(visible = hovered, enter = fadeIn(), exit = fadeOut()) { + Row( + horizontalArrangement = Arrangement.spacedBy(ACTION_GAP_DP.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isDocked) { + // A fixed pane has nowhere to float to. + if (satellite.isFloatable) HeaderAction(FLOAT_GLYPH) { undock() } + } else { + HeaderAction(DOCK_GLYPH) { dock() } + } + HeaderAction(HIDE_GLYPH) { close() } + } + } + } + if (isDocked && style == ReaderStyle.Classic) HorizontalDivider() + } +} + +/** + * What the floating pane draws in the strip its title bar leaves to the + * compositor: the grip that says "press here to move the window", as opposed + * to the header beside it, which drags the pane into the dock. + * + * Composed only where the two gestures have to be told apart + * ([SatelliteScope.isCompositorPlaced]); the slot is not composed at all + * elsewhere, so this costs nothing on Windows, macOS and X11. + */ +@Composable +fun SatelliteScope.PaneMoveAffordance() { + Text( + MOVE_GLYPH, + fontSize = ACTION_GLYPH_SP.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = MOVE_GLYPH_ALPHA), + ) +} + +@Composable +private fun HeaderAction( + glyph: String, + onClick: () -> Unit, +) { + IconButton(onClick = onClick, modifier = Modifier.size(ACTION_SIZE_DP.dp)) { + Text(glyph, fontSize = ACTION_GLYPH_SP.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +/** + * The reader's splitter: a 1 dp divider — invisible in the Islands style, where + * the cards' gaps are the dividers — carrying a wider invisible grip, exactly + * the split pane's `visiblePart` and `handle`. + */ +@Composable +fun DockSplitterScope.ReaderSplitter(style: ReaderStyle) { + val horizontal = orientation == Orientation.Horizontal + val line = + if (horizontal) { + Modifier.fillMaxHeight().width( + DIVIDER_DP.dp, + ) + } else { + Modifier.fillMaxWidth().height(DIVIDER_DP.dp) + } + val color = if (style == ReaderStyle.Islands) Color.Transparent else MaterialTheme.colorScheme.outlineVariant + Box(line.background(color), contentAlignment = Alignment.Center) { + val grip = + if (horizontal) { + Modifier + .requiredWidth( + GRIP_DP.dp, + ).fillMaxHeight() + } else { + Modifier.requiredHeight(GRIP_DP.dp).fillMaxWidth() + } + Box(grip.dockSplitterHandle()) + } +} + +/** + * The frame around a docked pane: nothing in the Classic style, where panes + * butt against each other along the dividers; a rounded card in the Islands + * style. + */ +@Composable +fun PaneCard( + style: ReaderStyle, + content: @Composable () -> Unit, +) { + if (style == ReaderStyle.Islands) { + Box( + Modifier + .fillMaxSize() + .padding( + top = CARD_GAP_V_DP.dp, + bottom = CARD_GAP_V_DP.dp, + start = CARD_GAP_H_DP.dp, + end = CARD_GAP_H_DP.dp, + ).clip(RoundedCornerShape(CARD_CORNER_DP.dp)) + .background(MaterialTheme.colorScheme.surface), + ) { content() } + } else { + Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) { content() } + } +} + +@Composable +fun HorizontalDivider() { + Box(Modifier.fillMaxWidth().height(DIVIDER_DP.dp).background(MaterialTheme.colorScheme.outlineVariant)) +} + +@Composable +fun VerticalDivider() { + Box(Modifier.fillMaxHeight().width(DIVIDER_DP.dp).background(MaterialTheme.colorScheme.outlineVariant)) +} + +private const val HEADER_HEIGHT_DP = 32 +private const val HEADER_PADDING_DP = 8 +private const val HEADER_TEXT_SP = 14 +private const val ACTION_GAP_DP = 4 +private const val ACTION_SIZE_DP = 24 +private const val MOVE_GLYPH_ALPHA = 0.55f +private const val ACTION_GLYPH_SP = 12 +private const val FLOAT_GLYPH = "\u2197" +private const val DOCK_GLYPH = "\u2199" +private const val HIDE_GLYPH = "\u2014" +private const val MOVE_GLYPH = "✥" +private const val ISLANDS_HEADER_ALPHA = 0.15f +private const val DIVIDER_DP = 1 +private const val GRIP_DP = 5 +private const val CARD_GAP_V_DP = 6 +private const val CARD_GAP_H_DP = 4 +private const val CARD_CORNER_DP = 12 diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt new file mode 100644 index 000000000..e52829b59 --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderState.kt @@ -0,0 +1,235 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteLayoutSnapshot +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TabWorkspace + +/** The two looks of the reader: dividers everywhere, or every pane a rounded card. */ +enum class ReaderStyle { + Classic, + Islands, +} + +/** + * Where a pane may be docked: anywhere but the top. The reader's top is its + * tab strip and the text's own header; a pane dragged there is refused, and + * the top strip never lights up. + */ +val ReaderDockSides: Set = setOf(DockSide.Left, DockSide.Right, DockSide.Bottom) + +/** The sides [Pane.fixed] panes accept: the right of the text, where the reader puts them. */ +val ReaderFixedDockSides: Set = setOf(DockSide.Right) + +/** + * One pane of the reader: a satellite with a home in the dock. + * + * [fixed] is the reader's furniture — the book tree and the table of contents + * belong on the right of the text, in that order, and nowhere else: they + * cannot be torn into a window of their own, moved to another side, or + * reordered, and no other pane can be dropped in front of them. They can + * still be hidden and resized. + */ +enum class Pane( + val id: String, + val title: String, + val home: SatellitePlacement.Docked, + val openAtStart: Boolean, + val fixed: Boolean = false, +) { + Tree( + "tree", + "ספרים", + SatellitePlacement.Docked(DockSide.Right, order = 0, extent = 200.dp), + openAtStart = true, + fixed = true, + ), + Toc( + "toc", + "תוכן", + SatellitePlacement.Docked(DockSide.Right, order = 1, extent = 170.dp), + openAtStart = true, + fixed = true, + ), + Notes("notes", "הערות", SatellitePlacement.Docked(DockSide.Right, order = 2, extent = 220.dp), openAtStart = false), + Targum("targum", "תרגום", SatellitePlacement.Docked(DockSide.Left, extent = 240.dp), openAtStart = false), + Comments("comments", "מפרשים", SatellitePlacement.Docked(DockSide.Bottom, extent = 220.dp), openAtStart = true), + Sources("sources", "מקורות", SatellitePlacement.Docked(DockSide.Bottom, extent = 200.dp), openAtStart = false), + ; + + /** + * This pane's entry id in the workspace of the reader window [groupId]. + * + * The panes are per **window**, not per book: a window's dock is its own + * furniture, and a tab change must neither create nor destroy a panel. + * What follows the tab is what the panes *draw*. + */ + fun idIn(groupId: String): String = "$groupId-$id" +} + +/** One sefer: one tab, its chapters, and the text they hold. */ +class Book( + val id: String, + val title: String, + val chapters: List, +) + +/** + * What the reader remembers about a book, wherever its tab is shown: which + * chapter is open and which line each pane has selected. + * + * It lives here rather than in the panes because it belongs to the book: a tab + * moved to another window, or brought back after being closed and reopened, + * has to find it unchanged — and the panes that draw it belong to a window, + * not to a book. + */ +class BookState { + var chapter by mutableIntStateOf(0) + private val selections = mutableStateMapOf() + + fun selected(pane: Pane): Int = selections[pane] ?: -1 + + fun select( + pane: Pane, + index: Int, + ) { + selections[pane] = index + } +} + +/** + * Everything the demo drives: the seforim as tabs, and one dock of panes per + * reader window. + * + * [tabs] owns which windows exist and which sefer each window shows. + * [panesOfWindow] hands out one [SatelliteWorkspace] **per window**, which is + * what lets a tab change leave the dock alone: the panes exist as long as + * their window does, and only their content follows the selected tab. Tear a + * tab into a window of its own and it arrives with a dock of its own, so two + * windows read two seforim side by side, each with its own pane widths. + */ +class ReaderState { + // `captureThumbnails` is what puts the page itself on a sefer's hover + // card: the workspace keeps a reduced picture of the body each tab last + // showed. Off by default — it costs a layer and a readback per tab. + val tabs = + TabWorkspace( + defaultWindowSize = DpSize(WINDOW_W_DP.dp, WINDOW_H_DP.dp), + captureThumbnails = true, + ) + + /** The open seforim, in declaration order. One tab each. */ + val books = + mutableStateListOf( + Book("bereshit", "בראשית", chapterNames(BERESHIT_CHAPTERS)), + Book("shemot", "שמות", chapterNames(SHEMOT_CHAPTERS)), + Book("tehillim", "תהילים", chapterNames(TEHILLIM_CHAPTERS)), + ) + + var style: ReaderStyle by mutableStateOf(ReaderStyle.Classic) + + private val workspaces = mutableStateMapOf() + private val bookStates = mutableStateMapOf() + private val savedLayouts = mutableStateMapOf() + + /** The pane workspace of the reader window [groupId], created on first use. */ + fun panesOfWindow(groupId: String): SatelliteWorkspace = workspaces.getOrPut(groupId) { SatelliteWorkspace() } + + /** Drops the workspace of a window that is gone. */ + fun forgetWindow(groupId: String) { + workspaces.remove(groupId) + savedLayouts.remove(groupId) + } + + /** The book [id] names, or `null` once its tab has been closed. */ + fun book(id: String): Book? = books.firstOrNull { it.id == id } + + /** What the reader remembers about [bookId], created on first use. */ + fun stateOf(bookId: String): BookState = bookStates.getOrPut(bookId) { BookState() } + + /** Drops a book — and what the reader remembered about it — once its tab is gone. */ + fun forget(bookId: String) { + books.removeAll { it.id == bookId } + bookStates.remove(bookId) + } + + private var opened = 0 + + /** Opens another sefer; its tab lands in the window focused last. */ + fun openBook() { + opened++ + val title = ExtraTitles[(opened - 1) % ExtraTitles.size] + books += Book("sefer-$opened", title, chapterNames(EXTRA_CHAPTERS)) + } + + /** Brings the book [id] to the front of whichever window shows its tab. */ + fun show(id: String) { + tabs.select(id) + } + + // ── Per-window pane layout ─────────────────────────────────────────── + + fun isOpen( + groupId: String, + pane: Pane, + ): Boolean = panesOfWindow(groupId).satellite(pane.idIn(groupId))?.isOpen == true + + /** Shows or hides a pane of one window. Commentaries and sources share the bottom, so one closes the other. */ + fun toggle( + groupId: String, + pane: Pane, + ) { + val workspace = panesOfWindow(groupId) + val opening = !isOpen(groupId, pane) + when (pane) { + Pane.Comments -> if (opening) workspace.close(Pane.Sources.idIn(groupId)) + Pane.Sources -> if (opening) workspace.close(Pane.Comments.idIn(groupId)) + else -> Unit + } + workspace.toggle(pane.idIn(groupId)) + } + + fun savedLayout(groupId: String): SatelliteLayoutSnapshot? = savedLayouts[groupId] + + fun saveLayout(groupId: String) { + savedLayouts[groupId] = panesOfWindow(groupId).snapshot() + } + + fun restoreLayout(groupId: String) { + savedLayouts[groupId]?.let(panesOfWindow(groupId)::restore) + } + + /** Every pane of one window back where it started, at its starting width. */ + fun resetLayout(groupId: String) { + val workspace = panesOfWindow(groupId) + for (pane in Pane.entries) { + val id = pane.idIn(groupId) + workspace.dock(id, pane.home.side, order = pane.home.order) + pane.home.extent?.let { workspace.setDockedExtent(id, it) } + workspace.setDockedWeight(id, pane.home.weight) + if (pane.openAtStart) workspace.open(id) else workspace.close(id) + } + } + + private companion object { + const val WINDOW_W_DP = 1280 + const val WINDOW_H_DP = 820 + const val BERESHIT_CHAPTERS = 50 + const val SHEMOT_CHAPTERS = 40 + const val TEHILLIM_CHAPTERS = 30 + const val EXTRA_CHAPTERS = 24 + + val ExtraTitles = listOf("ויקרא", "במדבר", "דברים", "משלי", "איוב") + + fun chapterNames(count: Int): List = List(count) { "פרק ${it + 1}" } + } +} diff --git a/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt new file mode 100644 index 000000000..d025e4ab7 --- /dev/null +++ b/examples/reader-dock-demo/src/main/kotlin/dev/nucleusframework/readerdockdemo/ReaderTabStrip.kt @@ -0,0 +1,87 @@ +package dev.nucleusframework.readerdockdemo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabHoverPreviewCard +import dev.nucleusframework.window.tao.TabStrip +import dev.nucleusframework.window.tao.TabStripScope + +/** + * The seforim of one window: the stock [TabStrip], plus the button that opens + * another sefer after the last tab, and the card shown under a sefer the + * pointer rests on. + * + * The stock strip is what publishes the geometry a tab dragged from another + * window is dropped onto, so the reader's own chrome goes *around* its tabs + * rather than in place of them. + * + * The card is right to left like everything else here: it hangs from the tab's + * *right* edge and grows leftwards, because the strip is composed in an + * `Rtl` direction and the card follows the reading direction it is given. It + * is never shown for the sefer being read — that page is on screen already. + */ +@Composable +fun TabStripScope.ReaderTabStrip( + reader: ReaderState, + onNewBook: () -> Unit, +) { + // The stock card with a line of the reader's own: the workspace knows a + // tab's title, so the number of chapters is looked up by the demo from the + // tab's id. The picture under it is the page the sefer was left on. + val preview = + remember(reader) { + TabHoverPreview { + TabHoverPreviewCard( + subtitle = { + val colors = LocalTitleBarStyle.current.colors + val chapters = reader.book(tab.id)?.chapters + Text( + text = "${chapters?.size ?: 0} פרקים", + color = colors.content.copy(alpha = SUBTITLE_ALPHA), + style = MaterialTheme.typography.bodySmall, + ) + }, + ) + } + } + TabStrip(hoverPreview = preview, trailing = { NewBookButton(onNewBook) }) +} + +/** Opens another sefer in this workspace. */ +@Composable +private fun NewBookButton(onClick: () -> Unit) { + val colors = LocalTitleBarStyle.current.colors + Box( + modifier = + Modifier + .padding(horizontal = BUTTON_PADDING_DP.dp) + .size(BUTTON_SIZE_DP.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .pointerHoverIcon(PointerIcon.Hand), + contentAlignment = Alignment.Center, + ) { + Text("+", color = colors.content, fontSize = BUTTON_GLYPH_SP.sp) + } +} + +private const val BUTTON_PADDING_DP = 6 +private const val BUTTON_SIZE_DP = 22 +private const val BUTTON_GLYPH_SP = 15 +private const val SUBTITLE_ALPHA = 0.7f diff --git a/examples/rect-stress-demo/api/rect-stress-demo.api b/examples/rect-stress-demo/api/rect-stress-demo.api deleted file mode 100644 index 141cda962..000000000 --- a/examples/rect-stress-demo/api/rect-stress-demo.api +++ /dev/null @@ -1,12 +0,0 @@ -public final class com/example/rectstress/ComposableSingletons$MainKt { - public static final field INSTANCE Lcom/example/rectstress/ComposableSingletons$MainKt; - public fun ()V - public final fun getLambda$-1782098224$Nucleus_examples_rect_stress_demo ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-535725003$Nucleus_examples_rect_stress_demo ()Lkotlin/jvm/functions/Function4; - public final fun getLambda$1066003079$Nucleus_examples_rect_stress_demo ()Lkotlin/jvm/functions/Function3; -} - -public final class com/example/rectstress/MainKt { - public static final fun main ([Ljava/lang/String;)V -} - diff --git a/examples/satellite-demo/build.gradle.kts b/examples/satellite-demo/build.gradle.kts new file mode 100644 index 000000000..871692506 --- /dev/null +++ b/examples/satellite-demo/build.gradle.kts @@ -0,0 +1,51 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// Showcase for the satellite workspace: two document windows sharing an +// Inspector and a Tools palette that float above whichever document owns them +// (focus-driven or pinned), follow it, dock into either document's DockLayout +// and lift off again in place, with a layout snapshot to save and restore. + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(project(":decorated-window-tao")) + implementation(project(":decorated-window-material3")) + implementation(project(":nucleus-application")) + implementation(project(":core-runtime")) + implementation(project(":darkmode-detector")) + implementation(project(":graalvm-runtime")) + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.compose.material3:material3:1.9.0") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") + } +} + +nucleus.application { + mainClass = "dev.nucleusframework.satellitedemo.MainKt" + + nativeDistributions { + packageName = "satellite-demo" + packageVersion = "1.0.0" + } + + graalvm { + isEnabled = true + javaLanguageVersion = 25 + imageName = "satellite-demo" + } +} diff --git a/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/DemoState.kt b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/DemoState.kt new file mode 100644 index 000000000..643e2703e --- /dev/null +++ b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/DemoState.kt @@ -0,0 +1,170 @@ +package dev.nucleusframework.satellitedemo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.application.NucleusWindow +import dev.nucleusframework.application.pinTo +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteEntry +import dev.nucleusframework.window.tao.SatelliteLayoutSnapshot +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.WindowAnchor +import dev.nucleusframework.window.tao.WindowConstraintAdjustment +import dev.nucleusframework.window.tao.WindowPositioner + +/** The document windows of the demo. */ +enum class DocumentId( + val title: String, +) { + A("Document A"), + B("Document B"), +} + +/** Anchor pairs worth demonstrating, named the way a user would describe them. */ +enum class AnchorPreset( + val label: String, + val parentAnchor: WindowAnchor, + val childAnchor: WindowAnchor, +) { + RightEdge("Right edge", WindowAnchor.Right, WindowAnchor.Left), + LeftEdge("Left edge", WindowAnchor.Left, WindowAnchor.Right), + TopRightOutside("Top-right, outside", WindowAnchor.TopRight, WindowAnchor.TopLeft), + BelowCentre("Below, centred", WindowAnchor.Bottom, WindowAnchor.Top), + OverCentre("Over the centre", WindowAnchor.Center, WindowAnchor.Center), +} + +/** The [WindowConstraintAdjustment] presets, for the screen-edge story. */ +enum class AdjustmentPreset( + val label: String, + val adjustment: WindowConstraintAdjustment, +) { + None("None — may overhang", WindowConstraintAdjustment.None), + Slide("Slide", WindowConstraintAdjustment.Slide), + Flip("Flip", WindowConstraintAdjustment.Flip), + FlipAndSlide("Flip, then slide", WindowConstraintAdjustment.FlipAndSlide), + All("All (shrink as a last resort)", WindowConstraintAdjustment.All), +} + +/** + * Everything the demo drives, hoisted to the application so both document + * windows and the satellites read the same source of truth. + * + * The [workspace] is the heart of it: both documents join it, the Inspector + * and the Tools palette are declared against it, and everything the UI does — + * dock, undock, pin, hide, save and restore the layout — is a workspace call. + */ +class DemoState { + val workspace = SatelliteWorkspace() + + var showDocumentB by mutableStateOf(false) + + var anchorPreset by mutableStateOf(AnchorPreset.RightEdge) + var adjustmentPreset by mutableStateOf(AdjustmentPreset.FlipAndSlide) + var gapDp by mutableStateOf(INITIAL_GAP_DP) + var hideWhenParentFills by mutableStateOf(false) + + /** The layout captured by "Save layout", ready for "Restore layout". */ + var savedLayout: SatelliteLayoutSnapshot? by mutableStateOf(null) + private set + + /** Document windows publish themselves here so the owner can be named and pinned. */ + private val documents = mutableStateMapOf() + + fun publish( + id: DocumentId, + window: NucleusWindow, + ) { + documents[id] = window + } + + fun forget(id: DocumentId) { + documents.remove(id) + } + + /** The document currently owning the floating satellites. */ + val ownerDocument: DocumentId? + get() = documents.entries.firstOrNull { it.value.unsafe.taoWindow === workspace.owner }?.key + + /** The document pinned as owner, or `null` while the owner follows focus. */ + val pinnedDocument: DocumentId? + get() = documents.entries.firstOrNull { it.value.unsafe.taoWindow === workspace.pinnedOwner }?.key + + /** Pins [id] as owner; `null` lets focus decide again. */ + fun pin(id: DocumentId?) { + workspace.pinTo(id?.let { documents[it] }) + } + + /** Which document a docked satellite lives in, if it is docked. */ + fun hostDocument(entry: SatelliteEntry): DocumentId? = + documents.entries.firstOrNull { it.value.unsafe.taoWindow === entry.dockHost }?.key + + val inspector: SatelliteEntry? get() = workspace.satellite(INSPECTOR_ID) + + /** + * Pushes the picker values into the floating inspector and re-applies them. + * Placement is a one-shot by design — the satellite keeps the offset the + * user gave it — so a new rule only takes effect through `reanchor()`. + */ + fun applyPositioner() { + val entry = inspector ?: return + entry.windowState.positioner = positionerFor(anchorPreset, adjustmentPreset, gapDp) + entry.windowState.reanchor() + } + + fun saveLayout() { + savedLayout = workspace.snapshot() + } + + fun restoreLayout() { + savedLayout?.let(workspace::restore) + } + + companion object { + const val INSPECTOR_ID = "inspector" + const val TOOLS_ID = "tools" + const val INITIAL_GAP_DP = 12f + private const val INSPECTOR_WIDTH_DP = 300 + private const val INSPECTOR_HEIGHT_DP = 400 + + /** The inspector starts floating off the owner's right edge. */ + val InspectorPlacement: SatellitePlacement = + SatellitePlacement.Floating( + positioner = positionerFor(AnchorPreset.RightEdge, AdjustmentPreset.FlipAndSlide, INITIAL_GAP_DP), + size = DpSize(INSPECTOR_WIDTH_DP.dp, INSPECTOR_HEIGHT_DP.dp), + ) + + /** The tools palette starts docked on the left of the owner. */ + val ToolsPlacement: SatellitePlacement = SatellitePlacement.Docked(DockSide.Left) + + fun positionerFor( + anchor: AnchorPreset, + adjustment: AdjustmentPreset, + gapDp: Float, + ): WindowPositioner = + WindowPositioner( + parentAnchor = anchor.parentAnchor, + childAnchor = anchor.childAnchor, + offset = gapOffsetFor(anchor, gapDp), + constraintAdjustment = adjustment.adjustment, + ) + + /** The gap has to point *away* from the parent, so its sign follows the anchor. */ + private fun gapOffsetFor( + anchor: AnchorPreset, + gapDp: Float, + ): DpOffset = + when (anchor) { + AnchorPreset.RightEdge -> DpOffset(gapDp.dp, 0.dp) + AnchorPreset.LeftEdge -> DpOffset(-gapDp.dp, 0.dp) + AnchorPreset.TopRightOutside -> DpOffset(gapDp.dp, 0.dp) + AnchorPreset.BelowCentre -> DpOffset(0.dp, gapDp.dp) + AnchorPreset.OverCentre -> DpOffset.Zero + } + } +} diff --git a/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/DocumentContent.kt b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/DocumentContent.kt new file mode 100644 index 000000000..68d73a140 --- /dev/null +++ b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/DocumentContent.kt @@ -0,0 +1,288 @@ +package dev.nucleusframework.satellitedemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteEntry +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import kotlin.math.roundToInt + +/** + * The control panel inside a document window. Every control here is a call on + * the shared [SatelliteWorkspace], so its effect shows on whichever document + * owns or hosts the satellites. + */ +@Composable +fun DocumentContent( + demo: DemoState, + documentId: DocumentId, +) { + val workspace = demo.workspace + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text(documentId.title, style = MaterialTheme.typography.headlineSmall) + Text( + "Both documents share one workspace with two satellites: the Inspector and the " + + "Tools palette. Floating, they belong to the document focused last and follow " + + "it around. Docked, they become panels inside a document's content. Drag a " + + "satellite by its header: the edges of the documents light up, drop there to " + + "dock it; drag a panel's header out over the document to lift it off again, " + + "state intact.", + style = MaterialTheme.typography.bodyMedium, + ) + + Section("Satellites") { + SatelliteControls(workspace, DemoState.INSPECTOR_ID, "Inspector") + SatelliteControls(workspace, DemoState.TOOLS_ID, "Tools") + LabelledSwitch( + label = "Show all satellites", + checked = workspace.visible, + onCheckedChange = { workspace.visible = it }, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { demo.saveLayout() }) { Text("Save layout") } + OutlinedButton(onClick = { demo.restoreLayout() }, enabled = demo.savedLayout != null) { + Text("Restore layout") + } + } + Text( + "The Tools palette keeps its selected tool through every dock and undock: " + + "that state is rememberSaveable, and the workspace carries it between hosts. " + + "Save the layout, rearrange everything, then restore it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Section("Owner") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = demo.pinnedDocument == null, + onClick = { demo.pin(null) }, + label = { Text("Follow focus") }, + ) + for (id in DocumentId.entries) { + FilterChip( + selected = demo.pinnedDocument == id, + onClick = { demo.pin(id) }, + enabled = id == DocumentId.A || demo.showDocumentB, + label = { Text("Pin to ${id.title}") }, + ) + } + } + LabelledSwitch( + label = "Open a second document window", + checked = demo.showDocumentB, + onCheckedChange = { demo.showDocumentB = it }, + ) + LabelledSwitch( + label = "Hide floating satellites while their owner is fullscreen or maximized", + checked = demo.hideWhenParentFills, + onCheckedChange = { demo.hideWhenParentFills = it }, + ) + Text( + "Click into the other document: the floating satellites switch owner without " + + "moving, then follow it. Close the owner and they move on to the survivor. " + + "Pinning keeps them on one document regardless of focus.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Section("Inspector positioner") { + Text("Anchor", style = MaterialTheme.typography.labelLarge) + PresetChips( + entries = AnchorPreset.entries, + label = { it.label }, + selected = demo.anchorPreset, + onSelect = { + demo.anchorPreset = it + demo.applyPositioner() + }, + ) + Spacer(Modifier.height(4.dp)) + Text("Gap: ${demo.gapDp.roundToInt()} dp", style = MaterialTheme.typography.labelLarge) + Slider( + value = demo.gapDp, + onValueChange = { demo.gapDp = it }, + onValueChangeFinished = { demo.applyPositioner() }, + valueRange = 0f..64f, + ) + Spacer(Modifier.height(4.dp)) + Text("Off-screen adjustment", style = MaterialTheme.typography.labelLarge) + PresetChips( + entries = AdjustmentPreset.entries, + label = { it.label }, + selected = demo.adjustmentPreset, + onSelect = { + demo.adjustmentPreset = it + demo.applyPositioner() + }, + ) + Text( + "Applies to the Inspector while it floats. Push this window against the right " + + "edge of the screen, pick “Right edge”, then compare “None” with “Flip”.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Section("Live state") { + StateLine("owner", demo.ownerDocument?.title ?: "—") + StateLine("pinned", demo.pinnedDocument?.title ?: "no (follows focus)") + StateLine("members", workspace.members.size.toString()) + for (entry in workspace.satellites.sortedBy { it.id }) { + StateLine(entry.id, describe(demo, entry)) + } + for (side in DockSide.entries) { + StateLine("extent ${side.name.lowercase()}", "${workspace.dockExtent(side).value.roundToInt()} dp") + } + } + } +} + +/** Show / hide, dock / float, and one button per dock side, for one satellite. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SatelliteControls( + workspace: SatelliteWorkspace, + id: String, + label: String, +) { + val entry = workspace.satellite(id) + val docked = entry?.isDocked == true + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Text(label, Modifier.width(80.dp), style = MaterialTheme.typography.labelLarge) + Button(onClick = { workspace.toggle(id) }, enabled = entry != null) { + Text(if (entry?.isOpen == true) "Hide" else "Show") + } + OutlinedButton( + onClick = { + if (docked) workspace.undock(id) else workspace.dock(id, entry?.preferredDockSide ?: DockSide.Right) + }, + enabled = entry != null, + ) { + Text(if (docked) "Float" else "Dock") + } + for (side in DockSide.entries) { + TextButton(onClick = { workspace.dock(id, side) }, enabled = entry != null) { Text(side.name) } + } + } +} + +private fun describe( + demo: DemoState, + entry: SatelliteEntry, +): String { + val placement = + when (val p = entry.placement) { + is SatellitePlacement.Floating -> { + val offset = entry.windowState.offsetFromParent + "floating" + (offset?.let { " @ ${it.x.value.roundToInt()}, ${it.y.value.roundToInt()} dp" } ?: "") + } + is SatellitePlacement.Docked -> { + "docked ${p.side.name.lowercase()} #${p.order} in ${demo.hostDocument(entry)?.title ?: "—"}" + } + } + return if (entry.isOpen) placement else "closed ($placement)" +} + +@Composable +private fun Section( + title: String, + content: @Composable () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} + +@Composable +private fun PresetChips( + entries: List, + label: (T) -> String, + selected: T, + onSelect: (T) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + for (entry in entries) { + FilterChip( + selected = entry == selected, + onClick = { onSelect(entry) }, + label = { Text(label(entry)) }, + ) + } + } +} + +@Composable +private fun LabelledSwitch( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Switch(checked = checked, onCheckedChange = onCheckedChange) + Text(label, style = MaterialTheme.typography.bodyMedium) + } +} + +@Composable +private fun StateLine( + name: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(name, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } +} diff --git a/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/InspectorContent.kt b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/InspectorContent.kt new file mode 100644 index 000000000..f36ce6b58 --- /dev/null +++ b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/InspectorContent.kt @@ -0,0 +1,114 @@ +package dev.nucleusframework.satellitedemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteScope +import kotlin.math.roundToInt + +/** + * Content of the Inspector satellite — a stand-in for the inspector an app + * would put here, plus a live readout of what the workspace knows about it. + * Composed unchanged whether the inspector floats or is docked; [scope] tells + * it which, and gives it the dock / undock / close actions. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun InspectorContent( + demo: DemoState, + scope: SatelliteScope, +) { + val entry = scope.satellite + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + if (scope.isDocked) { + "Docked into ${demo.hostDocument(entry)?.title ?: "a document"}. Part of that window " + + "now — resize the splitter, or lift it off." + } else { + "Owned by ${demo.ownerDocument?.title ?: "—"}. Always in front of it, never in the " + + "taskbar, never modal." + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + HorizontalDivider() + + Readout("placement", if (scope.isDocked) "docked" else "floating") + when (val placement = entry.placement) { + is SatellitePlacement.Docked -> { + Readout("side", placement.side.name.lowercase()) + Readout("order", placement.order.toString()) + Readout("extent", "${scope.workspace.dockExtent(placement.side).value.roundToInt()} dp") + } + is SatellitePlacement.Floating -> { + Readout("anchor", demo.anchorPreset.label) + Readout("gap", "${demo.gapDp.roundToInt()} dp") + Readout("adjustment", demo.adjustmentPreset.label) + val offset = entry.windowState.offsetFromParent + Readout( + "offsetFromParent", + offset?.let { "${it.x.value.roundToInt()}, ${it.y.value.roundToInt()}" } ?: "—", + ) + Readout("isActive", entry.windowState.isActive.toString()) + } + } + + HorizontalDivider() + Text( + if (scope.isDocked) { + "Drag the “Inspector” header out over the document to lift this back into a " + + "window of its own; drop it on another edge to move it there. “Float” lifts " + + "it off right over the panel." + } else { + "Drag the “Inspector” header: the edges of the documents light up as you " + + "approach them, and dropping there docks it. Elsewhere, the new offset is " + + "what the inspector keeps the next time the document moves. “Reanchor” puts " + + "it back on the positioner; “Dock” docks it on its last side." + }, + style = MaterialTheme.typography.bodySmall, + ) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (scope.isDocked) { + OutlinedButton(onClick = { scope.undock() }) { Text("Float") } + } else { + OutlinedButton(onClick = { entry.windowState.reanchor() }) { Text("Reanchor") } + OutlinedButton(onClick = { scope.dock() }) { Text("Dock") } + } + TextButton(onClick = { scope.close() }) { Text("Close") } + } + } +} + +@Composable +private fun Readout( + name: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(name, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } +} diff --git a/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/Main.kt b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/Main.kt new file mode 100644 index 000000000..a7f4e85d2 --- /dev/null +++ b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/Main.kt @@ -0,0 +1,208 @@ +package dev.nucleusframework.satellitedemo + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.NucleusApplicationScope +import dev.nucleusframework.application.Satellite +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import dev.nucleusframework.window.WindowAppearance +import dev.nucleusframework.window.WindowAppearanceMode +import dev.nucleusframework.window.WindowBackground +import dev.nucleusframework.window.WindowScaffold +import dev.nucleusframework.window.material.MaterialTitleBar +import dev.nucleusframework.window.material.rememberMaterialTitleBarStyle +import dev.nucleusframework.window.material.rememberMaterialWindowStyle +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.SatelliteScope + +private val DemoDarkColors = + darkColorScheme( + primary = Color(0xFF8AA4FF), + surface = Color(0xFF15171C), + surfaceContainer = Color(0xFF1C1F26), + surfaceContainerHigh = Color(0xFF232730), + background = Color(0xFF101216), + ) + +private val DemoLightColors = + lightColorScheme( + primary = Color(0xFF3F5DDB), + surface = Color(0xFFF7F8FB), + surfaceContainer = Color(0xFFEDEFF5), + surfaceContainerHigh = Color(0xFFE4E7EF), + background = Color(0xFFFBFCFE), + ) + +/** + * Satellite workspace demo. + * + * Two document windows join one `SatelliteWorkspace`; an Inspector and a Tools + * palette are declared against it, once, at application scope. Floating + * satellites belong to whichever document was focused last (or the pinned + * one), follow it, and survive its closing by moving on to the other. Either + * satellite can be docked into a document's `DockLayout` and lifted off again + * in place, with its `rememberSaveable` state intact. + */ +fun main() = + nucleusApplication { + val demo = remember { DemoState() } + val dark = isSystemInDarkMode() + val colors = if (dark) DemoDarkColors else DemoLightColors + + DocumentWindow( + demo = demo, + documentId = DocumentId.A, + colors = colors, + dark = dark, + position = WindowPosition.Absolute(DOCUMENT_A_X_DP.dp, DOCUMENT_Y_DP.dp), + onCloseRequest = ::exitApplication, + ) + + if (demo.showDocumentB) { + DocumentWindow( + demo = demo, + documentId = DocumentId.B, + colors = colors, + dark = dark, + position = WindowPosition.Absolute(DOCUMENT_B_X_DP.dp, DOCUMENT_Y_DP.dp), + onCloseRequest = { demo.showDocumentB = false }, + ) + } + + // The satellites. Declared here, next to the windows, not inside one: + // the workspace decides which window hosts them. The theme wrapped + // around them is bridged into the floating windows' own scenes, which + // is where their chrome comes from; docked, they inherit the host's. + DemoTheme(colors) { + Satellite( + workspace = demo.workspace, + id = DemoState.INSPECTOR_ID, + title = "Inspector", + initialPlacement = DemoState.InspectorPlacement, + hideWhileOwnerFullscreenOrMaximized = demo.hideWhenParentFills, + ) { + SatelliteSurface(colors) { InspectorContent(demo, this) } + } + Satellite( + workspace = demo.workspace, + id = DemoState.TOOLS_ID, + title = "Tools", + initialPlacement = DemoState.ToolsPlacement, + hideWhileOwnerFullscreenOrMaximized = demo.hideWhenParentFills, + ) { + SatelliteSurface(colors) { ToolsContent(this) } + } + } + } + +@Composable +private fun NucleusApplicationScope.DocumentWindow( + demo: DemoState, + documentId: DocumentId, + colors: ColorScheme, + dark: Boolean, + position: WindowPosition, + onCloseRequest: () -> Unit, +) { + DecoratedWindow( + onCloseRequest = onCloseRequest, + title = documentId.title, + state = + rememberWindowState( + width = DOCUMENT_WIDTH_DP.dp, + height = DOCUMENT_HEIGHT_DP.dp, + position = position, + ), + minimumSize = DpSize(MIN_WIDTH_DP.dp, MIN_HEIGHT_DP.dp), + ) { + // Member of the workspace for as long as the window lives: a candidate + // owner for the floating satellites, and a dock host. + JoinSatelliteWorkspace(demo.workspace) + + // Named so the UI can show and pin the owner; dropped with the window + // so a stale handle can never be pinned. + val window = nucleusWindow + DisposableEffect(window) { + demo.publish(documentId, window) + onDispose { demo.forget(documentId) } + } + + DemoTheme(colors) { + // Window-level chrome: the native frame follows the theme too. + WindowBackground(colors.background) + WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) + WindowScaffold( + titleBar = { MaterialTitleBar { Text(documentId.title) } }, + ) { contentPadding -> + Surface(Modifier.fillMaxSize(), color = colors.background) { + // Docked satellites are laid out around the document. + DockLayout(demo.workspace, Modifier.fillMaxSize().padding(contentPadding)) { + DocumentContent(demo, documentId) + } + } + } + } + } +} + +/** + * Material colours plus the window-chrome styles derived from them. + * + * Every Tao window owns its own ComposeScene, so this is established per + * window rather than once around the application — and once more around the + * satellites, whose floating windows get it through the bridged locals. + */ +@Composable +private fun DemoTheme( + colors: ColorScheme, + content: @Composable () -> Unit, +) { + MaterialTheme(colorScheme = colors) { + CompositionLocalProvider( + LocalTitleBarStyle provides rememberMaterialTitleBarStyle(colors), + LocalDecoratedWindowStyle provides rememberMaterialWindowStyle(colors), + content = content, + ) + } +} + +/** Themed body of a satellite, the same whether it floats or is docked. */ +@Composable +private fun SatelliteScope.SatelliteSurface( + colors: ColorScheme, + content: @Composable SatelliteScope.() -> Unit, +) { + Surface(Modifier.fillMaxSize(), color = colors.surface) { + Box(Modifier.fillMaxSize()) { content() } + } +} + +private const val DOCUMENT_WIDTH_DP = 720 +private const val DOCUMENT_HEIGHT_DP = 760 +private const val MIN_WIDTH_DP = 480 +private const val MIN_HEIGHT_DP = 480 +private const val DOCUMENT_A_X_DP = 80 +private const val DOCUMENT_B_X_DP = 840 +private const val DOCUMENT_Y_DP = 60 diff --git a/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/ToolsContent.kt b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/ToolsContent.kt new file mode 100644 index 000000000..131062b60 --- /dev/null +++ b/examples/satellite-demo/src/main/kotlin/dev/nucleusframework/satellitedemo/ToolsContent.kt @@ -0,0 +1,63 @@ +package dev.nucleusframework.satellitedemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.SatelliteScope +import kotlin.math.roundToInt + +private val Tools = listOf("Move", "Brush", "Eraser", "Fill", "Text", "Crop", "Lasso", "Zoom") + +/** + * The Tools palette: the GIMP-style toolbox that motivates satellites. Its + * selection and brush size are `rememberSaveable`, which is what lets them + * survive the trip from a floating window into a dock panel and back. + */ +@Composable +fun ToolsContent(scope: SatelliteScope) { + var tool by rememberSaveable { mutableStateOf(Tools.first()) } + var brushSize by rememberSaveable { mutableFloatStateOf(12f) } + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + if (scope.isDocked) "Docked palette" else "Floating palette", + style = MaterialTheme.typography.labelLarge, + ) + for (name in Tools) { + FilterChip( + selected = tool == name, + onClick = { tool = name }, + label = { Text(name) }, + modifier = Modifier.fillMaxWidth(), + ) + } + HorizontalDivider() + Text("Brush size: ${brushSize.roundToInt()} px", style = MaterialTheme.typography.bodySmall) + Slider(value = brushSize, onValueChange = { brushSize = it }, valueRange = 1f..64f) + Text( + "Selected tool and brush size are rememberSaveable: dock and undock this " + + "palette, they stay.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/examples/scheduler-demo/build.gradle.kts b/examples/scheduler-demo/build.gradle.kts index 6d86e9101..82e63f8db 100644 --- a/examples/scheduler-demo/build.gradle.kts +++ b/examples/scheduler-demo/build.gradle.kts @@ -15,7 +15,7 @@ dependencies { implementation(project(":core-runtime")) implementation(project(":darkmode-detector")) implementation(project(":decorated-window-jewel")) - implementation(project(":decorated-window-jni")) + implementation(project(":decorated-window-tao")) implementation(project(":nucleus-application")) implementation(project(":scheduler")) @@ -43,18 +43,16 @@ kotlin { } } +// Compiled to class file 69, so the app has to *run* on a 25 JVM too — and the Gradle JVM +// (the packaging default) is often older. Resolved through a toolchain, not a hard-coded path. +val jvm25 = + javaToolchains + .launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } + .map { it.metadata.installationPath.asFile.absolutePath } + nucleus.application { mainClass = "schedulerdemo.MainKt" - jvmArgs += - listOf( - "--add-opens", - "java.desktop/sun.awt=ALL-UNNAMED", - "--add-opens", - "java.desktop/sun.lwawt=ALL-UNNAMED", - "--add-opens", - "java.desktop/sun.lwawt.macosx=ALL-UNNAMED", - ) - + javaHome = jvm25.get() nativeDistributions { packageName = "SchedulerDemo" packageVersion = "1.0.0" diff --git a/examples/scheduler-demo/src/main/kotlin/schedulerdemo/Main.kt b/examples/scheduler-demo/src/main/kotlin/schedulerdemo/Main.kt index af725a352..57b11a88d 100644 --- a/examples/scheduler-demo/src/main/kotlin/schedulerdemo/Main.kt +++ b/examples/scheduler-demo/src/main/kotlin/schedulerdemo/Main.kt @@ -2,7 +2,6 @@ package schedulerdemo import androidx.compose.ui.Alignment import androidx.compose.ui.window.WindowPosition -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.darkmodedetector.isSystemInDarkMode import dev.nucleusframework.scheduler.DesktopBootReceiver @@ -39,7 +38,7 @@ fun main(args: Array) { DesktopBootReceiver.handle(args = args, registry = buildRegistry()) } - nucleusApplication(args = args, backend = NucleusBackend.Awt) { + nucleusApplication(args = args) { val textStyle = JewelTheme.createDefaultTextStyle() val editorStyle = JewelTheme.createEditorTextStyle() val isDark = isSystemInDarkMode() diff --git a/examples/service-management-demo/src/main/kotlin/servicemanagementdemo/Main.kt b/examples/service-management-demo/src/main/kotlin/servicemanagementdemo/Main.kt index 8004e0bbe..26763fc54 100644 --- a/examples/service-management-demo/src/main/kotlin/servicemanagementdemo/Main.kt +++ b/examples/service-management-demo/src/main/kotlin/servicemanagementdemo/Main.kt @@ -19,17 +19,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.notification.common.notification import dev.nucleusframework.servicemanagement.AppService import dev.nucleusframework.servicemanagement.AppServiceManager -import java.awt.EventQueue +import kotlinx.coroutines.launch import java.time.LocalTime import java.time.format.DateTimeFormatter @@ -56,7 +56,7 @@ private fun runBackgroundTask() { } private fun launchUi() = - nucleusApplication(backend = NucleusBackend.Awt) { + nucleusApplication { DecoratedWindow( onCloseRequest = ::exitApplication, title = "SMAppService Demo", @@ -73,17 +73,16 @@ private fun launchUi() = fun App() { var log by remember { mutableStateOf("") } val logScrollState = rememberScrollState() + val scope = rememberCoroutineScope() fun appendLog(message: String) { log = "$message\n$log" } + // SMAppService completion handlers call back on a private queue; hop to the + // composition's dispatcher (the Tao main thread) before touching state. fun appendLogSafe(message: String) { - if (EventQueue.isDispatchThread()) { - appendLog(message) - } else { - EventQueue.invokeLater { appendLog(message) } - } + scope.launch { appendLog(message) } } Column( diff --git a/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt b/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt index 64de6f2da..ca290cdff 100644 --- a/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt +++ b/examples/shared/src/main/kotlin/dev/nucleusframework/sampleshared/ZoomTab.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight @@ -31,8 +32,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp /** - * Demonstrates `detectTransformGestures` driven by macOS trackpad pinch / - * rotate / smart-magnify (Tao backend) and standard mouse drag. + * Demonstrates trackpad pinch / rotate / smart-magnify (Tao backend) and + * standard mouse drag. + * + * Pinch arrives as Compose `ScaleStart` / `ScaleChange` / `ScaleEnd` (#660); + * two-finger rotate still goes through `detectTransformGestures` (Compose + * has no rotation event). On a gesture that does both, the one that starts + * first owns it: a pinch drops the rotation, a rotation zooms through its + * contacts. * * Modifier topology — important: the gesture detector lives on the **outer** * (viewport) Box, the visual transform lives on the **inner** Box. Compose @@ -76,6 +83,25 @@ fun ZoomTab(modifier: Modifier = Modifier) { .clip(RoundedCornerShape(16.dp)) .background(Color(0xFF15181D)) .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent() + when (event.type) { + PointerEventType.ScaleStart, + PointerEventType.ScaleChange, + PointerEventType.ScaleEnd, + -> { + var factor = 1f + event.changes.forEach { factor *= it.scaleFactor } + if (factor != 1f) { + scale = (scale * factor).coerceIn(MIN_SCALE, MAX_SCALE) + } + } + else -> Unit + } + } + } + }.pointerInput(Unit) { detectTransformGestures { _, pan, zoom, rot -> scale = (scale * zoom).coerceIn(MIN_SCALE, MAX_SCALE) rotation += rot diff --git a/examples/system-info-demo/build.gradle.kts b/examples/system-info-demo/build.gradle.kts index 65874acc1..a46ad8b1f 100644 --- a/examples/system-info-demo/build.gradle.kts +++ b/examples/system-info-demo/build.gradle.kts @@ -53,8 +53,16 @@ kotlin { } } +// Compiled to class file 69, so the app has to *run* on a 25 JVM too — and the Gradle JVM +// (the packaging default) is often older. Resolved through a toolchain, not a hard-coded path. +val jvm25 = + javaToolchains + .launcherFor { languageVersion.set(JavaLanguageVersion.of(25)) } + .map { it.metadata.installationPath.asFile.absolutePath } + nucleus.application { mainClass = "systeminfodemo.MainKt" + javaHome = jvm25.get() graalvm { isEnabled = true diff --git a/examples/system-info-demo/src/main/kotlin/systeminfodemo/Main.kt b/examples/system-info-demo/src/main/kotlin/systeminfodemo/Main.kt index 6f745a8ea..51cf672ec 100644 --- a/examples/system-info-demo/src/main/kotlin/systeminfodemo/Main.kt +++ b/examples/system-info-demo/src/main/kotlin/systeminfodemo/Main.kt @@ -5,7 +5,6 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.rememberWindowState -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.aotTraining import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.window.jewel.JewelDecoratedWindow @@ -17,7 +16,7 @@ import kotlin.time.Duration.Companion.seconds @OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) fun main() = - nucleusApplication(backend = NucleusBackend.Tao) { + nucleusApplication { aotTraining(duration = 45.seconds) val (theme, styling) = buildIslandsTheme() diff --git a/examples/tab-satellites-demo/build.gradle.kts b/examples/tab-satellites-demo/build.gradle.kts new file mode 100644 index 000000000..a25e2dcfb --- /dev/null +++ b/examples/tab-satellites-demo/build.gradle.kts @@ -0,0 +1,51 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// The two multi-window archetypes composed: Chrome-like tabs where every tab +// owns its own satellites. One `SatelliteWorkspace` per document, whose only +// member is the window the document's tab is composed in — so the palettes +// belong to the tab and follow it from window to window. + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(project(":decorated-window-tao")) + implementation(project(":decorated-window-material3")) + implementation(project(":nucleus-application")) + implementation(project(":core-runtime")) + implementation(project(":darkmode-detector")) + implementation(project(":graalvm-runtime")) + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.compose.material3:material3:1.9.0") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") + } +} + +nucleus.application { + mainClass = "dev.nucleusframework.tabsatellitesdemo.MainKt" + + nativeDistributions { + packageName = "tab-satellites-demo" + packageVersion = "1.0.0" + } + + graalvm { + isEnabled = true + javaLanguageVersion = 25 + imageName = "tab-satellites-demo" + } +} diff --git a/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DemoState.kt b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DemoState.kt new file mode 100644 index 000000000..47e9b149f --- /dev/null +++ b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DemoState.kt @@ -0,0 +1,203 @@ +package dev.nucleusframework.tabsatellitesdemo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TabLayoutSnapshot +import dev.nucleusframework.window.tao.TabWorkspace +import dev.nucleusframework.window.tao.WindowAnchor +import dev.nucleusframework.window.tao.WindowConstraintAdjustment +import dev.nucleusframework.window.tao.WindowPositioner + +/** The kinds of satellite a document can ask for. */ +enum class SatelliteKind( + val label: String, +) { + Inspector("Inspector"), + Palette("Palette"), + ; + + /** Id of this kind's entry in the workspace of the tab window [groupId]. */ + fun idIn(groupId: String): String = "$groupId-${name.lowercase()}" +} + +/** + * One document of the demo: one tab, and the satellites it asks for. + * + * No document is *obliged* to have any. The entries are declared per tab window + * so that switching tabs creates and destroys nothing, and each one is opened + * or closed to match the selected document — so a document with no palettes + * shows none, and one with a single palette shows one. + * + * @property id the tab's identity. + * @property title shown on the tab and, while it is selected, as the window title. + * @property accent the colour its palette starts on, so each document is + * recognisable at a glance whichever window it ends up in. + * @property satellites which palettes this document wants; empty is a document + * with none. + */ +class Document( + val id: String, + val title: String, + val accent: Color, + val satellites: Set, +) + +/** + * The values a document's palettes edit, kept here rather than in the palettes: + * they belong to the document, so they have to outlive any window or panel it + * is shown in — and be there unchanged when its tab comes back into view. + */ +class DocumentState { + var strength: Float by mutableStateOf(INITIAL_STRENGTH) + var edits: Int by mutableStateOf(0) + var swatch: Int by mutableStateOf(0) + + private companion object { + const val INITIAL_STRENGTH = 0.4f + } +} + +/** + * Everything the demo drives. + * + * [tabs] is the one tab workspace: it owns which windows exist and which tab + * each window shows. [satellitesOfWindow] hands out one [SatelliteWorkspace] + * **per tab window**, and that is the whole trick: + * + * - a tab window joins its own workspace once, for as long as the window + * lives, so the palettes exist exactly as long as the window does. Switching + * tabs inside it neither creates nor destroys anything — tying membership to + * the *tab body* instead means a native palette window is destroyed and + * another created on every switch, which flashes; + * - what follows the tab is the palettes' **content**: they show the window's + * selected tab, and the per-document values live in [stateOf], outside + * composition, so each document brings its own back; + * - a tab torn into a window of its own gets that window's palettes, and two + * windows showing two tabs show two independent sets at the same time. + */ +class DemoState { + val tabs = TabWorkspace(defaultWindowSize = DpSize(WINDOW_WIDTH_DP.dp, WINDOW_HEIGHT_DP.dp)) + + /** The open documents, in declaration order. One tab each. */ + val documents = + mutableStateListOf( + // Both palettes, one, and none: a document decides. + Document("scene", "Scene.kt", Color(0xFF7AA2F7), setOf(SatelliteKind.Inspector, SatelliteKind.Palette)), + Document("shader", "Shader.glsl", Color(0xFF9ECE6A), setOf(SatelliteKind.Inspector)), + Document("notes", "notes.md", Color(0xFFE0AF68), emptySet()), + ) + + private val workspaces = mutableStateMapOf() + private val documentStates = mutableStateMapOf() + + /** + * The satellite workspace of the tab window [groupId], created the first + * time it is asked for and dropped with the window ([forgetWindow]). + */ + fun satellitesOfWindow(groupId: String): SatelliteWorkspace = + workspaces.getOrPut(groupId) { + // followFocus is beside the point with a single member: the tab + // window is the only candidate owner this workspace ever has. + SatelliteWorkspace() + } + + /** Drops the workspace of a tab window that is gone. */ + fun forgetWindow(groupId: String) { + workspaces.remove(groupId) + } + + /** The palette values of [documentId], created the first time they are asked for. */ + fun stateOf(documentId: String): DocumentState = documentStates.getOrPut(documentId) { DocumentState() } + + /** The document [id] names, or `null` once it has been closed. */ + fun document(id: String): Document? = documents.firstOrNull { it.id == id } + + /** The layout captured by "Save tab layout", ready for "Restore". */ + var savedLayout: TabLayoutSnapshot? by mutableStateOf(null) + private set + + private var opened = 0 + + /** + * Opens a new document; its tab lands in the window focused last. Drafts + * alternate between "a palette only" and "both", so the difference between + * documents is visible without editing any code. + */ + fun open() { + opened++ + val wants = + if (opened % 2 == 0) { + setOf(SatelliteKind.Palette) + } else { + setOf(SatelliteKind.Inspector, SatelliteKind.Palette) + } + documents += Document("draft-$opened", "draft$opened.kt", DraftAccents[opened % DraftAccents.size], wants) + } + + /** Drops the document [id] — and the values it owned — once its tab is gone. */ + fun forget(id: String) { + documents.removeAll { it.id == id } + documentStates.remove(id) + } + + fun saveLayout() { + savedLayout = tabs.snapshot() + } + + fun restoreLayout() { + savedLayout?.let(tabs::restore) + } + + /** + * The placement a kind starts in: the inspector floats off the window's + * right edge, the palette starts docked on its left. + */ + fun placementOf(kind: SatelliteKind): SatellitePlacement = + when (kind) { + SatelliteKind.Inspector -> InspectorPlacement + SatelliteKind.Palette -> PalettePlacement + } + + companion object { + /** The inspector floats off the right edge of whichever window holds the tab. */ + val InspectorPlacement: SatellitePlacement + get() = + SatellitePlacement.Floating( + positioner = + WindowPositioner( + parentAnchor = WindowAnchor.Right, + childAnchor = WindowAnchor.Left, + offset = DpOffset(GAP_DP.dp, 0.dp), + constraintAdjustment = WindowConstraintAdjustment.FlipAndSlide, + ), + size = DpSize(INSPECTOR_W_DP.dp, INSPECTOR_H_DP.dp), + ) + + /** The palette starts docked, so the composition of the two archetypes shows on first launch. */ + val PalettePlacement: SatellitePlacement get() = SatellitePlacement.Docked(DockSide.Left) + + private val DraftAccents = + listOf( + Color(0xFFBB9AF7), + Color(0xFF7DCFFF), + Color(0xFFF7768E), + Color(0xFF73DACA), + ) + + private const val WINDOW_WIDTH_DP = 860 + private const val WINDOW_HEIGHT_DP = 620 + private const val INSPECTOR_W_DP = 300 + private const val INSPECTOR_H_DP = 360 + private const val GAP_DP = 12 + } +} diff --git a/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DemoTabStrip.kt b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DemoTabStrip.kt new file mode 100644 index 000000000..dcb1e1290 --- /dev/null +++ b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DemoTabStrip.kt @@ -0,0 +1,52 @@ +package dev.nucleusframework.tabsatellitesdemo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.TabStrip +import dev.nucleusframework.window.tao.TabStripScope + +/** + * The strip of one window: the stock [TabStrip], plus a new-tab button right + * after the last tab. + * + * The stock strip is what publishes the geometry a tab dragged from another + * window is dropped onto, which is why chrome is added *around* its tabs + * rather than in place of them. A strip written from scratch would have to + * apply `Modifier.tabStripGeometry`, `Modifier.tabSlot` and + * `Modifier.tabDragHandle` itself. + */ +@Composable +fun TabStripScope.DemoTabStrip(onNewTab: () -> Unit) { + TabStrip(trailing = { NewTabButton(onNewTab) }) +} + +/** The "+" of a browser: opens a document in this workspace. */ +@Composable +private fun NewTabButton(onClick: () -> Unit) { + val colors = LocalTitleBarStyle.current.colors + Box( + modifier = + Modifier + .padding(horizontal = 6.dp) + .size(22.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .pointerHoverIcon(PointerIcon.Hand), + contentAlignment = Alignment.Center, + ) { + Text("+", color = colors.content, fontSize = 15.sp) + } +} diff --git a/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DocumentContent.kt b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DocumentContent.kt new file mode 100644 index 000000000..5cff86d99 --- /dev/null +++ b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/DocumentContent.kt @@ -0,0 +1,257 @@ +package dev.nucleusframework.tabsatellitesdemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.nucleusframework.application.LocalNucleusWindow +import dev.nucleusframework.window.tao.DockLayout +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.TabScope +import kotlin.math.roundToInt + +/** + * The body of one tab, and the document half of the seam between the two + * archetypes. + * + * The window itself joined its satellite workspace when it opened (`Main.kt`), + * so what is left here is a [DockLayout] for the docked palettes to live in and + * the controls that show, hide, dock and float them. Note what is *not* here: + * nothing that starts or stops a palette. Joining the workspace from the tab + * body instead would tie the palettes' existence to the selected tab, and every + * tab change would destroy a native window and create another. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TabScope.DocumentContent( + demo: DemoState, + document: Document, +) { + // The workspace of the *window* this tab is composed in — the window joined + // it once when it opened (see `Main.kt`), so nothing here starts or stops a + // palette; this only gives the docked ones somewhere to live and the + // controls something to act on. + val group = tab.group + val satellites = group?.let { demo.satellitesOfWindow(it.id) } + + val hostWindow = LocalNucleusWindow.current + var edits by rememberSaveable { mutableIntStateOf(0) } + + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) { + DockLayoutOrPlain(satellites) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text(document.title, style = MaterialTheme.typography.headlineSmall) + Text( + "Each document says which satellites it wants: Scene.kt asks for both, " + + "Shader.glsl for the Inspector only, notes.md for none. The entries " + + "themselves belong to this window, so switching between two documents that " + + "want the same palette only changes what it draws — and each document " + + "brings its own values back. Drag this tab into a window of its own and it " + + "arrives with palettes of its own: two windows, two independent sets.", + style = MaterialTheme.typography.bodyMedium, + ) + + Section("This document's satellites") { + if (group != null && satellites != null) { + if (document.satellites.isEmpty()) { + Text( + "This document asks for none, so this window shows none while it " + + "is the selected tab.", + style = MaterialTheme.typography.bodyMedium, + ) + } + for (kind in document.satellites) { + SatelliteRow(satellites, kind.idIn(group.id), kind.label) + } + val absent = SatelliteKind.entries.filterNot { it in document.satellites } + if (absent.isNotEmpty()) { + Text( + "Not asked for by this document: ${absent.joinToString { it.label }}.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Text( + "No tab is obliged to have satellites. The entries are declared per window, " + + "so switching tabs creates and destroys nothing; which of them are open " + + "is per document, so a palette only appears or disappears when the two " + + "documents actually disagree about it.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Section("This tab") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = { demo.open() }) { Text("New tab") } + OutlinedButton(onClick = { edits++ }) { Text("edits: $edits") } + TextButton(onClick = { close() }) { Text("Close this tab") } + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { demo.saveLayout() }) { Text("Save tab layout") } + OutlinedButton( + onClick = { demo.restoreLayout() }, + enabled = demo.savedLayout != null, + ) { + Text("Restore") + } + } + } + + Section("Live state") { + StateLine( + "tab windows", + demo.tabs.groups.size + .toString(), + ) + StateLine("this window's group", group?.id ?: "—") + StateLine("tabs in this window", (group?.ids?.size ?: 0).toString()) + StateLine("this window at", hostWindow.describeBounds()) + StateLine("workspace members", (satellites?.members?.size ?: 0).toString()) + StateLine( + "owner is this window", + (satellites?.owner === hostWindow.unsafe.taoWindow).toString(), + ) + val entries = satellites?.satellites?.sortedBy { it.id }.orEmpty() + for (entry in entries) { + StateLine( + entry.id.removePrefix("${group?.id}-"), + buildString { + append(if (entry.isOpen) "open" else "hidden") + if (entry.isDocked) { + append(", docked ${entry.preferredDockSide.name.lowercase()}") + } else { + append(", floating") + } + }, + ) + } + for (side in DockSide.entries) { + StateLine( + "dock extent ${side.name.lowercase()}", + "${(satellites?.dockExtent(side)?.value ?: 0f).roundToInt()} dp", + ) + } + } + } + } + } +} + +/** + * [DockLayout] when this window has a workspace — it has one from its second + * frame, the first being the one where the window has not yet been recorded by + * the tab workspace. + */ +@Composable +private fun DockLayoutOrPlain( + satellites: SatelliteWorkspace?, + content: @Composable () -> Unit, +) { + if (satellites == null) { + content() + } else { + DockLayout(satellites, Modifier.fillMaxSize(), content = content) + } +} + +/** Show / hide and dock / float for one satellite of this window. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SatelliteRow( + workspace: SatelliteWorkspace, + id: String, + label: String, +) { + val entry = workspace.satellite(id) + val docked = entry?.isDocked == true + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Text(label, Modifier.padding(end = 4.dp), style = MaterialTheme.typography.labelLarge) + Button(onClick = { workspace.toggle(id) }, enabled = entry != null) { + Text(if (entry?.isOpen == true) "Hide" else "Show") + } + OutlinedButton( + onClick = { + if (docked) workspace.undock(id) else workspace.dock(id, entry?.preferredDockSide ?: DockSide.Right) + }, + enabled = entry != null, + ) { + Text(if (docked) "Float" else "Dock") + } + for (side in DockSide.entries) { + TextButton(onClick = { workspace.dock(id, side) }, enabled = entry != null) { Text(side.name) } + } + } +} + +private fun dev.nucleusframework.application.NucleusWindow.describeBounds(): String = + boundsOnScreen()?.let { "${it.x.roundToInt()}, ${it.y.roundToInt()} dp" } ?: "—" + +@Composable +private fun Section( + title: String, + content: @Composable () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} + +@Composable +private fun StateLine( + name: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(name, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } +} diff --git a/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/Main.kt b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/Main.kt new file mode 100644 index 000000000..0abb2811c --- /dev/null +++ b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/Main.kt @@ -0,0 +1,251 @@ +package dev.nucleusframework.tabsatellitesdemo + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.nucleusframework.application.Satellite +import dev.nucleusframework.application.Tab +import dev.nucleusframework.application.TabWindows +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import dev.nucleusframework.window.WindowAppearance +import dev.nucleusframework.window.WindowAppearanceMode +import dev.nucleusframework.window.WindowBackground +import dev.nucleusframework.window.material.rememberMaterialTitleBarStyle +import dev.nucleusframework.window.material.rememberMaterialWindowStyle +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.JoinSatelliteWorkspace +import dev.nucleusframework.window.tao.TabWindowGroup +import dev.nucleusframework.window.tao.TabWorkspace + +private val DemoDarkColors = + darkColorScheme( + primary = Color(0xFF8AA4FF), + surface = Color(0xFF15171C), + surfaceContainer = Color(0xFF1C1F26), + surfaceContainerHigh = Color(0xFF232730), + background = Color(0xFF101216), + ) + +private val DemoLightColors = + lightColorScheme( + primary = Color(0xFF3F5DDB), + surface = Color(0xFFF7F8FB), + surfaceContainer = Color(0xFFEDEFF5), + surfaceContainerHigh = Color(0xFFE4E7EF), + background = Color(0xFFFBFCFE), + ) + +/** + * Chrome-like tabs where every tab has its satellites. + * + * `TabWindows` owns the windows and `Tab` declares the documents, as in + * `examples/tabs-demo`. On top of that, each **tab window** gets a + * `SatelliteWorkspace` of its own with an Inspector and a Palette, and those + * palettes show the window's selected tab: switch tabs and their content + * changes, tear a tab into a window of its own and it arrives with palettes of + * its own, so two windows show two independent sets at once. + * + * Why the workspace is per window and not per document: a satellite exists for + * as long as its entry is declared *and* its workspace has an owner. Hanging + * either of those on the selected tab means a native palette window is + * destroyed and a new one created on every tab change — visible as a flash. + * Per window, nothing is created or destroyed by a tab change at all; only the + * content the palettes draw changes, and the per-document values behind it live + * in [DemoState.stateOf]. + */ +fun main() = + nucleusApplication { + val demo = remember { DemoState() } + val dark = isSystemInDarkMode() + val colors = if (dark) DemoDarkColors else DemoLightColors + + DemoTheme(colors) { + TabWindows( + workspace = demo.tabs, + strip = { DemoTabStrip(onNewTab = demo::open) }, + windowWrapper = { content -> + WindowBackground(colors.background) + WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) + // This window joins its own satellite workspace, once, for + // as long as it lives — which is what keeps a tab change + // from touching the palettes at all. + val group = demo.tabs.groupOf(nucleusWindow.unsafe.taoWindow) + if (group != null) JoinSatelliteWorkspace(demo.satellitesOfWindow(group.id)) + Surface(Modifier.fillMaxSize(), color = colors.background) { content() } + }, + onLastWindowClosed = ::exitApplication, + ) + + for (document in demo.documents) { + key(document.id) { + Tab(demo.tabs, id = document.id, title = document.title) { + DocumentContent(demo, document) + } + DropClosedTab(demo, document.id) + } + } + + // One set of satellites per tab window, declared at application + // scope so they are not tied to whichever tab is showing. + for (group in rememberTabGroups(demo.tabs)) { + key(group.id) { WindowSatellites(demo, group) } + } + } + } + +/** + * The tab windows, mirrored out of the workspace through an effect. + * + * The groups are created by `Tab`, which is declared above this call, so the + * write that adds one lands during the composition that has already read the + * list — and Compose drops an invalidation aimed at a scope it has just + * composed. Read straight from `workspace.groups`, this loop would never see + * the first window. `TabWindows` mirrors the list for exactly the same reason. + */ +@Composable +private fun rememberTabGroups(workspace: TabWorkspace): List { + var groups by remember(workspace) { mutableStateOf(workspace.groups.toList()) } + LaunchedEffect(workspace) { + snapshotFlow { workspace.groups.toList() }.collect { groups = it } + } + return groups +} + +/** + * The satellites of one tab window: one entry per [SatelliteKind], declared + * against that window's workspace and drawing whichever tab the window is + * showing. + * + * The entries are per window so that a tab change creates and destroys nothing. + * Which of them are *open* is per document: a document that asks for no + * palettes shows none, one that asks for a single palette shows one. That does + * mean a palette genuinely appears or disappears when you move between + * documents that disagree about it — which is the point, and is not the same + * thing as every switch churning every palette. + */ +@Composable +private fun WindowSatellites( + demo: DemoState, + group: TabWindowGroup, +) { + val workspace = demo.satellitesOfWindow(group.id) + DisposableEffect(demo, group.id) { + onDispose { demo.forgetWindow(group.id) } + } + + // The selected tab of *this* window, resolved back to the document. The + // entry id is the document id, which is what ties the two archetypes + // together without either knowing about the other. + val document = demo.tabs.selectedTab(group)?.let { demo.document(it.id) } + val suffix = document?.let { " — ${it.title}" }.orEmpty() + + for (kind in SatelliteKind.entries) { + Satellite( + workspace = workspace, + id = kind.idIn(group.id), + title = "${kind.label}$suffix", + initialPlacement = demo.placementOf(kind), + // Closed until a document asks for it: the effect below is what + // decides, and it only runs once the entry exists. + initiallyOpen = false, + ) { + // A title change is just state on the entry; only a change of *id* + // would swap the entry — and with it the window it is composed in. + if (document == null) NoTabSelected() else KindContent(kind, demo, document) + } + } + + // Match the open entries to what the selected document asks for. From an + // effect, never during composition: `open` / `close` write workspace state, + // and a write mid-composition is exactly what the tab workspace had to be + // taught to survive. + LaunchedEffect(workspace, group.id, document?.id) { + val wanted = document?.satellites.orEmpty() + for (kind in SatelliteKind.entries) { + val id = kind.idIn(group.id) + if (kind in wanted) workspace.open(id) else workspace.close(id) + } + } +} + +/** The body of one kind of satellite, for the document it is drawing. */ +@Composable +private fun dev.nucleusframework.window.tao.SatelliteScope.KindContent( + kind: SatelliteKind, + demo: DemoState, + document: Document, +) { + when (kind) { + SatelliteKind.Inspector -> InspectorContent(demo, document) + SatelliteKind.Palette -> PaletteContent(demo, document) + } +} + +/** What a palette shows for a window that has no selected tab — a frame at most. */ +@Composable +private fun NoTabSelected() { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Box(Modifier.fillMaxSize().padding(12.dp), contentAlignment = Alignment.Center) { + Text("No tab selected", style = MaterialTheme.typography.bodySmall) + } + } +} + +/** + * Keeps the document list in step with the tab workspace: closing a tab is a + * workspace call, and a document still declared once its tab is gone would be + * registered again and hosted nowhere. + */ +@Composable +private fun DropClosedTab( + demo: DemoState, + id: String, +) { + val closed = demo.tabs.tab(id) == null + LaunchedEffect(closed) { + if (closed) demo.forget(id) + } +} + +/** + * Material colours plus the window-chrome styles derived from them. + * + * Established once, above the windows: the workspace opens and closes them, and + * these locals are bridged into every scene it creates — the tab strips in the + * title bars and the floating satellites' own scenes included. + */ +@Composable +private fun DemoTheme( + colors: ColorScheme, + content: @Composable () -> Unit, +) { + MaterialTheme(colorScheme = colors) { + CompositionLocalProvider( + LocalTitleBarStyle provides rememberMaterialTitleBarStyle(colors), + LocalDecoratedWindowStyle provides rememberMaterialWindowStyle(colors), + content = content, + ) + } +} diff --git a/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/SatelliteContent.kt b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/SatelliteContent.kt new file mode 100644 index 000000000..33c3f8d2e --- /dev/null +++ b/examples/tab-satellites-demo/src/main/kotlin/dev/nucleusframework/tabsatellitesdemo/SatelliteContent.kt @@ -0,0 +1,192 @@ +package dev.nucleusframework.tabsatellitesdemo + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatelliteScope +import kotlin.math.roundToInt + +/** + * The inspector of whichever tab its window is showing. + * + * The values are read from [DemoState], not remembered here: they belong to the + * document, so they have to be the same whichever window's inspector draws them + * and be waiting unchanged when the tab comes back. A `rememberSaveable` in a + * satellite survives dock / undock, but not being handed a different document. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SatelliteScope.InspectorContent( + demo: DemoState, + document: Document, +) { + val state = demo.stateOf(document.id) + + SatelliteSurface { + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Inspector — ${document.title}", style = MaterialTheme.typography.titleSmall) + Text( + "This palette is the one of the window it is anchored to, and it draws the tab " + + "that window is showing — switch tabs and the content changes with no window " + + "being created or destroyed.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text("Strength ${(state.strength * PERCENT).roundToInt()}%", style = MaterialTheme.typography.labelLarge) + Slider(value = state.strength, onValueChange = { state.strength = it }) + OutlinedButton(onClick = { state.edits++ }) { Text("edits: ${state.edits}") } + Text( + "Both values belong to this document: switch tabs and back, or move the tab to " + + "another window, and they are still here.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + PlacementControls() + StateLine("hosted as", if (isDocked) "a docked panel" else "a floating window") + StateLine("placement", describePlacement()) + } + } +} + +/** + * The palette of whichever tab its window is showing: a swatch grid whose + * selection, like the inspector's values, belongs to the document. + */ +@Composable +fun SatelliteScope.PaletteContent( + demo: DemoState, + document: Document, +) { + val state = demo.stateOf(document.id) + val swatches = remember(document.accent) { swatchesFor(document.accent) } + + SatelliteSurface { + Column( + modifier = Modifier.fillMaxSize().padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text("Palette — ${document.title}", style = MaterialTheme.typography.titleSmall) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + swatches.forEachIndexed { index, colour -> + Box( + modifier = + Modifier + .size(SWATCH_DP.dp) + .clip(CircleShape) + .background(colour) + .border( + width = if (index == state.swatch) SELECTED_BORDER_DP.dp else 0.dp, + color = MaterialTheme.colorScheme.onSurface, + shape = CircleShape, + ).clickable { state.swatch = index }, + ) + } + } + Text( + "Swatch ${state.swatch + 1} of ${swatches.size} — the selection belongs to the document too.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + PlacementControls() + } + } +} + +/** Float / dock buttons, the same for either satellite. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SatelliteScope.PlacementControls() { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + if (isDocked) { + OutlinedButton(onClick = { undock() }) { Text("Float") } + } else { + OutlinedButton(onClick = { dock() }) { Text("Dock") } + } + for (side in DockSide.entries) { + TextButton(onClick = { dock(side) }) { Text(side.name) } + } + } +} + +@Composable +private fun SatelliteScope.describePlacement(): String { + val entry = satellite + val owner = workspace.owner + return buildString { + append(if (entry.isDocked) "docked ${entry.preferredDockSide.name.lowercase()}" else "floating") + append(if (owner == null) ", no owner" else ", owned by the window showing the tab") + } +} + +/** Themed body of a satellite, the same whether it floats or is docked. */ +@Composable +private fun SatelliteSurface(content: @Composable () -> Unit) { + Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surface) { + Box(Modifier.fillMaxSize()) { content() } + } +} + +@Composable +private fun StateLine( + name: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(name, style = MaterialTheme.typography.bodySmall) + Text(value, style = MaterialTheme.typography.bodySmall) + } +} + +private fun swatchesFor(accent: Color): List = + listOf( + accent, + accent.copy(alpha = SHADE_STRONG), + accent.copy(alpha = SHADE_MEDIUM), + accent.copy(alpha = SHADE_LIGHT), + ) + +private const val PERCENT = 100 +private const val SWATCH_DP = 26 +private const val SELECTED_BORDER_DP = 2 +private const val SHADE_STRONG = 0.75f +private const val SHADE_MEDIUM = 0.5f +private const val SHADE_LIGHT = 0.3f diff --git a/examples/tabs-demo/build.gradle.kts b/examples/tabs-demo/build.gradle.kts new file mode 100644 index 000000000..46f78503f --- /dev/null +++ b/examples/tabs-demo/build.gradle.kts @@ -0,0 +1,51 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +// Showcase for the Chrome-like tab workspace: documents declared once as tabs, +// however many windows the user pulls them into, tear-off and merge by drag, +// state that follows a tab between windows, and a layout snapshot to save and +// restore. + +plugins { + kotlin("jvm") + alias(libs.plugins.kotlinComposePlugin) + alias(libs.plugins.jetbrainsCompose) + id("dev.nucleusframework") +} + +dependencies { + implementation(project(":decorated-window-tao")) + implementation(project(":decorated-window-material3")) + implementation(project(":nucleus-application")) + implementation(project(":core-runtime")) + implementation(project(":darkmode-detector")) + implementation(project(":graalvm-runtime")) + implementation(compose.desktop.currentOs) + implementation("org.jetbrains.compose.material3:material3:1.9.0") +} + +java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +kotlin { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_17) + optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") + } +} + +nucleus.application { + mainClass = "dev.nucleusframework.tabsdemo.MainKt" + + nativeDistributions { + packageName = "tabs-demo" + packageVersion = "1.0.0" + } + + graalvm { + isEnabled = true + javaLanguageVersion = 25 + imageName = "tabs-demo" + } +} diff --git a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoState.kt b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoState.kt new file mode 100644 index 000000000..e7c07ccc1 --- /dev/null +++ b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoState.kt @@ -0,0 +1,102 @@ +package dev.nucleusframework.tabsdemo + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import dev.nucleusframework.window.tao.TabLayoutSnapshot +import dev.nucleusframework.window.tao.TabWorkspace + +/** + * One document of the demo — one tab of the workspace. + * + * @property id the tab's identity, stable for as long as the document is open. + * @property title shown on the tab and, while it is the selected one, as the + * title of the window holding it. + * @property path shown under the title on the tab's hover card, the way an + * editor's tooltip shows where a file lives. + * @property draft what its editor starts with. + */ +class Document( + val id: String, + val title: String, + val path: String, + val draft: String, +) + +/** + * Everything the demo drives, hoisted to the application: the [workspace] and + * the documents declared against it. + * + * The document list is the app's own — the workspace owns *where* each tab is, + * never whether it exists. So opening a document means adding to this list, and + * a tab the user closes has to be dropped from it ([forget]) or it would be + * declared all over again. + */ +class DemoState { + // `captureThumbnails` is what puts a picture of the document on its hover + // card: the workspace keeps a reduced snapshot of whatever body was last + // on screen for each tab. Off by default — it costs a layer and a readback. + val workspace = + TabWorkspace( + defaultWindowSize = DpSize(WINDOW_WIDTH_DP.dp, WINDOW_HEIGHT_DP.dp), + captureThumbnails = true, + ) + + /** The open documents, in declaration order. One tab each. */ + val documents = + mutableStateListOf( + Document( + "readme", + "README.md", + "examples/tabs-demo/README.md", + "# Tabs demo\n\nDrag a tab out of this window.", + ), + Document("main", "Main.kt", "src/main/kotlin/Main.kt", "fun main() = nucleusApplication { }"), + Document( + "build", + "build.gradle.kts", + "examples/tabs-demo/build.gradle.kts", + "plugins { id(\"dev.nucleusframework\") }", + ), + ) + + /** The document behind a tab id, for chrome that draws more than a title. */ + fun document(id: String): Document? = documents.firstOrNull { it.id == id } + + /** The layout captured by "Save layout", ready for "Restore layout". */ + var savedLayout: TabLayoutSnapshot? by mutableStateOf(null) + private set + + private var opened = 0 + + /** + * Opens a new document. It is only added to the list here; the `Tab` + * declaration that follows puts it in the window focused last, exactly + * where a browser opens a new tab. + */ + fun open() { + opened++ + documents += Document("note-$opened", "Untitled $opened", "untitled-$opened.txt", "") + } + + /** Drops the document [id] once its tab is gone from the workspace. */ + fun forget(id: String) { + documents.removeAll { it.id == id } + } + + fun saveLayout() { + savedLayout = workspace.snapshot() + } + + fun restoreLayout() { + savedLayout?.let(workspace::restore) + } + + private companion object { + const val WINDOW_WIDTH_DP = 900 + const val WINDOW_HEIGHT_DP = 620 + } +} diff --git a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoTabStrip.kt b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoTabStrip.kt new file mode 100644 index 000000000..d45437ae8 --- /dev/null +++ b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DemoTabStrip.kt @@ -0,0 +1,84 @@ +package dev.nucleusframework.tabsdemo + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.nucleusframework.window.styling.LocalTitleBarStyle +import dev.nucleusframework.window.tao.TabHoverPreview +import dev.nucleusframework.window.tao.TabHoverPreviewCard +import dev.nucleusframework.window.tao.TabStrip +import dev.nucleusframework.window.tao.TabStripScope + +/** + * The strip of one window: the stock [TabStrip], plus a new-tab button right + * after the last tab and a hover card under the tab the pointer rests on. + * + * The stock strip is what publishes the geometry a tab dragged from another + * window is dropped onto, which is why chrome is added *around* its tabs + * rather than in place of them. A strip written from scratch would have to + * apply `Modifier.tabStripGeometry`, `Modifier.tabSlot` and + * `Modifier.tabDragHandle` itself. + */ +@Composable +fun TabStripScope.DemoTabStrip( + demo: DemoState, + onNewTab: () -> Unit, +) { + // The card is the stock one with a second line of the demo's own: the + // workspace knows a tab's title and nothing else, so anything past it — + // here the file's path — is looked up by the app from the tab's id. + // `TabHoverPreview(content = …)` would replace the card outright. + val preview = + remember(demo) { + TabHoverPreview { + TabHoverPreviewCard( + subtitle = { + val colors = LocalTitleBarStyle.current.colors + val path = demo.document(tab.id)?.path ?: "" + Text( + text = path, + color = colors.content.copy(alpha = SUBTITLE_ALPHA), + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + TabStrip(hoverPreview = preview, trailing = { NewTabButton(onNewTab) }) +} + +/** The "+" of a browser: opens a document in this workspace. */ +@Composable +private fun NewTabButton(onClick: () -> Unit) { + val colors = LocalTitleBarStyle.current.colors + Box( + modifier = + Modifier + .padding(horizontal = 6.dp) + .size(22.dp) + .clip(CircleShape) + .clickable(onClick = onClick) + .pointerHoverIcon(PointerIcon.Hand), + contentAlignment = Alignment.Center, + ) { + Text("+", color = colors.content, fontSize = 15.sp) + } +} + +private const val SUBTITLE_ALPHA = 0.7f diff --git a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DocumentContent.kt b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DocumentContent.kt new file mode 100644 index 000000000..4deed65ff --- /dev/null +++ b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/DocumentContent.kt @@ -0,0 +1,232 @@ +package dev.nucleusframework.tabsdemo + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import dev.nucleusframework.application.LocalNucleusWindow +import dev.nucleusframework.application.NucleusWindow +import dev.nucleusframework.window.tao.TabScope +import dev.nucleusframework.window.tao.TabWorkspace +import kotlin.math.roundToInt + +/** + * The body of one tab: an editor whose state has to survive being dragged to + * another window, plus the workspace controls and a live read-out of what the + * workspace thinks is going on. + * + * Composed by `TabWindows` in whichever window holds the tab — the same call + * site in every window, which is what lets the `rememberSaveable` values below + * be carried across a move. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun TabScope.DocumentContent( + demo: DemoState, + document: Document, +) { + val workspace = demo.workspace + val group = tab.group + + // Saveable: carried to the next window by the workspace. + var draft by rememberSaveable { mutableStateOf(document.draft) } + var savedClicks by rememberSaveable { mutableIntStateOf(0) } + val scroll = rememberScrollState() + // Not saveable, on purpose: the counterexample. A move rebuilds this + // subtree in the other window's composition, and a plain `remember` + // starts over there. + var plainClicks by remember { mutableIntStateOf(0) } + + val window = LocalNucleusWindow.current + val density = LocalDensity.current.density + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scroll) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Text(document.title, style = MaterialTheme.typography.headlineSmall) + Text( + "Every document of this demo is declared once as a tab; the workspace decides " + + "which window shows it. Drag this tab out of the strip and drop it on the " + + "desktop: it lands in a window of its own. Drag it back onto the other " + + "window's strip and it is inserted where you drop it. Drag the only tab of a " + + "window and the window itself follows the pointer, then merges into the strip " + + "it lands on — Chrome, exactly.", + style = MaterialTheme.typography.bodyMedium, + ) + + Section("This tab") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Button(onClick = { demo.open() }) { Text("New tab") } + OutlinedButton(onClick = { select() }, enabled = !tab.isSelected) { Text("Select") } + OutlinedButton( + onClick = { moveToOwnWindow(workspace, tab.id, window, density) }, + enabled = (group?.ids?.size ?: 0) > 1, + ) { + Text("Move to its own window") + } + TextButton(onClick = { close() }) { Text("Close this tab") } + } + Text( + "“Move to its own window” is the tear-off a drag performs, called directly: " + + "the workspace opens the window, so the app never does.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Section("State that follows the tab") { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + label = { Text("rememberSaveable draft") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = { savedClicks++ }) { Text("saveable: $savedClicks") } + OutlinedButton(onClick = { plainClicks++ }) { Text("plain remember: $plainClicks") } + } + Text( + "Type something, click both counters, scroll down a little, then drag this tab " + + "into the other window. The draft, the saveable counter and the scroll " + + "position come back; the plain one restarts at 0 — the two windows are two " + + "compositions, and only saveable state crosses.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Section("Layout") { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { demo.saveLayout() }) { Text("Save layout") } + OutlinedButton( + onClick = { demo.restoreLayout() }, + enabled = demo.savedLayout != null, + ) { + Text("Restore layout") + } + } + Text( + "A snapshot holds every window, the tabs it had in strip order, which one was " + + "selected and where the window sat. Spread the tabs over three windows, " + + "save, merge everything back into one, then restore.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Section("Live state") { + StateLine("windows", workspace.groups.size.toString()) + StateLine("tabs, all windows", workspace.tabs.size.toString()) + StateLine("this window's group", group?.id ?: "—") + StateLine("its tabs", group?.ids?.joinToString(", ") ?: "—") + StateLine("this window at", window.describeBounds()) + StateLine("dragging", workspace.draggedTab?.title ?: "—") + StateLine( + "drop preview", + workspace.dropPreview?.let { "${it.group.id} @ ${it.index}" } ?: "—", + ) + } + + // Something to scroll past, so the saved scroll position is visible. + Section("Notes") { + for (line in 1..NOTE_LINES) { + Text("$line. ${document.title} — line $line", style = MaterialTheme.typography.bodySmall) + } + } + } +} + +/** + * `TabWorkspace.tearOff` driven from a button: the host window's own frame, + * nudged down and to the right. + * + * The portable window handle reports dp and `tearOff` takes physical screen + * pixels, hence the density — a drag gets the same rect from the pointer. + */ +private fun moveToOwnWindow( + workspace: TabWorkspace, + tabId: String, + window: NucleusWindow, + density: Float, +) { + val bounds = window.boundsOnScreen() ?: return + val left = (bounds.x + TEAR_OFF_OFFSET_DP) * density + val top = (bounds.y + TEAR_OFF_OFFSET_DP) * density + workspace.tearOff( + tabId = tabId, + screenRectPx = Rect(left, top, left + bounds.width * density, top + bounds.height * density), + scaleFactor = density, + ) +} + +private fun NucleusWindow.describeBounds(): String = + boundsOnScreen()?.let { "${it.x.roundToInt()}, ${it.y.roundToInt()} dp" } ?: "—" + +@Composable +private fun Section( + title: String, + content: @Composable () -> Unit, +) { + Card(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + content() + } + } +} + +@Composable +private fun StateLine( + name: String, + value: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(name, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + Text(value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) + } +} + +private const val TEAR_OFF_OFFSET_DP = 48f +private const val NOTE_LINES = 24 diff --git a/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/Main.kt b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/Main.kt new file mode 100644 index 000000000..9f257e835 --- /dev/null +++ b/examples/tabs-demo/src/main/kotlin/dev/nucleusframework/tabsdemo/Main.kt @@ -0,0 +1,132 @@ +package dev.nucleusframework.tabsdemo + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.key +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import dev.nucleusframework.application.Tab +import dev.nucleusframework.application.TabWindows +import dev.nucleusframework.application.nucleusApplication +import dev.nucleusframework.darkmodedetector.isSystemInDarkMode +import dev.nucleusframework.window.WindowAppearance +import dev.nucleusframework.window.WindowAppearanceMode +import dev.nucleusframework.window.WindowBackground +import dev.nucleusframework.window.material.rememberMaterialTitleBarStyle +import dev.nucleusframework.window.material.rememberMaterialWindowStyle +import dev.nucleusframework.window.styling.LocalDecoratedWindowStyle +import dev.nucleusframework.window.styling.LocalTitleBarStyle + +private val DemoDarkColors = + darkColorScheme( + primary = Color(0xFF8AA4FF), + surface = Color(0xFF15171C), + surfaceContainer = Color(0xFF1C1F26), + surfaceContainerHigh = Color(0xFF232730), + background = Color(0xFF101216), + ) + +private val DemoLightColors = + lightColorScheme( + primary = Color(0xFF3F5DDB), + surface = Color(0xFFF7F8FB), + surfaceContainer = Color(0xFFEDEFF5), + surfaceContainerHigh = Color(0xFFE4E7EF), + background = Color(0xFFFBFCFE), + ) + +/** + * Chrome-like tab workspace demo. + * + * Three documents are declared once, at application scope, as tabs of one + * `TabWorkspace`. `TabWindows` composes the windows: one to start with, one + * more as soon as a tab is dragged out of a strip, one fewer when the last tab + * leaves it. Dragging a tab onto another window's strip inserts it where it is + * dropped; dragging the only tab of a window moves the window and merges it + * into whatever strip it lands on. The editor state in a tab is + * `rememberSaveable`, so it comes along. + */ +fun main() = + nucleusApplication { + val demo = remember { DemoState() } + val dark = isSystemInDarkMode() + val colors = if (dark) DemoDarkColors else DemoLightColors + + // The theme sits *above* the windows, not inside one: the workspace + // opens and closes them, and the locals established here are bridged + // into every scene it creates — which is where the tab strip in the + // title bar reads its colours from. + DemoTheme(colors) { + TabWindows( + workspace = demo.workspace, + strip = { DemoTabStrip(demo, onNewTab = demo::open) }, + // Per-window chrome goes here, since the app opens no window + // of its own: the receiver is the window being composed. + windowWrapper = { content -> + WindowBackground(colors.background) + WindowAppearance(if (dark) WindowAppearanceMode.Dark else WindowAppearanceMode.Light) + Surface(Modifier.fillMaxSize(), color = colors.background) { content() } + }, + onLastWindowClosed = ::exitApplication, + ) + + for (document in demo.documents) { + key(document.id) { + Tab(demo.workspace, id = document.id, title = document.title) { + DocumentContent(demo, document) + } + DropClosedTab(demo, document.id) + } + } + } + } + +/** + * Keeps the document list in step with the workspace. + * + * Closing a tab — the × on the tab, or the last tab of a window that the user + * closes — is a workspace call, and the workspace does not own the app's list. + * A document still declared once its tab is gone would be registered again and + * hosted nowhere, so it is dropped here instead. + */ +@Composable +private fun DropClosedTab( + demo: DemoState, + id: String, +) { + // Non-null on the composition that declared it, so this only fires once + // the workspace has really let the tab go. + val closed = demo.workspace.tab(id) == null + LaunchedEffect(closed) { + if (closed) demo.forget(id) + } +} + +/** + * Material colours plus the window-chrome styles derived from them. + * + * Every Tao window owns its own ComposeScene, so this would normally be + * established per window; with tabs the app has no window call site, so it is + * established once here and bridged into each window the workspace opens. + */ +@Composable +private fun DemoTheme( + colors: ColorScheme, + content: @Composable () -> Unit, +) { + MaterialTheme(colorScheme = colors) { + CompositionLocalProvider( + LocalTitleBarStyle provides rememberMaterialTitleBarStyle(colors), + LocalDecoratedWindowStyle provides rememberMaterialWindowStyle(colors), + content = content, + ) + } +} diff --git a/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/ActionsTab.kt b/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/ActionsTab.kt index abc2b3b1a..573dec3c1 100644 --- a/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/ActionsTab.kt +++ b/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/ActionsTab.kt @@ -124,7 +124,7 @@ fun ActionsTab( } } - SectionTitle("Placement (via WindowState)") + SectionTitle("Placement (via WindowState v2)") Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { ActionButton( label = "Floating" + if (placement == WindowPlacement.Floating) " ✓" else "", diff --git a/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/Main.kt b/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/Main.kt index 580e42ba0..b733747e6 100644 --- a/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/Main.kt +++ b/examples/tao-demo/src/main/kotlin/dev/nucleusframework/sampletao/Main.kt @@ -48,9 +48,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.window.rememberWindowState +import androidx.compose.ui.window.WindowPlacement import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.sampleshared.A11yTab import dev.nucleusframework.sampleshared.ComplexTab @@ -68,6 +67,9 @@ import dev.nucleusframework.window.macOSLargeCornerRadius import dev.nucleusframework.window.styling.TitleBarColors import dev.nucleusframework.window.styling.TitleBarMetrics import dev.nucleusframework.window.styling.TitleBarStyle +import dev.nucleusframework.window.tao.v2.WindowBoundsProvider +import dev.nucleusframework.window.tao.v2.WindowSizeProvider +import dev.nucleusframework.window.tao.v2.rememberWindowState import java.awt.datatransfer.StringSelection fun main() { @@ -92,7 +94,7 @@ private fun DnDStage0Banner(onLog: (String) -> Unit) { override fun onDrop(event: DragAndDropEvent): Boolean { dropCount++ // Transparent AWT path — same code that works against - // decorated-window-jni / standard Compose Desktop. + // the legacy AWT backend / standard Compose Desktop. lastDrop = runCatching { @Suppress("UNCHECKED_CAST") @@ -234,9 +236,10 @@ private fun DnDStage0Banner(onLog: (String) -> Unit) { } } +@OptIn(ExperimentalComposeUiApi::class) @Suppress("CyclomaticComplexMethod") private fun runApp() = - nucleusApplication(backend = NucleusBackend.Tao) { + nucleusApplication { val previewEvents = remember { mutableStateListOf() } var childRequest by remember { mutableStateOf?>(null) } @@ -252,13 +255,17 @@ private fun runApp() = metrics = TitleBarMetrics(height = 36.dp), ) - val mainState = rememberWindowState(size = DpSize(1024.dp, 720.dp)) + val mainState = + rememberWindowState( + initialBoundsProvider = + WindowBoundsProvider(WindowSizeProvider.Fixed(DpSize(1024.dp, 720.dp))), + ) NucleusDecoratedWindowTheme(isDark = true, titleBarStyle = titleBarStyle) { DecoratedWindow( onCloseRequest = ::exitApplication, state = mainState, title = "Tao Backend Demo", - minimumSize = DpSize(640.dp, 480.dp), + minSize = DpSize(640.dp, 480.dp), onPreviewKeyEvent = { event -> // Demo: consume Cmd/Ctrl+K so it never reaches Compose. Other keys // are still logged but pass through. @@ -398,8 +405,13 @@ private fun runApp() = ActionsTab( modifier = Modifier.fillMaxSize(), window = taoWindow, - placement = mainState.placement, - onPlacementChange = { mainState.placement = it }, + placement = + if (mainState.isInitialized) { + mainState.placement + } else { + WindowPlacement.Floating + }, + onPlacementChange = { mainState.requestPlacement(it) }, onLog = { logEvent(events, it) }, onOpenChildWindow = { childEnabled, childFocusable -> childRequest = childEnabled to childFocusable @@ -424,7 +436,11 @@ private fun runApp() = childRequest?.let { (childEnabled, childFocusable) -> DecoratedWindow( onCloseRequest = { childRequest = null }, - state = rememberWindowState(size = DpSize(480.dp, 240.dp)), + state = + rememberWindowState( + initialBoundsProvider = + WindowBoundsProvider(WindowSizeProvider.Fixed(DpSize(480.dp, 240.dp))), + ), title = "Child (enabled=$childEnabled, focusable=$childFocusable)", enabled = childEnabled, focusable = childFocusable, diff --git a/examples/tao-demo/src/test/kotlin/dev/nucleusframework/sampletao/SqliteReproMain.kt b/examples/tao-demo/src/test/kotlin/dev/nucleusframework/sampletao/SqliteReproMain.kt index fd6b8bc42..18c57b583 100644 --- a/examples/tao-demo/src/test/kotlin/dev/nucleusframework/sampletao/SqliteReproMain.kt +++ b/examples/tao-demo/src/test/kotlin/dev/nucleusframework/sampletao/SqliteReproMain.kt @@ -10,7 +10,6 @@ import androidx.sqlite.SQLiteStatement import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.execSQL import dev.nucleusframework.application.DecoratedWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.nucleusApplication import dev.nucleusframework.window.NucleusDecoratedWindowTheme import kotlinx.coroutines.Dispatchers @@ -57,7 +56,7 @@ fun main() { println("[repro] phase 1 read OK, count=${st.getLong(0)}") } - nucleusApplication(backend = NucleusBackend.Tao) { + nucleusApplication { NucleusDecoratedWindowTheme(isDark = true) { DecoratedWindow( onCloseRequest = ::exitApplication, diff --git a/examples/tao-native-test/build.gradle.kts b/examples/tao-native-test/build.gradle.kts index f2dd5c820..90b85a06d 100644 --- a/examples/tao-native-test/build.gradle.kts +++ b/examples/tao-native-test/build.gradle.kts @@ -16,13 +16,14 @@ plugins { dependencies { implementation(project(":decorated-window-tao")) // The suites live in decorated-window-tao's test source set; consumed as a - // classes jar through the module's taoTestArtifacts configuration. + // classes jar through the module's taoTestArtifacts configuration, which + // also carries what those classes need at run time (kotlin.test, Compose + // Desktop, Material 3) — so a dependency added to that test source set + // reaches this image without being repeated here. implementation(project(path = ":decorated-window-tao", configuration = "taoTestArtifacts")) implementation(project(":core-runtime")) implementation(project(":graalvm-runtime")) implementation(compose.desktop.currentOs) - // Runtime deps of the compiled test classes (kotlin.test assertions). - implementation(kotlin("test")) // Regression fixture for issue #443: an SLF4J 2.x backend that must initialize at // RUN time. If anything on the classpath restores `--initialize-at-build-time=org.slf4j`, // the native-image build fails on LogbackMDCAdapter in the image heap. diff --git a/fs-watcher/build.gradle.kts b/fs-watcher/build.gradle.kts index ce1e85e23..634a9746a 100644 --- a/fs-watcher/build.gradle.kts +++ b/fs-watcher/build.gradle.kts @@ -49,27 +49,29 @@ val nativeTasks = ) } -val verifyNativeResourcePresence by tasks.registering { - description = "Verifies the current host native artifact expected from the local build script exists in resources" - group = "verification" - dependsOn(nativeTasks) - val expectedArtifactPath = - when { - Os.isFamily(Os.FAMILY_MAC) -> - File(nativeOutputDir, "${hostArchDir("darwin")}/libnucleus_fs_watcher.dylib").absolutePath - Os.isFamily(Os.FAMILY_WINDOWS) -> - File(nativeOutputDir, "${hostArchDir("win32")}/nucleus_fs_watcher.dll").absolutePath - else -> - File(nativeOutputDir, "${hostArchDir("linux")}/libnucleus_fs_watcher.so").absolutePath - } +val verifyNativeResourcePresence = + tasks.register("verifyNativeResourcePresence") { + description = + "Verifies the current host native artifact expected from the local build script exists in resources" + group = "verification" + dependsOn(nativeTasks) + val expectedArtifactPath = + when { + Os.isFamily(Os.FAMILY_MAC) -> + File(nativeOutputDir, "${hostArchDir("darwin")}/libnucleus_fs_watcher.dylib").absolutePath + Os.isFamily(Os.FAMILY_WINDOWS) -> + File(nativeOutputDir, "${hostArchDir("win32")}/nucleus_fs_watcher.dll").absolutePath + else -> + File(nativeOutputDir, "${hostArchDir("linux")}/libnucleus_fs_watcher.so").absolutePath + } - doLast { - val expectedArtifact = File(expectedArtifactPath) - if (!expectedArtifact.exists()) { - throw GradleException("Expected native artifact is missing: $expectedArtifact") + doLast { + val expectedArtifact = File(expectedArtifactPath) + if (!expectedArtifact.exists()) { + throw GradleException("Expected native artifact is missing: $expectedArtifact") + } } } -} tasks.processResources { dependsOn(verifyNativeResourcePresence) diff --git a/fs-watcher/src/main/kotlin/dev/nucleusframework/fswatcher/FsWatcher.kt b/fs-watcher/src/main/kotlin/dev/nucleusframework/fswatcher/FsWatcher.kt index a0c324408..4aa859b73 100644 --- a/fs-watcher/src/main/kotlin/dev/nucleusframework/fswatcher/FsWatcher.kt +++ b/fs-watcher/src/main/kotlin/dev/nucleusframework/fswatcher/FsWatcher.kt @@ -26,9 +26,24 @@ public sealed interface FsWatchBackendStrategy { private const val DEFAULT_DEBOUNCE_WINDOW_MILLIS = 150L private val DEFAULT_DEBOUNCE_WINDOW: Duration = Duration.ofMillis(DEFAULT_DEBOUNCE_WINDOW_MILLIS) +/** + * How backend events reach [FsWatcher.events]. + * + * Renames differ between the two modes: [Raw] never pairs them, so a rename arrives as + * [FsWatchEvent.Removed] for the old path plus [FsWatchEvent.Created] for the new one, while + * [Debounced] pairs the two halves into a single [FsWatchEvent.Moved] whenever the backend lets + * it (inotify rename cookies on Linux, file ids on macOS and Windows) and falls back to the same + * `Removed` + `Created` shape otherwise. + */ public sealed interface FsWatchDeliveryMode { + /** + * Every backend event as it comes, without pairing or coalescing. On macOS that includes the + * historical flags FSEvents attaches to a path (a rename of a long-existing file may carry a + * `Created` for it); [Debounced] straightens those out before delivery. + */ public data object Raw : FsWatchDeliveryMode + /** Events coalesced per path over [window]; the default. */ public data class Debounced( val window: Duration = DEFAULT_DEBOUNCE_WINDOW, ) : FsWatchDeliveryMode { @@ -92,8 +107,12 @@ public interface FsWatcher : AutoCloseable { * * [path] needs no canonicalization: delivered [FsWatchEvent] paths are rooted at the spelling * passed here, whatever form the platform backend reports internally. Registering the same - * directory under two spellings does yield two independent registrations and two native - * watches, so pick one form per root if that matters. + * directory under two spellings does yield two independent registrations, so pick one form + * per root if that matters. + * + * Every registration of one [FsWatcher] shares its single native watcher — one inotify + * instance, one FSEvents stream, one directory-changes loop — so the OS resources consumed + * scale with the number of watchers, not with the number of roots. * * @throws FsWatchException if the root cannot be watched. */ diff --git a/fs-watcher/src/main/native/src/lib.rs b/fs-watcher/src/main/native/src/lib.rs index 715b23a7b..d33785100 100644 --- a/fs-watcher/src/main/native/src/lib.rs +++ b/fs-watcher/src/main/native/src/lib.rs @@ -3,18 +3,19 @@ use jni::sys::{jboolean, jint, jlong, JNI_FALSE, JNI_TRUE, JNI_VERSION_1_8}; use jni::{JNIEnv, JavaVM}; use notify::event::{ModifyKind, RenameMode}; use notify::{ - Config, Event, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, Result as NotifyResult, - Watcher, + Config, Event, EventHandler, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode, + Result as NotifyResult, Watcher, WatcherKind, }; +use notify_debouncer_full::file_id::FileId; use notify_debouncer_full::{ - new_debouncer, new_debouncer_opt, DebounceEventResult, Debouncer, FileIdMap, RecommendedCache, + new_debouncer_opt, DebounceEventResult, Debouncer, FileIdCache, FileIdMap, RecommendedCache, }; use once_cell::sync::{Lazy, OnceCell}; use std::collections::HashMap; use std::ffi::c_void; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicI64, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Duration; const WATCHER_LEVEL_REGISTRATION_ID: i64 = 0; @@ -37,25 +38,36 @@ static BRIDGE_CLASS: OnceCell = OnceCell::new(); struct RegistrationState { original_root: PathBuf, resolved_root: PathBuf, + /// The spelling handed to the backend; see [`watched_root_for`]. + watched_root: PathBuf, recursive: bool, - live: bool, } +/// One `FsWatcher`. Every registration shares the single `native_watcher` — one inotify instance, +/// one FSEvents stream, one `ReadDirectoryChangesW` loop per `FsWatcher` rather than per path +/// (#571) — and events are routed back to registrations by matching their roots. struct WatcherState { registrations: HashMap, - native_watchers: HashMap, + native_watcher: Option>, + /// Serialises watch / unwatch / close for this watcher. Never taken by a callback, so it may + /// be held while calling into notify (which joins backend threads). + mutation: Arc>, closed: Arc, follow_symlinks: bool, backend_mode: BackendMode, delivery_mode: DeliveryMode, } -enum NativeWatcherHandle { - Raw(Arc>), - // RecommendedCache resolves to FileIdMap on macOS/Windows and NoCache on Linux. - Debounced(Arc>>), - Polling(Arc>), - PollingDebounced(Arc>>), +/// The debouncer's backend: the platform watcher with FSEvents normalisation in front of it. +type DebouncedBackend = NormalizingWatcher; + +enum NativeWatcher { + /// Raw delivery hands the backend's own events through untouched — on macOS that includes + /// the historical flags FSEvents attaches to a path. + Raw(Mutex), + Debounced(Mutex>), + Polling(Mutex), + PollingDebounced(Mutex>), } #[derive(Copy, Clone)] @@ -79,6 +91,224 @@ enum MatchedRootKind { Resolved, } +// --------------------------------------------------------------------------------------------- +// File-id cache shared between the debouncer and the FSEvents normaliser +// --------------------------------------------------------------------------------------------- + +/// The debouncer's file-id store, shared with the event normaliser so the latter can tell a path +/// the watch already tracks from a genuinely new one. `RecommendedCache` is `FileIdMap` on +/// macOS / Windows and the no-op `NoCache` on Linux, where inotify cookies pair renames for free. +#[derive(Clone, Default)] +struct KnownPaths(Arc>); + +impl KnownPaths { + fn lock_store(&self) -> Option> { + self.0.lock().ok() + } + + #[cfg(target_os = "macos")] + fn contains(&self, path: &Path) -> bool { + self.lock_store() + .map(|store| store.cached_file_id(path).is_some()) + .unwrap_or(false) + } + + #[cfg(target_os = "macos")] + fn refresh_file(&self, path: &Path) { + if let Some(mut store) = self.lock_store() { + store.add_path(path, RecursiveMode::NonRecursive); + } + } +} + +/// `FileIdCache` handed to the debouncer; every call goes to the shared [`KnownPaths`] store. +struct SharedFileIdCache(KnownPaths); + +impl FileIdCache for SharedFileIdCache { + fn cached_file_id(&self, path: &Path) -> Option> { + self.0 + .lock_store() + .and_then(|store| store.cached_file_id(path).map(|id| *id.as_ref())) + } + + fn add_path(&mut self, path: &Path, recursive_mode: RecursiveMode) { + if let Some(mut store) = self.0.lock_store() { + store.add_path(path, recursive_mode); + } + } + + fn remove_path(&mut self, path: &Path) { + if let Some(mut store) = self.0.lock_store() { + store.remove_path(path); + } + } + + fn rescan(&mut self, root_paths: &[(PathBuf, RecursiveMode)]) { + if let Some(mut store) = self.0.lock_store() { + store.rescan(root_paths); + } + } +} + +// notify constructs the debouncer's watcher itself (`T::new(handler, config)`) and offers no way +// to hand it state, so the shared store travels through a thread-local set right before that +// synchronous constructor call and cleared right after. +#[cfg(target_os = "macos")] +thread_local! { + static PENDING_KNOWN_PATHS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +fn with_pending_known_paths(known: &KnownPaths, create: impl FnOnce() -> R) -> R { + #[cfg(target_os = "macos")] + { + PENDING_KNOWN_PATHS.with(|slot| *slot.borrow_mut() = Some(known.clone())); + let result = create(); + PENDING_KNOWN_PATHS.with(|slot| slot.borrow_mut().take()); + result + } + #[cfg(not(target_os = "macos"))] + { + let _ = known; + create() + } +} + +#[cfg(target_os = "macos")] +fn take_pending_known_paths() -> KnownPaths { + PENDING_KNOWN_PATHS + .with(|slot| slot.borrow_mut().take()) + .unwrap_or_default() +} + +// --------------------------------------------------------------------------------------------- +// FSEvents normalisation (macOS) +// --------------------------------------------------------------------------------------------- + +/// Makes FSEvents honest before the debouncer sees it (#570). Only the debounced backend is +/// wrapped: raw delivery promises the backend's events as they come. +/// +/// FSEvents attaches an inode's *accumulated* flags to every event it reports, so a plain rename +/// of a long-existing file arrives as `Create` + `Rename` + `Modify` on the old path — and the +/// debouncer, which reads `Create` as "created within this window", folds the rename into a bare +/// `Create(new)` and a delete into `Modify`. Each rule below only drops what cannot be true of +/// the path *right now*, which is all the debouncer needs to pair the rename through file ids. +#[cfg(target_os = "macos")] +#[derive(Default)] +struct FsEventsNormalizer { + known: KnownPaths, + last_path: Option, + removed_forwarded: bool, +} + +#[cfg(target_os = "macos")] +impl FsEventsNormalizer { + fn new(known: KnownPaths) -> Self { + Self { + known, + last_path: None, + removed_forwarded: false, + } + } + + fn normalize(&mut self, event: Event) -> Option { + let Some(path) = event.paths.first() else { + return Some(event); + }; + if self.last_path.as_deref() != Some(path.as_path()) { + self.last_path = Some(path.clone()); + self.removed_forwarded = false; + } + let metadata = std::fs::symlink_metadata(path).ok(); + let present = metadata.is_some(); + match event.kind { + // A create for something that is not there is history: the rename or remove that + // follows for the same path says what actually happened. + EventKind::Create(_) if !present => None, + // A create for a path the watch already tracks is history too; keep its id fresh in + // case the file was replaced under the same name. + EventKind::Create(_) if self.known.contains(path) => { + if metadata.is_some_and(|m| !m.is_dir()) { + self.known.refresh_file(path); + } + None + } + // Nothing that is gone was modified — and a trailing stale `Modify` would also + // displace the rename `From` the debouncer expects last in the path's queue. + EventKind::Modify( + ModifyKind::Data(_) | ModifyKind::Metadata(_) | ModifyKind::Any | ModifyKind::Other, + ) if !present => None, + // A remove for something that is present is history. + EventKind::Remove(_) if present => None, + EventKind::Remove(_) => { + self.removed_forwarded = true; + Some(event) + } + // `Removed | Renamed` on a gone path: the rename is the older half of the history. + EventKind::Modify(ModifyKind::Name(RenameMode::Any)) if !present && self.removed_forwarded => None, + _ => Some(event), + } + } +} + +struct NormalizingHandler { + inner: F, + #[cfg(target_os = "macos")] + normalizer: FsEventsNormalizer, +} + +impl EventHandler for NormalizingHandler { + fn handle_event(&mut self, event: NotifyResult) { + #[cfg(target_os = "macos")] + let event = match event { + Ok(event) => match self.normalizer.normalize(event) { + Some(event) => Ok(event), + None => return, + }, + Err(error) => Err(error), + }; + self.inner.handle_event(event); + } +} + +/// The platform watcher with [`NormalizingHandler`] in front of its event handler; a plain +/// pass-through everywhere but macOS. +struct NormalizingWatcher { + inner: W, +} + +impl Watcher for NormalizingWatcher { + fn new(event_handler: F, config: Config) -> NotifyResult { + let handler = NormalizingHandler { + inner: event_handler, + #[cfg(target_os = "macos")] + normalizer: FsEventsNormalizer::new(take_pending_known_paths()), + }; + Ok(Self { + inner: W::new(handler, config)?, + }) + } + + fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> NotifyResult<()> { + self.inner.watch(path, recursive_mode) + } + + fn unwatch(&mut self, path: &Path) -> NotifyResult<()> { + self.inner.unwatch(path) + } + + fn configure(&mut self, config: Config) -> NotifyResult { + self.inner.configure(config) + } + + fn kind() -> WatcherKind { + W::kind() + } +} + +// --------------------------------------------------------------------------------------------- +// JNI plumbing +// --------------------------------------------------------------------------------------------- + #[no_mangle] pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *mut c_void) -> jint { let _ = JVM.set(vm); @@ -102,6 +332,13 @@ fn detect_is_directory(path: &Path) -> i32 { } } +fn path_is_present(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok() +} + +/// Maps a notify event onto the bridge's event kinds. Renames the backend could not pair are +/// not discarded: the side that left is `Removed`, the side that appeared is `Created`, and an +/// FSEvents `Any` is told apart by whether its path is still there. fn classify_event(event: &Event) -> Option { match event.kind { EventKind::Create(_) => Some(EVENT_KIND_CREATED), @@ -111,6 +348,16 @@ fn classify_event(event: &Event) -> Option { EventKind::Modify(ModifyKind::Name(RenameMode::Both)) if event.paths.len() >= 2 => { Some(EVENT_KIND_MOVED) } + EventKind::Modify(ModifyKind::Name(RenameMode::From)) => Some(EVENT_KIND_REMOVED), + EventKind::Modify(ModifyKind::Name(RenameMode::To)) => Some(EVENT_KIND_CREATED), + EventKind::Modify(ModifyKind::Name(RenameMode::Any | RenameMode::Both)) => { + let path = event.paths.first()?; + Some(if path_is_present(path) { + EVENT_KIND_CREATED + } else { + EVENT_KIND_REMOVED + }) + } EventKind::Remove(_) => Some(EVENT_KIND_REMOVED), _ => None, } @@ -214,6 +461,23 @@ fn emit_error( }); } +fn emit_overflow(watcher_handle: i64) { + emit_event( + watcher_handle, + WATCHER_LEVEL_REGISTRATION_ID, + None, + EVENT_KIND_OVERFLOW, + None, + None, + true, + -1, + ); +} + +// --------------------------------------------------------------------------------------------- +// Routing: one shared backend, N registrations +// --------------------------------------------------------------------------------------------- + fn path_matches_root(root: &Path, recursive: bool, path: &Path) -> bool { if recursive { path == root || path.starts_with(root) @@ -232,38 +496,7 @@ fn match_registration(registration: &RegistrationState, path: &Path) -> Option Event { - Event { - kind, - paths: paths.iter().map(PathBuf::from).collect(), - attrs: Default::default(), - } - } - - #[test] - fn classify_event_accepts_only_paired_rename_with_both_paths_for_moved() { - let paired_rename = - event_with_paths(EventKind::Modify(ModifyKind::Name(RenameMode::Both)), &["from", "to"]); - let paired_rename_missing_to = - event_with_paths(EventKind::Modify(ModifyKind::Name(RenameMode::Both)), &["from"]); - let rename_from = - event_with_paths(EventKind::Modify(ModifyKind::Name(RenameMode::From)), &["from"]); - let rename_to = event_with_paths(EventKind::Modify(ModifyKind::Name(RenameMode::To)), &["to"]); - let rename_any = - event_with_paths(EventKind::Modify(ModifyKind::Name(RenameMode::Any)), &["from", "to"]); - - assert_eq!(classify_event(&paired_rename), Some(EVENT_KIND_MOVED)); - assert_eq!(classify_event(&paired_rename_missing_to), None); - assert_eq!(classify_event(&rename_from), None); - assert_eq!(classify_event(&rename_to), None); - assert_eq!(classify_event(&rename_any), None); - } -} - -fn with_registration_by_id(registration_id: i64, watcher_handle: i64) -> Option { +fn registration_by_id(registration_id: i64, watcher_handle: i64) -> Option { WATCHERS.lock().ok().and_then(|watchers| { watchers .get(&watcher_handle) @@ -271,203 +504,214 @@ fn with_registration_by_id(registration_id: i64, watcher_handle: i64) -> Option< }) } -fn with_live_registration_by_id(registration_id: i64, watcher_handle: i64) -> Option { - with_registration_by_id(registration_id, watcher_handle).filter(|registration| registration.live) +/// What the callback thread needs to route one event, read under the lock without cloning paths. +struct Routing { + /// Ids of the registrations whose root covers the path(s) the event is about. + targets: Vec, + has_registrations: bool, + moved_supported: bool, +} + +fn routing_for(watcher_handle: i64, covers: impl Fn(&RegistrationState) -> bool) -> Option { + let watchers = WATCHERS.lock().ok()?; + let state = watchers.get(&watcher_handle)?; + let mut targets: Vec = state + .registrations + .iter() + .filter(|(_, registration)| covers(registration)) + .map(|(id, _)| *id) + .collect(); + targets.sort_unstable(); + Some(Routing { + targets, + has_registrations: !state.registrations.is_empty(), + moved_supported: matches!(state.backend_mode, BackendMode::Native) + && matches!(state.delivery_mode, DeliveryMode::Debounced { .. }), + }) } -fn handle_debounce_result( +/// Emits `event_kind` for `path` to every registration covering it; `true` when at least one did. +fn emit_to_covering_registrations( watcher_handle: i64, - origin_native_registration_id: i64, - result: DebounceEventResult, -) { + event_kind: i32, + path: &Path, + secondary_path: Option<&Path>, + needs_rescan: bool, + is_directory: i32, +) -> bool { + let Some(routing) = routing_for(watcher_handle, |registration| { + match_registration(registration, path).is_some() + || secondary_path.is_some_and(|other| match_registration(registration, other).is_some()) + }) else { + return false; + }; + for registration_id in &routing.targets { + emit_event( + watcher_handle, + WATCHER_LEVEL_REGISTRATION_ID, + Some(*registration_id), + event_kind, + Some(path), + secondary_path, + needs_rescan, + is_directory, + ); + } + !routing.targets.is_empty() +} + +fn handle_debounce_result(watcher_handle: i64, result: DebounceEventResult) { match result { Ok(events) => { for debounced_event in events { - handle_notify_result(watcher_handle, origin_native_registration_id, Ok(debounced_event.event)); + handle_notify_result(watcher_handle, Ok(debounced_event.event)); } } Err(errors) => { for error in errors { - handle_notify_result(watcher_handle, origin_native_registration_id, Err(error)); + handle_notify_result(watcher_handle, Err(error)); } } } } -fn handle_notify_result(watcher_handle: i64, origin_native_registration_id: i64, result: NotifyResult) { +fn handle_notify_result(watcher_handle: i64, result: NotifyResult) { match result { Ok(event) => { + let Some(routing) = routing_for(watcher_handle, |_| false) else { + return; + }; + if !routing.has_registrations { + return; + } let first_path = event.paths.first().map(PathBuf::as_path); let second_path = event.paths.get(1).map(PathBuf::as_path); - let registration = with_live_registration_by_id(origin_native_registration_id, watcher_handle); - let moved_supported = WATCHERS.lock().ok().and_then(|watchers| { - watchers.get(&watcher_handle).map(|state| { - matches!(state.backend_mode, BackendMode::Native) - && matches!(state.delivery_mode, DeliveryMode::Debounced { .. }) - }) - }) == Some(true); - - if let Some(event_kind) = classify_event(&event) { - if registration.is_some() && (event_kind != EVENT_KIND_MOVED || moved_supported) { - emit_event( - watcher_handle, - WATCHER_LEVEL_REGISTRATION_ID, - Some(origin_native_registration_id), - event_kind, - first_path, - second_path, - event.need_rescan(), - first_path.map(detect_is_directory).unwrap_or(-1), - ); - } else if event_kind == EVENT_KIND_MOVED && registration.is_some() && event.need_rescan() { - emit_event( - watcher_handle, - WATCHER_LEVEL_REGISTRATION_ID, - None, - EVENT_KIND_OVERFLOW, - None, - None, - true, - -1, - ); + let needs_rescan = event.need_rescan(); + + let delivered = match (classify_event(&event), first_path) { + (Some(EVENT_KIND_MOVED), Some(from)) => { + let Some(to) = second_path else { + return; + }; + if routing.moved_supported { + emit_to_covering_registrations( + watcher_handle, + EVENT_KIND_MOVED, + from, + Some(to), + needs_rescan, + detect_is_directory(to), + ) + } else { + // Raw delivery never pairs renames; inotify's own `Both` duplicates the + // `From` / `To` pair it just emitted, so it carries nothing new. + false + } } - } else if registration.is_some() && event.need_rescan() { - emit_event( + (Some(event_kind), Some(path)) => emit_to_covering_registrations( watcher_handle, - WATCHER_LEVEL_REGISTRATION_ID, - None, - EVENT_KIND_OVERFLOW, + event_kind, + path, None, - None, - true, - -1, - ); + needs_rescan, + detect_is_directory(path), + ), + _ => false, + }; + if !delivered && needs_rescan { + emit_overflow(watcher_handle); } } Err(error) => { let first_path = error.paths.first().map(PathBuf::as_path); - let registration = with_live_registration_by_id(origin_native_registration_id, watcher_handle); - - if let Some(registration) = registration { - let error_path = first_path.filter(|path| match_registration(®istration, path).is_some()); - let callback_registration_id = if error_path.is_some() { - WATCHER_LEVEL_REGISTRATION_ID - } else { - origin_native_registration_id - }; - emit_error( - watcher_handle, - callback_registration_id, - Some(origin_native_registration_id), - &error.to_string(), - true, - error_path, - ); - } else if first_path.is_none() { - emit_error( - watcher_handle, - WATCHER_LEVEL_REGISTRATION_ID, - None, - &error.to_string(), - true, - None, - ); + let Some(routing) = routing_for(watcher_handle, |registration| { + first_path.is_some_and(|path| match_registration(registration, path).is_some()) + }) else { + return; + }; + if !routing.has_registrations { + return; + } + let message = error.to_string(); + match first_path { + Some(path) if !routing.targets.is_empty() => { + for registration_id in &routing.targets { + emit_error( + watcher_handle, + WATCHER_LEVEL_REGISTRATION_ID, + Some(*registration_id), + &message, + true, + Some(path), + ); + } + } + // No registration owns it: the shared backend itself is complaining. + _ => emit_error(watcher_handle, WATCHER_LEVEL_REGISTRATION_ID, None, &message, true, None), } } } } -fn native_handle_watch( - handle: &NativeWatcherHandle, - path: &Path, - recursive_mode: RecursiveMode, -) -> notify::Result<()> { - match handle { - NativeWatcherHandle::Raw(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock raw watcher"))?; - watcher_guard.watch(path, recursive_mode) - } - NativeWatcherHandle::Debounced(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock debounced watcher"))?; - watcher_guard.watch(path, recursive_mode) - } - NativeWatcherHandle::Polling(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock poll watcher"))?; - watcher_guard.watch(path, recursive_mode) - } - NativeWatcherHandle::PollingDebounced(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock debounced poll watcher"))?; - watcher_guard.watch(path, recursive_mode) - } +// --------------------------------------------------------------------------------------------- +// Native watcher lifecycle +// --------------------------------------------------------------------------------------------- + +fn native_watch(watcher: &NativeWatcher, path: &Path, recursive_mode: RecursiveMode) -> NotifyResult<()> { + match watcher { + NativeWatcher::Raw(inner) => lock_watcher(inner)?.watch(path, recursive_mode), + NativeWatcher::Debounced(inner) => lock_watcher(inner)?.watch(path, recursive_mode), + NativeWatcher::Polling(inner) => lock_watcher(inner)?.watch(path, recursive_mode), + NativeWatcher::PollingDebounced(inner) => lock_watcher(inner)?.watch(path, recursive_mode), } } -fn native_handle_unwatch(handle: &NativeWatcherHandle, path: &Path) -> notify::Result<()> { - match handle { - NativeWatcherHandle::Raw(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock raw watcher"))?; - watcher_guard.unwatch(path) - } - NativeWatcherHandle::Debounced(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock debounced watcher"))?; - watcher_guard.unwatch(path) - } - NativeWatcherHandle::Polling(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock poll watcher"))?; - watcher_guard.unwatch(path) - } - NativeWatcherHandle::PollingDebounced(watcher) => { - let mut watcher_guard = watcher - .lock() - .map_err(|_| notify::Error::generic("failed to lock debounced poll watcher"))?; - watcher_guard.unwatch(path) - } +fn native_unwatch(watcher: &NativeWatcher, path: &Path) -> NotifyResult<()> { + match watcher { + NativeWatcher::Raw(inner) => lock_watcher(inner)?.unwatch(path), + NativeWatcher::Debounced(inner) => lock_watcher(inner)?.unwatch(path), + NativeWatcher::Polling(inner) => lock_watcher(inner)?.unwatch(path), + NativeWatcher::PollingDebounced(inner) => lock_watcher(inner)?.unwatch(path), } } -fn create_native_handle( +fn lock_watcher(watcher: &Mutex) -> NotifyResult> { + watcher + .lock() + .map_err(|_| notify::Error::generic("failed to lock native watcher")) +} + +fn create_native_watcher( watcher_handle: i64, - registration_id: i64, follow_symlinks: bool, backend_mode: BackendMode, delivery_mode: DeliveryMode, -) -> Option { +) -> Option { match backend_mode { BackendMode::Native => { let config = Config::default().with_follow_symlinks(follow_symlinks); match delivery_mode { DeliveryMode::Raw => RecommendedWatcher::new( - move |result| handle_notify_result(watcher_handle, registration_id, result), + move |result| handle_notify_result(watcher_handle, result), config, ) .ok() - .map(|watcher| NativeWatcherHandle::Raw(Arc::new(Mutex::new(watcher)))), - DeliveryMode::Debounced { window } => new_debouncer( - window, - None, - move |result| handle_debounce_result(watcher_handle, registration_id, result), - ) - .ok() - .and_then(|mut debouncer| { - if debouncer.configure(config).is_err() { - return None; - } - Some(NativeWatcherHandle::Debounced(Arc::new(Mutex::new(debouncer)))) - }), + .map(|watcher| NativeWatcher::Raw(Mutex::new(watcher))), + DeliveryMode::Debounced { window } => { + let known = KnownPaths::default(); + let cache = SharedFileIdCache(known.clone()); + with_pending_known_paths(&known, || { + new_debouncer_opt::<_, DebouncedBackend, SharedFileIdCache>( + window, + None, + move |result| handle_debounce_result(watcher_handle, result), + cache, + config, + ) + }) + .ok() + .map(|debouncer| NativeWatcher::Debounced(Mutex::new(debouncer))) + } } } BackendMode::Polling { @@ -480,25 +724,127 @@ fn create_native_handle( .with_compare_contents(compare_contents); match delivery_mode { DeliveryMode::Raw => PollWatcher::new( - move |result| handle_notify_result(watcher_handle, registration_id, result), + move |result| handle_notify_result(watcher_handle, result), config, ) .ok() - .map(|watcher| NativeWatcherHandle::Polling(Arc::new(Mutex::new(watcher)))), + .map(|watcher| NativeWatcher::Polling(Mutex::new(watcher))), DeliveryMode::Debounced { window } => new_debouncer_opt::<_, PollWatcher, FileIdMap>( window, None, - move |result| handle_debounce_result(watcher_handle, registration_id, result), + move |result| handle_debounce_result(watcher_handle, result), FileIdMap::new(), config, ) .ok() - .map(|watcher| NativeWatcherHandle::PollingDebounced(Arc::new(Mutex::new(watcher)))), + .map(|debouncer| NativeWatcher::PollingDebounced(Mutex::new(debouncer))), } } } } +struct WatcherSettings { + mutation: Arc>, + follow_symlinks: bool, + backend_mode: BackendMode, + delivery_mode: DeliveryMode, +} + +fn watcher_settings(watcher_handle: i64) -> Option { + let watchers = WATCHERS.lock().ok()?; + let state = watchers.get(&watcher_handle)?; + Some(WatcherSettings { + mutation: Arc::clone(&state.mutation), + follow_symlinks: state.follow_symlinks, + backend_mode: state.backend_mode, + delivery_mode: state.delivery_mode, + }) +} + +/// How the backend currently covers `watched_root` through other registrations of this watcher. +struct RootCoverage { + watched: bool, + recursive: bool, +} + +fn root_coverage(watcher_handle: i64, watched_root: &Path, excluding: Option) -> Option { + let watchers = WATCHERS.lock().ok()?; + let state = watchers.get(&watcher_handle)?; + let mut coverage = RootCoverage { + watched: false, + recursive: false, + }; + for (id, registration) in &state.registrations { + if Some(*id) == excluding || registration.watched_root != watched_root { + continue; + } + coverage.watched = true; + coverage.recursive |= registration.recursive; + } + Some(coverage) +} + +/// Returns the watcher's shared backend, creating it on first use. `None` once the watcher is gone. +fn shared_native_watcher(watcher_handle: i64, settings: &WatcherSettings) -> Option> { + if let Some(existing) = WATCHERS + .lock() + .ok()? + .get(&watcher_handle)? + .native_watcher + .clone() + { + return Some(existing); + } + // Created outside the lock: backends spawn threads and the debouncer starts ticking. + let created = Arc::new(create_native_watcher( + watcher_handle, + settings.follow_symlinks, + settings.backend_mode, + settings.delivery_mode, + )?); + let mut watchers = WATCHERS.lock().ok()?; + let state = watchers.get_mut(&watcher_handle)?; + Some(Arc::clone(state.native_watcher.get_or_insert(created))) +} + +/// Drops the shared backend once no registration needs it, releasing its inotify instance, +/// FSEvents stream or directory handles. Returned so the caller drops it outside the lock. +fn release_native_watcher_if_unused(watcher_handle: i64) -> Option> { + let mut watchers = WATCHERS.lock().ok()?; + let state = watchers.get_mut(&watcher_handle)?; + if state.registrations.is_empty() { + state.native_watcher.take() + } else { + None + } +} + +/// The spelling handed to the backend. One backend watch per *real* directory, so two +/// registrations of the same directory under different spellings share it and the Kotlin side +/// projects events back onto each registration's own root. +/// +/// - macOS: FSEvents reports canonical paths anyway, and the debouncer keys its file-id cache by +/// the root it was given — a `/var/...` root would never pair a rename reported under +/// `/private/var`. +/// - Linux: inotify identifies a directory by inode, so two spellings share one watch descriptor +/// and notify keeps a single path per descriptor; watching the canonical spelling makes the +/// reported paths the same for every alias instead of whichever alias registered last. +/// - Windows: `canonicalize()` yields `\\?\` verbatim paths that Java's `toRealPath()` never +/// produces, so the registered spelling is kept; `ReadDirectoryChangesW` opens one handle per +/// watch anyway. +fn watched_root_for(original_root: &Path, resolved_root: &Path) -> PathBuf { + if cfg!(target_os = "windows") { + let _ = resolved_root; + original_root.to_path_buf() + } else { + resolved_root.to_path_buf() + } +} + +// --------------------------------------------------------------------------------------------- +// JNI entry points +// --------------------------------------------------------------------------------------------- + #[no_mangle] pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge_nativeIsSupported( _env: JNIEnv, @@ -539,15 +885,15 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge }; let watcher_handle = NEXT_WATCHER_HANDLE.fetch_add(1, Ordering::Relaxed); - let closed = Arc::new(AtomicBool::new(false)); if let Ok(mut watchers) = WATCHERS.lock() { watchers.insert( watcher_handle, WatcherState { registrations: HashMap::new(), - native_watchers: HashMap::new(), - closed, + native_watcher: None, + mutation: Arc::new(Mutex::new(())), + closed: Arc::new(AtomicBool::new(false)), follow_symlinks: follow_symlinks != JNI_FALSE, backend_mode, delivery_mode, @@ -565,30 +911,19 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge _class: JClass, watcher_handle: jlong, ) { - let Some((native_watchers, closed)) = WATCHERS.lock().ok().and_then(|mut watchers| { - let mut state = watchers.remove(&watcher_handle)?; - state.closed.store(true, Ordering::Release); - Some(( - state - .native_watchers - .drain() - .into_iter() - .filter_map(|(registration_id, native_handle)| { - state - .registrations - .get(®istration_id) - .map(|registration| (native_handle, registration.original_root.clone())) - }) - .collect::>(), - Arc::clone(&state.closed), - )) - }) else { + // Removing the state first stops callbacks from matching anything; the mutation lock then + // waits for a watch / unwatch in flight before the backend is dropped outside every lock. + let Some(state) = WATCHERS + .lock() + .ok() + .and_then(|mut watchers| watchers.remove(&watcher_handle)) + else { return; }; - closed.store(true, Ordering::Release); - for (native_handle, path) in native_watchers { - let _ = native_handle_unwatch(&native_handle, &path); - } + state.closed.store(true, Ordering::Release); + let mutation = Arc::clone(&state.mutation); + let _mutation_guard = mutation.lock(); + drop(state); } #[no_mangle] @@ -610,76 +945,75 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge let resolved_root = original_root .canonicalize() .unwrap_or_else(|_| original_root.clone()); - let recursive_mode = if recursive == JNI_FALSE { - RecursiveMode::NonRecursive - } else { + let watched_root = watched_root_for(&original_root, &resolved_root); + let recursive = recursive != JNI_FALSE; + let recursive_mode = if recursive { RecursiveMode::Recursive + } else { + RecursiveMode::NonRecursive }; - let follow_symlinks = { - let Ok(watchers) = WATCHERS.lock() else { - return JNI_FALSE; - }; - let Some(state) = watchers.get(&watcher_handle) else { - return JNI_FALSE; - }; - (state.follow_symlinks, state.backend_mode, state.delivery_mode) + + let Some(settings) = watcher_settings(watcher_handle) else { + return JNI_FALSE; }; - let (follow_symlinks, backend_mode, delivery_mode) = follow_symlinks; - if matches!(backend_mode, BackendMode::Polling { .. }) && std::fs::metadata(&original_root).is_err() { + let mutation = Arc::clone(&settings.mutation); + let Ok(_mutation_guard) = mutation.lock() else { + return JNI_FALSE; + }; + if matches!(settings.backend_mode, BackendMode::Polling { .. }) && std::fs::metadata(&original_root).is_err() { return JNI_FALSE; } - let mut native_watcher = Some(match create_native_handle( - watcher_handle, - registration_id, - follow_symlinks, - backend_mode, - delivery_mode, - ) { - Some(native_watcher) => native_watcher, - None => return JNI_FALSE, - }); + let Some(native_watcher) = shared_native_watcher(watcher_handle, &settings) else { + return JNI_FALSE; + }; + let Some(coverage) = root_coverage(watcher_handle, &watched_root, None) else { + return JNI_FALSE; + }; - if native_handle_watch( - native_watcher.as_ref().expect("native watcher must exist"), - &original_root, - recursive_mode, - ) - .is_err() - { + // The backend watches a root once per watcher. A recursive registration arriving over a + // non-recursive one re-watches it: notify's backends do not widen an existing watch in place + // (ReadDirectoryChangesW would even leak the old handle and report everything twice). + let backend_result = if !coverage.watched { + native_watch(&native_watcher, &watched_root, recursive_mode) + } else if recursive && !coverage.recursive { + let _ = native_unwatch(&native_watcher, &watched_root); + native_watch(&native_watcher, &watched_root, recursive_mode) + } else { + Ok(()) + }; + if backend_result.is_err() { + drop(release_native_watcher_if_unused(watcher_handle)); return JNI_FALSE; } - let registration = RegistrationState { - original_root, - resolved_root, - recursive: recursive != JNI_FALSE, - live: true, - }; - let should_cleanup = { - let Ok(mut watchers) = WATCHERS.lock() else { - return JNI_FALSE; - }; - match watchers.get_mut(&watcher_handle) { - Some(state) if !state.closed.load(Ordering::Acquire) => { - state.registrations.insert(registration_id, registration.clone()); - state.native_watchers.insert( - registration_id, - native_watcher.take().expect("native watcher must exist"), - ); - false + let registered = WATCHERS + .lock() + .ok() + .and_then(|mut watchers| { + let state = watchers.get_mut(&watcher_handle)?; + if state.closed.load(Ordering::Acquire) { + return None; } - _ => true, - } - }; + state.registrations.insert( + registration_id, + RegistrationState { + original_root, + resolved_root, + watched_root: watched_root.clone(), + recursive, + }, + ); + Some(()) + }) + .is_some(); - if should_cleanup { - let registration_path = registration.original_root.clone(); - if let Some(native_handle) = native_watcher.take() { - let _ = native_handle_unwatch(&native_handle, ®istration_path); + if registered { + JNI_TRUE + } else { + if !coverage.watched { + let _ = native_unwatch(&native_watcher, &watched_root); } JNI_FALSE - } else { - JNI_TRUE } } @@ -690,16 +1024,30 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge watcher_handle: jlong, registration_id: jlong, ) { - let Some((native_handle, path)) = WATCHERS.lock().ok().and_then(|mut watchers| { + let Some(settings) = watcher_settings(watcher_handle) else { + return; + }; + let Ok(_mutation_guard) = settings.mutation.lock() else { + return; + }; + let removed = WATCHERS.lock().ok().and_then(|mut watchers| { let state = watchers.get_mut(&watcher_handle)?; let registration = state.registrations.remove(®istration_id)?; - let path = registration.original_root; - let native_handle = state.native_watchers.remove(®istration_id)?; - Some((native_handle, path)) - }) else { + let native_watcher = state.native_watcher.clone(); + Some((registration, native_watcher)) + }); + let Some((registration, Some(native_watcher))) = removed else { + return; + }; + let Some(coverage) = root_coverage(watcher_handle, ®istration.watched_root, None) else { return; }; - let _ = native_handle_unwatch(&native_handle, &path); + // Dropping the whole backend stops every watch at once; otherwise the root is unwatched only + // when no other registration still relies on it. + let released = release_native_watcher_if_unused(watcher_handle); + if released.is_none() && !coverage.watched { + let _ = native_unwatch(&native_watcher, ®istration.watched_root); + } } #[no_mangle] @@ -735,7 +1083,7 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge return JNI_FALSE; } - let Some(registration) = with_live_registration_by_id(origin_native_registration_id, watcher_handle) else { + let Some(registration) = registration_by_id(origin_native_registration_id, watcher_handle) else { return JNI_FALSE; }; if match_registration(®istration, &first_path).is_none() { @@ -774,7 +1122,7 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge }; let first_path = PathBuf::from(path.to_string_lossy().into_owned()); - let Some(registration) = with_live_registration_by_id(origin_native_registration_id, watcher_handle) else { + let Some(registration) = registration_by_id(origin_native_registration_id, watcher_handle) else { return JNI_FALSE; }; if match_registration(®istration, &first_path).is_none() { @@ -807,9 +1155,9 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge return JNI_FALSE; }; - let Some(_registration) = with_live_registration_by_id(origin_native_registration_id, watcher_handle) else { + if registration_by_id(origin_native_registration_id, watcher_handle).is_none() { return JNI_FALSE; - }; + } emit_error( watcher_handle, @@ -821,3 +1169,104 @@ pub extern "system" fn Java_dev_nucleusframework_fswatcher_NativeFsWatcherBridge ); JNI_TRUE } + +#[cfg(test)] +mod tests { + use super::*; + + fn event_with_paths(kind: EventKind, paths: &[&Path]) -> Event { + Event { + kind, + paths: paths.iter().map(|path| path.to_path_buf()).collect(), + attrs: Default::default(), + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("nucleus-fs-watcher-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn classify_event_maps_paired_rename_to_moved_and_unpaired_halves_to_their_effect() { + let dir = temp_dir("classify"); + let present = dir.join("present.txt"); + std::fs::write(&present, "x").unwrap(); + let gone = dir.join("gone.txt"); + + let rename_kind = |mode| EventKind::Modify(ModifyKind::Name(mode)); + assert_eq!( + classify_event(&event_with_paths(rename_kind(RenameMode::Both), &[&gone, &present])), + Some(EVENT_KIND_MOVED) + ); + assert_eq!( + classify_event(&event_with_paths(rename_kind(RenameMode::From), &[&gone])), + Some(EVENT_KIND_REMOVED) + ); + assert_eq!( + classify_event(&event_with_paths(rename_kind(RenameMode::To), &[&present])), + Some(EVENT_KIND_CREATED) + ); + assert_eq!( + classify_event(&event_with_paths(rename_kind(RenameMode::Any), &[&gone])), + Some(EVENT_KIND_REMOVED) + ); + assert_eq!( + classify_event(&event_with_paths(rename_kind(RenameMode::Any), &[&present])), + Some(EVENT_KIND_CREATED) + ); + // A `Both` that lost its second path degrades like an `Any`. + assert_eq!( + classify_event(&event_with_paths(rename_kind(RenameMode::Both), &[&gone])), + Some(EVENT_KIND_REMOVED) + ); + assert_eq!( + classify_event(&event_with_paths(rename_kind(RenameMode::Other), &[&gone])), + None + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[cfg(target_os = "macos")] + #[test] + fn fsevents_normalizer_drops_history_and_keeps_what_is_true_now() { + use notify::event::{CreateKind, DataChange, MetadataKind, RemoveKind}; + + let dir = temp_dir("normalizer"); + let known_file = dir.join("known.txt"); + std::fs::write(&known_file, "known").unwrap(); + let fresh_file = dir.join("fresh.txt"); + std::fs::write(&fresh_file, "fresh").unwrap(); + let gone = dir.join("gone.txt"); + + let known = KnownPaths::default(); + known.lock_store().unwrap().add_path(&known_file, RecursiveMode::NonRecursive); + let mut normalizer = FsEventsNormalizer::new(known); + let mut normalize = |kind, path: &Path| normalizer.normalize(event_with_paths(kind, &[path])).is_some(); + + // The rename source as FSEvents reports it: Create + Rename + Modify on a gone path. + assert!(!normalize(EventKind::Create(CreateKind::File), &gone)); + assert!(normalize(EventKind::Modify(ModifyKind::Name(RenameMode::Any)), &gone)); + assert!(!normalize(EventKind::Modify(ModifyKind::Metadata(MetadataKind::Extended)), &gone)); + assert!(!normalize(EventKind::Modify(ModifyKind::Data(DataChange::Content)), &gone)); + + // A delete carrying the file's historical Created bit. + assert!(!normalize(EventKind::Create(CreateKind::File), &gone)); + assert!(normalize(EventKind::Remove(RemoveKind::File), &gone)); + // `Removed | Renamed` on the same gone path: the rename half is history. + assert!(!normalize(EventKind::Modify(ModifyKind::Name(RenameMode::Any)), &gone)); + + // Stale Created on a file the watch already tracks vs a genuinely new file. + assert!(!normalize(EventKind::Create(CreateKind::File), &known_file)); + assert!(normalize(EventKind::Create(CreateKind::File), &fresh_file)); + // Present paths keep their modifications; a Remove for a present path is history. + assert!(normalize(EventKind::Modify(ModifyKind::Data(DataChange::Content)), &known_file)); + assert!(!normalize(EventKind::Remove(RemoveKind::File), &known_file)); + // The rename target is present and passes through untouched. + assert!(normalize(EventKind::Modify(ModifyKind::Name(RenameMode::Any)), &fresh_file)); + + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/fs-watcher/src/test/kotlin/dev/nucleusframework/fswatcher/FsWatcherRealFileSystemTest.kt b/fs-watcher/src/test/kotlin/dev/nucleusframework/fswatcher/FsWatcherRealFileSystemTest.kt index b209c9f80..bb490948f 100644 --- a/fs-watcher/src/test/kotlin/dev/nucleusframework/fswatcher/FsWatcherRealFileSystemTest.kt +++ b/fs-watcher/src/test/kotlin/dev/nucleusframework/fswatcher/FsWatcherRealFileSystemTest.kt @@ -3,6 +3,7 @@ package dev.nucleusframework.fswatcher import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -188,77 +189,103 @@ class FsWatcherRealFileSystemTest { } } + // #570: a rename must report the old path leaving. FSEvents attaches an inode's *historical* + // flags (ItemCreated, ItemModified, ...) to every event, which used to make the debouncer fold + // a rename of a long-existing file into a bare Created(new) — and a delete into Modified. @Test - fun defaultDebouncedWatcherTreatsRealRenameAsHostSensitiveObservation() = + fun debouncedRenameOfPreExistingFileReportsMoved() = runBlocking { if (!FsWatchers.isSupported()) return@runBlocking - val root = createRealTempDirectory("fs-watcher-real-fs-debounced-rename") + val root = createRealTempDirectory("fs-watcher-real-fs-rename-pre-existing") val from = root.resolve("before.txt") val to = root.resolve("after.txt") - try { Files.writeString(from, "before-rename") - FsWatchers.create().use { watcher -> val registration = watcher.watch(root, recursive = true) + collectingEvents(watcher) { seen -> + Files.move(from, to) + awaitRenameSettled(seen, from, to) + assertRenameReportedAsMoved(seen, from, to, registration.source) + } + } + } finally { + deleteRecursively(root) + } + } - val seen = java.util.Collections.synchronizedList(mutableListOf()) - val collector = - launch(start = CoroutineStart.UNDISPATCHED) { - watcher.events.collect { seen += it } - } + @Test + fun debouncedRenameOfFileCreatedAfterWatchReportsMoved() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking + + val root = createRealTempDirectory("fs-watcher-real-fs-rename-fresh") + val from = root.resolve("fresh.txt") + val to = root.resolve("fresh-renamed.txt") + try { + FsWatchers.create().use { watcher -> + val registration = watcher.watch(root, recursive = true) + collectingEvents(watcher) { seen -> + Files.writeString(from, "fresh") + awaitEvents { seen.anyCreated(from) } + // Let the creation leave the debounce window before renaming. + delay(600) + seen.clear() - try { Files.move(from, to) + awaitRenameSettled(seen, from, to) + assertRenameReportedAsMoved(seen, from, to, registration.source) + } + } + } finally { + deleteRecursively(root) + } + } - awaitEvents { - synchronized(seen) { - seen.any { event -> - event.matchesSource(registration.source) && - (event.matchesPath(from) || event.matchesPath(to)) - } - } - } + @Test + fun debouncedMoveAcrossDirectoriesReportsMoved() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking - val moved = - synchronized(seen) { - seen.filterIsInstance().firstOrNull { - it.source == registration.source - } - } - val removedFrom = - synchronized(seen) { - seen.filterIsInstance().firstOrNull { - it.path == from && it.source == registration.source - } - } - val createdTo = - synchronized(seen) { - seen.filterIsInstance().firstOrNull { - it.path == to && it.source == registration.source - } - } - val observedRenameLikeEvent = - synchronized(seen) { - seen.firstOrNull { event -> - event.matchesSource(registration.source) && - (event.matchesPath(from) || event.matchesPath(to)) - } - } + val root = createRealTempDirectory("fs-watcher-real-fs-move-across-dirs") + val from = Files.createDirectories(root.resolve("a")).resolve("mover.txt") + val to = Files.createDirectories(root.resolve("b")).resolve("mover.txt") + try { + Files.writeString(from, "mover") + FsWatchers.create().use { watcher -> + val registration = watcher.watch(root, recursive = true) + collectingEvents(watcher) { seen -> + Files.move(from, to) + awaitRenameSettled(seen, from, to) + assertRenameReportedAsMoved(seen, from, to, registration.source) + } + } + } finally { + deleteRecursively(root) + } + } + @Test + fun debouncedDirectoryRenameReportsMoved() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking + + val root = createRealTempDirectory("fs-watcher-real-fs-dir-rename") + val from = Files.createDirectories(root.resolve("adir")) + val to = root.resolve("bdir") + try { + Files.writeString(from.resolve("child.txt"), "child") + FsWatchers.create().use { watcher -> + val registration = watcher.watch(root, recursive = true) + collectingEvents(watcher) { seen -> + Files.move(from, to) + awaitRenameSettled(seen, from, to) + assertRenameReportedAsMoved(seen, from, to, registration.source) + val moved = seen.filterIsInstance().firstOrNull { it.from == from } if (moved != null) { - assertEquals(from, moved.from) - assertEquals(to, moved.to) - } else { - if (removedFrom != null || createdTo != null) { - assertTrue(removedFrom != null || createdTo != null) - } else { - assertNotNull(observedRenameLikeEvent) - } + assertTrue(moved.isDirectory != false, "directory rename flagged as a file: $moved") } - } finally { - collector.cancelAndJoin() } } } finally { @@ -266,6 +293,154 @@ class FsWatcherRealFileSystemTest { } } + @Test + fun debouncedRenameUnderNonCanonicalRootReportsMovedInRegisteredSpelling() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking + + // Deliberately *not* canonicalized: on macOS this is /var/folders/... while FSEvents + // reports /private/var/folders/..., which used to defeat the file-id rename pairing. + val root = Files.createTempDirectory("fs-watcher-real-fs-rename-non-canonical") + val from = root.resolve("before.txt") + val to = root.resolve("after.txt") + try { + Files.writeString(from, "before-rename") + FsWatchers.create().use { watcher -> + val registration = watcher.watch(root, recursive = true) + collectingEvents(watcher) { seen -> + Files.move(from, to) + awaitRenameSettled(seen, from, to) + assertRenameReportedAsMoved(seen, from, to, registration.source) + } + } + } finally { + deleteRecursively(root) + } + } + + @Test + fun debouncedDeleteOfPreExistingFileReportsRemovedWithoutStaleModified() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking + + val root = createRealTempDirectory("fs-watcher-real-fs-delete-pre-existing") + val target = root.resolve("doomed.txt") + try { + Files.writeString(target, "doomed") + FsWatchers.create().use { watcher -> + watcher.watch(root, recursive = true) + collectingEvents(watcher) { seen -> + Files.delete(target) + awaitEvents { seen.any { it.matchesPath(target) } } + delay(RENAME_SETTLE_MILLIS) + + assertTrue(seen.anyRemoved(target), "delete not reported as Removed: $seen") + assertFalse( + seen.any { it is FsWatchEvent.Modified && it.path == target }, + "stale Modified reported for a deleted file: $seen", + ) + assertFalse(seen.anyCreated(target), "stale Created reported for a deleted file: $seen") + } + } + } finally { + deleteRecursively(root) + } + } + + @Test + fun rawRenameReportsOldPathRemovedAndNewPathCreated() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking + + val root = createRealTempDirectory("fs-watcher-real-fs-raw-rename") + val from = root.resolve("before.txt") + val to = root.resolve("after.txt") + try { + Files.writeString(from, "before-rename") + FsWatchers + .create(FsWatcherConfig(deliveryMode = FsWatchDeliveryMode.Raw)) + .use { watcher -> + watcher.watch(root, recursive = true) + collectingEvents(watcher) { seen -> + Files.move(from, to) + awaitEvents { seen.anyRemoved(from) && seen.anyCreated(to) } + delay(RENAME_SETTLE_MILLIS) + + // Raw delivery never pairs renames; the contract is Removed(old) + Created(new). + // (FSEvents may add the path's historical flags on top — raw means raw.) + assertTrue(seen.anyRemoved(from), "raw rename lost the old path: $seen") + assertTrue(seen.anyCreated(to), "raw rename lost the new path: $seen") + assertTrue(seen.none { it is FsWatchEvent.Moved }, "raw delivery emitted Moved: $seen") + } + } + } finally { + deleteRecursively(root) + } + } + + // #571: every registration used to open its own native watcher — on Linux one inotify + // instance each, drawn from the machine-wide fs.inotify.max_user_instances budget. + @Test + fun manyRegistrationsOnOneDebouncedWatcherShareOneNativeWatcher() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking + assertRegistrationsShareOneNativeWatcher(FsWatcherConfig()) + } + + @Test + fun manyRegistrationsOnOneRawWatcherShareOneNativeWatcher() = + runBlocking { + if (!FsWatchers.isSupported()) return@runBlocking + assertRegistrationsShareOneNativeWatcher(FsWatcherConfig(deliveryMode = FsWatchDeliveryMode.Raw)) + } + + private suspend fun assertRegistrationsShareOneNativeWatcher(config: FsWatcherConfig) { + val registrationCount = 24 + val root = createRealTempDirectory("fs-watcher-real-fs-shared-native") + val projects = List(registrationCount) { Files.createDirectories(root.resolve("project-$it")) } + try { + val baseline = NativeResourceSnapshot.take() + FsWatchers.create(config).use { watcher -> + collectingEvents(watcher) { seen -> + val registrations = + projects.map { project -> + watcher.watch(project, recursive = true, name = project.fileName.toString()) + } + // FSEvents restarts its stream on every watch(); let the backend settle. + delay(500) + NativeResourceSnapshot.take().assertSharedWith(baseline, registrationCount) + + // Events still route to their own registration through the shared watcher. + val third = projects[3].resolve("third.txt") + val seventeenth = projects[17].resolve("seventeenth.txt") + Files.writeString(third, "3") + Files.writeString(seventeenth, "17") + awaitEvents { + seen.hasEventFromSource(third, registrations[3].source) && + seen.hasEventFromSource(seventeenth, registrations[17].source) + } + assertFalse( + seen.any { it.matchesPath(third) && !it.matchesSource(registrations[3].source) }, + "event for project 3 leaked to another registration: $seen", + ) + + // Closing one registration only stops its own path. + registrations[3].close() + seen.clear() + val afterClose = projects[3].resolve("after-close.txt") + val stillWatched = projects[17].resolve("still-watched.txt") + Files.writeString(afterClose, "3") + Files.writeString(stillWatched, "17") + awaitEvents { seen.hasEventFromSource(stillWatched, registrations[17].source) } + delay(400) + assertFalse(seen.any { it.matchesPath(afterClose) }, "closed registration still delivered: $seen") + } + } + } finally { + deleteRecursively(root) + } + } + @Test fun rawDeliveryModeStillDeliversCoreRealFileEvents() = runBlocking { @@ -903,9 +1078,9 @@ class FsWatcherRealFileSystemTest { fun symlinkRootResolvedFileEventsDoNotRemapWhenFollowSymlinksDisabled() = runBlocking { if (!FsWatchers.isSupported()) return@runBlocking - // Linux and Windows report this real-fs symlink case differently - // from the lexical-path behavior asserted here. - if (isLinuxHost() || isWindowsHost()) return@runBlocking + // ReadDirectoryChangesW is handed the registered spelling, so Windows reports this + // real-fs symlink case under the lexical path regardless of followSymlinks. + if (isWindowsHost()) return@runBlocking val canonicalRoot = createRealTempDirectory("fs-watcher-real-fs-no-follow-target") val symlinkRoot = canonicalRoot.parent.resolve("${canonicalRoot.fileName}-link") @@ -1312,3 +1487,120 @@ private fun tryDeleteRecursively(root: Path): Boolean = } catch (_: Exception) { false } + +private const val RENAME_SETTLE_MILLIS = 600L + +private suspend fun collectingEvents( + watcher: FsWatcher, + block: suspend (MutableList) -> Unit, +) { + val seen = java.util.Collections.synchronizedList(mutableListOf()) + coroutineScope { + val collector = + launch(start = CoroutineStart.UNDISPATCHED) { + watcher.events.collect { seen += it } + } + try { + block(seen) + } finally { + collector.cancelAndJoin() + } + } +} + +// Waits for the first event about either rename endpoint, then lets the rest of the batch land. +private suspend fun awaitRenameSettled( + seen: List, + from: Path, + to: Path, +) { + awaitEvents { seen.any { it.matchesPath(from) || it.matchesPath(to) } } + delay(RENAME_SETTLE_MILLIS) +} + +private fun assertRenameReportedAsMoved( + seen: List, + from: Path, + to: Path, + source: FsWatchSource, +) { + val snapshot = synchronized(seen) { seen.toList() } + val moved = + snapshot.filterIsInstance().firstOrNull { + it.from == from && it.to == to && it.source == source + } + if (moved == null && isWindowsHost()) { + // ReadDirectoryChangesW pairs renames through file ids only; accept the degraded shape there. + assertTrue( + snapshot.anyRemoved(from) && snapshot.anyCreated(to), + "rename reported neither as Moved nor as Removed+Created: $snapshot", + ) + } else { + assertNotNull(moved, "expected Moved($from -> $to), saw: $snapshot") + } + assertFalse(snapshot.anyCreated(from), "stale Created reported for the old path: $snapshot") + assertFalse( + snapshot.any { it is FsWatchEvent.Modified && it.path == from }, + "stale Modified reported for the old path: $snapshot", + ) +} + +private fun isMacHost(): Boolean = System.getProperty("os.name").startsWith("Mac") + +// OS-level view of what a native watcher costs: inotify instances (Linux) and OS threads +// (every notify backend runs one event-loop thread per watcher, plus one per debouncer). +private data class NativeResourceSnapshot( + val inotifyInstances: Int?, + val osThreads: Int?, +) { + fun assertSharedWith( + baseline: NativeResourceSnapshot, + registrationCount: Int, + ) { + if (inotifyInstances != null && baseline.inotifyInstances != null) { + val added = inotifyInstances - baseline.inotifyInstances + assertTrue( + added <= 1, + "$registrationCount registrations opened $added inotify instances; expected at most 1", + ) + } + if (osThreads != null && baseline.osThreads != null) { + val added = osThreads - baseline.osThreads + assertTrue( + added < registrationCount, + "$registrationCount registrations started $added OS threads; a shared native watcher needs a handful", + ) + } + } + + companion object { + fun take(): NativeResourceSnapshot = + when { + isLinuxHost() -> NativeResourceSnapshot(countInotifyInstances(), countProcThreads()) + isMacHost() -> NativeResourceSnapshot(inotifyInstances = null, osThreads = countPsThreads()) + else -> NativeResourceSnapshot(inotifyInstances = null, osThreads = null) + } + + private fun countInotifyInstances(): Int = + Files.list(Path.of("/proc/self/fd")).use { fds -> + fds + .filter { fd -> + runCatching { Files.readSymbolicLink(fd).toString() }.getOrNull() == "anon_inode:inotify" + }.count() + .toInt() + } + + private fun countProcThreads(): Int = Files.list(Path.of("/proc/self/task")).use { it.count().toInt() } + + private fun countPsThreads(): Int { + val process = + ProcessBuilder("ps", "-M", "-p", ProcessHandle.current().pid().toString()) + .redirectErrorStream(true) + .start() + val lines = process.inputStream.bufferedReader().readLines() + process.waitFor() + // One header line, then one line per thread. + return (lines.size - 1).coerceAtLeast(0) + } + } +} diff --git a/global-hotkey/build.gradle.kts b/global-hotkey/build.gradle.kts index e326aefa9..d70ce0730 100644 --- a/global-hotkey/build.gradle.kts +++ b/global-hotkey/build.gradle.kts @@ -14,9 +14,10 @@ val publishVersion = ?: "1.0.0" // Controlled repro for issue #264 residual portal bugs (see src/repro/...). -val repro by sourceSets.creating { - kotlin.srcDir("src/repro/kotlin") -} +val repro = + sourceSets.create("repro") { + kotlin.srcDir("src/repro/kotlin") + } configurations { named("reproImplementation") { extendsFrom(configurations["implementation"]) } diff --git a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/GlobalHotKeyManager.kt b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/GlobalHotKeyManager.kt index fe2ec397b..2429bf880 100644 --- a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/GlobalHotKeyManager.kt +++ b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/GlobalHotKeyManager.kt @@ -121,7 +121,7 @@ public object GlobalHotKeyManager { * @param description user-readable description of what the shortcut does (e.g. "Play/Pause"). * Shown in the system shortcut dialog on Linux/Wayland (portal backend); ignored * on other platforms. When null, the key combination is used as a fallback. - * @param listener callback invoked when the hotkey is pressed. + * @param listener callback invoked when the hotkey is pressed, on the host UI thread. * @return a registration handle for [unregister], or -1 on failure. */ public fun register( @@ -144,7 +144,7 @@ public object GlobalHotKeyManager { * Register a media key as a global hotkey. * * @param mediaKey the media key to register. - * @param listener callback invoked when the key is pressed. + * @param listener callback invoked when the key is pressed, on the host UI thread. * @return a registration handle for [unregister], or -1 on failure. */ public fun register( diff --git a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/HotKeyListener.kt b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/HotKeyListener.kt index b4e8c5025..e56a74ea6 100644 --- a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/HotKeyListener.kt +++ b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/HotKeyListener.kt @@ -1,6 +1,13 @@ package dev.nucleusframework.globalhotkey -/** Callback invoked when a registered global hotkey is pressed. */ +/** + * Callback invoked when a registered global hotkey is pressed. + * + * Always invoked on the host's UI thread (see [dev.nucleusframework.core.runtime.NucleusUiThread]): + * the Tao main thread under `nucleusApplication`, the AWT event dispatch thread otherwise. + * Never called on the native thread that received the key press. A press still queued + * when [GlobalHotKeyManager.unregister] or [GlobalHotKeyManager.shutdown] returns is dropped. + */ public fun interface HotKeyListener { /** * Called when the hotkey is triggered. diff --git a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridge.kt b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridge.kt index 174823120..194bbf869 100644 --- a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridge.kt +++ b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridge.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.globalhotkey.linux import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NucleusUiThread import dev.nucleusframework.globalhotkey.HotKeyListener import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong @@ -53,7 +54,9 @@ internal object NativeLinuxHotKeyBridge { keyCode: Int, modifiers: Int, ) { - listeners[id]?.onHotKey(keyCode, modifiers) + // Native fires on its own thread; resolve the listener on the UI thread so a + // press queued before unregister() is dropped rather than delivered late. + NucleusUiThread.post { listeners[id]?.onHotKey(keyCode, modifiers) } } fun registerListener(listener: HotKeyListener): Long { diff --git a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/macos/NativeMacOsHotKeyBridge.kt b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/macos/NativeMacOsHotKeyBridge.kt index a1dd9110f..c9c2d58e6 100644 --- a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/macos/NativeMacOsHotKeyBridge.kt +++ b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/macos/NativeMacOsHotKeyBridge.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.globalhotkey.macos import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NucleusUiThread import dev.nucleusframework.globalhotkey.HotKeyListener import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong @@ -56,7 +57,9 @@ internal object NativeMacOsHotKeyBridge { keyCode: Int, modifiers: Int, ) { - listeners[id]?.onHotKey(keyCode, modifiers) + // Native fires on its own thread; resolve the listener on the UI thread so a + // press queued before unregister() is dropped rather than delivered late. + NucleusUiThread.post { listeners[id]?.onHotKey(keyCode, modifiers) } } fun registerListener(listener: HotKeyListener): Long { diff --git a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/windows/NativeWindowsHotKeyBridge.kt b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/windows/NativeWindowsHotKeyBridge.kt index 395c8f7f6..43ea18e0e 100644 --- a/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/windows/NativeWindowsHotKeyBridge.kt +++ b/global-hotkey/src/main/kotlin/dev/nucleusframework/globalhotkey/windows/NativeWindowsHotKeyBridge.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.globalhotkey.windows import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NucleusUiThread import dev.nucleusframework.globalhotkey.HotKeyListener import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong @@ -56,7 +57,9 @@ internal object NativeWindowsHotKeyBridge { keyCode: Int, modifiers: Int, ) { - listeners[id]?.onHotKey(keyCode, modifiers) + // Native fires on its own thread; resolve the listener on the UI thread so a + // press queued before unregister() is dropped rather than delivered late. + NucleusUiThread.post { listeners[id]?.onHotKey(keyCode, modifiers) } } fun registerListener(listener: HotKeyListener): Long { diff --git a/global-hotkey/src/main/native/linux/nucleus_global_hotkey_linux.c b/global-hotkey/src/main/native/linux/nucleus_global_hotkey_linux.c index 5b9bbaca4..54a2f91a4 100644 --- a/global-hotkey/src/main/native/linux/nucleus_global_hotkey_linux.c +++ b/global-hotkey/src/main/native/linux/nucleus_global_hotkey_linux.c @@ -17,6 +17,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -123,7 +124,7 @@ static void fireHotKey(jlong id, jint keyCode, jint modifiers) { } else if (st != JNI_OK) return; (*env)->CallStaticVoidMethod(env, g_bridgeClass, g_onHotKeyMethod, id, keyCode, modifiers); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); if (didAttach) (*g_jvm)->DetachCurrentThread(g_jvm); } @@ -132,8 +133,7 @@ static void fireHotKeyPortal(jlong id, jint keyCode, jint modifiers) { if (!g_portal_env || !g_bridgeClass || !g_onHotKeyMethod) return; (*g_portal_env)->CallStaticVoidMethod(g_portal_env, g_bridgeClass, g_onHotKeyMethod, id, keyCode, modifiers); - if ((*g_portal_env)->ExceptionCheck(g_portal_env)) - (*g_portal_env)->ExceptionClear(g_portal_env); + nucleus_jni_clear_exception(g_portal_env); } /* awtToKeySym() and buildTrigger() live in nucleus_hotkey_keys.h so the diff --git a/global-hotkey/src/main/native/macos/nucleus_global_hotkey_macos.m b/global-hotkey/src/main/native/macos/nucleus_global_hotkey_macos.m index 988cdcb0c..14a447686 100644 --- a/global-hotkey/src/main/native/macos/nucleus_global_hotkey_macos.m +++ b/global-hotkey/src/main/native/macos/nucleus_global_hotkey_macos.m @@ -1,6 +1,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" #include // ---- Global state ---- @@ -201,9 +202,7 @@ static void fireHotKeyToJVM(jlong id, jint keyCode) { (*env)->CallStaticVoidMethod(env, g_bridgeClass, g_onHotKeyMethod, id, keyCode, (jint)0); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); diff --git a/global-hotkey/src/main/native/windows/nucleus_global_hotkey.cpp b/global-hotkey/src/main/native/windows/nucleus_global_hotkey.cpp index 06dee3af4..f5ab91983 100644 --- a/global-hotkey/src/main/native/windows/nucleus_global_hotkey.cpp +++ b/global-hotkey/src/main/native/windows/nucleus_global_hotkey.cpp @@ -1,4 +1,5 @@ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -42,9 +43,7 @@ static void fireHotKey(jlong id, int keyCode, int modifiers) { static_cast(keyCode), static_cast(modifiers) ); - if (env->ExceptionCheck()) { - env->ExceptionClear(); - } + nucleus_jni_clear_exception(env); } } diff --git a/global-hotkey/src/test/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridgeTest.kt b/global-hotkey/src/test/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridgeTest.kt index 1997720e1..0554933a5 100644 --- a/global-hotkey/src/test/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridgeTest.kt +++ b/global-hotkey/src/test/kotlin/dev/nucleusframework/globalhotkey/linux/NativeLinuxHotKeyBridgeTest.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.globalhotkey.linux +import dev.nucleusframework.core.runtime.NucleusUiThread import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.globalhotkey.GlobalHotKeyManager import dev.nucleusframework.globalhotkey.HotKeyModifier @@ -14,6 +15,7 @@ class NativeLinuxHotKeyBridgeTest { @AfterTest fun tearDown() { GlobalHotKeyManager.shutdown() + NucleusUiThread.setExecutor(null) } @Test @@ -23,6 +25,8 @@ class NativeLinuxHotKeyBridgeTest { assertTrue(GlobalHotKeyManager.lastError != null) return } + // Run posted callbacks inline so the native-callback assertions stay synchronous. + NucleusUiThread.setExecutor { it.run() } val fired = AtomicInteger(0) val handle = GlobalHotKeyManager.register( diff --git a/global-hotkey/src/test/kotlin/dev/nucleusframework/globalhotkey/windows/WindowsHotKeyUiMarshalTest.kt b/global-hotkey/src/test/kotlin/dev/nucleusframework/globalhotkey/windows/WindowsHotKeyUiMarshalTest.kt new file mode 100644 index 000000000..1cb014250 --- /dev/null +++ b/global-hotkey/src/test/kotlin/dev/nucleusframework/globalhotkey/windows/WindowsHotKeyUiMarshalTest.kt @@ -0,0 +1,69 @@ +package dev.nucleusframework.globalhotkey.windows + +import dev.nucleusframework.core.runtime.NucleusUiThread +import dev.nucleusframework.globalhotkey.HotKeyListener +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Hotkey presses must reach the host's UI thread through [NucleusUiThread], + * not run on the native message-loop thread that received them (issue #310's rule, + * which every other native-callback module already follows). + */ +class WindowsHotKeyUiMarshalTest { + @AfterTest + fun tearDown() { + NativeWindowsHotKeyBridge.clearListeners() + NucleusUiThread.setExecutor(null) + } + + @Test + fun `presses are marshalled through the registered ui executor`() { + val ranOn = AtomicReference(null) + val received = AtomicReference?>(null) + val latch = CountDownLatch(1) + NucleusUiThread.setExecutor { runnable -> thread(name = UI_THREAD_NAME) { runnable.run() } } + val id = + NativeWindowsHotKeyBridge.registerListener( + HotKeyListener { keyCode, modifiers -> + received.set(keyCode to modifiers) + ranOn.set(Thread.currentThread().name) + latch.countDown() + }, + ) + + // Native delivers this from its own message-loop thread. + thread(name = "win32-loop-stub") { NativeWindowsHotKeyBridge.onHotKey(id, 0x7B, 0x2) } + + assertTrue(latch.await(5, TimeUnit.SECONDS), "press was not delivered") + assertEquals(0x7B to 0x2, received.get()) + assertEquals(UI_THREAD_NAME, ranOn.get()) + } + + @Test + fun `a press still queued when the hotkey is unregistered is dropped`() { + val queued = ConcurrentLinkedQueue() + NucleusUiThread.setExecutor { queued += it } + val fired = AtomicReference(null) + val id = NativeWindowsHotKeyBridge.registerListener(HotKeyListener { keyCode, _ -> fired.set(keyCode) }) + + NativeWindowsHotKeyBridge.onHotKey(id, 0x7B, 0) + NativeWindowsHotKeyBridge.removeListener(id) + queued.forEach(Runnable::run) + + assertEquals(1, queued.size) + assertNull(fired.get()) + } + + private companion object { + const val UI_THREAD_NAME = "ui-thread-under-test" + } +} diff --git a/gradle.properties b/gradle.properties index af1a34652..dad952375 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,9 @@ -org.gradle.jvmargs=-Xmx1536m +# Heap budget: the build may take up to 80% of the machine's RAM. That is 40% here and not +# 80% because the Kotlin compile daemon is a second process that mirrors this daemon's +# computed max heap — `kotlin.daemon.jvmargs` is ignored by the current Kotlin Gradle plugin, +# so the two together are the budget and each gets half of it. +# A percentage rather than a fixed -Xmx, so the same number holds on CI and on dev machines. +org.gradle.jvmargs=-XX:MaxRAMPercentage=40 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.configuration-cache=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fde9ae524..42f0d5db6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,48 +1,50 @@ [versions] agp = "9.1.1" +# The Chromium branch number the ANGLE DLLs were built from, so it tracks +# ANGLE's own cadence. Built by github.com/NucleusFramework/angle. +angleNatives = "8037.1" asm = "9.10.1" -bcv = "0.18.1" -awsSdk = "2.54.4" +bcv = "0.18.2" +awsSdk = "2.55.5" batik = "1.19" -coilVersion = "3.5.0" -compose = "1.12.0" +coilVersion = "3.6.3" +compose = "1.12.1" coroutines = "1.11.0" -composenativetray = "2.1.0" -composewebview = "1.0.1" +composenativetray = "2.1.6" +composewebview = "1.0.3" detekt = "2.0.0-alpha.6" downloadTask = "5.7.0" -filekit = "0.15.0" -graalvmNative = "1.1.3" +filekit = "0.16.0" +graalvmNative = "1.1.14" # Must match the hot-reload version bundled by the Compose Gradle plugin (which auto-applies # hot-reload to every Compose module): TaoHotReloadBridgeImpl compiles against these artifacts # but the runtime ones come from the agent, and the WindowsState API is not binary-stable # across releases. Compose 1.12.x bundles 1.2.0 — bump both together. hotReload = "1.2.0" -icons = "262.9437.16" -jbrApi = "1.10.1" -jewel = "0.39.1-262.9437.29" +icons = "262.10968.63" +jewel = "0.41.0-262.10968.63" jna = "5.19.1" -kotlin = "2.4.10" -kotlinPoet = "2.3.0" +kotlin = "2.4.20" +kotlinPoet = "2.4.0" kotlinxSerialization = "1.11.0" kover = "0.9.9" ktlintGradle = "14.2.0" -ktor = "3.5.2" +ktor = "3.6.0" lifecycleViewmodelNavigation3 = "2.11.0" lighthouse = "2.3.2" # Only used by :examples:tao-native-test, as the native-image regression fixture for the # SLF4J/Logback build-time-initialization clash (issue #443). logback = "1.6.3" material3 = "1.12.0-alpha03" -materialkolor = "4.1.1" +materialkolor = "5.0.1" navigation3 = "1.1.1" materialIcons = "1.7.3" okhttp = "5.5.0" -pluginPublish = "2.1.1" +pluginPublish = "2.2.1" reorderable = "3.1.0" thumbnailator = "0.4.21" vanniktechMavenPublish = "0.37.0" -versionCheck = "0.61.0" +versionCheck = "0.64.0" zstdKmp = "0.4.0" [plugins] @@ -52,7 +54,7 @@ kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlintGradle"} pluginPublish = { id = "com.gradle.plugin-publish", version.ref = "pluginPublish"} -versionCheck = { id = "com.github.ben-manes.versions", version.ref = "versionCheck"} +versionCheck = { id = "io.github.ben-manes.versions", version.ref = "versionCheck"} vanniktechMavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "vanniktechMavenPublish"} kotlinComposePlugin = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin"} jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose"} @@ -64,6 +66,7 @@ androidApplication = { id = "com.android.application", version.ref = "agp" } kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } [libraries] +angle-natives = { module = "dev.nucleusframework:nucleus.angle-natives", version.ref = "angleNatives" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilVersion" } hot-reload-agent = { module = "org.jetbrains.compose.hot-reload:hot-reload-agent", version.ref = "hotReload" } hot-reload-core = { module = "org.jetbrains.compose.hot-reload:hot-reload-core", version.ref = "hotReload" } @@ -79,7 +82,6 @@ agp-api = { module = "com.android.tools.build:gradle-api", version.ref = "agp" } download-task = { module = "de.undercouch:gradle-download-task", version.ref = "downloadTask" } kotlin-poet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinPoet" } batik-transcoder = { module = "org.apache.xmlgraphics:batik-transcoder", version.ref = "batik" } -jbr-api = { module = "org.jetbrains.runtime:jbr-api", version.ref = "jbrApi" } jna-jpms = { module = "net.java.dev.jna:jna-jpms", version.ref = "jna" } jna-platform-jpms = { module = "net.java.dev.jna:jna-platform-jpms", version.ref = "jna" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 1b33c55ba..5097068a8 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 5be30bbeb..3e6d20428 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-rc-3-all.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 23d15a936..249efbb03 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/gradlew.bat b/gradlew.bat index db3a6ac20..3185a43f7 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,12 +19,39 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH= -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/launcher-linux/src/main/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklist.kt b/launcher-linux/src/main/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklist.kt index 653bfd2d3..c2492430d 100644 --- a/launcher-linux/src/main/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklist.kt +++ b/launcher-linux/src/main/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklist.kt @@ -1,6 +1,6 @@ package dev.nucleusframework.launcher.linux -import javax.swing.SwingUtilities +import dev.nucleusframework.core.runtime.NucleusUiThread /** * Dynamic quicklist server implementing `com.canonical.dbusmenu` over D-Bus. @@ -141,7 +141,7 @@ public class LinuxQuicklist( itemId: Int, ) { val quicklist = registry[objectPath] ?: return - SwingUtilities.invokeLater { + NucleusUiThread.post { quicklist.listener?.onItemClicked(itemId) } } diff --git a/launcher-linux/src/main/native/linux/nucleus_launcher_linux.c b/launcher-linux/src/main/native/linux/nucleus_launcher_linux.c index e9ad62e68..843c20300 100644 Binary files a/launcher-linux/src/main/native/linux/nucleus_launcher_linux.c and b/launcher-linux/src/main/native/linux/nucleus_launcher_linux.c differ diff --git a/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxLauncherTest.kt b/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxLauncherTest.kt index 02f32903b..2c2f43cbf 100644 --- a/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxLauncherTest.kt +++ b/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxLauncherTest.kt @@ -95,6 +95,12 @@ class LinuxLauncherTest { @Test fun `launcher entry methods drive the native bridge when it is loaded`() { if (!LinuxLauncherEntry.isAvailable) return + // Every call below reaches the session bus; see LinuxQuicklistNativeTest + // for why there is nothing to exercise — and previously a hang — without one. + if (System.getenv("DBUS_SESSION_BUS_ADDRESS").isNullOrBlank()) { + println("SKIPPED: no D-Bus session bus") + return + } val uri = LinuxLauncherEntry.appUri("nucleus-kover-coverage.desktop") LinuxLauncherEntry.update(uri, LauncherProperties(count = 1L, countVisible = true)) LinuxLauncherEntry.update(uri, LauncherProperties(progress = 0.2, progressVisible = false)) diff --git a/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklistNativeTest.kt b/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklistNativeTest.kt index b8ea08375..f9049801d 100644 --- a/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklistNativeTest.kt +++ b/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklistNativeTest.kt @@ -10,6 +10,15 @@ class LinuxQuicklistNativeTest { @Test fun `setMenu registers a dbusmenu object and delivers clicks on the edt`() { if (!NativeLinuxLauncherBridge.isLoaded) return + // `setMenu` reaches `g_bus_get_sync(G_BUS_TYPE_SESSION, …)`, which has + // no timeout: on a runner with no session bus it blocks until the job + // is killed, taking `preMerge` with it (pre-merge.yaml's 30-minute cap + // exists for exactly this). Nothing to register against without a bus, + // so skip rather than hang. + if (!hasSessionBus()) { + println("SKIPPED: no D-Bus session bus; g_bus_get_sync would block") + return + } val path = "/dev/nucleusframework/kover/Menu" val quicklist = LinuxQuicklist(path) @@ -39,4 +48,12 @@ class LinuxQuicklistNativeTest { quicklist.dispose() } } + + /** + * Whether GLib will find a session bus. GDBus only honours + * `DBUS_SESSION_BUS_ADDRESS` — unlike libdbus it does not probe + * `$XDG_RUNTIME_DIR/bus` — and falls back to autolaunch otherwise, which + * the native bridge now refuses (see `get_connection`). + */ + private fun hasSessionBus(): Boolean = !System.getenv("DBUS_SESSION_BUS_ADDRESS").isNullOrBlank() } diff --git a/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklistUiMarshalTest.kt b/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklistUiMarshalTest.kt new file mode 100644 index 000000000..d5dc931da --- /dev/null +++ b/launcher-linux/src/test/kotlin/dev/nucleusframework/launcher/linux/LinuxQuicklistUiMarshalTest.kt @@ -0,0 +1,53 @@ +package dev.nucleusframework.launcher.linux + +import dev.nucleusframework.core.runtime.NucleusUiThread +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Quicklist clicks must reach the host's UI thread through [NucleusUiThread], + * not the AWT EDT: under the Tao backend the EDT is not Compose's UI thread, + * so a `SwingUtilities.invokeLater` here lands on a thread that paints nothing + * (issue #310). + */ +class LinuxQuicklistUiMarshalTest { + @AfterTest + fun tearDown() { + NucleusUiThread.setExecutor(null) + } + + @Test + fun `item clicks are marshalled through the registered ui executor`() { + val path = "/dev/nucleusframework/test/quicklist" + val ranOn = AtomicReference(null) + val latch = CountDownLatch(1) + NucleusUiThread.setExecutor { runnable -> + thread(name = UI_THREAD_NAME) { runnable.run() } + } + val quicklist = LinuxQuicklist(path) + quicklist.listener = + LinuxQuicklist.Listener { + ranOn.set(Thread.currentThread().name) + latch.countDown() + } + LinuxQuicklist.register(path, quicklist) + try { + // Native delivers this from the dbusmenu GDBus thread, never the UI one. + thread(name = "dbusmenu-stub") { LinuxQuicklist.onItemEvent(path, 7) } + assertTrue(latch.await(5, TimeUnit.SECONDS), "click was not delivered") + assertEquals(UI_THREAD_NAME, ranOn.get()) + } finally { + LinuxQuicklist.unregister(path) + } + } + + private companion object { + const val UI_THREAD_NAME = "ui-thread-under-test" + } +} diff --git a/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/DockMenuListener.kt b/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/DockMenuListener.kt index 4e8d2de45..3e08dec6e 100644 --- a/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/DockMenuListener.kt +++ b/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/DockMenuListener.kt @@ -2,6 +2,11 @@ package dev.nucleusframework.launcher.macos /** Listener for dock menu item clicks. */ public fun interface DockMenuListener { - /** Called when the user clicks a dock menu item. Invoked on the Swing EDT. */ + /** + * Called when the user clicks a dock menu item. + * + * Invoked on the host's UI thread (the Tao main thread under Nucleus, the + * AWT EDT in a plain Swing / Compose Desktop host). + */ public fun onItemClicked(itemId: Int) } diff --git a/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/MacOsDockMenu.kt b/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/MacOsDockMenu.kt index 776b4c6af..a85c6edaf 100644 --- a/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/MacOsDockMenu.kt +++ b/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/MacOsDockMenu.kt @@ -14,7 +14,12 @@ public object MacOsDockMenu { public val isAvailable: Boolean get() = NativeMacOsDockMenuBridge.isLoaded - /** Listener for dock menu item clicks. Callbacks are dispatched on the Swing EDT. */ + /** + * Listener for dock menu item clicks. + * + * Callbacks are dispatched on the host's UI thread (the Tao main thread + * under Nucleus, the AWT EDT in a plain Swing / Compose Desktop host). + */ public var listener: DockMenuListener? = null /** @@ -23,7 +28,7 @@ public object MacOsDockMenu { * On first call, installs a method swizzle on the existing * `NSApplicationDelegate` to intercept `applicationDockMenu:`. * - * Item clicks are reported via [listener] on the Swing EDT. + * Item clicks are reported via [listener] on the host's UI thread. * * @param items The menu items to display. Supports hierarchical menus via [DockMenuItem.children]. */ diff --git a/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/NativeMacOsDockMenuBridge.kt b/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/NativeMacOsDockMenuBridge.kt index 2d6e66196..59bea6401 100644 --- a/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/NativeMacOsDockMenuBridge.kt +++ b/launcher-macos/src/main/kotlin/dev/nucleusframework/launcher/macos/NativeMacOsDockMenuBridge.kt @@ -1,7 +1,7 @@ package dev.nucleusframework.launcher.macos import dev.nucleusframework.core.runtime.NativeLibraryLoader -import javax.swing.SwingUtilities +import dev.nucleusframework.core.runtime.NucleusUiThread private const val LIBRARY_NAME = "nucleus_launcher_macos" @@ -25,6 +25,6 @@ internal object NativeMacOsDockMenuBridge { @JvmStatic fun onMenuItemClicked(itemId: Int) { val listener = MacOsDockMenu.listener ?: return - SwingUtilities.invokeLater { listener.onItemClicked(itemId) } + NucleusUiThread.post { listener.onItemClicked(itemId) } } } diff --git a/launcher-macos/src/main/native/macos/nucleus_launcher_macos.m b/launcher-macos/src/main/native/macos/nucleus_launcher_macos.m index d8753fc76..4959f289a 100644 --- a/launcher-macos/src/main/native/macos/nucleus_launcher_macos.m +++ b/launcher-macos/src/main/native/macos/nucleus_launcher_macos.m @@ -11,6 +11,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" #include // ============================================================================ @@ -57,9 +58,7 @@ static void releaseEnv(BOOL didAttach) { } static void clearException(JNIEnv *env) { - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } // Helper: run a block on the main thread (sync if off-main, direct if on-main) diff --git a/launcher-macos/src/test/kotlin/dev/nucleusframework/launcher/macos/MacOsDockMenuUiMarshalTest.kt b/launcher-macos/src/test/kotlin/dev/nucleusframework/launcher/macos/MacOsDockMenuUiMarshalTest.kt new file mode 100644 index 000000000..e5d91ce78 --- /dev/null +++ b/launcher-macos/src/test/kotlin/dev/nucleusframework/launcher/macos/MacOsDockMenuUiMarshalTest.kt @@ -0,0 +1,50 @@ +package dev.nucleusframework.launcher.macos + +import dev.nucleusframework.core.runtime.NucleusUiThread +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Dock menu clicks must reach the host's UI thread through [NucleusUiThread], + * not the AWT EDT: under the Tao backend the EDT is not Compose's UI thread, + * so a `SwingUtilities.invokeLater` here lands on a thread that paints nothing + * (issue #310). + */ +class MacOsDockMenuUiMarshalTest { + @AfterTest + fun tearDown() { + MacOsDockMenu.listener = null + NucleusUiThread.setExecutor(null) + } + + @Test + fun `item clicks are marshalled through the registered ui executor`() { + val ranOn = AtomicReference(null) + val clicked = AtomicReference(null) + val latch = CountDownLatch(1) + NucleusUiThread.setExecutor { runnable -> + thread(name = UI_THREAD_NAME) { runnable.run() } + } + MacOsDockMenu.listener = + DockMenuListener { itemId -> + clicked.set(itemId) + ranOn.set(Thread.currentThread().name) + latch.countDown() + } + // Native delivers this from the AppKit main thread, never the AWT EDT. + thread(name = "appkit-stub") { NativeMacOsDockMenuBridge.onMenuItemClicked(42) } + assertTrue(latch.await(5, TimeUnit.SECONDS), "click was not delivered") + assertEquals(42, clicked.get()) + assertEquals(UI_THREAD_NAME, ranOn.get()) + } + + private companion object { + const val UI_THREAD_NAME = "ui-thread-under-test" + } +} diff --git a/launcher-windows/src/main/native/windows/nucleus_launcher_windows.cpp b/launcher-windows/src/main/native/windows/nucleus_launcher_windows.cpp index 38f7b9bc8..805fc6a07 100644 --- a/launcher-windows/src/main/native/windows/nucleus_launcher_windows.cpp +++ b/launcher-windows/src/main/native/windows/nucleus_launcher_windows.cpp @@ -36,6 +36,7 @@ #include #include +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -652,33 +653,33 @@ static HWND GetHwndFromAwtWindow(JNIEnv *env, jobject awtWindow) { if (!awtWindow) return nullptr; jclass awtAccessorClass = env->FindClass("sun/awt/AWTAccessor"); - if (!awtAccessorClass || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!awtAccessorClass || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jmethodID getCompAccessor = env->GetStaticMethodID(awtAccessorClass, "getComponentAccessor", "()Lsun/awt/AWTAccessor$ComponentAccessor;"); - if (!getCompAccessor || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!getCompAccessor || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jobject compAccessor = env->CallStaticObjectMethod(awtAccessorClass, getCompAccessor); - if (!compAccessor || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!compAccessor || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jclass compAccessorClass = env->FindClass("sun/awt/AWTAccessor$ComponentAccessor"); - if (!compAccessorClass || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!compAccessorClass || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jmethodID getPeer = env->GetMethodID(compAccessorClass, "getPeer", "(Ljava/awt/Component;)Ljava/awt/peer/ComponentPeer;"); - if (!getPeer || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!getPeer || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jobject peer = env->CallObjectMethod(compAccessor, getPeer, awtWindow); - if (!peer || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!peer || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jclass wCompPeerClass = env->FindClass("sun/awt/windows/WComponentPeer"); - if (!wCompPeerClass || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!wCompPeerClass || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jmethodID getHWnd = env->GetMethodID(wCompPeerClass, "getHWnd", "()J"); - if (!getHWnd || env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (!getHWnd || env->ExceptionCheck()) { nucleus_jni_clear_exception(env); return nullptr; } jlong hwnd = env->CallLongMethod(peer, getHWnd); - if (env->ExceptionCheck()) { env->ExceptionClear(); return nullptr; } + if (nucleus_jni_clear_exception(env)) { return nullptr; } return (HWND)(intptr_t)hwnd; } @@ -729,7 +730,7 @@ static LRESULT CALLBACK ThumbBarWndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPA JNIEnv *env = nullptr; if (g_jvm->GetEnv((void **)&env, JNI_VERSION_1_8) == JNI_OK && env) { env->CallVoidMethod(state->callbackRef, state->onClickMethod, (jint)buttonId); - if (env->ExceptionCheck()) env->ExceptionClear(); + nucleus_jni_clear_exception(env); } } } diff --git a/media-control/src/main/kotlin/dev/nucleusframework/media/control/MediaControlService.kt b/media-control/src/main/kotlin/dev/nucleusframework/media/control/MediaControlService.kt index adf7b21b2..7c6fd3c10 100644 --- a/media-control/src/main/kotlin/dev/nucleusframework/media/control/MediaControlService.kt +++ b/media-control/src/main/kotlin/dev/nucleusframework/media/control/MediaControlService.kt @@ -2,13 +2,13 @@ package dev.nucleusframework.media.control import dev.nucleusframework.core.runtime.ExecutableRuntime import dev.nucleusframework.core.runtime.NucleusApp +import dev.nucleusframework.core.runtime.NucleusUiThread import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.media.control.linux.NativeLinuxBridge import dev.nucleusframework.media.control.macos.NativeMacOsBridge import dev.nucleusframework.media.control.windows.NativeWindowsBridge import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import javax.swing.SwingUtilities /** * Entry point for OS-level media controls. @@ -18,7 +18,9 @@ import javax.swing.SwingUtilities * - macOS: MPNowPlayingInfoCenter + MPRemoteCommandCenter (Control Center / Now Playing) * - Windows: System Media Transport Controls (SMTC / WinRT) * - * Events dispatched to the callback are delivered on the Swing EDT. + * Events dispatched to the callback are delivered on the host's UI thread + * (the Tao main thread under Nucleus, the AWT EDT in a plain Swing / + * Compose Desktop host). */ public object MediaControlService { private val json = Json { ignoreUnknownKeys = true } @@ -98,7 +100,7 @@ public object MediaControlService { /** * Listen for control events from the OS (play, pause, seek, next, previous...). * - * The callback is dispatched on the Swing EDT — safe to mutate Compose/Swing state directly. + * The callback is dispatched on the host's UI thread — safe to mutate Compose state directly. * Only one listener is active at a time; calling attach replaces any previous listener. * * Events emitted per platform: @@ -109,7 +111,7 @@ public object MediaControlService { public fun attach(callback: (MediaControlEvent) -> Unit) { backend.attach { raw -> val event = parseEvent(raw) ?: return@attach - SwingUtilities.invokeLater { callback(event) } + NucleusUiThread.post { callback(event) } } } diff --git a/media-control/src/main/native/linux/nucleus_media_control_linux.c b/media-control/src/main/native/linux/nucleus_media_control_linux.c index cc11ff93d..3afd2e05a 100644 --- a/media-control/src/main/native/linux/nucleus_media_control_linux.c +++ b/media-control/src/main/native/linux/nucleus_media_control_linux.c @@ -12,6 +12,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -156,13 +157,13 @@ static int ensure_callback_ids(JNIEnv *env) { if (g_bridge_class != NULL) return 1; jclass cls = (*env)->FindClass(env, "dev/nucleusframework/media/control/linux/NativeLinuxBridge"); - if (!cls) { if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); return 0; } + if (!cls) { nucleus_jni_clear_exception(env); return 0; } g_bridge_class = (jclass)(*env)->NewGlobalRef(env, cls); (*env)->DeleteLocalRef(env, cls); g_on_event_method = (*env)->GetStaticMethodID(env, g_bridge_class, "onMediaControlEvent", "(Ljava/lang/String;)V"); if (!g_on_event_method) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteGlobalRef(env, g_bridge_class); g_bridge_class = NULL; return 0; @@ -206,7 +207,7 @@ static void dispatch_event_simple(const char *type) { jstring js = (*env)->NewStringUTF(env, s->str); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_event_method, js); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, js); g_string_free(s, TRUE); @@ -224,7 +225,7 @@ static void dispatch_event_offset(const char *type, gint64 value_us) { jstring js = (*env)->NewStringUTF(env, s->str); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_event_method, js); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, js); g_string_free(s, TRUE); @@ -241,7 +242,7 @@ static void dispatch_event_position(gint64 position_us) { jstring js = (*env)->NewStringUTF(env, s->str); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_event_method, js); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, js); g_string_free(s, TRUE); @@ -258,7 +259,7 @@ static void dispatch_event_volume(gdouble volume) { jstring js = (*env)->NewStringUTF(env, buf); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_event_method, js); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, js); release_env(attached); } @@ -274,7 +275,7 @@ static void dispatch_event_uri(const char *uri) { jstring js = (*env)->NewStringUTF(env, s->str); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_event_method, js); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, js); g_string_free(s, TRUE); diff --git a/media-control/src/main/native/macos/nucleus_media_control_macos.m b/media-control/src/main/native/macos/nucleus_media_control_macos.m index ca4b4e4af..fe16f2e47 100644 --- a/media-control/src/main/native/macos/nucleus_media_control_macos.m +++ b/media-control/src/main/native/macos/nucleus_media_control_macos.m @@ -19,6 +19,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" #include // ============================================================================ @@ -88,7 +89,7 @@ static int ensureCallbackIds(JNIEnv *env) { if (g_bridge_class != NULL) return 1; jclass cls = (*env)->FindClass(env, BRIDGE_CLASS); if (!cls) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return 0; } g_bridge_class = (jclass)(*env)->NewGlobalRef(env, cls); @@ -96,7 +97,7 @@ static int ensureCallbackIds(JNIEnv *env) { g_on_event_method = (*env)->GetStaticMethodID(env, g_bridge_class, "onMediaControlEvent", "(Ljava/lang/String;)V"); if (!g_on_event_method) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteGlobalRef(env, g_bridge_class); g_bridge_class = NULL; return 0; @@ -114,7 +115,7 @@ static void dispatchJson(NSString *json) { const char *utf = [json UTF8String]; jstring js = (*env)->NewStringUTF(env, utf ? utf : "{}"); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_event_method, js); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, js); releaseEnv(didAttach); } diff --git a/media-control/src/main/native/windows/nucleus_media_control_windows.cpp b/media-control/src/main/native/windows/nucleus_media_control_windows.cpp index a39c9b2f0..f2de0a3e9 100644 --- a/media-control/src/main/native/windows/nucleus_media_control_windows.cpp +++ b/media-control/src/main/native/windows/nucleus_media_control_windows.cpp @@ -40,6 +40,7 @@ #include #include +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -152,7 +153,7 @@ static void fireEvent(const std::string &json) { } env->DeleteLocalRef(cls); } - if (env->ExceptionCheck()) env->ExceptionClear(); + nucleus_jni_clear_exception(env); releaseEnv(didAttach); } diff --git a/menu-macos/src/main/kotlin/dev/nucleusframework/menu/macos/NativeNsMenuBridge.kt b/menu-macos/src/main/kotlin/dev/nucleusframework/menu/macos/NativeNsMenuBridge.kt index 4aac7a5e7..f7375795b 100644 --- a/menu-macos/src/main/kotlin/dev/nucleusframework/menu/macos/NativeNsMenuBridge.kt +++ b/menu-macos/src/main/kotlin/dev/nucleusframework/menu/macos/NativeNsMenuBridge.kt @@ -22,11 +22,11 @@ internal object NativeNsMenuBridge { // Menu actions/delegates fire from the AppKit main thread (JNI) and must be // marshalled to the host's Compose UI thread. Dispatchers.Main resolves to - // the right thread per backend — the Swing EDT under the AWT backend, the - // Tao main thread under the Tao backend (TaoMainDispatcherFactory). Using - // SwingUtilities.invokeLater instead posted to the AWT EDT, which is NOT - // Compose's UI thread in the Tao backend, so the callbacks were silently - // dropped there — no menu action, no delegate event (issue #310). + // the right thread per host — the Tao main thread under Nucleus + // (TaoMainDispatcherFactory), the Swing EDT in a plain AWT/Compose Desktop + // app. Using SwingUtilities.invokeLater instead posted to the AWT EDT, + // which is NOT Compose's UI thread under Tao, so the callbacks were + // silently dropped there — no menu action, no delegate event (issue #310). private val uiScope = CoroutineScope(Dispatchers.Main) // ---- Action callbacks (handle → callback) ---- diff --git a/menu-macos/src/main/native/macos/nucleus_menu_macos.m b/menu-macos/src/main/native/macos/nucleus_menu_macos.m index dd60d0ddb..e7b81b58f 100644 --- a/menu-macos/src/main/native/macos/nucleus_menu_macos.m +++ b/menu-macos/src/main/native/macos/nucleus_menu_macos.m @@ -11,6 +11,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" // ============================================================================ // JNI function name macro @@ -147,7 +148,7 @@ static void releaseEnv(BOOL didAttach) { } static void clearException(JNIEnv *env) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } static NSString *toNSString(JNIEnv *env, jstring jstr) { diff --git a/native-common/nucleus_jni.h b/native-common/nucleus_jni.h new file mode 100644 index 000000000..2381dfe6b --- /dev/null +++ b/native-common/nucleus_jni.h @@ -0,0 +1,104 @@ +#ifndef NUCLEUS_JNI_H +#define NUCLEUS_JNI_H + +#include + +/* + * Shared JNI helpers for every Nucleus native bridge. + * + * Pending exceptions must never be ExceptionClear'd silently: a Kotlin + * listener that throws would otherwise vanish with no log line. Call + * nucleus_jni_clear_exception() instead; it reports through + * JniExceptionReporter (JUL) and falls back to ExceptionDescribe. + */ + +#ifdef __cplusplus +#define NUCLEUS_JNI_EXCEPTION_CHECK(env) ((env)->ExceptionCheck()) +#define NUCLEUS_JNI_EXCEPTION_OCCURRED(env) ((env)->ExceptionOccurred()) +#define NUCLEUS_JNI_EXCEPTION_CLEAR(env) ((env)->ExceptionClear()) +#define NUCLEUS_JNI_EXCEPTION_DESCRIBE(env) ((env)->ExceptionDescribe()) +#define NUCLEUS_JNI_EXCEPTION_THROW(env, thrown) ((env)->Throw(thrown)) +#define NUCLEUS_JNI_FIND_CLASS(env, name) ((env)->FindClass(name)) +#define NUCLEUS_JNI_GET_STATIC_METHOD_ID(env, cls, name, sig) \ + ((env)->GetStaticMethodID(cls, name, sig)) +#define NUCLEUS_JNI_CALL_STATIC_VOID_METHOD(env, cls, mid, arg) \ + ((env)->CallStaticVoidMethod(cls, mid, arg)) +#define NUCLEUS_JNI_DELETE_LOCAL_REF(env, ref) ((env)->DeleteLocalRef(ref)) +#else +#define NUCLEUS_JNI_EXCEPTION_CHECK(env) ((*(env))->ExceptionCheck(env)) +#define NUCLEUS_JNI_EXCEPTION_OCCURRED(env) ((*(env))->ExceptionOccurred(env)) +#define NUCLEUS_JNI_EXCEPTION_CLEAR(env) ((*(env))->ExceptionClear(env)) +#define NUCLEUS_JNI_EXCEPTION_DESCRIBE(env) ((*(env))->ExceptionDescribe(env)) +#define NUCLEUS_JNI_EXCEPTION_THROW(env, thrown) ((*(env))->Throw(env, thrown)) +#define NUCLEUS_JNI_FIND_CLASS(env, name) ((*(env))->FindClass(env, name)) +#define NUCLEUS_JNI_GET_STATIC_METHOD_ID(env, cls, name, sig) \ + ((*(env))->GetStaticMethodID(env, cls, name, sig)) +#define NUCLEUS_JNI_CALL_STATIC_VOID_METHOD(env, cls, mid, arg) \ + ((*(env))->CallStaticVoidMethod(env, cls, mid, arg)) +#define NUCLEUS_JNI_DELETE_LOCAL_REF(env, ref) ((*(env))->DeleteLocalRef(env, ref)) +#endif + +#define NUCLEUS_JNI_REPORTER_CLASS "dev/nucleusframework/core/runtime/JniExceptionReporter" +#define NUCLEUS_JNI_REPORTER_METHOD "report" +#define NUCLEUS_JNI_REPORTER_SIGNATURE "(Ljava/lang/Throwable;)V" + +/** + * If a JNI exception is pending, report it and clear it so native code can + * continue. Returns JNI_TRUE when an exception was present. + */ +static inline jboolean nucleus_jni_clear_exception(JNIEnv *env) { + if (env == NULL || !NUCLEUS_JNI_EXCEPTION_CHECK(env)) { + return JNI_FALSE; + } + + jthrowable thrown = NUCLEUS_JNI_EXCEPTION_OCCURRED(env); + NUCLEUS_JNI_EXCEPTION_CLEAR(env); + + jclass reporter = NUCLEUS_JNI_FIND_CLASS(env, NUCLEUS_JNI_REPORTER_CLASS); + if (reporter == NULL) { + NUCLEUS_JNI_EXCEPTION_CLEAR(env); + if (thrown != NULL) { + NUCLEUS_JNI_EXCEPTION_THROW(env, thrown); + NUCLEUS_JNI_EXCEPTION_DESCRIBE(env); + NUCLEUS_JNI_EXCEPTION_CLEAR(env); + NUCLEUS_JNI_DELETE_LOCAL_REF(env, thrown); + } + return JNI_TRUE; + } + + jmethodID report = NUCLEUS_JNI_GET_STATIC_METHOD_ID( + env, + reporter, + NUCLEUS_JNI_REPORTER_METHOD, + NUCLEUS_JNI_REPORTER_SIGNATURE + ); + if (report == NULL) { + NUCLEUS_JNI_EXCEPTION_CLEAR(env); + NUCLEUS_JNI_DELETE_LOCAL_REF(env, reporter); + if (thrown != NULL) { + NUCLEUS_JNI_EXCEPTION_THROW(env, thrown); + NUCLEUS_JNI_EXCEPTION_DESCRIBE(env); + NUCLEUS_JNI_EXCEPTION_CLEAR(env); + NUCLEUS_JNI_DELETE_LOCAL_REF(env, thrown); + } + return JNI_TRUE; + } + + NUCLEUS_JNI_CALL_STATIC_VOID_METHOD(env, reporter, report, thrown); + if (NUCLEUS_JNI_EXCEPTION_CHECK(env)) { + NUCLEUS_JNI_EXCEPTION_CLEAR(env); + if (thrown != NULL) { + NUCLEUS_JNI_EXCEPTION_THROW(env, thrown); + NUCLEUS_JNI_EXCEPTION_DESCRIBE(env); + NUCLEUS_JNI_EXCEPTION_CLEAR(env); + } + } + + NUCLEUS_JNI_DELETE_LOCAL_REF(env, reporter); + if (thrown != NULL) { + NUCLEUS_JNI_DELETE_LOCAL_REF(env, thrown); + } + return JNI_TRUE; +} + +#endif /* NUCLEUS_JNI_H */ diff --git a/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/Notification.kt b/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/Notification.kt index 859c2d79b..d34b53619 100644 --- a/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/Notification.kt +++ b/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/Notification.kt @@ -81,7 +81,10 @@ public typealias NotificationButtonBuilder = NotificationBuilder /** * Creates a cross-platform notification. - * Lifecycle callbacks are not guaranteed to run on a UI thread. + * Interaction callbacks ([onActivated], [onDismissed], button clicks) are + * dispatched on the host's UI thread (the Tao main thread under Nucleus, the + * AWT EDT in a plain Swing / Compose Desktop host). [onFailed] can still run + * on the calling thread, since a send can fail before it ever reaches the OS. * * ```kotlin * val n = notification( diff --git a/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/internal/MacOsDispatcher.kt b/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/internal/MacOsDispatcher.kt index e7d240251..ed58078aa 100644 --- a/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/internal/MacOsDispatcher.kt +++ b/notification-common/src/main/kotlin/dev/nucleusframework/notification/common/internal/MacOsDispatcher.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.notification.common.internal +import dev.nucleusframework.core.runtime.NucleusUiThread import dev.nucleusframework.notification.ActionOption import dev.nucleusframework.notification.CategoryOption import dev.nucleusframework.notification.DeliveredNotification @@ -31,37 +32,46 @@ internal class MacOsDispatcher private constructor() : PlatformDispatcher { // Cache category registrations: button-titles-signature -> categoryId private val categoryCache = ConcurrentHashMap() - private val delegate = + // Visible for tests: the delegate the macOS notification center calls back into. + internal val delegate = object : NotificationCenterDelegate { override fun willPresent(notification: DeliveredNotification): Set = setOf(PresentationOption.BANNER, PresentationOption.SOUND) override fun didReceive(response: NotificationResponse) { - val id = response.notification.identifier - val actionId = response.actionIdentifier - val callbacks = - when (actionId) { - NotificationAction.DISMISS_ACTION_IDENTIFIER -> CallbackRegistry.remove(id) - else -> CallbackRegistry.get(id) - } - callbacks ?: return - - try { - when { - actionId == NotificationAction.DEFAULT_ACTION_IDENTIFIER -> - callbacks.onActivated?.invoke() - actionId == NotificationAction.DISMISS_ACTION_IDENTIFIER -> - callbacks.onDismissed?.invoke(DismissReason.USER_DISMISSED) - actionId.startsWith("btn_") -> - callbacks.buttonCallbacks[actionId]?.invoke() - } - } catch ( - @Suppress("TooGenericExceptionCaught") e: RuntimeException, - ) { - logger.log(Level.WARNING, "Error in notification callback", e) - } + // `NotificationCenter` dispatches delegate callbacks on its own + // worker pool ("NucleusNotificationCallback-N"), so without this + // the DSL callbacks would run off the UI thread on macOS while + // the Linux and Windows bridges deliver them on it (issue #310). + NucleusUiThread.post { deliver(response) } + } + } + + private fun deliver(response: NotificationResponse) { + val id = response.notification.identifier + val actionId = response.actionIdentifier + val callbacks = + when (actionId) { + NotificationAction.DISMISS_ACTION_IDENTIFIER -> CallbackRegistry.remove(id) + else -> CallbackRegistry.get(id) + } + callbacks ?: return + + try { + when { + actionId == NotificationAction.DEFAULT_ACTION_IDENTIFIER -> + callbacks.onActivated?.invoke() + actionId == NotificationAction.DISMISS_ACTION_IDENTIFIER -> + callbacks.onDismissed?.invoke(DismissReason.USER_DISMISSED) + actionId.startsWith("btn_") -> + callbacks.buttonCallbacks[actionId]?.invoke() } + } catch ( + @Suppress("TooGenericExceptionCaught") e: RuntimeException, + ) { + logger.log(Level.WARNING, "Error in notification callback", e) } + } companion object { fun createIfAvailable(): MacOsDispatcher? = @@ -141,7 +151,8 @@ internal class MacOsDispatcher private constructor() : PlatformDispatcher { NotificationCenter.add(request) { error -> if (error != null) { CallbackRegistry.remove(identifier) - notification.onFailed?.invoke() + // Same worker pool as the delegate callbacks above. + notification.onFailed?.let { onFailed -> NucleusUiThread.post(onFailed) } } } diff --git a/notification-common/src/test/kotlin/dev/nucleusframework/notification/common/internal/MacOsDispatcherUiMarshalTest.kt b/notification-common/src/test/kotlin/dev/nucleusframework/notification/common/internal/MacOsDispatcherUiMarshalTest.kt new file mode 100644 index 000000000..3c4400b64 --- /dev/null +++ b/notification-common/src/test/kotlin/dev/nucleusframework/notification/common/internal/MacOsDispatcherUiMarshalTest.kt @@ -0,0 +1,113 @@ +package dev.nucleusframework.notification.common.internal + +import dev.nucleusframework.core.runtime.NucleusUiThread +import dev.nucleusframework.notification.DeliveredNotification +import dev.nucleusframework.notification.NotificationAction +import dev.nucleusframework.notification.NotificationResponse +import dev.nucleusframework.notification.common.DismissReason +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The portable `notification { }` callbacks must reach the host's UI thread on + * macOS too: `NotificationCenter` dispatches its delegate callbacks on a worker + * pool of its own, so without marshalling an app's `onActivated` would run off + * the UI thread here while the Linux and Windows bridges deliver it on it + * (issue #310). + */ +class MacOsDispatcherUiMarshalTest { + @AfterTest + fun tearDown() { + NucleusUiThread.setExecutor(null) + CallbackRegistry.remove(NOTIFICATION_ID) + } + + @Test + fun `body clicks are marshalled through the registered ui executor`() { + assertMarshalled(NotificationAction.DEFAULT_ACTION_IDENTIFIER) { record -> + NotificationCallbacks( + onActivated = record, + onDismissed = null, + onFailed = null, + buttonCallbacks = emptyMap(), + ) + } + } + + @Test + fun `button clicks are marshalled through the registered ui executor`() { + assertMarshalled("btn_0") { record -> + NotificationCallbacks( + onActivated = null, + onDismissed = null, + onFailed = null, + buttonCallbacks = mapOf("btn_0" to record), + ) + } + } + + @Test + fun `dismissals are marshalled through the registered ui executor`() { + assertMarshalled(NotificationAction.DISMISS_ACTION_IDENTIFIER) { record -> + NotificationCallbacks( + onActivated = null, + onDismissed = { _: DismissReason -> record() }, + onFailed = null, + buttonCallbacks = emptyMap(), + ) + } + } + + private fun assertMarshalled( + actionIdentifier: String, + callbacks: (record: () -> Unit) -> NotificationCallbacks, + ) { + val dispatcher = MacOsDispatcher.createIfAvailable() ?: return + val ranOn = AtomicReference(null) + val latch = CountDownLatch(1) + NucleusUiThread.setExecutor { runnable -> thread(name = UI_THREAD_NAME) { runnable.run() } } + CallbackRegistry.register( + NOTIFICATION_ID, + callbacks { + ranOn.set(Thread.currentThread().name) + latch.countDown() + }, + ) + + // Native delivers this on a NucleusNotificationCallback pool thread. + thread(name = "notification-callback-stub") { + dispatcher.delegate.didReceive( + NotificationResponse( + actionIdentifier = actionIdentifier, + notification = deliveredNotification(), + userText = null, + ), + ) + } + + assertTrue(latch.await(5, TimeUnit.SECONDS), "callback was not delivered") + assertEquals(UI_THREAD_NAME, ranOn.get()) + } + + private fun deliveredNotification() = + DeliveredNotification( + identifier = NOTIFICATION_ID, + title = "Title", + subtitle = "", + body = "Body", + date = 0, + categoryIdentifier = "", + threadIdentifier = "", + ) + + private companion object { + const val UI_THREAD_NAME = "ui-thread-under-test" + const val NOTIFICATION_ID = "ui-marshal-test" + } +} diff --git a/notification-common/src/test/kotlin/dev/nucleusframework/notification/common/internal/PlatformDispatcherTest.kt b/notification-common/src/test/kotlin/dev/nucleusframework/notification/common/internal/PlatformDispatcherTest.kt index 7ae8c22a7..6e9f382da 100644 --- a/notification-common/src/test/kotlin/dev/nucleusframework/notification/common/internal/PlatformDispatcherTest.kt +++ b/notification-common/src/test/kotlin/dev/nucleusframework/notification/common/internal/PlatformDispatcherTest.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.notification.common.internal +import dev.nucleusframework.core.runtime.NucleusUiThread import dev.nucleusframework.notification.InterruptionLevel import dev.nucleusframework.notification.common.DismissReason import dev.nucleusframework.notification.common.NotificationResult @@ -9,6 +10,7 @@ import dev.nucleusframework.notification.linux.Urgency import dev.nucleusframework.notification.windows.DismissalReason import dev.nucleusframework.notification.windows.ToastDuration import dev.nucleusframework.notification.windows.ToastScenario +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -18,6 +20,11 @@ import kotlin.test.assertNull import kotlin.test.assertTrue class PlatformDispatcherTest { + @AfterTest + fun resetUiExecutor() { + NucleusUiThread.setExecutor(null) + } + @Test fun `factory returns a macos dispatcher on this host`() { val dispatcher = DispatcherFactory.create() @@ -48,6 +55,9 @@ class PlatformDispatcherTest { fun `macos dispatcher send exercises buttons dismiss category and images`() { val dispatcher = MacOsDispatcher.createIfAvailable() ?: return dispatcher.initialize() + // `onFailed` is posted to NucleusUiThread; run it inline so the counts + // below are settled by the time they are asserted. + NucleusUiThread.setExecutor { it.run() } var failed = 0 var activated = 0 @@ -301,7 +311,10 @@ class PlatformDispatcherTest { @Test fun `macos delegate routes default dismiss and button actions`() { val dispatcher = MacOsDispatcher.createIfAvailable() ?: return - val delegate = fieldOf(dispatcher, "delegate") + val delegate = dispatcher.delegate + // The delegate hands responses to NucleusUiThread; run them inline so + // this test keeps asserting straight after each call. + NucleusUiThread.setExecutor { it.run() } val presented = delegate.willPresent( dev.nucleusframework.notification.DeliveredNotification("id", "t", "s", "b", 1L, "c", "th"), diff --git a/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationCenter.kt b/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationCenter.kt index 34c2924ce..25aaae8f7 100644 --- a/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationCenter.kt +++ b/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationCenter.kt @@ -4,7 +4,7 @@ package dev.nucleusframework.notification.linux * Entry point for the freedesktop Desktop Notifications API on Linux. * * Communicates with `org.freedesktop.Notifications` over D-Bus via JNI (GIO/GDBus). - * All methods are thread-safe. Signal listener callbacks are dispatched on the Swing EDT. + * All methods are thread-safe. Signal listener callbacks are dispatched on the host's UI thread. * * Specification: https://specifications.freedesktop.org/notification/latest-single/ */ diff --git a/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationListener.kt b/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationListener.kt index 53cd79cb6..1ca86f8b5 100644 --- a/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationListener.kt +++ b/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationListener.kt @@ -3,7 +3,8 @@ package dev.nucleusframework.notification.linux /** * Listener for asynchronous notification signals from the freedesktop notification server. * - * All callbacks are dispatched on the Swing EDT. + * All callbacks are dispatched on the host's UI thread (the Tao main thread + * under Nucleus, the AWT EDT in a plain Swing / Compose Desktop host). * Register via [LinuxNotificationCenter.addListener]; signal monitoring starts automatically * when the first listener is added and stops when the last is removed. */ diff --git a/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/NativeLinuxNotificationBridge.kt b/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/NativeLinuxNotificationBridge.kt index f3a5ee9cb..f0a67938c 100644 --- a/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/NativeLinuxNotificationBridge.kt +++ b/notification-linux/src/main/kotlin/dev/nucleusframework/notification/linux/NativeLinuxNotificationBridge.kt @@ -1,8 +1,8 @@ package dev.nucleusframework.notification.linux import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NucleusUiThread import java.util.concurrent.ConcurrentHashMap -import javax.swing.SwingUtilities private const val LIBRARY_NAME = "nucleus_notification_linux" @@ -84,7 +84,7 @@ internal object NativeLinuxNotificationBridge { reason: Int, ) { val closeReason = CloseReason.fromValue(reason) - SwingUtilities.invokeLater { + NucleusUiThread.post { listeners.forEach { it.onClosed(id, closeReason) } } } @@ -94,7 +94,7 @@ internal object NativeLinuxNotificationBridge { id: Int, actionKey: String, ) { - SwingUtilities.invokeLater { + NucleusUiThread.post { listeners.forEach { it.onActionInvoked(id, actionKey) } } } @@ -104,7 +104,7 @@ internal object NativeLinuxNotificationBridge { id: Int, token: String, ) { - SwingUtilities.invokeLater { + NucleusUiThread.post { listeners.forEach { it.onActivationToken(id, token) } } } diff --git a/notification-linux/src/main/native/linux/nucleus_notification_linux.c b/notification-linux/src/main/native/linux/nucleus_notification_linux.c index b5bc038ab..b9a2290f8 100644 --- a/notification-linux/src/main/native/linux/nucleus_notification_linux.c +++ b/notification-linux/src/main/native/linux/nucleus_notification_linux.c @@ -11,6 +11,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -104,7 +105,7 @@ static int ensure_callback_ids(JNIEnv *env) { jclass cls = (*env)->FindClass(env, "dev/nucleusframework/notification/linux/NativeLinuxNotificationBridge"); if (cls == NULL) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return 0; } g_bridge_class = (jclass)(*env)->NewGlobalRef(env, cls); @@ -118,7 +119,7 @@ static int ensure_callback_ids(JNIEnv *env) { "onActivationToken", "(ILjava/lang/String;)V"); if (!g_on_closed_method || !g_on_action_method || !g_on_token_method) { - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteGlobalRef(env, g_bridge_class); g_bridge_class = NULL; return 0; @@ -493,7 +494,7 @@ static void on_notification_closed( if (ensure_callback_ids(env)) { (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_closed_method, (jint)id, (jint)reason); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); } release_env(attached); @@ -519,7 +520,7 @@ static void on_action_invoked( jstring j_key = (*env)->NewStringUTF(env, action_key); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_action_method, (jint)id, j_key); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, j_key); } @@ -546,7 +547,7 @@ static void on_activation_token( jstring j_token = (*env)->NewStringUTF(env, token); (*env)->CallStaticVoidMethod(env, g_bridge_class, g_on_token_method, (jint)id, j_token); - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, j_token); } diff --git a/notification-linux/src/test/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationUiMarshalTest.kt b/notification-linux/src/test/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationUiMarshalTest.kt new file mode 100644 index 000000000..4072dcef2 --- /dev/null +++ b/notification-linux/src/test/kotlin/dev/nucleusframework/notification/linux/LinuxNotificationUiMarshalTest.kt @@ -0,0 +1,67 @@ +package dev.nucleusframework.notification.linux + +import dev.nucleusframework.core.runtime.NucleusUiThread +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Signal callbacks must reach the host's UI thread through [NucleusUiThread], + * not the AWT EDT: under the Tao backend the EDT is not Compose's UI thread, + * so a `SwingUtilities.invokeLater` here lands on a thread that paints nothing + * (issue #310). + */ +class LinuxNotificationUiMarshalTest { + @AfterTest + fun tearDown() { + NucleusUiThread.setExecutor(null) + } + + @Test + fun `signal callbacks are marshalled through the registered ui executor`() { + val ranOn = AtomicReference(null) + val latch = CountDownLatch(2) + NucleusUiThread.setExecutor { runnable -> + thread(name = UI_THREAD_NAME) { runnable.run() } + } + val listener = + object : LinuxNotificationListener { + override fun onClosed( + notificationId: Int, + reason: CloseReason, + ) { + ranOn.set(Thread.currentThread().name) + latch.countDown() + } + + override fun onActionInvoked( + notificationId: Int, + actionKey: String, + ) { + ranOn.set(Thread.currentThread().name) + latch.countDown() + } + } + NativeLinuxNotificationBridge.addListener(listener) + try { + // Native delivers these from a GDBus signal thread, never the UI one. + thread(name = "gdbus-signal-stub") { + NativeLinuxNotificationBridge.onActionInvoked(1, NotificationAction.DEFAULT_KEY) + NativeLinuxNotificationBridge.onNotificationClosed(1, CloseReason.EXPIRED.value) + } + assertTrue(latch.await(5, TimeUnit.SECONDS), "callbacks were not delivered") + assertEquals(UI_THREAD_NAME, ranOn.get()) + } finally { + NativeLinuxNotificationBridge.removeListener(listener) + } + } + + private companion object { + const val UI_THREAD_NAME = "ui-thread-under-test" + } +} diff --git a/notification-macos/src/main/native/macos/NucleusNotificationBridge.m b/notification-macos/src/main/native/macos/NucleusNotificationBridge.m index 556e2b8b1..2d1a35e84 100644 --- a/notification-macos/src/main/native/macos/NucleusNotificationBridge.m +++ b/notification-macos/src/main/native/macos/NucleusNotificationBridge.m @@ -1,6 +1,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" // ============================================================================ // Globals @@ -44,9 +45,7 @@ static void releaseEnv(BOOL didAttach) { } static void clearException(JNIEnv *env) { - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } static jstring toJString(JNIEnv *env, NSString *str) { @@ -137,11 +136,7 @@ - (void)userNotificationCenter:(UNUserNotificationCenter *)center jint result = (*env)->CallStaticIntMethod(env, cls, method, jIdentifier, jTitle, jSubtitle, jBody, dateMs, jCategoryId, jThreadId); - BOOL hadException = (*env)->ExceptionCheck(env); - if (hadException) { - (*env)->ExceptionDescribe(env); // prints to stderr for debugging - (*env)->ExceptionClear(env); - } + BOOL hadException = nucleus_jni_clear_exception(env); releaseEnv(didAttach); // If Kotlin callback failed, fall back to defaults. diff --git a/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/NativeWindowsNotificationBridge.kt b/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/NativeWindowsNotificationBridge.kt index 8161727f9..98ef8c5ef 100644 --- a/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/NativeWindowsNotificationBridge.kt +++ b/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/NativeWindowsNotificationBridge.kt @@ -3,10 +3,10 @@ package dev.nucleusframework.notification.windows import dev.nucleusframework.core.runtime.NativeLibraryLoader +import dev.nucleusframework.core.runtime.NucleusUiThread import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong -import javax.swing.SwingUtilities private const val LIBRARY_NAME = "nucleus_notification_windows" @@ -259,7 +259,7 @@ internal object NativeWindowsNotificationBridge { inputValues: Array, ) { val inputs = inputKeys.indices.associate { inputKeys[it] to inputValues[it] } - SwingUtilities.invokeLater { + NucleusUiThread.post { for (listener in listeners) { listener.onActivated(tag, group, arguments, inputs) } @@ -274,7 +274,7 @@ internal object NativeWindowsNotificationBridge { reason: Int, ) { val dismissalReason = DismissalReason.fromRawValue(reason) - SwingUtilities.invokeLater { + NucleusUiThread.post { for (listener in listeners) { listener.onDismissed(tag, group, dismissalReason) } @@ -288,7 +288,7 @@ internal object NativeWindowsNotificationBridge { group: String, errorCode: Int, ) { - SwingUtilities.invokeLater { + NucleusUiThread.post { for (listener in listeners) { listener.onFailed(tag, group, errorCode) } diff --git a/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/ToastNotificationListener.kt b/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/ToastNotificationListener.kt index 56240df1e..31a0b0088 100644 --- a/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/ToastNotificationListener.kt +++ b/notification-windows/src/main/kotlin/dev/nucleusframework/notification/windows/ToastNotificationListener.kt @@ -3,7 +3,8 @@ package dev.nucleusframework.notification.windows /** * Listener for toast notification lifecycle events. * - * All callbacks are dispatched on the Swing EDT for thread safety. + * All callbacks are dispatched on the host's UI thread (the Tao main thread + * under Nucleus, the AWT EDT in a plain Swing / Compose Desktop host). */ public interface ToastNotificationListener { /** diff --git a/notification-windows/src/main/native/windows/nucleus_notification_windows.cpp b/notification-windows/src/main/native/windows/nucleus_notification_windows.cpp index 0f63ccc99..f2a0ccdfe 100644 --- a/notification-windows/src/main/native/windows/nucleus_notification_windows.cpp +++ b/notification-windows/src/main/native/windows/nucleus_notification_windows.cpp @@ -33,6 +33,7 @@ #include #include +#include "../../../../../native-common/nucleus_jni.h" #include #include @@ -128,7 +129,7 @@ static void releaseEnv(bool didAttach) { } static void clearException(JNIEnv *env) { - if (env->ExceptionCheck()) env->ExceptionClear(); + nucleus_jni_clear_exception(env); } static jstring toJString(JNIEnv *env, const wchar_t *wstr) { diff --git a/notification-windows/src/test/kotlin/dev/nucleusframework/notification/windows/WindowsToastUiMarshalTest.kt b/notification-windows/src/test/kotlin/dev/nucleusframework/notification/windows/WindowsToastUiMarshalTest.kt new file mode 100644 index 000000000..10b7f4a7e --- /dev/null +++ b/notification-windows/src/test/kotlin/dev/nucleusframework/notification/windows/WindowsToastUiMarshalTest.kt @@ -0,0 +1,76 @@ +package dev.nucleusframework.notification.windows + +import dev.nucleusframework.core.runtime.NucleusUiThread +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Toast callbacks must reach the host's UI thread through [NucleusUiThread], + * not the AWT EDT: under the Tao backend the EDT is not Compose's UI thread, + * so a `SwingUtilities.invokeLater` here lands on a thread that paints nothing + * (issue #310). + */ +class WindowsToastUiMarshalTest { + @AfterTest + fun tearDown() { + NucleusUiThread.setExecutor(null) + } + + @Test + fun `toast callbacks are marshalled through the registered ui executor`() { + val ranOn = AtomicReference(null) + val latch = CountDownLatch(3) + NucleusUiThread.setExecutor { runnable -> + thread(name = UI_THREAD_NAME) { runnable.run() } + } + val listener = + object : ToastNotificationListener { + override fun onActivated( + tag: String, + group: String, + arguments: String, + inputs: Map, + ) = record() + + override fun onDismissed( + tag: String, + group: String, + reason: DismissalReason, + ) = record() + + override fun onFailed( + tag: String, + group: String, + errorCode: Int, + ) = record() + + private fun record() { + ranOn.set(Thread.currentThread().name) + latch.countDown() + } + } + NativeWindowsNotificationBridge.addListener(listener) + try { + // Native delivers these from a WinRT completion thread, never the UI one. + thread(name = "winrt-completion-stub") { + NativeWindowsNotificationBridge.onToastActivated("t", "g", "", emptyArray(), emptyArray()) + NativeWindowsNotificationBridge.onToastDismissed("t", "g", 0) + NativeWindowsNotificationBridge.onToastFailed("t", "g", 1) + } + assertTrue(latch.await(5, TimeUnit.SECONDS), "callbacks were not delivered") + assertEquals(UI_THREAD_NAME, ranOn.get()) + } finally { + NativeWindowsNotificationBridge.removeListener(listener) + } + } + + private companion object { + const val UI_THREAD_NAME = "ui-thread-under-test" + } +} diff --git a/nucleus-application/api/nucleus-application.api b/nucleus-application/api/nucleus-application.api index ca97f5d1c..df8acf71b 100644 --- a/nucleus-application/api/nucleus-application.api +++ b/nucleus-application/api/nucleus-application.api @@ -3,65 +3,94 @@ public final class dev/nucleusframework/application/AotTrainingKt { public static synthetic fun aotTraining-8Mi8wO0$default (Ldev/nucleusframework/application/NucleusApplicationScope;JLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V } +public final class dev/nucleusframework/application/ComposableSingletons$SatelliteKt { + public static final field INSTANCE Ldev/nucleusframework/application/ComposableSingletons$SatelliteKt; + public fun ()V + public final fun getLambda$-290429981$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-939267807$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1449473754$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$624849194$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; +} + +public final class dev/nucleusframework/application/ComposableSingletons$TabKt { + public static final field INSTANCE Ldev/nucleusframework/application/ComposableSingletons$TabKt; + public fun ()V + public final fun getLambda$-1587791916$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1645611812$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-2099696962$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-439585005$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-631721227$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-761788651$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$-848945442$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$1050919581$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1361492192$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; + public final fun getLambda$964616105$Nucleus_nucleus_application ()Lkotlin/jvm/functions/Function4; +} + public final class dev/nucleusframework/application/DecoratedDialogKt { public static final fun DecoratedDialog (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V public static final fun DecoratedDialog (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun DecoratedDialog-4gHVL9c (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun DecoratedDialog-cRDJ8gY (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V } public final class dev/nucleusframework/application/DecoratedWindowKt { - public static final fun DecoratedWindow-Ar7Y484 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V - public static final fun DecoratedWindow-oXav3jA (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun DecoratedWindow-7V76Zqo (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun DecoratedWindow-CW-zljo (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun DecoratedWindow-I6I5CN0 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun DecoratedWindow-bHFx5Fo (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZZZZZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V } public final class dev/nucleusframework/application/DefaultNucleusDialogHost : dev/nucleusframework/application/NucleusDialogHost { public static final field $stable I public static final field INSTANCE Ldev/nucleusframework/application/DefaultNucleusDialogHost; public fun Dialog (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public fun Dialog-uUwftkQ (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } public final class dev/nucleusframework/application/DefaultNucleusWindowHost : dev/nucleusframework/application/NucleusWindowHost { public static final field $stable I public static final field INSTANCE Ldev/nucleusframework/application/DefaultNucleusWindowHost; - public fun Window-ghhko4k (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public fun Window-OFHgUAc (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public fun Window-rOktWo0 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V +} + +public final class dev/nucleusframework/application/FileKitDialogsKt { + public static final fun withFileKitDialogSettings (Ldev/nucleusframework/application/NucleusWindow;Lio/github/vinceglb/filekit/dialogs/FileKitDialogSettings;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun withFileKitDialogSettings$default (Ldev/nucleusframework/application/NucleusWindow;Lio/github/vinceglb/filekit/dialogs/FileKitDialogSettings;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; } public final class dev/nucleusframework/application/NucleusApplicationKt { - public static final fun nucleusApplication ([Ljava/lang/String;Ldev/nucleusframework/application/NucleusBackend;ZLjava/util/Locale;ZLkotlin/jvm/functions/Function3;)V - public static synthetic fun nucleusApplication$default ([Ljava/lang/String;Ldev/nucleusframework/application/NucleusBackend;ZLjava/util/Locale;ZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)V + public static final fun nucleusApplication ([Ljava/lang/String;ZLjava/util/Locale;ZZZLkotlin/jvm/functions/Function3;)V + public static synthetic fun nucleusApplication$default ([Ljava/lang/String;ZLjava/util/Locale;ZZZLkotlin/jvm/functions/Function3;ILjava/lang/Object;)V } public abstract interface class dev/nucleusframework/application/NucleusApplicationScope : androidx/compose/ui/window/ApplicationScope { public abstract fun exitApplication ()V + public fun expectUnresponsive (Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public fun getAotMode ()Ldev/nucleusframework/aot/runtime/AotRuntimeMode; - public abstract fun getBackend ()Ldev/nucleusframework/application/NucleusBackend; public fun isAotRuntime ()Z public fun isAotTraining ()Z + public fun isQuitting ()Z public abstract fun onDeepLink (Lkotlin/jvm/functions/Function1;)V + public fun onResponsive (Lkotlin/jvm/functions/Function0;)V + public fun onUnresponsive (Lkotlin/jvm/functions/Function0;)V } public final class dev/nucleusframework/application/NucleusApplicationScope$DefaultImpls { + public static fun expectUnresponsive (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;)Ljava/lang/Object; public static fun getAotMode (Ldev/nucleusframework/application/NucleusApplicationScope;)Ldev/nucleusframework/aot/runtime/AotRuntimeMode; public static fun isAotRuntime (Ldev/nucleusframework/application/NucleusApplicationScope;)Z public static fun isAotTraining (Ldev/nucleusframework/application/NucleusApplicationScope;)Z + public static fun isQuitting (Ldev/nucleusframework/application/NucleusApplicationScope;)Z + public static fun onResponsive (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;)V + public static fun onUnresponsive (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;)V } public final class dev/nucleusframework/application/NucleusApplicationScopeKt { public static final fun getLocalNucleusApplicationScope ()Landroidx/compose/runtime/ProvidableCompositionLocal; } -public final class dev/nucleusframework/application/NucleusBackend : java/lang/Enum { - public static final field Auto Ldev/nucleusframework/application/NucleusBackend; - public static final field Awt Ldev/nucleusframework/application/NucleusBackend; - public static final field Tao Ldev/nucleusframework/application/NucleusBackend; - public static fun getEntries ()Lkotlin/enums/EnumEntries; - public static fun valueOf (Ljava/lang/String;)Ldev/nucleusframework/application/NucleusBackend; - public static fun values ()[Ldev/nucleusframework/application/NucleusBackend; -} - -public final class dev/nucleusframework/application/NucleusBackendKt { - public static final fun getLocalNucleusBackend ()Landroidx/compose/runtime/ProvidableCompositionLocal; -} - public abstract interface class dev/nucleusframework/application/NucleusDecoratedDialogScope : dev/nucleusframework/window/DecoratedDialogScope { public abstract fun getNucleusWindow ()Ldev/nucleusframework/application/NucleusWindow; } @@ -72,6 +101,11 @@ public abstract interface class dev/nucleusframework/application/NucleusDecorate public abstract interface class dev/nucleusframework/application/NucleusDialogHost { public abstract fun Dialog (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public fun Dialog-uUwftkQ (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + +public final class dev/nucleusframework/application/NucleusDialogHost$DefaultImpls { + public static fun Dialog-uUwftkQ (Ldev/nucleusframework/application/NucleusDialogHost;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V } public abstract interface class dev/nucleusframework/application/NucleusWindow { @@ -121,12 +155,19 @@ public final class dev/nucleusframework/application/NucleusWindowBounds { } public abstract interface class dev/nucleusframework/application/NucleusWindowHost { - public abstract fun Window-ghhko4k (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public fun Window-OFHgUAc (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public abstract fun Window-rOktWo0 (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V +} + +public final class dev/nucleusframework/application/NucleusWindowHost$DefaultImpls { + public static fun Window-OFHgUAc (Ldev/nucleusframework/application/NucleusWindowHost;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V } public final class dev/nucleusframework/application/NucleusWindowHostKt { public static final fun HostedDialog (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V - public static final fun HostedWindow-rSwaGlE (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun HostedDialog-4gHVL9c (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/DialogState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun HostedWindow-FhAYxCU (Lkotlin/jvm/functions/Function0;Landroidx/compose/ui/window/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZLandroidx/compose/ui/unit/DpSize;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun HostedWindow-QoA9wtg (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/window/tao/v2/WindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZZZZLdev/nucleusframework/application/NucleusWindow;ZZZJJLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ZLkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V public static final fun getLocalNucleusDialogHost ()Landroidx/compose/runtime/ProvidableCompositionLocal; public static final fun getLocalNucleusWindowHost ()Landroidx/compose/runtime/ProvidableCompositionLocal; } @@ -136,23 +177,37 @@ public final class dev/nucleusframework/application/NucleusWindowKt { } public abstract interface class dev/nucleusframework/application/NucleusWindowUnsafe { - public fun getAwtDialog ()Landroidx/compose/ui/awt/ComposeDialog; - public fun getAwtWindow ()Landroidx/compose/ui/awt/ComposeWindow; public fun getTaoHandle ()Ljava/lang/Long; public fun getTaoWindow ()Ldev/nucleusframework/window/tao/TaoWindow; } public final class dev/nucleusframework/application/NucleusWindowUnsafe$DefaultImpls { - public static fun getAwtDialog (Ldev/nucleusframework/application/NucleusWindowUnsafe;)Landroidx/compose/ui/awt/ComposeDialog; - public static fun getAwtWindow (Ldev/nucleusframework/application/NucleusWindowUnsafe;)Landroidx/compose/ui/awt/ComposeWindow; public static fun getTaoHandle (Ldev/nucleusframework/application/NucleusWindowUnsafe;)Ljava/lang/Long; public static fun getTaoWindow (Ldev/nucleusframework/application/NucleusWindowUnsafe;)Ldev/nucleusframework/window/tao/TaoWindow; } +public final class dev/nucleusframework/application/SatelliteKt { + public static final fun Satellite (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZLjava/util/Set;ZZZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun Satellite (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ljava/lang/String;Ljava/lang/String;Ldev/nucleusframework/window/tao/SatellitePlacement;ZLjava/util/Set;ZZZZZLkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun pinTo (Ldev/nucleusframework/window/tao/SatelliteWorkspace;Ldev/nucleusframework/application/NucleusWindow;)V +} + +public final class dev/nucleusframework/application/SatelliteWindowKt { + public static final fun SatelliteWindow (Ldev/nucleusframework/application/NucleusApplicationScope;Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/application/NucleusWindow;Ldev/nucleusframework/window/tao/SatelliteWindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V + public static final fun SatelliteWindow (Lkotlin/jvm/functions/Function0;Ldev/nucleusframework/application/NucleusWindow;Ldev/nucleusframework/window/tao/SatelliteWindowState;ZLjava/lang/String;Landroidx/compose/ui/graphics/painter/Painter;ZZZZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;III)V +} + public final class dev/nucleusframework/application/SingleInstanceRestoreBusKt { public static final fun SingleInstanceRestoreEffect (Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;I)V } +public final class dev/nucleusframework/application/TabKt { + public static final fun Tab (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun Tab (Ldev/nucleusframework/window/tao/TabWorkspace;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun TabWindows (Ldev/nucleusframework/application/NucleusApplicationScope;Ldev/nucleusframework/window/tao/TabWorkspace;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;ZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V + public static final fun TabWindows (Ldev/nucleusframework/window/tao/TabWorkspace;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;ZLkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function4;Lkotlin/jvm/functions/Function0;Landroidx/compose/runtime/Composer;II)V +} + public abstract class dev/nucleusframework/application/contextmenu/ContextMenuEntry { public static final field $stable I } diff --git a/nucleus-application/build.gradle.kts b/nucleus-application/build.gradle.kts index 97ae1ae67..ceca76ab7 100644 --- a/nucleus-application/build.gradle.kts +++ b/nucleus-application/build.gradle.kts @@ -16,7 +16,6 @@ val publishVersion = dependencies { api(project(":decorated-window-core")) - api(project(":decorated-window-awt")) api(project(":aot-runtime")) // api: nucleusApplication bridges Compose's isSystemInDarkTheme() to the // reactive OS detector, so consumers always get darkmode-detector on the @@ -35,14 +34,20 @@ dependencies { // supertype must be visible on consumers' compile classpath. api(libs.compose.desktop.common) - // An app ships exactly one backend at runtime — by construction (their - // imports overlap, so coexistence is unsupported). We compile against - // jni (which provides the AWT-bound DecoratedWindow signature, identical - // to jbr's) and tao for the no-AWT path. - compileOnly(project(":decorated-window-jni")) - compileOnly(project(":decorated-window-tao")) + // Tao is the only window backend: `nucleusApplication` always drives its + // native event loop, and the public window/dialog scopes expose Tao types. + // `api` so consumers get it without declaring it themselves. + api(project(":decorated-window-tao")) + + // compileOnly: nucleusApplication initializes FileKit only when the app + // ships it (see FileKitIntegration.kt), and withFileKitDialogSettings is + // only callable by an app that has filekit-dialogs; never forced on consumers. + compileOnly(libs.filekit.core) + compileOnly(libs.filekit.dialogs) testImplementation(libs.junit) + testImplementation(libs.filekit.core) + testImplementation(libs.filekit.dialogs) testImplementation(compose.desktop.currentOs) testImplementation("org.jetbrains.compose.ui:ui-test-junit4:${libs.versions.compose.get()}") } @@ -55,6 +60,7 @@ java { kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_17) + optIn.add("dev.nucleusframework.window.ExperimentalNucleusApi") } } @@ -78,6 +84,49 @@ tasks.register("spellcheckConsumer") { mainClass.set("dev.nucleusframework.application.spellcheck.SpellcheckConsumerMainKt") } +/** + * Writes the test runtime classpath for `scripts/context-menu-wayland-e2e.py`, + * which launches `ContextMenuE2EMainKt` itself under a nested compositor (a + * JavaExec would not see the driver's WAYLAND_DISPLAY through the daemon). + */ +tasks.register("contextMenuE2EClasspath") { + group = "verification" + description = "Builds the test classes and writes their runtime classpath for the context menu E2E driver" + dependsOn(tasks.named("testClasses")) + val output = layout.buildDirectory.file("e2e/context-menu-classpath.txt") + val classpath = sourceSets["test"].runtimeClasspath + inputs.files(classpath) + outputs.file(output) + doLast { + output + .get() + .asFile + .apply { parentFile.mkdirs() } + .writeText(classpath.asPath) + } +} + +/** + * Process E2E for FileKit auto-initialization: each scenario boots a real + * `nucleusApplication` in its own JVM, one of them on a classpath without FileKit. + * Not part of `check` — run explicitly: `./gradlew :nucleus-application:fileKitE2E` + */ +tasks.register("fileKitE2E") { + group = "verification" + description = "Boots nucleusApplication with and without FileKit and checks what FileKit resolves" + dependsOn(tasks.named("testClasses")) + val runtimeClasspath = sourceSets["test"].runtimeClasspath + classpath = runtimeClasspath + mainClass.set("dev.nucleusframework.application.filekit.FileKitE2EMainKt") + doFirst { + systemProperty("fileKitE2E.classpath", runtimeClasspath.asPath) + systemProperty( + "fileKitE2E.classpathWithoutFileKit", + runtimeClasspath.filter { !it.name.startsWith("filekit-") }.asPath, + ) + } +} + tasks.register("systemThemeE2E") { group = "verification" description = @@ -102,8 +151,8 @@ mavenPublishing { pom { name.set("Nucleus Application") description.set( - "Unified entry point picking the decorated-window backend " + - "(JBR/JNI AWT or no-AWT Tao) and exposing a backend-agnostic window handle.", + "Unified entry point for a Nucleus desktop application on the " + + "no-AWT Tao backend, exposing a portable window handle.", ) url.set("https://github.com/NucleusFramework/Nucleus") diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/AwtDialogNucleusWindow.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/AwtDialogNucleusWindow.kt deleted file mode 100644 index 17f0f3c23..000000000 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/AwtDialogNucleusWindow.kt +++ /dev/null @@ -1,108 +0,0 @@ -package dev.nucleusframework.application - -import androidx.compose.ui.awt.ComposeDialog -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.unit.DpSize -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.awt.event.WindowEvent -import javax.swing.SwingUtilities - -/** - * AWT [NucleusWindow] wrapping a [ComposeDialog]. Dialogs cannot minimize, - * maximize, or fullscreen — those setters are no-ops here. - */ -internal class AwtDialogNucleusWindow( - private val composeDialog: ComposeDialog, - private val onCloseRequest: () -> Unit, -) : NucleusWindow { - private val _focus = MutableStateFlow(composeDialog.isFocused) - private val _minimized = MutableStateFlow(false) - private val _maximized = MutableStateFlow(false) - private val _fullscreen = MutableStateFlow(false) - - init { - composeDialog.addWindowFocusListener( - object : java.awt.event.WindowFocusListener { - override fun windowGainedFocus(e: WindowEvent?) { - _focus.value = true - } - - override fun windowLostFocus(e: WindowEvent?) { - _focus.value = false - } - }, - ) - } - - override val isFocused: Boolean get() = composeDialog.isFocused - override val isMinimized: Boolean get() = false - override val isMaximized: Boolean get() = false - override val isFullscreen: Boolean get() = false - - override fun boundsOnScreen(): NucleusWindowBounds? = - runCatching { - if (!composeDialog.isShowing) return null - val location = composeDialog.locationOnScreen - NucleusWindowBounds( - x = location.x.toFloat(), - y = location.y.toFloat(), - width = composeDialog.width.toFloat(), - height = composeDialog.height.toFloat(), - ) - }.getOrNull() - - override fun show() = onEdt { composeDialog.isVisible = true } - - override fun hide() = onEdt { composeDialog.isVisible = false } - - override fun toFront() = onEdt { composeDialog.toFront() } - - override fun requestFocus() = onEdt { composeDialog.requestFocus() } - - override fun setMinimized(minimized: Boolean) = Unit - - override fun setMaximized(maximized: Boolean) = Unit - - override fun setFullscreen(fullscreen: Boolean) = Unit - - override fun setAlwaysOnTop(alwaysOnTop: Boolean) = - onEdt { - composeDialog.isAlwaysOnTop = alwaysOnTop - } - - override fun setMinimumSize(size: DpSize?) = - onEdt { - composeDialog.minimumSize = - size?.let { - val scale = - composeDialog.graphicsConfiguration - ?.defaultTransform - ?.scaleX - ?.toFloat() ?: 1f - java.awt.Dimension( - (it.width.value * scale).toInt(), - (it.height.value * scale).toInt(), - ) - } - } - - override fun setIcon(painter: Painter?) = Unit - - override fun close() = onEdt { onCloseRequest() } - - override val focusFlow: StateFlow = _focus.asStateFlow() - override val minimizedFlow: StateFlow = _minimized.asStateFlow() - override val maximizedFlow: StateFlow = _maximized.asStateFlow() - override val fullscreenFlow: StateFlow = _fullscreen.asStateFlow() - - override val unsafe: NucleusWindowUnsafe = - object : NucleusWindowUnsafe { - override val awtDialog: ComposeDialog get() = composeDialog - } - - private inline fun onEdt(crossinline block: () -> Unit) { - if (SwingUtilities.isEventDispatchThread()) block() else SwingUtilities.invokeLater { block() } - } -} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/AwtNucleusWindow.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/AwtNucleusWindow.kt deleted file mode 100644 index 345a40cce..000000000 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/AwtNucleusWindow.kt +++ /dev/null @@ -1,147 +0,0 @@ -package dev.nucleusframework.application - -import androidx.compose.ui.awt.ComposeWindow -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.window.WindowPlacement -import androidx.compose.ui.window.WindowState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import java.awt.Frame -import java.awt.event.ComponentAdapter -import java.awt.event.ComponentEvent -import java.awt.event.WindowAdapter -import java.awt.event.WindowEvent -import javax.swing.SwingUtilities - -/** - * AWT-backed [NucleusWindow]. Wraps a [ComposeWindow] together with the - * [WindowState] driven by the user — state writes go through [state] so they - * compose correctly with the existing `DecoratedWindow` reactivity, while - * imperative reads come straight off the AWT window. - */ -internal class AwtNucleusWindow( - private val composeWindow: ComposeWindow, - private val state: WindowState, - private val onCloseRequest: () -> Unit, -) : NucleusWindow { - private val _focus = MutableStateFlow(composeWindow.isFocused) - private val _minimized = MutableStateFlow(state.isMinimized) - private val _maximized = MutableStateFlow(state.placement == WindowPlacement.Maximized) - private val _fullscreen = MutableStateFlow(state.placement == WindowPlacement.Fullscreen) - - init { - composeWindow.addWindowFocusListener( - object : java.awt.event.WindowFocusListener { - override fun windowGainedFocus(e: WindowEvent?) { - _focus.value = true - } - - override fun windowLostFocus(e: WindowEvent?) { - _focus.value = false - } - }, - ) - composeWindow.addWindowStateListener( - object : WindowAdapter() { - override fun windowStateChanged(e: WindowEvent) { - _minimized.value = (e.newState and Frame.ICONIFIED) != 0 - _maximized.value = (e.newState and Frame.MAXIMIZED_BOTH) == Frame.MAXIMIZED_BOTH - } - }, - ) - composeWindow.addComponentListener( - object : ComponentAdapter() { - override fun componentResized(e: ComponentEvent?) { - _fullscreen.value = state.placement == WindowPlacement.Fullscreen - } - }, - ) - } - - override val isFocused: Boolean get() = composeWindow.isFocused - override val isMinimized: Boolean get() = state.isMinimized - override val isMaximized: Boolean get() = state.placement == WindowPlacement.Maximized - override val isFullscreen: Boolean get() = state.placement == WindowPlacement.Fullscreen - - override fun boundsOnScreen(): NucleusWindowBounds? = - runCatching { - if (!composeWindow.isShowing) return null - val location = composeWindow.locationOnScreen - NucleusWindowBounds( - x = location.x.toFloat(), - y = location.y.toFloat(), - width = composeWindow.width.toFloat(), - height = composeWindow.height.toFloat(), - ) - }.getOrNull() - - override fun show() = onEdt { composeWindow.isVisible = true } - - override fun hide() = onEdt { composeWindow.isVisible = false } - - override fun toFront() = onEdt { composeWindow.toFront() } - - override fun requestFocus() = onEdt { composeWindow.requestFocus() } - - override fun setMinimized(minimized: Boolean) { - state.isMinimized = minimized - _minimized.value = minimized - } - - override fun setMaximized(maximized: Boolean) { - state.placement = if (maximized) WindowPlacement.Maximized else WindowPlacement.Floating - _maximized.value = maximized - } - - override fun setFullscreen(fullscreen: Boolean) { - state.placement = if (fullscreen) WindowPlacement.Fullscreen else WindowPlacement.Floating - _fullscreen.value = fullscreen - } - - override fun setAlwaysOnTop(alwaysOnTop: Boolean) = - onEdt { - composeWindow.isAlwaysOnTop = alwaysOnTop - } - - override fun setMinimumSize(size: DpSize?) = - onEdt { - if (size == null) { - composeWindow.minimumSize = null - } else { - val scale = - composeWindow.graphicsConfiguration - ?.defaultTransform - ?.scaleX - ?.toFloat() ?: 1f - composeWindow.minimumSize = - java.awt.Dimension( - (size.width.value * scale).toInt(), - (size.height.value * scale).toInt(), - ) - } - } - - override fun setIcon(painter: Painter?) { - // AWT icon is set via the `icon` parameter of Compose's Window. Live - // updates of the icon belong to the @Composable layer; this method is - // a no-op to avoid fighting the parameter-driven path. - } - - override fun close() = onEdt { onCloseRequest() } - - override val focusFlow: StateFlow = _focus.asStateFlow() - override val minimizedFlow: StateFlow = _minimized.asStateFlow() - override val maximizedFlow: StateFlow = _maximized.asStateFlow() - override val fullscreenFlow: StateFlow = _fullscreen.asStateFlow() - - override val unsafe: NucleusWindowUnsafe = - object : NucleusWindowUnsafe { - override val awtWindow: ComposeWindow get() = composeWindow - } - - private inline fun onEdt(crossinline block: () -> Unit) { - if (SwingUtilities.isEventDispatchThread()) block() else SwingUtilities.invokeLater { block() } - } -} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedDialog.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedDialog.kt index 1fe0a1131..6a377e47a 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedDialog.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedDialog.kt @@ -3,27 +3,26 @@ // UI — so a non-UI composable called in the caller's scope cannot reclassify // the window content. ktlint's `annotation` and `function-type-modifier-spacing` // rules contradict each other on the resulting two-annotation parameter type. +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @file:Suppress("ktlint:standard:annotation") package dev.nucleusframework.application import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposableOpenTarget -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.UiComposable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.unit.DpSize import androidx.compose.ui.window.DialogState import androidx.compose.ui.window.rememberDialogState import dev.nucleusframework.application.internal.TaoDecoratedDialogAdapter -import dev.nucleusframework.window.AwtDecoratedDialogScope -import dev.nucleusframework.window.DecoratedDialogState -import dev.nucleusframework.window.DecoratedDialog as AwtDecoratedDialog +import dev.nucleusframework.window.tao.v2.DialogState as NucleusDialogState /** - * Backend-agnostic decorated dialog. Mirrors [DecoratedWindow] but for modal / - * secondary windows: non-resizable by default, no maximize / minimize affordance. + * Decorated dialog. Mirrors [DecoratedWindow] but for modal / secondary + * windows: non-resizable by default, no maximize / minimize affordance. */ @Suppress("FunctionNaming", "LongParameterList") @Composable @@ -42,8 +41,9 @@ public fun NucleusApplicationScope.DecoratedDialog( content: @Composable @UiComposable NucleusDecoratedDialogScope.() -> Unit, ) { when (this) { - is AwtNucleusApplicationScope -> - AwtDecoratedDialog( + is TaoNucleusApplicationScope -> + TaoDecoratedDialogAdapter.Dialog( + scope = this, onCloseRequest = onCloseRequest, state = state, visible = visible, @@ -54,26 +54,78 @@ public fun NucleusApplicationScope.DecoratedDialog( focusable = focusable, onPreviewKeyEvent = onPreviewKeyEvent, onKeyEvent = onKeyEvent, - ) { - val awtScope: AwtDecoratedDialogScope = this - val nucleusWindow = - remember(window) { - AwtDialogNucleusWindow(window, onCloseRequest) - } - val scope = - remember(awtScope, nucleusWindow) { - AwtNucleusDecoratedDialogScope(awtScope, nucleusWindow) - } - CompositionLocalProvider( - LocalNucleusBackend provides NucleusBackend.Awt, - LocalNucleusWindow provides nucleusWindow, - ) { - scope.content() - } - } + content = content, + ) + } +} + +/** + * Receiver-less [DecoratedDialog], resolving the application scope from + * [LocalNucleusApplicationScope]. Parameters behave exactly like the + * [NucleusApplicationScope] overload. Fails outside a `nucleusApplication { … }` + * block, where no scope exists. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +@ComposableOpenTarget(-1) +public fun DecoratedDialog( + onCloseRequest: () -> Unit, + state: DialogState = rememberDialogState(), + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = false, + enabled: Boolean = true, + focusable: Boolean = true, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + content: @Composable @UiComposable NucleusDecoratedDialogScope.() -> Unit, +) { + LocalNucleusApplicationScope.current.DecoratedDialog( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + enabled = enabled, + focusable = focusable, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = content, + ) +} +/** + * [DecoratedDialog] overload for the AWT-free dialog API v2 clone. + * + * [state] has no default so `DecoratedDialog(onCloseRequest) { }` still + * resolves to the v1 overload. + * + * `requestScreen` / `screenId` are not applied on Tao (primary work area + * only). + */ +@ExperimentalComposeUiApi +@Suppress("FunctionNaming", "LongParameterList") +@Composable +public fun NucleusApplicationScope.DecoratedDialog( + onCloseRequest: () -> Unit, + state: NucleusDialogState, + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = false, + enabled: Boolean = true, + focusable: Boolean = true, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, +) { + when (this) { is TaoNucleusApplicationScope -> - TaoDecoratedDialogAdapter.Dialog( + TaoDecoratedDialogAdapter.DialogNucleusV2( scope = this, onCloseRequest = onCloseRequest, state = state, @@ -83,6 +135,8 @@ public fun NucleusApplicationScope.DecoratedDialog( resizable = resizable, enabled = enabled, focusable = focusable, + minSize = minSize, + maxSize = maxSize, onPreviewKeyEvent = onPreviewKeyEvent, onKeyEvent = onKeyEvent, content = content, @@ -91,26 +145,26 @@ public fun NucleusApplicationScope.DecoratedDialog( } /** - * Receiver-less [DecoratedDialog], resolving the application scope from - * [LocalNucleusApplicationScope]. Parameters behave exactly like the - * [NucleusApplicationScope] overload. Fails outside a `nucleusApplication { … }` - * block, where no scope exists. + * Receiver-less [DecoratedDialog] for Compose window API v2. See the + * [NucleusApplicationScope] overload. */ +@ExperimentalComposeUiApi @Suppress("FunctionNaming", "LongParameterList") @Composable -@ComposableOpenTarget(-1) public fun DecoratedDialog( onCloseRequest: () -> Unit, - state: DialogState = rememberDialogState(), + state: NucleusDialogState, visible: Boolean = true, title: String = "", icon: Painter? = null, resizable: Boolean = false, enabled: Boolean = true, focusable: Boolean = true, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, onKeyEvent: (KeyEvent) -> Boolean = { false }, - content: @Composable @UiComposable NucleusDecoratedDialogScope.() -> Unit, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, ) { LocalNucleusApplicationScope.current.DecoratedDialog( onCloseRequest = onCloseRequest, @@ -121,16 +175,10 @@ public fun DecoratedDialog( resizable = resizable, enabled = enabled, focusable = focusable, + minSize = minSize, + maxSize = maxSize, onPreviewKeyEvent = onPreviewKeyEvent, onKeyEvent = onKeyEvent, content = content, ) } - -internal class AwtNucleusDecoratedDialogScope( - private val delegate: AwtDecoratedDialogScope, - override val nucleusWindow: NucleusWindow, -) : NucleusDecoratedDialogScope, - AwtDecoratedDialogScope by delegate { - override val state: DecoratedDialogState get() = delegate.state -} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt index 86a455ae3..5076c8d6d 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/DecoratedWindow.kt @@ -3,14 +3,14 @@ // UI — so a non-UI composable called in the caller's scope cannot reclassify // the window content. ktlint's `annotation` and `function-type-modifier-spacing` // rules contradict each other on the resulting two-annotation parameter type. +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @file:Suppress("ktlint:standard:annotation") package dev.nucleusframework.application import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposableOpenTarget -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.UiComposable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent @@ -18,14 +18,12 @@ import androidx.compose.ui.unit.DpSize import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberWindowState import dev.nucleusframework.application.internal.TaoDecoratedWindowAdapter -import dev.nucleusframework.window.AwtDecoratedWindowScope -import dev.nucleusframework.window.DecoratedWindowState -import dev.nucleusframework.window.DecoratedWindow as AwtDecoratedWindow +import dev.nucleusframework.window.tao.v2.WindowState as NucleusWindowState /** - * Backend-agnostic decorated window. Inside [content], `window` is a - * [NucleusWindow] usable on any backend; reach for `window.unsafe.*` only when - * you genuinely need backend-specific behaviour. + * Decorated window. Inside [content], `nucleusWindow` is a portable + * [NucleusWindow] handle; reach for `nucleusWindow.unsafe.*` only when you + * genuinely need the Tao-specific window. */ @Suppress("FunctionNaming", "LongParameterList") @Composable @@ -37,33 +35,34 @@ public fun NucleusApplicationScope.DecoratedWindow( title: String = "", icon: Painter? = null, resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, enabled: Boolean = true, focusable: Boolean = true, alwaysOnTop: Boolean = false, // Fully borderless window (no macOS traffic lights) — for overlay/ghost windows. - // Honoured by the Tao backend; the AWT backend currently ignores it. undecorated: Boolean = false, - // Linux/Tao only: make this window a popup overlay of [popupFor]. On - // Wayland it maps as a wl_subsurface of the parent — the only window kind - // a client can freely position under xdg-shell (coordinates are - // parent-relative). For cursor-following overlays such as drag ghosts. - // Ignored by the AWT backend and on macOS/Windows. + // Linux only: make this window a popup overlay of [popupFor]. On Wayland + // it maps as a wl_subsurface of the parent — the only window kind a client + // can freely position under xdg-shell (coordinates are parent-relative). + // For cursor-following overlays such as drag ghosts. Ignored on + // macOS/Windows. popupFor: NucleusWindow? = null, // Materialise Compose Popup layers as native transparent windows // (NSPanel / WS_POPUP HWND) instead of drawing them inline in this - // window's render target. Honoured by the Tao backend on all three - // platforms; ignored by AWT. + // window's render target. Supported on all three platforms. nativePopupLayers: Boolean = false, // Replace Compose-drawn context menus (ContextMenuArea, text - // Cut/Copy/Paste, spellcheck items) with the OS-looking menu. Tao + - // macOS (`NSMenu`), or a Compose flyout on Linux (Adwaita) / Windows - // (Fluent). No-op on AWT. - // Independent of [nativePopupLayers]. + // Cut/Copy/Paste, spellcheck items) with the OS-looking menu: `NSMenu` on + // macOS, or a Compose flyout on Linux (Adwaita) / Windows (Fluent). The + // flyout always opens in a native popup surface, whatever + // [nativePopupLayers] says — the rest of the window's popups follow that + // flag alone. nativeContextMenu: Boolean = false, // Hide this window from the OS taskbar/Dock while it stays visible and // focusable (macOS: NSApplication accessory policy, app-wide; Windows: // WS_EX_TOOLWINDOW, per-window; Linux: GTK skip-taskbar hint, per-window, - // X11/XWayland only). Honoured by the Tao backend; ignored by AWT. + // X11/XWayland only). hiddenFromDock: Boolean = false, minimumSize: DpSize? = null, onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, @@ -75,71 +74,35 @@ public fun NucleusApplicationScope.DecoratedWindow( // Full-window per-pixel transparency: pixels the content leaves at alpha 0 // show the desktop behind the window (#416). Creation-time only — cannot // change after the native window exists. Typically combined with - // [undecorated]. Honoured by the Tao backend; the AWT backend ignores it. + // [undecorated]. transparent: Boolean = false, // Click-through window: pointer events fall through to whatever sits // below, and the window never intercepts input. Pair with // `focusable = false` for passive overlays (watermarks, HUDs). Reactive. - // Honoured by the Tao backend; the AWT backend ignores it. clickThrough: Boolean = false, // Show the window on every desktop instead of only the one it was created // on — macOS Spaces (`NSWindowCollectionBehaviorCanJoinAllSpaces`), Linux // workspaces (`gtk_window_stick`, X11/XWayland only — native Wayland has no // workspace protocol and logs a warning). No-op on Windows, where a // [hiddenFromDock] window already shows on every virtual desktop. Reactive. - // Honoured by the Tao backend; the AWT backend ignores it. visibleOnAllWorkspaces: Boolean = false, // Linux only: give this window an X11 surface even when the app runs on a // native Wayland session (a second GdkDisplay opened on DISPLAY, i.e. // XWayland). Creation-time only. Wayland has no protocol for client-side // stacking, programmatic positioning or workspace stickiness, so an overlay // that needs them can take an X11 surface for itself while the rest of the - // app keeps its Wayland surfaces. Honoured by the Tao backend; ignored by - // the AWT backend and on other platforms. + // app keeps its Wayland surfaces. Ignored on other platforms. forceX11: Boolean = false, // Pin the window below every other window instead of above them — macOS // `NSWindowLevel.BelowNormal`, Windows `HWND_BOTTOM`, Linux // `gtk_window_set_keep_below` (X11/XWayland only, native Wayland has no // client-side stacking protocol). For wallpaper-level overlays such as // desktop widgets. Mutually exclusive with [alwaysOnTop] — last one set - // wins. Reactive. Honoured by the Tao backend; the AWT backend ignores it. + // wins. Reactive. alwaysOnBottom: Boolean = false, content: @Composable @UiComposable NucleusDecoratedWindowScope.() -> Unit, ) { when (this) { - is AwtNucleusApplicationScope -> - AwtDecoratedWindow( - onCloseRequest = onCloseRequest, - state = state, - visible = visible, - title = title, - icon = icon, - resizable = resizable, - enabled = enabled, - focusable = focusable, - alwaysOnTop = alwaysOnTop, - minimumSize = minimumSize, - onPreviewKeyEvent = onPreviewKeyEvent, - onKeyEvent = onKeyEvent, - ) { - val awtScope: AwtDecoratedWindowScope = this - val nucleusWindow = - remember(window) { - AwtNucleusWindow(window, state, onCloseRequest) - } - val scope = - remember(awtScope, nucleusWindow) { - AwtNucleusDecoratedWindowScope(awtScope, nucleusWindow) - } - ObserveSingleInstanceRestore(nucleusWindow) - CompositionLocalProvider( - LocalNucleusBackend provides NucleusBackend.Awt, - LocalNucleusWindow provides nucleusWindow, - ) { - scope.content() - } - } - is TaoNucleusApplicationScope -> TaoDecoratedWindowAdapter.Window( scope = this, @@ -149,6 +112,8 @@ public fun NucleusApplicationScope.DecoratedWindow( title = title, icon = icon, resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, enabled = enabled, focusable = focusable, alwaysOnTop = alwaysOnTop, @@ -190,6 +155,8 @@ public fun DecoratedWindow( title: String = "", icon: Painter? = null, resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, enabled: Boolean = true, focusable: Boolean = true, alwaysOnTop: Boolean = false, @@ -215,6 +182,8 @@ public fun DecoratedWindow( title = title, icon = icon, resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, enabled = enabled, focusable = focusable, alwaysOnTop = alwaysOnTop, @@ -235,10 +204,141 @@ public fun DecoratedWindow( ) } -internal class AwtNucleusDecoratedWindowScope( - private val delegate: AwtDecoratedWindowScope, - override val nucleusWindow: NucleusWindow, -) : NucleusDecoratedWindowScope, - AwtDecoratedWindowScope by delegate { - override val state: DecoratedWindowState get() = delegate.state +/** + * [DecoratedWindow] overload for the AWT-free window API v2 clone. + * + * [state] has no default so `DecoratedWindow(onCloseRequest) { }` still + * resolves to the v1 overload. + * + * Every request is applied here, `requestScreen` included — see + * [dev.nucleusframework.window.tao.v2.rememberWindowState]. + */ +@ExperimentalComposeUiApi +@Suppress("FunctionNaming", "LongParameterList") +@Composable +public fun NucleusApplicationScope.DecoratedWindow( + onCloseRequest: () -> Unit, + state: NucleusWindowState, + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + undecorated: Boolean = false, + popupFor: NucleusWindow? = null, + nativePopupLayers: Boolean = false, + nativeContextMenu: Boolean = false, + hiddenFromDock: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + transparent: Boolean = false, + clickThrough: Boolean = false, + visibleOnAllWorkspaces: Boolean = false, + forceX11: Boolean = false, + alwaysOnBottom: Boolean = false, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, +) { + when (this) { + is TaoNucleusApplicationScope -> + TaoDecoratedWindowAdapter.WindowNucleusV2( + scope = this, + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + undecorated = undecorated, + transparent = transparent, + clickThrough = clickThrough, + visibleOnAllWorkspaces = visibleOnAllWorkspaces, + forceX11 = forceX11, + alwaysOnBottom = alwaysOnBottom, + popupFor = popupFor, + nativePopupLayers = nativePopupLayers, + nativeContextMenu = nativeContextMenu, + hiddenFromDock = hiddenFromDock, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = content, + ) + } +} + +/** + * Receiver-less [DecoratedWindow] for the AWT-free window API v2 clone. See the + * [NucleusApplicationScope] overload. + */ +@ExperimentalComposeUiApi +@Suppress("FunctionNaming", "LongParameterList") +@Composable +public fun DecoratedWindow( + onCloseRequest: () -> Unit, + state: NucleusWindowState, + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + undecorated: Boolean = false, + popupFor: NucleusWindow? = null, + nativePopupLayers: Boolean = false, + nativeContextMenu: Boolean = false, + hiddenFromDock: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + transparent: Boolean = false, + clickThrough: Boolean = false, + visibleOnAllWorkspaces: Boolean = false, + forceX11: Boolean = false, + alwaysOnBottom: Boolean = false, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, +) { + LocalNucleusApplicationScope.current.DecoratedWindow( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + undecorated = undecorated, + popupFor = popupFor, + nativePopupLayers = nativePopupLayers, + nativeContextMenu = nativeContextMenu, + hiddenFromDock = hiddenFromDock, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + transparent = transparent, + clickThrough = clickThrough, + visibleOnAllWorkspaces = visibleOnAllWorkspaces, + forceX11 = forceX11, + alwaysOnBottom = alwaysOnBottom, + content = content, + ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt new file mode 100644 index 000000000..7c932c9c4 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/FileKitDialogs.kt @@ -0,0 +1,77 @@ +package dev.nucleusframework.application + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.window.tao.TaoWindow +import dev.nucleusframework.window.tao.XdgPortalParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Runs [block] with [settings] parented to this window, so the FileKit dialog it opens is attached + * to the window instead of floating free: + * + * - **Windows**: the window's HWND becomes the dialog's owner. + * - **Linux X11 / XWayland**: the portal gets `x11:`. + * - **Linux Wayland**: the window is exported through `xdg_foreign` for the duration of [block] + * and unexported when it returns, which is the lifetime the portal requires. + * - **macOS**: left unparented — FileKit's `runModal` panel is already app-modal (it runs + * `NSApplication.runModal(for:)`), so no other window can take it over. + * + * A [settings] that already carries a parent is passed through untouched, and so is every + * setting when the window exposes no platform identity (not realized yet, native bridge missing). + * + * ```kotlin + * val window = LocalNucleusWindow.current + * scope.launch { + * val file = window.withFileKitDialogSettings { settings -> + * FileKit.openFilePicker(dialogSettings = settings) + * } + * } + * ``` + * + * Requires `filekit-dialogs` on the app's classpath; `nucleus-application` never ships it. + */ +public suspend fun NucleusWindow.withFileKitDialogSettings( + settings: FileKitDialogSettings = FileKitDialogSettings.createDefault(), + block: suspend (FileKitDialogSettings) -> T, +): T = withDialogParent(settings, { unsafe.taoWindow?.fileKitDialogParent() }, block) + +/** A dialog parent plus whatever keeps it valid (the Wayland export), released after the dialog. */ +internal class BorrowedDialogParent( + val parent: FileKitDialogParent, + private val lease: AutoCloseable? = null, +) : AutoCloseable { + override fun close() { + lease?.close() + } +} + +internal suspend fun withDialogParent( + settings: FileKitDialogSettings, + resolveParent: () -> BorrowedDialogParent?, + block: suspend (FileKitDialogSettings) -> T, +): T { + if (settings.parent != null) return block(settings) + // The Wayland export blocks until the compositor answers, so keep it off the UI thread. + val borrowed = withContext(Dispatchers.IO) { resolveParent() } ?: return block(settings) + return borrowed.use { block(settings.copy(parent = it.parent)) } +} + +private fun TaoWindow.fileKitDialogParent(): BorrowedDialogParent? = + when (Platform.Current) { + Platform.Windows -> { + val hwnd = nativeHandle + if (hwnd == 0L) null else BorrowedDialogParent(FileKitDialogParent.windows(hwnd)) + } + Platform.Linux -> + when (val portalParent = xdgPortalParent()) { + is XdgPortalParent.X11 -> BorrowedDialogParent(FileKitDialogParent.x11(portalParent.xid)) + is XdgPortalParent.Wayland -> + BorrowedDialogParent(FileKitDialogParent.wayland(portalParent.handle), lease = portalParent) + null -> null + } + // runModal is already app-modal on macOS; FileKit also rejects any non-AWT parent there. + else -> null + } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplication.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplication.kt index 4658f5797..d3a86101f 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplication.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplication.kt @@ -1,9 +1,12 @@ package dev.nucleusframework.application import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.window.application +import androidx.compose.ui.ComposeUiFlags +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.pollSystemTheme import dev.nucleusframework.application.internal.TaoLauncher +import dev.nucleusframework.application.internal.initializeFileKitIfPresent +import dev.nucleusframework.core.runtime.ExecutableRuntime import dev.nucleusframework.core.runtime.WindowBackend import dev.nucleusframework.graalvm.GraalVmInitializer import java.util.Locale @@ -11,19 +14,18 @@ import java.util.Locale /** * Single entry point for a Nucleus desktop application. * - * Picks the window backend (AWT-based JBR/JNI or no-AWT Tao) and dispatches to - * Compose Desktop's `application { … }` or Tao's `taoApplication { … }`. + * Runs the app on the no-AWT Tao backend (`decorated-window-tao`): a single + * native event loop owns the main thread and doubles as `Dispatchers.Main`. * Inside [content], use [DecoratedWindow] / [DecoratedDialog], or * [HostedWindow] / [HostedDialog] when libraries must not hard-code chrome. - * All open secondary windows/dialogs on the active backend (Tao or AWT) and - * expose a [NucleusWindow] handle. + * All open secondary windows/dialogs and expose a [NucleusWindow] handle. * * Compose's [androidx.compose.foundation.isSystemInDarkTheme] is bridged to * Nucleus's reactive OS detector (`darkmode-detector`), so official and library * call sites track live system theme changes without polling. * * ``` - * fun main() = nucleusApplication(backend = NucleusBackend.Auto) { + * fun main() = nucleusApplication { * val state = rememberWindowState(size = DpSize(1200.dp, 800.dp)) * DecoratedWindow( * onCloseRequest = ::exitApplication, @@ -36,24 +38,38 @@ import java.util.Locale * } * ``` * - * `Auto` resolution: - * 1. Explicit [backend] (≠ [NucleusBackend.Auto]) is respected as-is. - * 2. Otherwise the runtime classpath is probed. An app is expected to ship - * a single backend module — when both `decorated-window-tao` and an AWT - * backend (`-jbr` or `-jni`) are present, Tao wins. + * After the last window closes (or [NucleusApplicationScope.exitApplication] + * is called), the JVM is terminated by default. Pass + * `exitProcessOnExit = false` to return normally instead, matching Compose + * Desktop's `application(exitProcessOnExit)`. */ +@OptIn(ExperimentalComposeUiApi::class) public fun nucleusApplication( args: Array = emptyArray(), - backend: NucleusBackend = NucleusBackend.Auto, - enableSingleInstance: Boolean = true, + // Defaults to off in a dev run (`./gradlew run`, IDE launch) so a second + // debug instance, or one started while a packaged copy is running, is not + // silently forwarded to the first one and exited. + enableSingleInstance: Boolean = !ExecutableRuntime.isDev(), defaultLocale: Locale? = null, - // macOS + Tao backend only: run as a menu-bar / agent app whose Dock icon - // tracks window visibility. The app starts without a Dock icon (accessory - // policy) and shows one only while at least one [DecoratedWindow] with + // macOS only: run as a menu-bar / agent app whose Dock icon tracks window + // visibility. The app starts without a Dock icon (accessory policy) and + // shows one only while at least one [DecoratedWindow] with // `hiddenFromDock = false` is visible; closing the last such window drops it - // back out of the Dock. Standalone tray popups never count. Ignored on the - // AWT backend and off macOS. + // back out of the Dock. Standalone tray popups never count. Ignored off + // macOS. dockIconFollowsWindows: Boolean = false, + // When true (default), the JVM is terminated after the application exits + // (`exitProcess(0)` on a normal quit, `exitProcess(1)` after a fatal error). + // When false, [nucleusApplication] returns so the caller can continue + // in-process. The default matches Compose Desktop and is required because + // Compose/Skiko initialisation indirectly touches AWT, whose non-daemon + // EDT would otherwise keep the JVM alive after the Tao loop has shut down. + exitProcessOnExit: Boolean = true, + // When true (default) and FileKit is on the runtime classpath, calls + // `FileKit.init(NucleusApp.appId)` unless the app already initialized it, + // so FileKit's files directory is the one the NSIS uninstaller removes + // with `deleteAppDataOnUninstall`. Pass false to leave FileKit untouched. + initializeFileKit: Boolean = true, content: @Composable NucleusApplicationScope.() -> Unit, ) { GraalVmInitializer.initialize() @@ -77,60 +93,29 @@ public fun nucleusApplication( } } + // Compose 1.12 polls the OS theme once a second on Dispatchers.IO for + // isSystemInDarkTheme(). Nucleus provides LocalSystemTheme from its reactive + // detector (ProvideNucleusSystemTheme), so that poll is pure overhead. The + // flag is read when a scene is created, so it must be cleared before any UI. + ComposeUiFlags.pollSystemTheme = false + if (enableSingleInstance) { acquireSingleInstanceLock(args) } primePlatformIntegrations(args) - val resolved = resolveBackend(backend) + // Point FileKit at the app's data directory (the one the NSIS uninstaller + // removes) when it is on the classpath; an app that already called + // FileKit.init keeps its own configuration. + if (initializeFileKit) { + initializeFileKitIfPresent() + } - // Record the resolved backend so external libraries (depending only on + // Record the active backend so external libraries (depending only on // core-runtime) can query WindowBackend.Current without a reflective // classpath probe or a Compose composition local. - WindowBackend.setActive( - if (resolved == NucleusBackend.Tao) WindowBackend.Tao else WindowBackend.Awt, - ) - - when (resolved) { - NucleusBackend.Tao -> TaoLauncher.run(args, dockIconFollowsWindows, content) - NucleusBackend.Awt, NucleusBackend.Auto -> - application { - val nucleusScope = AwtNucleusApplicationScope(this, args) - ProvideNucleusSystemTheme { - CompositionLocalProvider( - LocalNucleusBackend provides NucleusBackend.Awt, - LocalNucleusApplicationScope provides nucleusScope, - LocalNucleusWindowHost provides DefaultNucleusWindowHost, - LocalNucleusDialogHost provides DefaultNucleusDialogHost, - ) { - nucleusScope.content() - } - } - } - } -} - -internal fun resolveBackend(requested: NucleusBackend): NucleusBackend = - when (requested) { - NucleusBackend.Awt, NucleusBackend.Tao -> requested - NucleusBackend.Auto -> - when { - TaoBackendOnClasspath -> NucleusBackend.Tao - else -> NucleusBackend.Awt - } - } + WindowBackend.setActive(WindowBackend.Tao) -/** Probes the classpath once. Tao ships `TaoApplication`; absence ⇒ AWT. */ -private val TaoBackendOnClasspath: Boolean by lazy { - try { - Class.forName( - "dev.nucleusframework.window.tao.TaoApplication", - false, - NucleusBackend::class.java.classLoader, - ) - true - } catch (_: ClassNotFoundException) { - false - } + TaoLauncher.run(args, dockIconFollowsWindows, exitProcessOnExit, content) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplicationScope.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplicationScope.kt index 63e1abd1e..542627f97 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplicationScope.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusApplicationScope.kt @@ -6,35 +6,31 @@ import androidx.compose.runtime.staticCompositionLocalOf import dev.nucleusframework.aot.runtime.AotRuntime import dev.nucleusframework.aot.runtime.AotRuntimeMode import dev.nucleusframework.core.runtime.DeepLinkHandler +import dev.nucleusframework.window.tao.TaoApplication import dev.nucleusframework.window.tao.TaoDeepLinkBridge import java.net.URI -import androidx.compose.ui.window.ApplicationScope as AwtApplicationScope +import androidx.compose.ui.window.ApplicationScope as ComposeApplicationScope import dev.nucleusframework.window.tao.ApplicationScope as TaoApplicationScope /** - * Backend-agnostic scope exposed by [nucleusApplication]. The two concrete - * subtypes wrap the AWT / Tao application scopes so [DecoratedWindow] can - * dispatch on `when (this)` without leaking backend types into user code. + * Scope exposed by [nucleusApplication], wrapping the Tao application scope so + * [DecoratedWindow] never leaks backend types into user code. * - * Extends Compose's [AwtApplicationScope] so libraries scoped to the plain + * Extends Compose's [ComposeApplicationScope] so libraries scoped to the plain * Compose application scope (e.g. tray composables) work inside * [nucleusApplication] blocks without Nucleus-specific overloads. * * Composables that rely on AWT under the hood (Compose's `Tray`, `Window`, …) - * are only supported on the AWT backends (JNI / JBR). On the Tao backend the - * process runs without an AWT event loop and the native event loop owns the - * main thread, so calling them compiles but is unsupported — AWT would - * initialize off-thread (deadlock-prone on macOS). Use AWT-free alternatives - * (e.g. ComposeNativeTray) with Tao. + * are **not** supported: the process runs without an AWT event loop and the + * native Tao event loop owns the main thread, so calling them compiles but AWT + * would initialize off-thread (deadlock-prone on macOS). Use AWT-free + * alternatives (e.g. ComposeNativeTray, [HostedWindow]). */ @Stable -public sealed interface NucleusApplicationScope : AwtApplicationScope { +public sealed interface NucleusApplicationScope : ComposeApplicationScope { /** Posts an exit request to the underlying event loop. */ override fun exitApplication() - /** The backend currently driving this scope. Never [NucleusBackend.Auto]. */ - public val backend: NucleusBackend - /** Current AOT runtime mode, resolved from the `nucleus.aot.mode` system property. */ public val aotMode: AotRuntimeMode get() = AotRuntime.mode() @@ -45,16 +41,71 @@ public sealed interface NucleusApplicationScope : AwtApplicationScope { public val isAotRuntime: Boolean get() = aotMode == AotRuntimeMode.RUNTIME /** - * Registers [block] as the deep-link callback. Picks the right path for - * the active backend: - * - AWT: installs the macOS Apple Events handler via `java.awt.Desktop` - * and parses the CLI [args] passed to [nucleusApplication]. - * - Tao: registers the block as the sink for the native macOS Apple - * Events handler (installed pre-launch by `TaoLauncher`) and parses - * the CLI [args]. Any deep link delivered before this call is buffered - * and replayed. + * `true` while a system quit (macOS Cmd+Q, Dock → Quit, logout) is asking + * the windows to close — see [TaoApplication.isQuitting]. A hide-to-tray + * `onCloseRequest` checks it to let the quit through. + */ + public val isQuitting: Boolean get() = TaoApplication.isQuitting + + /** + * Registers [block] as the deep-link callback: the sink for the native + * macOS Apple Events handler (installed pre-launch by `TaoLauncher`), plus + * the CLI [args] passed to [nucleusApplication]. Any deep link delivered + * before this call is buffered and replayed. */ public fun onDeepLink(block: (URI) -> Unit) + + /** + * Registers [block] for "the UI stopped responding" — Electron's + * `unresponsive` event on a `webContents`, and the counterpart of + * [onResponsive]. + * + * Nucleus detects the stall by asking the OS (Windows `IsHungAppWindow`; + * other platforms have no non-perturbing probe yet) and logs `SEVERE` with + * a thread dump, but shows nothing: what the user sees is the app's + * decision, exactly as in Electron. A crash-reporting hook, or the + * browsers' "wait or quit" prompt, both belong here. + * + * ```kotlin + * nucleusApplication(args) { + * onUnresponsive { crashReporter.reportHang() } + * onResponsive { crashReporter.hangEnded() } + * } + * ``` + * + * **[block] runs on `nucleus-tao-watchdog-events`, not the UI thread** — + * the UI thread is the stuck one, so anything it posts there (Compose + * state, `Dispatchers.Main`) would only run once the stall ends, if ever. + * That thread is the callbacks' own, so blocking in it (a "wait or quit" + * prompt) delays only the next callback, never the detection. + */ + public fun onUnresponsive(block: () -> Unit): Unit = TaoApplication.onUnresponsive(block) + + /** + * Registers [block] for "the UI is responding again" — Electron's + * `responsive` event. Fired only after a stall that was reported through + * [onUnresponsive]; same threading rules. + */ + public fun onResponsive(block: () -> Unit): Unit = TaoApplication.onResponsive(block) + + /** + * Runs [block] with the hang watchdog told that a stall is *expected* — + * Chromium's `HangWatcher::InvalidateActiveExpectations()`. + * + * An operation the app knows is long and synchronous on the UI thread + * looks exactly like a freeze from the outside, so wrap it and neither the + * `SEVERE` report nor [onUnresponsive] fires for it. Everything else stays + * watched, unlike `-Dnucleus.tao.watchdog=false`, which gives up on the + * whole process. + * + * ```kotlin + * expectUnresponsive { importHugeProjectSynchronously() } + * ``` + * + * Reentrant and thread-safe. Prefer moving the work off the UI thread; + * this is for when that is not an option, not a way to silence a slow UI. + */ + public fun expectUnresponsive(block: () -> T): T = TaoApplication.expectUnresponsive(block) } /** @@ -80,39 +131,22 @@ public sealed interface NucleusApplicationScope : AwtApplicationScope { * Libraries and navigation that must open a secondary window or dialog without * hard-coding Material/Jewel chrome should use [LocalNucleusWindowHost] / * [HostedWindow] and [LocalNucleusDialogHost] / [HostedDialog] instead of - * Compose Desktop's AWT `Window` / `Dialog` (unsupported on Tao). Apps may - * override either host to inject themed wrappers. + * Compose Desktop's AWT `Window` / `Dialog` (unsupported). Apps may override + * either host to inject themed wrappers. * - * Provided by [nucleusApplication] on both backends. On Tao each window owns - * its own `ComposeScene`, but the whole parent local context is bridged into - * it, so the scope (and the window/dialog hosts) stay reachable from nested - * window content too. + * Provided by [nucleusApplication]. Each window owns its own `ComposeScene`, + * but the whole parent local context is bridged into it, so the scope (and the + * window/dialog hosts) stay reachable from nested window content too. */ public val LocalNucleusApplicationScope: ProvidableCompositionLocal = staticCompositionLocalOf { error("LocalNucleusApplicationScope not provided — use it inside a nucleusApplication { … } block.") } -internal class AwtNucleusApplicationScope( - val composeScope: AwtApplicationScope, - private val args: Array, -) : NucleusApplicationScope { - override val backend: NucleusBackend = NucleusBackend.Awt - - override fun exitApplication() = composeScope.exitApplication() - - override fun onDeepLink(block: (URI) -> Unit) { - DeepLinkHandler.installAwtAppleEventHandler() - DeepLinkHandler.setHandler(args, block) - } -} - internal class TaoNucleusApplicationScope( val taoScope: TaoApplicationScope, private val args: Array, ) : NucleusApplicationScope { - override val backend: NucleusBackend = NucleusBackend.Tao - override fun exitApplication() = taoScope.exitApplication() override fun onDeepLink(block: (URI) -> Unit) { diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusBackend.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusBackend.kt deleted file mode 100644 index 3890d481f..000000000 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusBackend.kt +++ /dev/null @@ -1,33 +0,0 @@ -package dev.nucleusframework.application - -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.staticCompositionLocalOf - -/** - * Selects the window backend used by [nucleusApplication]. - * - * An application is expected to ship **exactly one** of the - * `decorated-window-jbr` / `decorated-window-jni` / `decorated-window-tao` - * runtime modules — their imports overlap by design. [Auto] detects which one - * is on the classpath at runtime. - */ -public enum class NucleusBackend { - /** Detect at runtime: prefer Tao when present, else AWT (JBR/JNI). */ - Auto, - - /** AWT-bound backend (`decorated-window-jbr` or `decorated-window-jni`). */ - Awt, - - /** No-AWT backend (`decorated-window-tao`). */ - Tao, -} - -/** - * Composition local exposing the backend that the surrounding - * [nucleusApplication] is running on. Internal libraries can branch on this - * to adapt their behaviour without reflective classpath checks. - * - * Resolves to [NucleusBackend.Auto] outside of a [nucleusApplication] block. - */ -public val LocalNucleusBackend: ProvidableCompositionLocal = - staticCompositionLocalOf { NucleusBackend.Auto } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindow.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindow.kt index 02e4f824a..a0acc98eb 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindow.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindow.kt @@ -3,7 +3,6 @@ package dev.nucleusframework.application import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.Stable import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.DpSize import dev.nucleusframework.window.DecoratedDialogScope @@ -22,8 +21,7 @@ public data class NucleusWindowBounds( ) /** - * Backend-agnostic handle to a window opened by [DecoratedWindow]. Mirrors the - * intersection of `ComposeWindow` and `TaoWindow`. + * Portable handle to a window opened by [DecoratedWindow]. * * Backend-specific bridges live behind [unsafe] — using them is an explicit * opt-out of the portable contract. @@ -38,10 +36,9 @@ public interface NucleusWindow { /** * Outer (decoration-inclusive) window bounds in logical screen coordinates, - * or `null` while the native window isn't realized yet. Backend-agnostic: - * AWT reads user-space coordinates directly; Tao converts the physical - * window rect through the window's scale factor. Intended for cross-window - * features (drag & drop hit-testing, window placement). + * or `null` while the native window isn't realized yet. Converted from the + * physical window rect through the window's scale factor. Intended for + * cross-window features (drag & drop hit-testing, window placement). */ public fun boundsOnScreen(): NucleusWindowBounds? = null @@ -76,16 +73,11 @@ public interface NucleusWindow { } /** - * Backend-specific escape hatches. The accessor matching the active backend - * returns a non-null value; the others always return `null`. Access is - * intentionally namespaced to flag uses that break portability. + * Backend-specific escape hatches, intentionally namespaced to flag uses that + * break portability across future backends. */ @Stable public interface NucleusWindowUnsafe { - public val awtWindow: ComposeWindow? get() = null - - public val awtDialog: androidx.compose.ui.awt.ComposeDialog? get() = null - /** Tao-owned window (no-AWT backend). */ public val taoWindow: dev.nucleusframework.window.tao.TaoWindow? get() = null @@ -94,12 +86,11 @@ public interface NucleusWindowUnsafe { } /** - * Decorated-window scope exposing a backend-agnostic [nucleusWindow]. Returned - * inside the `content` lambda of [DecoratedWindow]. The concrete adapter also - * implements the active backend's scope (`AwtDecoratedWindowScope` / - * `TaoDecoratedWindowScope`), so the existing `TitleBar { … }` extension works - * unchanged. The backend-specific `window` is reachable from those scopes; - * use [nucleusWindow] (or [LocalNucleusWindow]) for portable code. + * Decorated-window scope exposing the portable [nucleusWindow]. Returned inside + * the `content` lambda of [DecoratedWindow]. The concrete adapter also + * implements `TaoDecoratedWindowScope`, so the `TitleBar { … }` extension works + * unchanged and the Tao `window` stays reachable; use [nucleusWindow] (or + * [LocalNucleusWindow]) for portable code. */ @Stable public interface NucleusDecoratedWindowScope : DecoratedWindowScope { diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindowHost.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindowHost.kt index faeabe49b..40d2df8cc 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindowHost.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/NucleusWindowHost.kt @@ -3,6 +3,7 @@ // UI — so a non-UI composable called in the caller's scope cannot reclassify // the window content. ktlint's `annotation` and `function-type-modifier-spacing` // rules contradict each other on the resulting two-annotation parameter type. +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) @file:Suppress("ktlint:standard:annotation") package dev.nucleusframework.application @@ -11,14 +12,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.ComposableOpenTarget import androidx.compose.runtime.ProvidableCompositionLocal import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.UiComposable import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.window.DialogState import androidx.compose.ui.window.WindowState import androidx.compose.ui.window.rememberDialogState import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.window.tao.rememberSyncedNucleusDialogState +import dev.nucleusframework.window.tao.rememberSyncedNucleusWindowState +import dev.nucleusframework.window.tao.v2.DialogState as NucleusDialogState +import dev.nucleusframework.window.tao.v2.WindowState as NucleusWindowState /** * Opens secondary windows on the active Nucleus backend. @@ -72,6 +79,8 @@ public fun interface NucleusWindowHost { title: String, icon: Painter?, resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, enabled: Boolean, focusable: Boolean, alwaysOnTop: Boolean, @@ -86,6 +95,70 @@ public fun interface NucleusWindowHost { alwaysOnBottom: Boolean, content: @Composable @UiComposable NucleusDecoratedWindowScope.() -> Unit, ) + + /** + * Opens a window driven by the AWT-free window API v2 clone + * ([dev.nucleusframework.window.tao.v2.WindowState]). + * + * Default implementation converts [state] to v1 and calls [Window] so + * existing themed hosts keep their chrome. `maxSize` is v2-only and is + * dropped on that fallback, and geometry providers resolve against monitor + * data only — the native window is not reachable from here. Override, or + * use the `DecoratedWindow` overload directly, to get the full v2 path + * (`requestScreen` included). + */ + @Suppress("UnusedParameter") + @Composable + public fun Window( + onCloseRequest: () -> Unit, + state: NucleusWindowState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, + enabled: Boolean, + focusable: Boolean, + alwaysOnTop: Boolean, + undecorated: Boolean, + popupFor: NucleusWindow?, + nativePopupLayers: Boolean, + nativeContextMenu: Boolean, + hiddenFromDock: Boolean, + minSize: DpSize, + maxSize: DpSize, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + alwaysOnBottom: Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, + ) { + val v1 = rememberSyncedNucleusWindowState(state, visible) + Window( + onCloseRequest = onCloseRequest, + state = v1, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + undecorated = undecorated, + popupFor = popupFor, + nativePopupLayers = nativePopupLayers, + nativeContextMenu = nativeContextMenu, + hiddenFromDock = hiddenFromDock, + minimumSize = + if (minSize.width.isSpecified && minSize.height.isSpecified) minSize else null, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + alwaysOnBottom = alwaysOnBottom, + content = content, + ) + } } /** @@ -117,6 +190,47 @@ public fun interface NucleusDialogHost { onKeyEvent: (KeyEvent) -> Boolean, content: @Composable @UiComposable NucleusDecoratedDialogScope.() -> Unit, ) + + /** + * Opens a dialog driven by the AWT-free dialog API v2 clone + * ([dev.nucleusframework.window.tao.v2.DialogState]). + * + * Same fallback contract as the [NucleusWindowHost] clone overload: + * `minSize` / `maxSize` are dropped and geometry providers see monitor + * data only. Use the `DecoratedDialog` overload for the full v2 path. + */ + @Suppress("UnusedParameter") + @Composable + public fun Dialog( + onCloseRequest: () -> Unit, + state: NucleusDialogState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + enabled: Boolean, + focusable: Boolean, + minSize: DpSize, + maxSize: DpSize, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, + ) { + val v1 = rememberSyncedNucleusDialogState(state, visible) + Dialog( + onCloseRequest = onCloseRequest, + state = v1, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + enabled = enabled, + focusable = focusable, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = content, + ) + } } /** @@ -163,6 +277,8 @@ public object DefaultNucleusWindowHost : NucleusWindowHost { title: String, icon: Painter?, resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, enabled: Boolean, focusable: Boolean, alwaysOnTop: Boolean, @@ -184,6 +300,8 @@ public object DefaultNucleusWindowHost : NucleusWindowHost { title = title, icon = icon, resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, enabled = enabled, focusable = focusable, alwaysOnTop = alwaysOnTop, @@ -199,6 +317,62 @@ public object DefaultNucleusWindowHost : NucleusWindowHost { content = content, ) } + + /** + * Full v2 path for the AWT-free clone: `DecoratedWindow` keeps `maxSize` + * and hands the bridge the native window, so `requestScreen` and every + * geometry provider are applied. + */ + @Composable + override fun Window( + onCloseRequest: () -> Unit, + state: NucleusWindowState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, + enabled: Boolean, + focusable: Boolean, + alwaysOnTop: Boolean, + undecorated: Boolean, + popupFor: NucleusWindow?, + nativePopupLayers: Boolean, + nativeContextMenu: Boolean, + hiddenFromDock: Boolean, + minSize: DpSize, + maxSize: DpSize, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + alwaysOnBottom: Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, + ) { + DecoratedWindow( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + undecorated = undecorated, + popupFor = popupFor, + nativePopupLayers = nativePopupLayers, + nativeContextMenu = nativeContextMenu, + hiddenFromDock = hiddenFromDock, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + alwaysOnBottom = alwaysOnBottom, + content = content, + ) + } } /** @@ -235,6 +409,40 @@ public object DefaultNucleusDialogHost : NucleusDialogHost { content = content, ) } + + /** Full v2 path for the AWT-free clone. See [DefaultNucleusWindowHost]. */ + @Composable + override fun Dialog( + onCloseRequest: () -> Unit, + state: NucleusDialogState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + enabled: Boolean, + focusable: Boolean, + minSize: DpSize, + maxSize: DpSize, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, + ) { + DecoratedDialog( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + enabled = enabled, + focusable = focusable, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = content, + ) + } } /** @@ -256,6 +464,8 @@ public fun HostedWindow( title: String = "", icon: Painter? = null, resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, enabled: Boolean = true, focusable: Boolean = true, alwaysOnTop: Boolean = false, @@ -277,6 +487,8 @@ public fun HostedWindow( title = title, icon = icon, resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, enabled = enabled, focusable = focusable, alwaysOnTop = alwaysOnTop, @@ -331,3 +543,103 @@ public fun HostedDialog( content = content, ) } + +/** + * Opens a secondary window via [LocalNucleusWindowHost] using the AWT-free + * window API v2 clone ([dev.nucleusframework.window.tao.v2.WindowState]). + * + * `requestScreen` and every geometry provider are applied on the default host; + * a themed host that does not override the clone overload falls back to the v1 + * surface (see [NucleusWindowHost.Window]). + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +public fun HostedWindow( + onCloseRequest: () -> Unit, + state: NucleusWindowState, + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = true, + minimizable: Boolean = true, + maximizable: Boolean = true, + enabled: Boolean = true, + focusable: Boolean = true, + alwaysOnTop: Boolean = false, + undecorated: Boolean = false, + popupFor: NucleusWindow? = null, + nativePopupLayers: Boolean = false, + nativeContextMenu: Boolean = false, + hiddenFromDock: Boolean = false, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + alwaysOnBottom: Boolean = false, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, +) { + LocalNucleusWindowHost.current.Window( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + undecorated = undecorated, + popupFor = popupFor, + nativePopupLayers = nativePopupLayers, + nativeContextMenu = nativeContextMenu, + hiddenFromDock = hiddenFromDock, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + alwaysOnBottom = alwaysOnBottom, + content = content, + ) +} + +/** + * Opens a secondary dialog via [LocalNucleusDialogHost] using the AWT-free + * dialog API v2 clone ([dev.nucleusframework.window.tao.v2.DialogState]). + * + * Same host contract as the [HostedWindow] clone overload. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +public fun HostedDialog( + onCloseRequest: () -> Unit, + state: NucleusDialogState, + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = false, + enabled: Boolean = true, + focusable: Boolean = true, + minSize: DpSize = DpSize.Unspecified, + maxSize: DpSize = DpSize.Unspecified, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, +) { + LocalNucleusDialogHost.current.Dialog( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + enabled = enabled, + focusable = focusable, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = content, + ) +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt index 627f1cf69..647163174 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/ProvideNucleusSystemTheme.kt @@ -1,31 +1,33 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +@file:Suppress("DEPRECATION") package dev.nucleusframework.application import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.LocalSystemTheme +import androidx.compose.ui.SystemTheme import dev.nucleusframework.darkmodedetector.isSystemInDarkMode -import org.jetbrains.skiko.SystemTheme /** * Feeds Compose's [androidx.compose.foundation.isSystemInDarkTheme] from * Nucleus's reactive OS detector. * - * Compose 1.12 made [LocalSystemTheme] internal and typed it as Skiko's - * [SystemTheme]. Official `isSystemInDarkTheme()` now polls the OS about once - * a second; providing the local from [isSystemInDarkMode] keeps every call - * site on Nucleus's live detector instead of that poll. + * Official `isSystemInDarkTheme()` polls the OS about once a second; providing + * [LocalSystemTheme] from [isSystemInDarkMode] keeps every call site on + * Nucleus's live detector instead of that poll. Compose 1.12.1 deprecates the + * local (public by mistake) but still reads it, so it remains the only hook. * * The value is computed *outside* the provider, so the detector never reads the * local it is about to set (preview path of [isSystemInDarkMode] falls back to * `isSystemInDarkTheme()`). */ +@OptIn(InternalComposeUiApi::class) @Composable internal fun ProvideNucleusSystemTheme(content: @Composable () -> Unit) { val isDark = isSystemInDarkMode() CompositionLocalProvider( - LocalSystemTheme provides if (isDark) SystemTheme.DARK else SystemTheme.LIGHT, + LocalSystemTheme provides if (isDark) SystemTheme.Dark else SystemTheme.Light, content = content, ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt new file mode 100644 index 000000000..fb8c7e9b9 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Satellite.kt @@ -0,0 +1,157 @@ +// #636: the window openers below are `@ComposableOpenTarget(-1)` with +// `@UiComposable` content lambdas — callable from any applier, always composing +// UI — so a non-UI composable called in the caller's scope cannot reclassify +// the window content. ktlint's `annotation` and `function-type-modifier-spacing` +// rules contradict each other on the resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation") + +package dev.nucleusframework.application + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.ui.UiComposable +import dev.nucleusframework.application.internal.TaoSatelliteWorkspaceAdapter +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.DefaultSatelliteHeader +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteScope +import dev.nucleusframework.window.tao.SatelliteWorkspace + +/** + * A satellite of a [SatelliteWorkspace]: declared once, hosted as a floating + * window owned by the workspace's current owner or as a panel docked inside a + * `DockLayout`, according to its placement. + * + * ```kotlin + * nucleusApplication(args) { + * val workspace = rememberSatelliteWorkspace() + * DecoratedWindow(onCloseRequest = ::exitApplication) { + * JoinSatelliteWorkspace(workspace) + * WindowScaffold(titleBar = { TitleBar { Text("Document") } }) { padding -> + * DockLayout(workspace, Modifier.padding(padding)) { Document() } + * } + * } + * Satellite(workspace, id = "tools", title = "Tools") { ToolsPanel() } + * Satellite( + * workspace, + * id = "colors", + * title = "Colors", + * initialPlacement = SatellitePlacement.Docked(DockSide.Right), + * ) { ColorPanel() } + * } + * ``` + * + * See [dev.nucleusframework.window.tao.Satellite] for the full contract: + * `rememberSaveable` state survives dock / undock, the workspace remembers a + * satellite after it leaves composition, and the owner follows focus between + * the windows that joined. `rememberSatelliteWorkspace`, `JoinSatelliteWorkspace` + * and `DockLayout` are used as-is from `decorated-window-tao`. + * + * @param dockSides the sides the satellite may be docked on; the others are + * never offered nor accepted. Empty: a floating-only palette. + * @param floatable whether the satellite can be a window of its own; `false` + * is a fixed panel that cannot be torn out. Requires a docked + * [initialPlacement]. + * @param reorderable whether the user may change its rank on its side; + * `false` pins it to the rank it was declared with. Requires a docked + * [initialPlacement]. + * @param floatingCaption composed in the strip of the floating title bar left + * to the compositor's window move, where the window is placed by the + * compositor; see [dev.nucleusframework.window.tao.Satellite]. + * @param nativeContextMenu whether text fields in the floating window get the + * native context menu, as for [SatelliteWindow]. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun NucleusApplicationScope.Satellite( + workspace: SatelliteWorkspace, + id: String, + title: String, + initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), + initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, + resizable: Boolean = true, + hideWhileOwnerFullscreenOrMaximized: Boolean = true, + nativeContextMenu: Boolean = true, + header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, + floatingCaption: @Composable @UiComposable SatelliteScope.() -> Unit = {}, + content: @Composable @UiComposable SatelliteScope.() -> Unit, +) { + when (this) { + is TaoNucleusApplicationScope -> + TaoSatelliteWorkspaceAdapter.Satellite( + scope = this, + workspace = workspace, + id = id, + title = title, + initialPlacement = initialPlacement, + initiallyOpen = initiallyOpen, + dockSides = dockSides, + floatable = floatable, + reorderable = reorderable, + resizable = resizable, + hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, + nativeContextMenu = nativeContextMenu, + header = header, + floatingCaption = floatingCaption, + content = content, + ) + } +} + +/** + * Receiver-less [Satellite], resolving the application scope from + * [LocalNucleusApplicationScope]. Fails outside a `nucleusApplication { … }` block. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun Satellite( + workspace: SatelliteWorkspace, + id: String, + title: String, + initialPlacement: SatellitePlacement = SatellitePlacement.Floating(), + initiallyOpen: Boolean = true, + dockSides: Set = DockSide.entries.toSet(), + floatable: Boolean = true, + reorderable: Boolean = true, + resizable: Boolean = true, + hideWhileOwnerFullscreenOrMaximized: Boolean = true, + nativeContextMenu: Boolean = true, + header: @Composable @UiComposable SatelliteScope.() -> Unit = { DefaultSatelliteHeader() }, + floatingCaption: @Composable @UiComposable SatelliteScope.() -> Unit = {}, + content: @Composable @UiComposable SatelliteScope.() -> Unit, +) { + LocalNucleusApplicationScope.current.Satellite( + workspace = workspace, + id = id, + title = title, + initialPlacement = initialPlacement, + initiallyOpen = initiallyOpen, + dockSides = dockSides, + floatable = floatable, + reorderable = reorderable, + resizable = resizable, + hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, + nativeContextMenu = nativeContextMenu, + header = header, + floatingCaption = floatingCaption, + content = content, + ) +} + +/** + * [SatelliteWorkspace.pinTo] for the portable window handle: makes [window] + * the owner of the workspace's floating satellites regardless of focus; + * `null` returns to the focus-driven choice. + */ +@ExperimentalNucleusApi +public fun SatelliteWorkspace.pinTo(window: NucleusWindow?) { + pinTo(window?.unsafe?.taoWindow) +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/SatelliteWindow.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/SatelliteWindow.kt new file mode 100644 index 000000000..dfa7669a1 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/SatelliteWindow.kt @@ -0,0 +1,142 @@ +// #636: the window openers below are `@ComposableOpenTarget(-1)` with +// `@UiComposable` content lambdas — callable from any applier, always composing +// UI — so a non-UI composable called in the caller's scope cannot reclassify +// the window content. ktlint's `annotation` and `function-type-modifier-spacing` +// rules contradict each other on the resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation") + +package dev.nucleusframework.application + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.ui.UiComposable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import dev.nucleusframework.application.internal.TaoSatelliteWindowAdapter +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.SatelliteWindowState +import dev.nucleusframework.window.tao.rememberSatelliteWindowState + +/** + * Satellite window — an auxiliary window that belongs to another window. + * + * The floating tool palette / inspector / mixer archetype: anchored to its + * parent by a `WindowPositioner`, moves with it, stays above it without being + * modal, keeps out of the taskbar, hides while the parent is fullscreen or + * maximized, and closes with it. + * + * ```kotlin + * nucleusApplication(args) { + * DecoratedWindow(onCloseRequest = ::exitApplication) { + * TitleBar { Text("Document") } + * Button({ inspector = !inspector }) { Text("Inspector") } + * if (inspector) { + * SatelliteWindow( + * onCloseRequest = { inspector = false }, + * state = rememberSatelliteWindowState( + * size = DpSize(260.dp, 420.dp), + * positioner = WindowPositioner( + * parentAnchor = WindowAnchor.TopRight, + * childAnchor = WindowAnchor.TopLeft, + * offset = DpOffset(12.dp, 0.dp), + * ), + * ), + * title = "Inspector", + * ) { + * DialogTitleBar { Text("Inspector") } + * InspectorPanel() + * } + * } + * } + * } + * ``` + * + * See [dev.nucleusframework.window.tao.SatelliteWindow] for the full contract + * and the platform notes (native Wayland cannot position client windows, so + * the anchoring degrades to compositor placement there). + * + * @param parent the owner window. Defaults to the enclosing window via + * [LocalNucleusWindow] — pass it explicitly to move a shared palette between + * document windows, which reparents it without changing its position. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun NucleusApplicationScope.SatelliteWindow( + onCloseRequest: () -> Unit, + parent: NucleusWindow? = null, + state: SatelliteWindowState = rememberSatelliteWindowState(), + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = true, + focusable: Boolean = true, + hideWhileParentFullscreenOrMaximized: Boolean = true, + nativeContextMenu: Boolean = true, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + content: @Composable @UiComposable NucleusDecoratedWindowScope.() -> Unit, +) { + when (this) { + is TaoNucleusApplicationScope -> + TaoSatelliteWindowAdapter.Satellite( + scope = this, + onCloseRequest = onCloseRequest, + parent = parent, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + focusable = focusable, + hideWhileParentFullscreenOrMaximized = hideWhileParentFullscreenOrMaximized, + nativeContextMenu = nativeContextMenu, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = content, + ) + } +} + +/** + * Receiver-less [SatelliteWindow], resolving the application scope from + * [LocalNucleusApplicationScope]. Parameters behave exactly like the + * [NucleusApplicationScope] overload. Fails outside a `nucleusApplication { … }` + * block, where no scope exists. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun SatelliteWindow( + onCloseRequest: () -> Unit, + parent: NucleusWindow? = null, + state: SatelliteWindowState = rememberSatelliteWindowState(), + visible: Boolean = true, + title: String = "", + icon: Painter? = null, + resizable: Boolean = true, + focusable: Boolean = true, + hideWhileParentFullscreenOrMaximized: Boolean = true, + nativeContextMenu: Boolean = true, + onPreviewKeyEvent: (KeyEvent) -> Boolean = { false }, + onKeyEvent: (KeyEvent) -> Boolean = { false }, + content: @Composable @UiComposable NucleusDecoratedWindowScope.() -> Unit, +) { + LocalNucleusApplicationScope.current.SatelliteWindow( + onCloseRequest = onCloseRequest, + parent = parent, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + focusable = focusable, + hideWhileParentFullscreenOrMaximized = hideWhileParentFullscreenOrMaximized, + nativeContextMenu = nativeContextMenu, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + content = content, + ) +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Tab.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Tab.kt new file mode 100644 index 000000000..c70a0c4ee --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/Tab.kt @@ -0,0 +1,204 @@ +// #636: the window openers below are `@ComposableOpenTarget(-1)` with +// `@UiComposable` content lambdas — callable from any applier, always composing +// UI — so a non-UI composable called in the caller's scope cannot reclassify +// the window content. ktlint's `annotation` and `function-type-modifier-spacing` +// rules contradict each other on the resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation") + +package dev.nucleusframework.application + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.ui.UiComposable +import dev.nucleusframework.application.internal.TaoTabWorkspaceAdapter +import dev.nucleusframework.window.ExperimentalNucleusApi +import dev.nucleusframework.window.tao.DefaultTabTitleBar +import dev.nucleusframework.window.tao.TabDragGhost +import dev.nucleusframework.window.tao.TabDragGhostCard +import dev.nucleusframework.window.tao.TabScope +import dev.nucleusframework.window.tao.TabStrip +import dev.nucleusframework.window.tao.TabStripScope +import dev.nucleusframework.window.tao.TabWorkspace + +/** + * One window per group of [workspace] — the Chrome tab model: a tab strip in + * every window's title bar, the group's selected tab as its content, and + * windows that follow the tabs instead of the app opening and closing them. + * + * ```kotlin + * nucleusApplication(args) { + * val workspace = rememberTabWorkspace() + * TabWindows(workspace, onLastWindowClosed = ::exitApplication) + * for (document in documents) { + * Tab(workspace, id = document.id, title = document.name) { Editor(document) } + * } + * } + * ``` + * + * See [dev.nucleusframework.window.tao.TabWindows] for the full contract: + * a tear-off opens a window and the last tab out closes one, a tab dragged + * onto another window's strip is inserted where it is dropped, and + * `rememberSaveable` state inside a tab survives every move. + * `rememberTabWorkspace`, `TabStrip` and `Modifier.tabDragHandle` are used + * as-is from `decorated-window-tao`. + * + * @param strip the chrome of one window's tab strip; [TabStrip] by default. + * Composed inside that window's title bar. + * @param titleBar the title bar of each window, handed the strip to place in + * it — a `JewelTitleBar` with the app's gradient, say. + * `DefaultTabTitleBar` by default: a `BasicTitleBar` giving the strip all the + * width between the platform controls. + * @param dragGhost what a tab being dragged out of its strip looks like under + * the pointer — a borderless window the size the tab had in its strip, laid + * out in that strip's direction. [TabDragGhostCard] by default; an app + * draws its own, the tab's `thumbnail` included if it likes, and draws the + * strip's `dropGhostCard` with the same composable (`TabGhostCard` is the + * shape both take). It is composed in the ghost's own window with the same + * Nucleus locals as a tab window gets, but outside [windowWrapper] — that + * one dresses a window, background included, and a ghost is translucent. + * Never composed on native Wayland, where the tab travels as the + * compositor's drag icon — `TabWorkspace.dragKind` says which. + * @param nativeContextMenu whether text fields in the tab windows get the + * native context menu, as for [DecoratedWindow]. + * @param windowWrapper composed around each window's chrome and content, with + * that window's scope as receiver — where per-window chrome goes, since the + * app does not open these windows itself: `WindowBackground`, + * `WindowAppearance`, a themed `Surface`. Must invoke the lambda it is given. + * @param windowBodyWrapper composed inside each window, below the tab strip, + * around the selected tab's body: chrome that belongs to the window rather + * than to a tab goes here — a `DockLayout` with its satellites, an activity + * bar. [windowWrapper] wraps the window including its strip; this one wraps + * only what is under it. Must invoke the lambda it is given. + * @param onLastWindowClosed called every time the workspace goes from holding + * tabs to holding none, which is where an app calls `exitApplication`. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun NucleusApplicationScope.TabWindows( + workspace: TabWorkspace, + strip: @Composable @UiComposable TabStripScope.() -> Unit = { TabStrip() }, + titleBar: @Composable @UiComposable NucleusDecoratedWindowScope.(strip: @Composable () -> Unit) -> Unit = + { DefaultTabTitleBar(it) }, + dragGhost: @Composable @UiComposable NucleusDecoratedWindowScope.(TabDragGhost) -> Unit = { TabDragGhostCard(it) }, + nativeContextMenu: Boolean = true, + windowWrapper: @Composable @UiComposable NucleusDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit = + { it() }, + windowBodyWrapper: @Composable @UiComposable NucleusDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit = + { it() }, + onLastWindowClosed: () -> Unit = {}, +) { + when (this) { + is TaoNucleusApplicationScope -> + TaoTabWorkspaceAdapter.TabWindows( + scope = this, + workspace = workspace, + strip = strip, + titleBar = titleBar, + dragGhost = dragGhost, + nativeContextMenu = nativeContextMenu, + windowWrapper = windowWrapper, + windowBodyWrapper = windowBodyWrapper, + onLastWindowClosed = onLastWindowClosed, + ) + } +} + +/** + * Receiver-less [TabWindows], resolving the application scope from + * [LocalNucleusApplicationScope]. Fails outside a `nucleusApplication { … }` block. + */ +@Suppress("FunctionNaming", "LongParameterList") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun TabWindows( + workspace: TabWorkspace, + strip: @Composable @UiComposable TabStripScope.() -> Unit = { TabStrip() }, + titleBar: @Composable @UiComposable NucleusDecoratedWindowScope.(strip: @Composable () -> Unit) -> Unit = + { DefaultTabTitleBar(it) }, + dragGhost: @Composable @UiComposable NucleusDecoratedWindowScope.(TabDragGhost) -> Unit = { TabDragGhostCard(it) }, + nativeContextMenu: Boolean = true, + windowWrapper: @Composable @UiComposable NucleusDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit = + { it() }, + windowBodyWrapper: @Composable @UiComposable NucleusDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit = + { it() }, + onLastWindowClosed: () -> Unit = {}, +) { + LocalNucleusApplicationScope.current.TabWindows( + workspace = workspace, + strip = strip, + titleBar = titleBar, + dragGhost = dragGhost, + nativeContextMenu = nativeContextMenu, + windowWrapper = windowWrapper, + windowBodyWrapper = windowBodyWrapper, + onLastWindowClosed = onLastWindowClosed, + ) +} + +/** + * Declares a tab of [workspace]. Which window shows it is the workspace's + * business, so declare every tab once, next to [TabWindows], and never inside + * one of its windows. + * + * On first declaration the tab joins [group] when given, else the window + * focused last, else a new one; after that the workspace owns its placement + * and an id already known only has its title and body refreshed. + * + * See [dev.nucleusframework.window.tao.Tab] for the full contract. + * + * @param id stable identity within the workspace. + * @param title shown on the tab and, for the selected tab, as the window title. + * @param group the group to open in on first declaration. + * @param content the tab's body. `rememberSaveable` state in it survives a + * move between windows; plain `remember` state does not. + */ +@Suppress("FunctionNaming") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun NucleusApplicationScope.Tab( + workspace: TabWorkspace, + id: String, + title: String, + group: String? = null, + content: @Composable @UiComposable TabScope.() -> Unit, +) { + when (this) { + is TaoNucleusApplicationScope -> + TaoTabWorkspaceAdapter.Tab( + scope = this, + workspace = workspace, + id = id, + title = title, + group = group, + content = content, + ) + } +} + +/** + * Receiver-less [Tab], resolving the application scope from + * [LocalNucleusApplicationScope]. Fails outside a `nucleusApplication { … }` block. + */ +@Suppress("FunctionNaming") +@Composable +@ComposableOpenTarget(-1) +@ExperimentalNucleusApi +public fun Tab( + workspace: TabWorkspace, + id: String, + title: String, + group: String? = null, + content: @Composable @UiComposable TabScope.() -> Unit, +) { + LocalNucleusApplicationScope.current.Tab( + workspace = workspace, + id = id, + title = title, + group = group, + content = content, + ) +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/TaoNucleusWindow.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/TaoNucleusWindow.kt index bada23dda..32bd62319 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/TaoNucleusWindow.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/TaoNucleusWindow.kt @@ -1,7 +1,6 @@ package dev.nucleusframework.application import androidx.compose.runtime.State -import androidx.compose.ui.awt.ComposeWindow import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.unit.DpSize import dev.nucleusframework.window.DecoratedWindowState @@ -111,7 +110,6 @@ internal class TaoNucleusWindow( override val unsafe: NucleusWindowUnsafe = object : NucleusWindowUnsafe { - override val awtWindow: ComposeWindow? = null override val taoWindow: TaoWindow = this@TaoNucleusWindow.taoWindow override val taoHandle: Long = this@TaoNucleusWindow.taoWindow.handle } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/AdwaitaContextMenu.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/AdwaitaContextMenu.kt index 38c59fd3d..025efcac8 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/AdwaitaContextMenu.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/AdwaitaContextMenu.kt @@ -14,34 +14,45 @@ private val AdwaitaUiFont = FontFamily("Adwaita Sans") internal val AdwaitaMenuTheme = ContextMenuFlyoutTheme( - menuShape = RoundedCornerShape(15.dp), + menuCornerRadius = 15.dp, itemShape = RoundedCornerShape(9.dp), uiFont = AdwaitaUiFont, iconFont = AdwaitaUiFont, chevron = "›", chevronSize = 16.sp, - chevronAlpha = 0.30f, + chevronGap = 6.dp, minWidth = 120.dp, maxWidth = 280.dp, menuPadding = PaddingValues(6.dp), itemHeight = 32.dp, itemHorizontalPadding = 12.dp, - itemOuterHorizontalPadding = 0.dp, + itemMargin = PaddingValues(0.dp), separatorPadding = PaddingValues(vertical = 6.dp), iconSize = 16.dp, iconGap = 6.dp, - shadowElevation = 8.dp, shadowPad = 16.dp, - ambientShadow = Color.Black.copy(alpha = 0.09f), - spotShadow = Color.Black.copy(alpha = 0.05f), + shadows = { AdwaitaMenuShadows }, showIcons = false, shortcutGap = 24.dp, shortcutSize = 14.sp, - shortcutAlpha = 0.55f, + shortcutPadding = PaddingValues(0.dp), colors = ::adwaitaColors, glyph = { null }, ) +/** + * `popover > contents { box-shadow: ... }` in libadwaita's `_popovers.scss`: + * `0 0 0 1px RGB(0 0 0 / 5%)`, `0 1px 5px 1px RGB(0 0 0 / 9%)`, + * `0 2px 14px 3px RGB(0 0 0 / 5%)`. The first, a hairline ring, is the + * [ContextMenuFlyoutColors.border]; the other two are the shadow proper. Same + * in the dark variant. + */ +private val AdwaitaMenuShadows = + listOf( + ContextMenuBoxShadow(offsetY = 1.dp, blur = 5.dp, spread = 1.dp, color = Color.Black.copy(alpha = 0.09f)), + ContextMenuBoxShadow(offsetY = 2.dp, blur = 14.dp, spread = 3.dp, color = Color.Black.copy(alpha = 0.05f)), + ) + /** * `separator { background: $border_color; }` in libadwaita's `_misc.scss`, with * `$border_color: color-mix(in srgb, currentColor var(--border-opacity), transparent)` @@ -62,6 +73,8 @@ private fun adwaitaColors(dark: Boolean): ContextMenuFlyoutColors = hover = Color.White.copy(alpha = 0.10f), separator = Color.White.copy(alpha = ADWAITA_BORDER_OPACITY), border = Color.Black.copy(alpha = 0.05f), + chevron = Color.White.copy(alpha = 0.30f), + shortcut = Color.White.copy(alpha = 0.55f), ) } else { ContextMenuFlyoutColors( @@ -71,5 +84,7 @@ private fun adwaitaColors(dark: Boolean): ContextMenuFlyoutColors = hover = Color(red = 0, green = 0, blue = 6, alpha = 0x1A), separator = Color(red = 0, green = 0, blue = 6).copy(alpha = 0.80f * ADWAITA_BORDER_OPACITY), border = Color.Black.copy(alpha = 0.05f), + chevron = Color(red = 0, green = 0, blue = 6, alpha = 0xCC).copy(alpha = 0.30f), + shortcut = Color(red = 0, green = 0, blue = 6, alpha = 0xCC).copy(alpha = 0.55f), ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/BreezeContextMenu.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/BreezeContextMenu.kt index c438ab505..b58a9c7bf 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/BreezeContextMenu.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/BreezeContextMenu.kt @@ -16,52 +16,80 @@ private val BreezeAccent = Color(red = 61, green = 174, blue = 233) internal val BreezeMenuTheme = ContextMenuFlyoutTheme( - menuShape = RoundedCornerShape(5.dp), + menuCornerRadius = 5.dp, itemShape = RoundedCornerShape(5.dp), uiFont = BreezeUiFont, iconFont = BreezeUiFont, chevron = "›", chevronSize = 14.sp, - chevronAlpha = 1f, + chevronGap = 4.dp, minWidth = 128.dp, maxWidth = 320.dp, - menuPadding = PaddingValues(4.dp), + // Frame width 1 (the border ring) + MenuItem_MarginWidth 3. + menuPadding = PaddingValues(3.dp), itemHeight = 30.dp, itemHorizontalPadding = 12.dp, - itemOuterHorizontalPadding = 0.dp, + itemMargin = PaddingValues(0.dp), separatorPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), iconSize = 16.dp, iconGap = 4.dp, - shadowElevation = 10.dp, shadowPad = 12.dp, - ambientShadow = Color.Black.copy(alpha = 0.18f), - spotShadow = Color.Black.copy(alpha = 0.10f), + shadows = { BreezeMenuShadows }, showIcons = true, shortcutGap = 16.dp, shortcutSize = 14.sp, - shortcutAlpha = 0.70f, + shortcutPadding = PaddingValues(0.dp), colors = ::breezeColors, glyph = { null }, vector = ContextMenuIcon::toBreezeVector, ) +/** + * Breeze's `ShadowLarge` — the kstyle default for menus — from + * `lookupShadowParams` in `kstyle/breezeshadowhelper.cpp`: + * `CompositeShadowParams(QPoint(0, 5), ShadowParams(QPoint(0, 0), 20, 0.22), + * ShadowParams(QPoint(0, -3), 10, 0.12))`. Each layer's offset is the + * composite offset plus its own, its radius a CSS blur radius + * (`BoxShadowRenderer` uses `radius / 2` as the standard deviation), at the + * default `ShadowStrength` of 255 and the default black shadow colour. + */ +private val BreezeMenuShadows = + listOf( + ContextMenuBoxShadow(offsetY = 5.dp, blur = 20.dp, color = Color.Black.copy(alpha = 0.22f)), + ContextMenuBoxShadow(offsetY = 2.dp, blur = 10.dp, color = Color.Black.copy(alpha = 0.12f)), + ) + +/** + * Breeze strokes its menu frame *over* the filled rect (`renderMenuFrame`: one + * `drawRoundedRect` with both brush and pen), so its 20 % outline is seen + * against the menu's own background. The flyout paints the ring outside the + * surface, so the colours below are that composite, already resolved. + */ private fun breezeColors(dark: Boolean): ContextMenuFlyoutColors = if (dark) { + val text = Color(red = 252, green = 252, blue = 252) ContextMenuFlyoutColors( surface = Color(red = 32, green = 35, blue = 38), - text = Color(red = 252, green = 252, blue = 252), + text = text, textDisabled = Color(red = 161, green = 169, blue = 177), hover = BreezeAccent.copy(alpha = 0.30f), - separator = Color(red = 252, green = 252, blue = 252, alpha = 0x26), - border = Color(red = 252, green = 252, blue = 252, alpha = 0x33), + separator = text.copy(alpha = 0x26 / 255f), + // (252, 252, 252) at 0x33 over the surface + border = Color(red = 76, green = 78, blue = 81), + chevron = text, + shortcut = text.copy(alpha = 0.70f), ) } else { + val text = Color(red = 35, green = 38, blue = 41) ContextMenuFlyoutColors( surface = Color(red = 239, green = 240, blue = 241), - text = Color(red = 35, green = 38, blue = 41), + text = text, textDisabled = Color(red = 112, green = 125, blue = 138), hover = BreezeAccent.copy(alpha = 0.30f), - separator = Color(red = 35, green = 38, blue = 41, alpha = 0x26), - border = Color(red = 35, green = 38, blue = 41, alpha = 0x33), + separator = text.copy(alpha = 0x26 / 255f), + // (35, 38, 41) at 0x33 over the surface + border = Color(red = 198, green = 200, blue = 201), + chevron = text, + shortcut = text.copy(alpha = 0.70f), ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt index 276e44e4e..3faebd3dc 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuFlyout.kt @@ -40,10 +40,13 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.paint -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.layout.ContentScale @@ -66,9 +69,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map +import org.jetbrains.skia.FilterBlurMode +import org.jetbrains.skia.MaskFilter +import org.jetbrains.skia.RRect +import org.jetbrains.skia.Paint as SkiaPaint private const val SUBMENU_OPEN_DELAY_MS = 200L private const val SUBMENU_CLOSE_DELAY_MS = 160L +private val BORDER_WIDTH = 1.dp internal class ContextMenuFlyoutColors( val surface: Color, @@ -76,38 +84,76 @@ internal class ContextMenuFlyoutColors( val textDisabled: Color, val hover: Color, val separator: Color, + /** + * The 1 dp ring at the menu's edge. It is painted *outside* the surface, + * over whatever is behind the menu — WinUI's `BackgroundSizing = + * InnerBorderEdge`, libadwaita's `0 0 0 1px` box-shadow — so a translucent + * colour here blends with the backdrop, not with [surface]. + */ val border: Color, + /** The submenu chevron. */ + val chevron: Color, + /** The keyboard shortcut next to an enabled item's label. */ + val shortcut: Color, +) + +/** + * One CSS `box-shadow` layer under the menu surface: the menu's rounded + * rectangle grown by [spread], moved down by [offsetY] and blurred with the + * CSS blur radius [blur] — a Gaussian whose standard deviation is half the + * radius, as css-backgrounds-3 specifies and as GTK and Breeze both render. + * + * The OS menus the flyouts imitate all describe their shadow this way + * (libadwaita's `_popovers.scss`, Breeze's `ShadowParams`, Fluent 2's shadow + * tokens), so the themes carry those declarations verbatim. Compose's own + * `Modifier.shadow` is a Material elevation model instead — and on desktop its + * `ambientColor` / `spotColor` alphas are further multiplied by fixed 0.039 / + * 0.19 factors — so no elevation value reproduces a given `box-shadow`. + */ +internal class ContextMenuBoxShadow( + val offsetY: Dp, + val blur: Dp, + val color: Color, + val spread: Dp = 0.dp, ) internal class ContextMenuFlyoutTheme( - val menuShape: RoundedCornerShape, + val menuCornerRadius: Dp, val itemShape: RoundedCornerShape, val uiFont: FontFamily, val iconFont: FontFamily, val chevron: String, val chevronSize: TextUnit, - val chevronAlpha: Float, + /** Space between the label (or shortcut) and the submenu chevron. */ + val chevronGap: Dp, val minWidth: Dp, + /** [Dp.Unspecified] leaves the width to the content, as WinUI's presenter does. */ val maxWidth: Dp, + /** Inside the 1 dp border ring, around the whole item stack. */ val menuPadding: PaddingValues, val itemHeight: Dp, val itemHorizontalPadding: Dp, - val itemOuterHorizontalPadding: Dp, + /** Around each row, outside its hover highlight. */ + val itemMargin: PaddingValues, val separatorPadding: PaddingValues, val iconSize: Dp, val iconGap: Dp, - val shadowElevation: Dp, val shadowPad: Dp, - val ambientShadow: Color, - val spotShadow: Color, + val shadows: (dark: Boolean) -> List, val showIcons: Boolean, val shortcutGap: Dp, val shortcutSize: TextUnit, - val shortcutAlpha: Float, + /** Around the shortcut text, inside the row; a top-only value nudges its baseline down. */ + val shortcutPadding: PaddingValues, val colors: (dark: Boolean) -> ContextMenuFlyoutColors, val glyph: (ContextMenuIcon) -> String?, val vector: (ContextMenuIcon) -> ImageVector? = { null }, ) { + val menuShape: RoundedCornerShape = RoundedCornerShape(menuCornerRadius) + + /** [menuShape] one border ring further in: the surface inside the ring stays concentric with it. */ + val surfaceShape: RoundedCornerShape = RoundedCornerShape((menuCornerRadius - BORDER_WIDTH).coerceAtLeast(0.dp)) + internal fun hasIcon(icon: ContextMenuIcon?): Boolean { if (icon == null) return false return vector(icon) != null || glyph(icon) != null @@ -190,21 +236,17 @@ private fun ContextMenuFlyoutSurface( entries.any { entry -> entry is ContextMenuEntry.Item && theme.hasIcon(entry.icon) } - val maxWidth = theme.maxWidth.takeOrElse { 320.dp } + val maxWidth = theme.maxWidth.takeOrElse { Dp.Infinity } Box(Modifier.padding(theme.shadowPad)) { Column( Modifier .widthIn(min = theme.minWidth, max = maxWidth) - .shadow( - elevation = theme.shadowElevation, - shape = theme.menuShape, - clip = false, - ambientColor = theme.ambientShadow, - spotColor = theme.spotShadow, - ).width(IntrinsicSize.Max) + .boxShadows(theme.shadows(dark), theme.menuCornerRadius) + .width(IntrinsicSize.Max) .clip(theme.menuShape) - .border(1.dp, colors.border, theme.menuShape) - .background(colors.surface) + .border(BORDER_WIDTH, colors.border, theme.menuShape) + .padding(BORDER_WIDTH) + .background(colors.surface, theme.surfaceShape) .padding(theme.menuPadding), ) { entries.forEach { entry -> @@ -247,6 +289,45 @@ private fun ContextMenuFlyoutSurface( } } +/** + * Draws [shadows] behind the content, each as the content's rounded rectangle + * of corner radius [cornerRadius] under a blur mask. The content is opaque and + * drawn on top, so nothing of the shadow shows through the surface itself, as + * with CSS. + */ +private fun Modifier.boxShadows( + shadows: List, + cornerRadius: Dp, +): Modifier = + drawWithCache { + val radius = cornerRadius.toPx() + val layers = + shadows.map { shadow -> + val sigma = shadow.blur.toPx() / 2f + val paint = + SkiaPaint().apply { + color = shadow.color.toArgb() + if (sigma > 0f) maskFilter = MaskFilter.makeBlur(FilterBlurMode.NORMAL, sigma) + } + val spread = shadow.spread.toPx() + val offsetY = shadow.offsetY.toPx() + val rect = + RRect.makeLTRB( + -spread, + offsetY - spread, + size.width + spread, + size.height + offsetY + spread, + radius + spread, + ) + rect to paint + } + onDrawBehind { + drawIntoCanvas { canvas -> + layers.forEach { (rect, paint) -> canvas.nativeCanvas.drawRRect(rect, paint) } + } + } + } + @Composable private fun ContextMenuFlyoutSubmenu( entry: ContextMenuEntry.Submenu, @@ -325,7 +406,7 @@ private fun ContextMenuFlyoutRow( Row( Modifier .fillMaxWidth() - .padding(horizontal = theme.itemOuterHorizontalPadding) + .padding(theme.itemMargin) .clip(theme.itemShape) .hoverable(interactionSource, enabled = enabled) .background(if (hovered && enabled) colors.hover else Color.Transparent) @@ -363,9 +444,10 @@ private fun ContextMenuFlyoutRow( Spacer(Modifier.width(theme.shortcutGap)) BasicText( text = shortcut, + modifier = Modifier.padding(theme.shortcutPadding), style = TextStyle( - color = if (enabled) content.copy(alpha = theme.shortcutAlpha) else colors.textDisabled, + color = if (enabled) colors.shortcut else colors.textDisabled, fontSize = theme.shortcutSize, fontFamily = theme.uiFont, ), @@ -373,12 +455,12 @@ private fun ContextMenuFlyoutRow( ) } if (chevron) { - Spacer(Modifier.width(theme.iconGap)) + Spacer(Modifier.width(theme.chevronGap)) BasicText( text = theme.chevron, style = TextStyle( - color = content.copy(alpha = theme.chevronAlpha), + color = colors.chevron, fontSize = theme.chevronSize, fontFamily = theme.iconFont, ), diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/FluentContextMenu.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/FluentContextMenu.kt index df0417533..c6f7ff5c0 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/FluentContextMenu.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/FluentContextMenu.kt @@ -6,60 +6,138 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +// WinUI 3 `MenuFlyout`, as a mouse / pen / keyboard right click opens it +// (`microsoft-ui-xaml`, `controls/dev/CommonStyles/MenuFlyout_themeresources.xaml` +// and `Common_themeresources_any.xaml`; the generic.xaml values they do not +// override). `GetShouldBeNarrow` puts the items in their `NarrowPadding` state +// for those devices — `MenuFlyoutItemThemePaddingNarrow` `11,4,11,5`, so a 14 px +// label (19 px tall after layout rounding) makes a 28 px row; the touch padding +// `11,8,11,9` is the one a finger gets. private val FluentUiFont = FontFamily("Segoe UI Variable Text") private val FluentIconFont = FontFamily("Segoe Fluent Icons") internal val FluentMenuTheme = ContextMenuFlyoutTheme( - menuShape = RoundedCornerShape(8.dp), + // OverlayCornerRadius / ControlCornerRadius + menuCornerRadius = 8.dp, itemShape = RoundedCornerShape(4.dp), uiFont = FluentUiFont, iconFont = FluentIconFont, - chevron = "\uE76C", + // SubItemChevron: Glyph E974 (ChevronRightMed), FontSize 12, MenuFlyoutItemChevronMargin 24,0,0,-1 + chevron = "\uE974", chevronSize = 12.sp, - chevronAlpha = 1f, - minWidth = 168.dp, - maxWidth = 448.dp, - menuPadding = PaddingValues(vertical = 4.dp), - itemHeight = 36.dp, - itemHorizontalPadding = 12.dp, - itemOuterHorizontalPadding = 4.dp, - separatorPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + chevronGap = 24.dp, + // FlyoutThemeMinWidth; the presenter sets no MaxWidth + minWidth = 96.dp, + maxWidth = Dp.Unspecified, + // MenuFlyoutPresenterThemePadding 0,2,0,2 (inside MenuFlyoutPresenterBorderThemeThickness 1) + menuPadding = PaddingValues(vertical = 2.dp), + itemHeight = 28.dp, + itemHorizontalPadding = 11.dp, + // MenuFlyoutItemMargin + itemMargin = PaddingValues(horizontal = 4.dp, vertical = 2.dp), + // MenuFlyoutSeparatorThemePadding -4,1,-4,1: edge to edge, 1 px above and below + separatorPadding = PaddingValues(vertical = 1.dp), + // IconRoot Viewbox 16x16; MenuFlyoutItemPlaceholderThemeThickness 28 = 16 + 12 iconSize = 16.dp, iconGap = 12.dp, - shadowElevation = 16.dp, shadowPad = 0.dp, - ambientShadow = Color.Black.copy(alpha = 0.20f), - spotShadow = Color.Black.copy(alpha = 0.20f), + shadows = ::fluentShadows, showIcons = true, - shortcutGap = 36.dp, + // KeyboardAcceleratorTextBlock: CaptionTextBlockStyle (12), Margin 24,4,0,0 + shortcutGap = 24.dp, shortcutSize = 12.sp, - shortcutAlpha = 0.60f, + shortcutPadding = PaddingValues(top = 4.dp), colors = ::fluentColors, glyph = ContextMenuIcon::toFluentGlyph, ) +/** + * The shadow WinUI's `ThemeShadow` casts for a `MenuFlyout`, which sits at + * `Translation.Z = 32` (context menus, command bars, flyouts), from + * `GetDropShadowRecipe` in `dxaml/xcp/components/graphics/inc/DropShadowRecipe.h`: + * elevation `Z / 2 = 16`, which is the top of the `2..16` band — no ambient + * layer, one directional layer with a blur radius equal to the elevation + * (plus one, added when the shadow is built), shifted down by half of it, at + * `min(elevation / 100 + 0.06, 0.14)` in light and a flat `0.26` in dark. + * + * The composition `DropShadow.BlurRadius` is a Gaussian radius in the Direct2D + * sense — WinUI reserves exactly that many pixels around the caster for the + * shadow, so it is the ~3 σ extent, not the CSS radius of 2 σ: 17 px there is + * an ~11 px CSS blur here. + * + * Not the Fluent 2 web token (`shadow16`, `0 0 8px 12%` + `0 8px 16px 14%`): + * that is what Fluent UI React menus draw, but the flyout imitates the OS menu. + */ +private fun fluentShadows(dark: Boolean): List = + listOf( + ContextMenuBoxShadow( + offsetY = 8.dp, + blur = 11.dp, + color = Color.Black.copy(alpha = if (dark) 0.26f else 0.14f), + ), + ) + +// The colour resources `MenuFlyout_themeresources.xaml` binds, from +// `Common_themeresources_any.xaml`'s Default (dark) and Light dictionaries. The +// translucent ones stay translucent: WinUI composites them at draw time. +private val TextFillColorPrimaryDark = Color.White +private val TextFillColorPrimaryLight = Color(red = 0, green = 0, blue = 0, alpha = 0xE4) +private val TextFillColorSecondaryDark = Color(red = 255, green = 255, blue = 255, alpha = 0xC5) +private val TextFillColorSecondaryLight = Color(red = 0, green = 0, blue = 0, alpha = 0x9E) +private val TextFillColorDisabledDark = Color(red = 255, green = 255, blue = 255, alpha = 0x5D) +private val TextFillColorDisabledLight = Color(red = 0, green = 0, blue = 0, alpha = 0x5C) +private val SubtleFillColorSecondaryDark = Color(red = 255, green = 255, blue = 255, alpha = 0x0F) +private val SubtleFillColorSecondaryLight = Color(red = 0, green = 0, blue = 0, alpha = 0x09) +private val DividerStrokeColorDefaultDark = Color(red = 255, green = 255, blue = 255, alpha = 0x15) +private val DividerStrokeColorDefaultLight = Color(red = 0, green = 0, blue = 0, alpha = 0x0F) +private val SurfaceStrokeColorFlyoutDark = Color(red = 0, green = 0, blue = 0, alpha = 0x33) +private val SurfaceStrokeColorFlyoutLight = Color(red = 0, green = 0, blue = 0, alpha = 0x0F) + +/** + * `MenuFlyoutPresenterBackground` is a `DesktopAcrylicBackdrop`; these are its + * fallbacks (`AcrylicBackgroundFillColorDefaultBrush`'s `FallbackColor`), i.e. + * the menu as Windows draws it with transparency effects off. The acrylic + * itself — a blur of what is behind the menu, tinted and luminosity-blended — + * needs the compositor and is not reproduced. + */ +private val AcrylicFallbackDark = Color(red = 44, green = 44, blue = 44) +private val AcrylicFallbackLight = Color(red = 249, green = 249, blue = 249) + +/** + * `MenuFlyout_themeresources.xaml`'s brush bindings: item foreground + * `TextFillColorPrimary`, disabled `TextFillColorDisabled`, pointer-over + * background `SubtleFillColorSecondary`, separator `DividerStrokeColorDefault`, + * presenter border `SurfaceStrokeColorFlyout` (drawn outside the background, + * `BackgroundSizing = InnerBorderEdge`), chevron and keyboard accelerator text + * `TextFillColorSecondary`. + */ private fun fluentColors(dark: Boolean): ContextMenuFlyoutColors = if (dark) { ContextMenuFlyoutColors( - surface = Color(red = 44, green = 44, blue = 44), - text = Color.White, - textDisabled = Color(red = 115, green = 115, blue = 115), - hover = Color.White.copy(alpha = 0.12f), - separator = Color(red = 61, green = 61, blue = 61), - border = Color(red = 61, green = 61, blue = 61), + surface = AcrylicFallbackDark, + text = TextFillColorPrimaryDark, + textDisabled = TextFillColorDisabledDark, + hover = SubtleFillColorSecondaryDark, + separator = DividerStrokeColorDefaultDark, + border = SurfaceStrokeColorFlyoutDark, + chevron = TextFillColorSecondaryDark, + shortcut = TextFillColorSecondaryDark, ) } else { ContextMenuFlyoutColors( - surface = Color(red = 249, green = 249, blue = 249), - text = Color(red = 26, green = 26, blue = 26), - textDisabled = Color(red = 154, green = 154, blue = 154), - hover = Color.Black.copy(alpha = 0.08f), - separator = Color(red = 229, green = 229, blue = 229), - border = Color(red = 229, green = 229, blue = 229), + surface = AcrylicFallbackLight, + text = TextFillColorPrimaryLight, + textDisabled = TextFillColorDisabledLight, + hover = SubtleFillColorSecondaryLight, + separator = DividerStrokeColorDefaultLight, + border = SurfaceStrokeColorFlyoutLight, + chevron = TextFillColorSecondaryLight, + shortcut = TextFillColorSecondaryLight, ) } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt index 3e7bcb83a..59ce18e10 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/contextmenu/NativeContextMenuRepresentation.kt @@ -12,6 +12,7 @@ import dev.nucleusframework.core.runtime.Platform import dev.nucleusframework.menu.macos.NativePopupMenuItem import dev.nucleusframework.menu.macos.NsMenuItemImage import dev.nucleusframework.menu.macos.popUpNativeMenu +import dev.nucleusframework.window.tao.NativePopupLayers import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -21,6 +22,11 @@ import kotlinx.coroutines.withContext * Cinnamon, MATE, …), a Compose Breeze flyout on Qt Linux desktops (KDE * Plasma, LXQt, Deepin, …). * + * The Compose flyouts open in a native popup surface whatever the window's + * `nativePopupLayers` flag says ([NativePopupLayers]): an OS-looking menu has + * to be able to leave the window, like the menus it imitates, and the + * application's choice for its own popups must not decide that. + * * Calling [Representation] off a supported OS closes the menu immediately * so a stray install cannot leave Compose in `Open`. */ @@ -44,8 +50,10 @@ public object NativeContextMenuRepresentation : ContextMenuRepresentation { return } when (Platform.Current) { - Platform.Windows -> ContextMenuFlyout(status, entries, FluentMenuTheme, onDismiss) - Platform.Linux -> ContextMenuFlyout(status, entries, linuxContextMenuTheme(), onDismiss) + Platform.Windows -> + NativePopupLayers { ContextMenuFlyout(status, entries, FluentMenuTheme, onDismiss) } + Platform.Linux -> + NativePopupLayers { ContextMenuFlyout(status, entries, linuxContextMenuTheme(), onDismiss) } Platform.MacOS -> { val macEntries = entries.map { it.toMacPopupItem() } LaunchedEffect(status) { diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/FileKitIntegration.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/FileKitIntegration.kt new file mode 100644 index 000000000..4ab9c70c4 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/FileKitIntegration.kt @@ -0,0 +1,55 @@ +package dev.nucleusframework.application.internal + +import dev.nucleusframework.core.runtime.NucleusApp +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.exceptions.FileKitNotInitializedException +import io.github.vinceglb.filekit.filesDir +import java.util.logging.Level +import java.util.logging.Logger + +private val logger = Logger.getLogger("dev.nucleusframework.application.internal.FileKitIntegration") + +/** + * Initializes FileKit with [NucleusApp.appId] — only when FileKit is on the runtime classpath and + * the app has not initialized it already. + * + * On Windows `FileKit.filesDir` is then `%APPDATA%\`, exactly the directory the NSIS + * uninstaller removes with `deleteAppDataOnUninstall`: the plugin passes the Windows package name + * (= appId) as `win.executableName`, from which electron-builder derives `productFilename`. + * + * FileKit is a `compileOnly` dependency: when the app does not ship it, touching [FileKitBootstrap] + * fails with a [LinkageError] (at verification or first resolution), which is also what an + * incompatible FileKit version produces. The catch must stay here, outside the class that + * references FileKit, since that class is the one that fails to load. + */ +internal fun initializeFileKitIfPresent() { + try { + FileKitBootstrap.initializeIfUnset(NucleusApp.appId) + } catch (_: LinkageError) { + // FileKit absent (or binary-incompatible): nothing to initialize. + } catch ( + @Suppress("TooGenericExceptionCaught") e: RuntimeException, // never take the app down for this + ) { + logger.log(Level.WARNING, "FileKit auto-initialization failed", e) + } +} + +private object FileKitBootstrap { + fun initializeIfUnset(appId: String) { + if (isInitialized()) return + FileKit.init(appId = appId) + logger.fine { "FileKit initialized with appId=$appId" } + } + + // `appId` covers `init(appId)`; `filesDir` covers `init(filesDir, cacheDir)`, which sets no + // appId. Checked in that order because `filesDir` creates the directory it resolves. + private fun isInitialized(): Boolean = isSet { FileKit.appId } || isSet { FileKit.filesDir } + + private inline fun isSet(probe: () -> Any): Boolean = + try { + probe() + true + } catch (_: FileKitNotInitializedException) { + false + } +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/IdleGc.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/IdleGc.kt new file mode 100644 index 000000000..e3fa663e5 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/IdleGc.kt @@ -0,0 +1,137 @@ +package dev.nucleusframework.application.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import dev.nucleusframework.application.NucleusWindow +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.util.IdentityHashMap +import java.util.Properties +import java.util.logging.Logger + +/** + * Runtime side of the `nucleusOptimization { idleGc }` knob. + * Keep the property name in sync with the plugin's `NUCLEUS_IDLE_GC_PROPERTY`, and the + * resource key with `NUCLEUS_IDLE_GC_RESOURCE_KEY`. + * + * The system property (set in the jpackage `.cfg`) wins; the plugin also bakes the knob into + * `nucleus/nucleus-app.properties`, which is the only carrier in a GraalVM native image. + */ +internal object NucleusOptimization { + const val PROPERTY: String = "nucleus.optimization.idleGc" + private const val RESOURCE_PATH = "nucleus/nucleus-app.properties" + private const val RESOURCE_KEY = "optimization.idleGc" + + val isEnabled: Boolean by lazy { + val property = System.getProperty(PROPERTY) + if (property != null) property == "true" else readResourceFlag() + } + + @Suppress("TooGenericExceptionCaught") + private fun readResourceFlag(): Boolean = + try { + NucleusOptimization::class.java.classLoader + ?.getResourceAsStream(RESOURCE_PATH) + ?.use { Properties().apply { load(it) } } + ?.getProperty(RESOURCE_KEY) == "true" + } catch (_: Exception) { + false + } +} + +/** + * Collects focus / minimized flows from every decorated window and dialog and + * runs [System.gc] according to [IdleGcController]. + */ +internal object IdleGc { + private val logger = Logger.getLogger(IdleGc::class.java.name) + private val controller = IdleGcController() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val jobsLock = Any() + private val jobs = IdentityHashMap() + private val applyLock = Any() + private var debounceJob: Job? = null + + fun attach(window: NucleusWindow) { + if (!NucleusOptimization.isEnabled) return + synchronized(jobsLock) { + if (window in jobs) return + controller.register(window, window.focusFlow.value, window.minimizedFlow.value) + jobs[window] = + scope.launch { + launch { window.focusFlow.collect { handle(window) } } + launch { window.minimizedFlow.collect { handle(window) } } + } + } + } + + fun detach(window: NucleusWindow) { + val cmd = + synchronized(jobsLock) { + jobs.remove(window)?.cancel() + controller.unregister(window) + } + apply(cmd) + } + + private fun handle(window: NucleusWindow) { + apply(controller.update(window, window.focusFlow.value, window.minimizedFlow.value)) + } + + private fun apply(cmd: IdleGcCommand) { + val runNow = + synchronized(applyLock) { + when (cmd) { + IdleGcCommand.NoChange -> false + IdleGcCommand.Cancel -> { + cancelDebounce() + false + } + IdleGcCommand.CollectNow -> { + cancelDebounce() + true + } + IdleGcCommand.Debounce -> { + scheduleDebounce() + false + } + } + } + if (runNow) runGc() + } + + private fun cancelDebounce() { + debounceJob?.cancel() + debounceJob = null + } + + private fun scheduleDebounce() { + cancelDebounce() + debounceJob = + scope.launch { + delay(IdleGcController.UNFOCUS_DELAY_MS) + if (controller.shouldRunDeferredGc()) { + runGc() + } + } + } + + private fun runGc() { + logger.fine("Idle GC") + @Suppress("ExplicitGarbageCollectionCall") + System.gc() + } +} + +@Composable +internal fun ObserveIdleGc(window: NucleusWindow) { + if (!NucleusOptimization.isEnabled) return + DisposableEffect(window) { + IdleGc.attach(window) + onDispose { IdleGc.detach(window) } + } +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/IdleGcController.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/IdleGcController.kt new file mode 100644 index 000000000..25fa78b47 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/IdleGcController.kt @@ -0,0 +1,94 @@ +package dev.nucleusframework.application.internal + +/** + * Decides when idle GC should run for the `nucleusOptimization` pack. + * + * Serial is stop-the-world, so a collection is only requested when no tracked + * window is still focused and visible. A minimized window collects immediately; + * a mere focus loss waits [UNFOCUS_DELAY_MS] so alt-tab / click-away that + * comes back quickly does not hitch. + * + * The first snapshot for a window (registration) never triggers a collection, + * so a window that starts unfocused before its first paint cannot GC during + * startup. + */ +internal class IdleGcController { + private val lock = Any() + private val windows = LinkedHashMap() + private var deferredArmed: Boolean = false + + fun register( + id: Any, + focused: Boolean, + minimized: Boolean, + ) { + synchronized(lock) { + windows[id] = WindowIdle(focused, minimized) + } + } + + fun unregister(id: Any): IdleGcCommand = + synchronized(lock) { + if (windows.remove(id) == null) IdleGcCommand.NoChange else commit(decide()) + } + + fun update( + id: Any, + focused: Boolean, + minimized: Boolean, + ): IdleGcCommand = + synchronized(lock) { + val next = WindowIdle(focused, minimized) + val prev = windows[id] ?: return@synchronized IdleGcCommand.NoChange + if (prev == next) return@synchronized IdleGcCommand.NoChange + windows[id] = next + commit(decide()) + } + + /** + * True when a deferred (unfocus) collection was armed and is still valid: + * every tracked window is unfocused and none is minimized. Minimize already + * collected immediately, so the delay must not fire a second time. + */ + fun shouldRunDeferredGc(): Boolean = + synchronized(lock) { + deferredArmed && decide() == IdleGcCommand.Debounce + } + + private fun decide(): IdleGcCommand { + if (windows.isEmpty() || windows.values.any { it.isInteracting }) { + return IdleGcCommand.Cancel + } + if (windows.values.any { it.minimized }) { + return IdleGcCommand.CollectNow + } + return IdleGcCommand.Debounce + } + + private fun commit(cmd: IdleGcCommand): IdleGcCommand { + when (cmd) { + IdleGcCommand.Debounce -> deferredArmed = true + IdleGcCommand.Cancel, IdleGcCommand.CollectNow -> deferredArmed = false + IdleGcCommand.NoChange -> Unit + } + return cmd + } + + private data class WindowIdle( + val focused: Boolean, + val minimized: Boolean, + ) { + val isInteracting: Boolean get() = focused && !minimized + } + + companion object { + const val UNFOCUS_DELAY_MS: Long = 3_000 + } +} + +internal enum class IdleGcCommand { + NoChange, + Cancel, + Debounce, + CollectNow, +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedDialogAdapter.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedDialogAdapter.kt index 099143c55..03e121d0a 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedDialogAdapter.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedDialogAdapter.kt @@ -1,3 +1,5 @@ +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + package dev.nucleusframework.application.internal import androidx.compose.runtime.Composable @@ -11,9 +13,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.window.DialogState -import dev.nucleusframework.application.LocalNucleusBackend import dev.nucleusframework.application.LocalNucleusWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.NucleusDecoratedDialogScope import dev.nucleusframework.application.NucleusWindow import dev.nucleusframework.application.TaoNucleusApplicationScope @@ -82,46 +82,101 @@ internal object TaoDecoratedDialogAdapter { // throwing default, e.g. LocalAppGraph, would crash otherwise). compositionLocalContext = outerLocals, ) { - val taoScope: TaoDecoratedDialogScope = this - // Tao dialogs share TaoWindow with regular windows; rebuild the - // active-state mirror as a single-bit DecoratedWindowState so - // [TaoNucleusWindow] can read uniform flow values. - val windowStateMirror = - remember(taoScope) { - derivedStateOf { - DecoratedWindowState.of(active = taoScope.state.isActive) - } - } - val nucleusWindow: NucleusWindow = - remember(taoScope.window) { - TaoNucleusWindow(taoScope.window, windowStateMirror) - } - val nucleusScope = - remember(taoScope, nucleusWindow) { - TaoNucleusDecoratedDialogScope(taoScope, nucleusWindow) - } - // Bridge the parent composition's locals (theme, density, - // user-provided locals, …) into the dialog's own ComposeScene - // via `ComposeScene.compositionLocalContext` rather than a - // `CompositionLocalProvider(outerLocals)` wrapper. The wrapper - // would re-provide Compose's internal `LocalComposeSceneContext` - // captured from the PARENT scene, routing every Popup / - // DropdownMenu / Tooltip layer back into the parent window — the - // popup-mispositioned-relative-to-parent bug. The scene property - // is applied above the scene's own `LocalComposeSceneContext` - // (see RootNodeOwner.setContent), so theme flows while the dialog - // scene keeps authority over popup layer creation. - val bridge = LocalTaoCompositionLocalContextBridge.current - SideEffect { bridge?.invoke(outerLocals) } - CompositionLocalProvider( - LocalLayoutDirection provides parentLayoutDirection, - LocalNucleusBackend provides NucleusBackend.Tao, - LocalNucleusWindow provides nucleusWindow, - ) { - nucleusScope.content() - } + bindNucleusDialogContent(outerLocals, parentLayoutDirection, content) + } + } + } + + @Suppress("LongParameterList") + @Composable + fun DialogNucleusV2( + scope: TaoNucleusApplicationScope, + onCloseRequest: () -> Unit, + state: dev.nucleusframework.window.tao.v2.DialogState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + enabled: Boolean, + focusable: Boolean, + minSize: androidx.compose.ui.unit.DpSize, + maxSize: androidx.compose.ui.unit.DpSize, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, + ) { + val outerLocals = currentCompositionLocalContext + val parentLayoutDirection = LocalLayoutDirection.current + val parentModalCount = LocalModalDialogCount.current + DisposableEffect(Unit) { + parentModalCount.value++ + onDispose { parentModalCount.value-- } + } + with(scope.taoScope) { + TaoDecoratedDialog( + onCloseRequest = onCloseRequest, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + enabled = enabled, + focusable = focusable, + minSize = minSize, + maxSize = maxSize, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + compositionLocalContext = outerLocals, + ) { + bindNucleusDialogContent(outerLocals, parentLayoutDirection, content) + } + } + } +} + +@Composable +private fun TaoDecoratedDialogScope.bindNucleusDialogContent( + outerLocals: androidx.compose.runtime.CompositionLocalContext, + parentLayoutDirection: androidx.compose.ui.unit.LayoutDirection, + content: @Composable NucleusDecoratedDialogScope.() -> Unit, +) { + val taoScope: TaoDecoratedDialogScope = this + // Tao dialogs share TaoWindow with regular windows; rebuild the + // active-state mirror as a single-bit DecoratedWindowState so + // [TaoNucleusWindow] can read uniform flow values. + val windowStateMirror = + remember(taoScope) { + derivedStateOf { + DecoratedWindowState.of(active = taoScope.state.isActive) } } + val nucleusWindow: NucleusWindow = + remember(taoScope.window) { + TaoNucleusWindow(taoScope.window, windowStateMirror) + } + val nucleusScope = + remember(taoScope, nucleusWindow) { + TaoNucleusDecoratedDialogScope(taoScope, nucleusWindow) + } + ObserveIdleGc(nucleusWindow) + // Bridge the parent composition's locals (theme, density, + // user-provided locals, …) into the dialog's own ComposeScene + // via `ComposeScene.compositionLocalContext` rather than a + // `CompositionLocalProvider(outerLocals)` wrapper. The wrapper + // would re-provide Compose's internal `LocalComposeSceneContext` + // captured from the PARENT scene, routing every Popup / + // DropdownMenu / Tooltip layer back into the parent window — the + // popup-mispositioned-relative-to-parent bug. The scene property + // is applied above the scene's own `LocalComposeSceneContext` + // (see RootNodeOwner.setContent), so theme flows while the dialog + // scene keeps authority over popup layer creation. + val bridge = LocalTaoCompositionLocalContextBridge.current + SideEffect { bridge?.invoke(outerLocals) } + CompositionLocalProvider( + LocalLayoutDirection provides parentLayoutDirection, + LocalNucleusWindow provides nucleusWindow, + ) { + nucleusScope.content() } } diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedWindowAdapter.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedWindowAdapter.kt index 2d7d8a4a5..79219a377 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedWindowAdapter.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoDecoratedWindowAdapter.kt @@ -1,3 +1,5 @@ +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + package dev.nucleusframework.application.internal import androidx.compose.runtime.Composable @@ -11,9 +13,7 @@ import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.DpSize import androidx.compose.ui.window.WindowState -import dev.nucleusframework.application.LocalNucleusBackend import dev.nucleusframework.application.LocalNucleusWindow -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.NucleusDecoratedWindowScope import dev.nucleusframework.application.NucleusWindow import dev.nucleusframework.application.ObserveSingleInstanceRestore @@ -44,6 +44,8 @@ internal object TaoDecoratedWindowAdapter { title: String, icon: Painter?, resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, enabled: Boolean, focusable: Boolean, alwaysOnTop: Boolean, @@ -89,6 +91,8 @@ internal object TaoDecoratedWindowAdapter { minimumSize = minimumSize, visible = visible, resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, enabled = enabled, focusable = focusable, alwaysOnTop = alwaysOnTop, @@ -114,78 +118,174 @@ internal object TaoDecoratedWindowAdapter { // adapter is the one top-level-window caller that never did. compositionLocalContext = outerLocals, ) { - val taoScope: TaoDecoratedWindowScope = this - val decoratedState = - remember(taoScope) { - derivedStateOf { taoScope.state } - } - val nucleusWindow: NucleusWindow = - remember(taoScope.window) { - TaoNucleusWindow(taoScope.window, decoratedState) - } - val nucleusScope = - remember(taoScope, nucleusWindow) { - TaoNucleusDecoratedWindowScope(taoScope, nucleusWindow) - } - ObserveSingleInstanceRestore(nucleusWindow) - // outerLocals were captured in the OUTER composition and cross the - // scene boundary as this scene's own compositionLocalContext (the - // parameter above for the first composition, the bridge below for - // every one after). Compose applies that property ABOVE the scene's - // own provisions (RootNodeOwner.setContent), which is the whole - // point: Compose's internal LocalComposeSceneContext stays the one - // THIS scene provided, so Popup/Dialog/DropdownMenu/Tooltip create - // their layers here. The previous shape — a plain - // CompositionLocalProvider(outerLocals) wrapper nested INSIDE the - // scene — re-provided the captured scene context instead, so a - // window opened from another window's content routed its popups - // back into the PARENT scene (and threw once that scene was gone). - // TaoDecoratedDialogAdapter always bridged its locals this way; this - // adapter never did. - // - // Ordering consequence: everything the scene and DecoratedWindow - // provide for themselves — LocalDensity, LocalTaoWindow, - // LocalTitleBarInfo, LocalTaoTextSelectionA11yPublisher — now sits - // BELOW outerLocals and wins on its own, so the snapshot-and- - // re-provide below is no longer load-bearing. It stays as an - // explicit guard: without LocalTaoWindow bound to THIS window, - // windowDragArea() and WindowControlsWindows drive the PARENT - // window and a secondary window looks immovable. LocalLayoutDirection - // is the one local that does not come back on its own — the scene - // re-provides GlobalLayoutDirection over the bridged value — hence - // parentLayoutDirection, captured outside. - val bridge = LocalTaoCompositionLocalContextBridge.current - SideEffect { bridge?.invoke(outerLocals) } - // The app theme's own LocalTextContextMenu (e.g. Jewel's) is not a - // scene-owned local, so it does come through outerLocals and shadows - // the scene's selection observer — silently breaking cross-process - // selection reading (PopClip, AppleScript). TaoTextSelectionAccessibility - // below re-installs the observer INSIDE the theme's menu, keeping it as - // its delegate — cut/copy/paste icons & shortcuts preserved — and reads - // the scene's publisher from this snapshot. - val scenePublisher = LocalTaoTextSelectionA11yPublisher.current - val sceneTaoWindow = LocalTaoWindow.current - val sceneTitleBarInfo = LocalTitleBarInfo.current - CompositionLocalProvider( - LocalLayoutDirection provides parentLayoutDirection, - LocalTaoTextSelectionA11yPublisher provides scenePublisher, - LocalNucleusBackend provides NucleusBackend.Tao, - LocalNucleusWindow provides nucleusWindow, - LocalTaoWindow provides sceneTaoWindow, - LocalTitleBarInfo provides sceneTitleBarInfo, - ) { - TaoTextSelectionAccessibility { - NativeContextMenuProvider(enabled = nativeContextMenu) { - nucleusScope.content() - } - } - } + bindNucleusContent(outerLocals, parentLayoutDirection, nativeContextMenu, content) + } + } + } + + @Suppress("LongParameterList") + @Composable + fun WindowNucleusV2( + scope: TaoNucleusApplicationScope, + onCloseRequest: () -> Unit, + state: dev.nucleusframework.window.tao.v2.WindowState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, + enabled: Boolean, + focusable: Boolean, + alwaysOnTop: Boolean, + undecorated: Boolean, + transparent: Boolean, + clickThrough: Boolean, + visibleOnAllWorkspaces: Boolean, + forceX11: Boolean, + alwaysOnBottom: Boolean, + popupFor: NucleusWindow?, + nativePopupLayers: Boolean, + nativeContextMenu: Boolean, + hiddenFromDock: Boolean, + minSize: DpSize, + maxSize: DpSize, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, + ) { + val outerLocals = currentCompositionLocalContext + val parentLayoutDirection = LocalLayoutDirection.current + with(scope.taoScope) { + TaoDecoratedWindow( + onCloseRequest = onCloseRequest, + state = state, + title = title, + icon = icon, + minSize = minSize, + maxSize = maxSize, + visible = visible, + resizable = resizable, + minimizable = minimizable, + maximizable = maximizable, + enabled = enabled, + focusable = focusable, + alwaysOnTop = alwaysOnTop, + undecorated = undecorated, + transparent = transparent, + clickThrough = clickThrough, + visibleOnAllWorkspaces = visibleOnAllWorkspaces, + forceX11 = forceX11, + alwaysOnBottom = alwaysOnBottom, + popupFor = popupFor?.unsafe?.taoWindow, + nativePopupLayers = nativePopupLayers, + hiddenFromDock = hiddenFromDock, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + compositionLocalContext = outerLocals, + ) { + bindNucleusContent(outerLocals, parentLayoutDirection, nativeContextMenu, content) + } + } + } +} + +/** This window's [NucleusDecoratedWindowScope]: the Tao scope plus its [NucleusWindow]. */ +@Composable +internal fun TaoDecoratedWindowScope.rememberNucleusScope(): NucleusDecoratedWindowScope { + val taoScope: TaoDecoratedWindowScope = this + val decoratedState = + remember(taoScope) { + derivedStateOf { taoScope.state } + } + val nucleusWindow: NucleusWindow = + remember(taoScope.window) { + TaoNucleusWindow(taoScope.window, decoratedState) + } + return remember(taoScope, nucleusWindow) { + TaoNucleusDecoratedWindowScope(taoScope, nucleusWindow) + } +} + +/** + * The Nucleus locals of a window scene, composed around [content]: the bridged + * outer locals, this window as [LocalNucleusWindow], single-instance restore, + * text-selection accessibility and the native context menu. + * + * Shared with [TaoTabWorkspaceAdapter], whose windows are opened by the tab + * workspace rather than by this adapter but are decorated windows all the same. + */ + +@Composable +internal fun TaoDecoratedWindowScope.bindNucleusContent( + outerLocals: androidx.compose.runtime.CompositionLocalContext, + parentLayoutDirection: androidx.compose.ui.unit.LayoutDirection, + nativeContextMenu: Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, +) { + val nucleusScope = rememberNucleusScope() + val nucleusWindow = nucleusScope.nucleusWindow + ObserveSingleInstanceRestore(nucleusWindow) + ObserveIdleGc(nucleusWindow) + // outerLocals were captured in the OUTER composition and cross the + // scene boundary as this scene's own compositionLocalContext (the + // parameter above for the first composition, the bridge below for + // every one after). Compose applies that property ABOVE the scene's + // own provisions (RootNodeOwner.setContent), which is the whole + // point: Compose's internal LocalComposeSceneContext stays the one + // THIS scene provided, so Popup/Dialog/DropdownMenu/Tooltip create + // their layers here. The previous shape — a plain + // CompositionLocalProvider(outerLocals) wrapper nested INSIDE the + // scene — re-provided the captured scene context instead, so a + // window opened from another window's content routed its popups + // back into the PARENT scene (and threw once that scene was gone). + // TaoDecoratedDialogAdapter always bridged its locals this way; this + // adapter never did. + // + // Ordering consequence: everything the scene and DecoratedWindow + // provide for themselves — LocalDensity, LocalTaoWindow, + // LocalTitleBarInfo, LocalTaoTextSelectionA11yPublisher — now sits + // BELOW outerLocals and wins on its own, so the snapshot-and- + // re-provide below is no longer load-bearing. It stays as an + // explicit guard: without LocalTaoWindow bound to THIS window, + // windowDragArea() and WindowControlsWindows drive the PARENT + // window and a secondary window looks immovable. LocalLayoutDirection + // is the one local that does not come back on its own — the scene + // re-provides GlobalLayoutDirection over the bridged value — hence + // parentLayoutDirection, captured outside. + val bridge = LocalTaoCompositionLocalContextBridge.current + SideEffect { bridge?.invoke(outerLocals) } + // The app theme's own LocalTextContextMenu (e.g. Jewel's) is not a + // scene-owned local, so it does come through outerLocals and shadows + // the scene's selection observer — silently breaking cross-process + // selection reading (PopClip, AppleScript). TaoTextSelectionAccessibility + // below re-installs the observer INSIDE the theme's menu, keeping it as + // its delegate — cut/copy/paste icons & shortcuts preserved — and reads + // the scene's publisher from this snapshot. + val scenePublisher = LocalTaoTextSelectionA11yPublisher.current + val sceneTaoWindow = LocalTaoWindow.current + val sceneTitleBarInfo = LocalTitleBarInfo.current + CompositionLocalProvider( + LocalLayoutDirection provides parentLayoutDirection, + LocalTaoTextSelectionA11yPublisher provides scenePublisher, + LocalNucleusWindow provides nucleusWindow, + LocalTaoWindow provides sceneTaoWindow, + LocalTitleBarInfo provides sceneTitleBarInfo, + ) { + TaoTextSelectionAccessibility { + NativeContextMenuProvider(enabled = nativeContextMenu) { + nucleusScope.content() } } } } -private class TaoNucleusDecoratedWindowScope( +/** + * The Nucleus content scope of a Tao-hosted window. Shared with + * [TaoSatelliteWindowAdapter]: a satellite is a decorated window as far as its + * content is concerned. + */ +internal class TaoNucleusDecoratedWindowScope( private val taoScope: TaoDecoratedWindowScope, override val nucleusWindow: NucleusWindow, ) : NucleusDecoratedWindowScope, diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoLauncher.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoLauncher.kt index 0bf6a6c18..de3b3fee0 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoLauncher.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoLauncher.kt @@ -6,38 +6,32 @@ import androidx.compose.runtime.LaunchedEffect import dev.nucleusframework.application.DefaultNucleusDialogHost import dev.nucleusframework.application.DefaultNucleusWindowHost import dev.nucleusframework.application.LocalNucleusApplicationScope -import dev.nucleusframework.application.LocalNucleusBackend import dev.nucleusframework.application.LocalNucleusDialogHost import dev.nucleusframework.application.LocalNucleusWindowHost import dev.nucleusframework.application.NucleusApplicationScope -import dev.nucleusframework.application.NucleusBackend import dev.nucleusframework.application.ProvideNucleusSystemTheme import dev.nucleusframework.application.TaoNucleusApplicationScope import dev.nucleusframework.window.tao.TaoDockPolicy import dev.nucleusframework.window.tao.taoApplication -/** - * Isolates references to Tao symbols. Loaded only when [NucleusBackend.Tao] is - * chosen — keeps `nucleusApplication` callable on classpaths that lack the - * `decorated-window-tao` module. - */ +/** Isolates the Tao entry point (`taoApplication`) from `nucleusApplication`. */ internal object TaoLauncher { fun run( args: Array, dockIconFollowsWindows: Boolean, + exitProcessOnExit: Boolean, content: @Composable NucleusApplicationScope.() -> Unit, ) { // macOS deep links arrive through Tao's `application:openURLs:` delegate // (forwarded by the native event loop to `TaoDeepLinkBridge`). The user's // callback is wired later from `TaoNucleusApplicationScope.onDeepLink { … }`; // URIs received before then are buffered and replayed by `TaoDeepLinkBridge`. - taoApplication { + taoApplication(exitProcessOnExit = exitProcessOnExit) { val scope = TaoNucleusApplicationScope(this, args) // Provide before other locals so Tao's per-window outerLocals bridge // carries LocalSystemTheme into each scene (see TaoDecoratedWindowAdapter). ProvideNucleusSystemTheme { CompositionLocalProvider( - LocalNucleusBackend provides NucleusBackend.Tao, LocalNucleusApplicationScope provides scope, LocalNucleusWindowHost provides DefaultNucleusWindowHost, LocalNucleusDialogHost provides DefaultNucleusDialogHost, diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWindowAdapter.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWindowAdapter.kt new file mode 100644 index 000000000..9b622da2e --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWindowAdapter.kt @@ -0,0 +1,130 @@ +package dev.nucleusframework.application.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalContext +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.currentCompositionLocalContext +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.LayoutDirection +import dev.nucleusframework.application.LocalNucleusWindow +import dev.nucleusframework.application.NucleusDecoratedWindowScope +import dev.nucleusframework.application.NucleusWindow +import dev.nucleusframework.application.TaoNucleusApplicationScope +import dev.nucleusframework.application.TaoNucleusWindow +import dev.nucleusframework.application.contextmenu.NativeContextMenuProvider +import dev.nucleusframework.window.LocalTitleBarInfo +import dev.nucleusframework.window.tao.LocalTaoCompositionLocalContextBridge +import dev.nucleusframework.window.tao.LocalTaoWindow +import dev.nucleusframework.window.tao.SatelliteWindowState +import dev.nucleusframework.window.tao.TaoDecoratedWindowScope +import dev.nucleusframework.window.tao.render.LocalTaoTextSelectionA11yPublisher +import dev.nucleusframework.window.tao.render.TaoTextSelectionAccessibility +import dev.nucleusframework.window.tao.SatelliteWindow as TaoSatelliteWindow + +/** + * Isolates references to Tao symbols for the satellite archetype. Mirrors + * [TaoDecoratedWindowAdapter] — a satellite *is* a decorated window as far as + * the content scope is concerned — minus the modal-count bookkeeping + * [TaoDecoratedDialogAdapter] does: a satellite is explicitly non-modal and + * must never scrim its parent. + */ +internal object TaoSatelliteWindowAdapter { + @Suppress("LongParameterList") + @Composable + fun Satellite( + scope: TaoNucleusApplicationScope, + onCloseRequest: () -> Unit, + parent: NucleusWindow?, + state: SatelliteWindowState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + focusable: Boolean, + hideWhileParentFullscreenOrMaximized: Boolean, + nativeContextMenu: Boolean, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, + ) { + // Every local (theme, density, user locals, …) has to cross the fresh + // ComposeScene the satellite gets — see TaoDecoratedWindowAdapter for + // why this is the scene's `compositionLocalContext` and not a wrapping + // CompositionLocalProvider. + val outerLocals = currentCompositionLocalContext + val parentLayoutDirection = LocalLayoutDirection.current + // Resolved here, in the parent's composition: the ambient Nucleus + // window is the satellite's owner unless the caller named another one. + val parentTaoWindow = parent?.unsafe?.taoWindow ?: LocalTaoWindow.current + + with(scope.taoScope) { + TaoSatelliteWindow( + onCloseRequest = onCloseRequest, + parent = parentTaoWindow, + state = state, + visible = visible, + title = title, + icon = icon, + resizable = resizable, + focusable = focusable, + hideWhileParentFullscreenOrMaximized = hideWhileParentFullscreenOrMaximized, + onPreviewKeyEvent = onPreviewKeyEvent, + onKeyEvent = onKeyEvent, + compositionLocalContext = outerLocals, + ) { + NucleusSatelliteScene(outerLocals, parentLayoutDirection, nativeContextMenu, content) + } + } + } + + /** + * The Nucleus locals of a satellite window's scene, composed around + * [content]: the bridged outer locals, this window as [LocalNucleusWindow], + * text-selection accessibility and the native context menu. Shared by the + * standalone [Satellite] and the workspace adapter's floating windows. + */ + @Composable + fun TaoDecoratedWindowScope.NucleusSatelliteScene( + outerLocals: CompositionLocalContext, + parentLayoutDirection: LayoutDirection, + nativeContextMenu: Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, + ) { + val taoScope: TaoDecoratedWindowScope = this + val decoratedState = remember(taoScope) { derivedStateOf { taoScope.state } } + val nucleusWindow: NucleusWindow = + remember(taoScope.window) { + TaoNucleusWindow(taoScope.window, decoratedState) + } + val nucleusScope = + remember(taoScope, nucleusWindow) { + TaoNucleusDecoratedWindowScope(taoScope, nucleusWindow) + } + val bridge = LocalTaoCompositionLocalContextBridge.current + SideEffect { bridge?.invoke(outerLocals) } + // Snapshot of this scene's own locals, re-provided below the + // bridged outer ones: without LocalTaoWindow bound to *this* + // window, windowDragArea() would drag the parent instead. + val scenePublisher = LocalTaoTextSelectionA11yPublisher.current + val sceneTaoWindow = LocalTaoWindow.current + val sceneTitleBarInfo = LocalTitleBarInfo.current + CompositionLocalProvider( + LocalLayoutDirection provides parentLayoutDirection, + LocalTaoTextSelectionA11yPublisher provides scenePublisher, + LocalNucleusWindow provides nucleusWindow, + LocalTaoWindow provides sceneTaoWindow, + LocalTitleBarInfo provides sceneTitleBarInfo, + ) { + TaoTextSelectionAccessibility { + NativeContextMenuProvider(enabled = nativeContextMenu) { + nucleusScope.content() + } + } + } + } +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWorkspaceAdapter.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWorkspaceAdapter.kt new file mode 100644 index 000000000..eef19bccb --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoSatelliteWorkspaceAdapter.kt @@ -0,0 +1,64 @@ +package dev.nucleusframework.application.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.currentCompositionLocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import dev.nucleusframework.application.TaoNucleusApplicationScope +import dev.nucleusframework.application.internal.TaoSatelliteWindowAdapter.NucleusSatelliteScene +import dev.nucleusframework.window.tao.DockSide +import dev.nucleusframework.window.tao.SatellitePlacement +import dev.nucleusframework.window.tao.SatelliteScope +import dev.nucleusframework.window.tao.SatelliteWorkspace +import dev.nucleusframework.window.tao.Satellite as TaoSatellite + +/** + * Workspace satellites on Tao: the tao `Satellite` composable, with the + * floating window's scene wrapped in the same Nucleus locals a standalone + * satellite window gets ([TaoSatelliteWindowAdapter]). Docked content composes + * inside the host window, where those locals already exist. + */ +internal object TaoSatelliteWorkspaceAdapter { + @Suppress("LongParameterList") + @Composable + fun Satellite( + scope: TaoNucleusApplicationScope, + workspace: SatelliteWorkspace, + id: String, + title: String, + initialPlacement: SatellitePlacement, + initiallyOpen: Boolean, + dockSides: Set, + floatable: Boolean, + reorderable: Boolean, + resizable: Boolean, + hideWhileOwnerFullscreenOrMaximized: Boolean, + nativeContextMenu: Boolean, + header: @Composable SatelliteScope.() -> Unit, + floatingCaption: @Composable SatelliteScope.() -> Unit, + content: @Composable SatelliteScope.() -> Unit, + ) { + val outerLocals = currentCompositionLocalContext + val parentLayoutDirection = LocalLayoutDirection.current + with(scope.taoScope) { + TaoSatellite( + workspace = workspace, + id = id, + title = title, + initialPlacement = initialPlacement, + initiallyOpen = initiallyOpen, + dockSides = dockSides, + floatable = floatable, + reorderable = reorderable, + resizable = resizable, + hideWhileOwnerFullscreenOrMaximized = hideWhileOwnerFullscreenOrMaximized, + compositionLocalContext = outerLocals, + floatingContentWrapper = { inner -> + NucleusSatelliteScene(outerLocals, parentLayoutDirection, nativeContextMenu) { inner() } + }, + header = header, + floatingCaption = floatingCaption, + content = content, + ) + } + } +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoTabWorkspaceAdapter.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoTabWorkspaceAdapter.kt new file mode 100644 index 000000000..39eb0abc5 --- /dev/null +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/internal/TaoTabWorkspaceAdapter.kt @@ -0,0 +1,96 @@ +// #636: `TabWindows` is a window opener — `@ComposableOpenTarget(-1)` with +// `@UiComposable` content lambdas. ktlint's `annotation` and +// `function-type-modifier-spacing` rules contradict each other on the +// resulting two-annotation parameter type. +@file:Suppress("ktlint:standard:annotation") + +package dev.nucleusframework.application.internal + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ComposableOpenTarget +import androidx.compose.runtime.currentCompositionLocalContext +import androidx.compose.ui.UiComposable +import androidx.compose.ui.platform.LocalLayoutDirection +import dev.nucleusframework.application.NucleusDecoratedWindowScope +import dev.nucleusframework.application.TaoNucleusApplicationScope +import dev.nucleusframework.window.tao.TabDragGhost +import dev.nucleusframework.window.tao.TabScope +import dev.nucleusframework.window.tao.TabStripScope +import dev.nucleusframework.window.tao.TabWorkspace +import dev.nucleusframework.window.tao.Tab as TaoTab +import dev.nucleusframework.window.tao.TabWindows as TaoTabWindows + +/** + * Isolates references to Tao symbols for the tab archetype: the tao + * `TabWindows` / `Tab` composables, with every window the workspace opens + * wrapped in the same Nucleus locals a [dev.nucleusframework.application.DecoratedWindow] + * gets ([bindNucleusContent]) — a tab window *is* a decorated window, the + * workspace simply decides when it exists. + */ +internal object TaoTabWorkspaceAdapter { + @Suppress("LongParameterList") + @Composable + @ComposableOpenTarget(-1) + fun TabWindows( + scope: TaoNucleusApplicationScope, + workspace: TabWorkspace, + strip: @Composable @UiComposable TabStripScope.() -> Unit, + titleBar: @Composable @UiComposable NucleusDecoratedWindowScope.(strip: @Composable () -> Unit) -> Unit, + dragGhost: @Composable @UiComposable NucleusDecoratedWindowScope.(TabDragGhost) -> Unit, + nativeContextMenu: Boolean, + windowWrapper: @Composable @UiComposable NucleusDecoratedWindowScope.(content: @Composable () -> Unit) -> Unit, + windowBodyWrapper: @Composable @UiComposable NucleusDecoratedWindowScope.(body: @Composable () -> Unit) -> Unit, + onLastWindowClosed: () -> Unit, + ) { + // Each window the workspace opens gets a fresh ComposeScene — see + // TaoDecoratedWindowAdapter for why the locals cross it as the scene's + // own `compositionLocalContext` and not as a wrapping provider. + val outerLocals = currentCompositionLocalContext + val parentLayoutDirection = LocalLayoutDirection.current + with(scope.taoScope) { + TaoTabWindows( + workspace = workspace, + compositionLocalContext = outerLocals, + strip = strip, + // Inside the window's content, where bindNucleusContent has + // already run: only the Nucleus scope is needed as receiver. + titleBar = { tabStrip -> rememberNucleusScope().titleBar(tabStrip) }, + // The ghost is a window of its own: it gets the Nucleus locals + // a tab window gets, laid out in the direction of the strip the + // tab came from — not the app's `windowWrapper`, which dresses + // a window, background included. + dragGhost = { ghost -> + bindNucleusContent(outerLocals, ghost.layoutDirection, nativeContextMenu) { dragGhost(ghost) } + }, + windowContentWrapper = { inner -> + bindNucleusContent(outerLocals, parentLayoutDirection, nativeContextMenu) { + windowWrapper(inner) + } + }, + // Inside the window's own scene, where `bindNucleusContent` + // has already provided the Nucleus locals: the wrapper is + // handed the same scope the content wrapper gets. + windowBodyWrapper = { body -> + bindNucleusContent(outerLocals, parentLayoutDirection, nativeContextMenu) { + windowBodyWrapper(body) + } + }, + onLastWindowClosed = onLastWindowClosed, + ) + } + } + + @Composable + fun Tab( + scope: TaoNucleusApplicationScope, + workspace: TabWorkspace, + id: String, + title: String, + group: String?, + content: @Composable TabScope.() -> Unit, + ) { + with(scope.taoScope) { + TaoTab(workspace = workspace, id = id, title = title, group = group, content = content) + } + } +} diff --git a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/spellcheck/SpellcheckContextMenu.kt b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/spellcheck/SpellcheckContextMenu.kt index 5a8d42a7a..38243b24c 100644 --- a/nucleus-application/src/main/kotlin/dev/nucleusframework/application/spellcheck/SpellcheckContextMenu.kt +++ b/nucleus-application/src/main/kotlin/dev/nucleusframework/application/spellcheck/SpellcheckContextMenu.kt @@ -27,12 +27,12 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.layout.onLayoutRectChanged import androidx.compose.ui.platform.InterceptPlatformTextInput import androidx.compose.ui.platform.PlatformTextInputInterceptor import androidx.compose.ui.platform.PlatformTextInputMethodRequest import androidx.compose.ui.text.TextRange +import androidx.compose.ui.unit.toOffset import dev.nucleusframework.application.contextmenu.LocalContextMenuDivider import dev.nucleusframework.spellcheck.SpellChecker import dev.nucleusframework.spellcheck.SpellcheckMenuModel @@ -263,8 +263,10 @@ private fun SpellcheckImeUnderlineBox( val latestClick = rememberUpdatedState(onSecondaryClickInRoot) Box( Modifier - .onGloballyPositioned { boxOriginInRoot = it.positionInRoot() } - .detectSecondaryClickInRoot( + // First in the chain, so the layout node's rect is this box's (#560). + .onLayoutRectChanged(throttleMillis = 0, debounceMillis = 0) { + boxOriginInRoot = it.positionInRoot.toOffset() + }.detectSecondaryClickInRoot( originInRoot = { boxOriginInRoot }, onClick = { latestClick.value(it) }, ).drawWithContent { diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/ComposableTargetIsolationFixture.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/ComposableTargetIsolationFixture.kt index 28d4a27e3..01ffcb977 100644 --- a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/ComposableTargetIsolationFixture.kt +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/ComposableTargetIsolationFixture.kt @@ -37,7 +37,7 @@ private fun InferredWrapper(content: @Composable () -> Unit) { @Suppress("UnusedPrivateMember") private fun windowsStayUiRegardlessOfTheScopeApplier() { - nucleusApplication(enableSingleInstance = false) { + nucleusApplication(enableSingleInstance = false, exitProcessOnExit = false) { // Binds the application scope's applier to a non-UI one. rememberNonUiTargetedState() diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/FileKitDialogsTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/FileKitDialogsTest.kt new file mode 100644 index 000000000..87a3229be --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/FileKitDialogsTest.kt @@ -0,0 +1,57 @@ +package dev.nucleusframework.application + +import io.github.vinceglb.filekit.dialogs.FileKitDialogParent +import io.github.vinceglb.filekit.dialogs.FileKitDialogSettings +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class FileKitDialogsTest { + private val windowParent = FileKitDialogParent.windows(0x42) + + @Test + fun `parents the settings and releases the lease after the dialog`() = + runBlocking { + var released = false + val settings = FileKitDialogSettings(title = "Open") + val seen = + withDialogParent(settings, { BorrowedDialogParent(windowParent) { released = true } }) { + assertFalse("lease released before the dialog finished", released) + it + } + assertSame(windowParent, seen.parent) + assertEquals("Open", seen.title) + assertTrue(released) + } + + @Test + fun `releases the lease when the dialog throws`() { + var released = false + runCatching { + runBlocking { + withDialogParent(FileKitDialogSettings(), { BorrowedDialogParent(windowParent) { released = true } }) { + error("picker failed") + } + } + } + assertTrue(released) + } + + @Test + fun `keeps a parent the caller already chose`() = + runBlocking { + val chosen = FileKitDialogSettings(parent = FileKitDialogParent.x11(7)) + val seen = withDialogParent(chosen, { error("must not resolve") }) { it } + assertSame(chosen, seen) + } + + @Test + fun `leaves the settings unparented without a platform identity`() = + runBlocking { + val settings = FileKitDialogSettings() + assertSame(settings, withDialogParent(settings, { null }) { it }) + } +} diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/LinuxColorSchemeToggle.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/LinuxColorSchemeToggle.kt index ea27c4874..8ed8eb7a0 100644 --- a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/LinuxColorSchemeToggle.kt +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/LinuxColorSchemeToggle.kt @@ -10,30 +10,40 @@ internal object LinuxColorSchemeToggle { private const val SCHEMA = "org.gnome.desktop.interface" private const val KEY = "color-scheme" + /** + * The live toggle is only observable where the XDG desktop portal answers + * on a session bus: the detector reads `org.freedesktop.portal.Settings`, + * not gsettings. Without it (CI runners), `gsettings set` lands in an + * in-memory backend nobody reads and the test could only time out. + */ val isAvailable: Boolean by lazy { System .getProperty("os.name") .orEmpty() .lowercase() .contains("linux") && - runCatching { - ProcessBuilder("gsettings", "get", SCHEMA, KEY) - .redirectErrorStream(true) - .start() - .waitFor(3, TimeUnit.SECONDS) - }.getOrDefault(false).let { started -> - // waitFor returns true if finished; check exit 0 - started && - runCatching { - val p = - ProcessBuilder("gsettings", "get", SCHEMA, KEY) - .redirectErrorStream(true) - .start() - p.waitFor(3, TimeUnit.SECONDS) && p.exitValue() == 0 - }.getOrDefault(false) - } + succeeds("gsettings", "get", SCHEMA, KEY) && + succeeds( + "gdbus", + "call", + "--session", + "--dest", + "org.freedesktop.portal.Desktop", + "--object-path", + "/org/freedesktop/portal/desktop", + "--method", + "org.freedesktop.portal.Settings.Read", + "org.freedesktop.appearance", + KEY, + ) } + private fun succeeds(vararg command: String): Boolean = + runCatching { + val p = ProcessBuilder(*command).redirectErrorStream(true).start() + p.waitFor(3, TimeUnit.SECONDS) && p.exitValue() == 0 + }.getOrDefault(false) + fun read(): String { val p = ProcessBuilder("gsettings", "get", SCHEMA, KEY) diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusApplicationScopeTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusApplicationScopeTest.kt index cb999d9c0..66a7177f6 100644 --- a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusApplicationScopeTest.kt +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusApplicationScopeTest.kt @@ -1,9 +1,9 @@ package dev.nucleusframework.application -import androidx.compose.ui.window.ApplicationScope import dev.nucleusframework.aot.runtime.AotRuntime import dev.nucleusframework.aot.runtime.AotRuntimeMode import dev.nucleusframework.core.runtime.DeepLinkHandler +import dev.nucleusframework.window.tao.TaoApplication import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertSame @@ -13,22 +13,22 @@ import java.net.URI import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.milliseconds +import dev.nucleusframework.window.tao.ApplicationScope as TaoApplicationScope class NucleusApplicationScopeTest { @Test - fun `awt scope reports Awt and delegates exit`() { - val compose = RecordingApplicationScope() - val scope = AwtNucleusApplicationScope(compose, arrayOf("--flag")) - assertEquals(NucleusBackend.Awt, scope.backend) - assertSame(compose, scope.composeScope) - assertFalse(compose.exited) + fun `scope wraps the tao scope and delegates exit`() { + val tao = RecordingApplicationScope() + val scope = TaoNucleusApplicationScope(tao, arrayOf("--flag")) + assertSame(tao, scope.taoScope) + assertFalse(tao.exited) scope.exitApplication() - assertTrue(compose.exited) + assertTrue(tao.exited) } @Test fun `aot flags follow the nucleus aot mode property`() { - val scope = AwtNucleusApplicationScope(RecordingApplicationScope(), emptyArray()) + val scope = TaoNucleusApplicationScope(RecordingApplicationScope(), emptyArray()) val key = "nucleus.aot.mode" val previous = System.getProperty(key) try { @@ -53,7 +53,7 @@ class NucleusApplicationScopeTest { @Test fun `onDeepLink registers a handler that receives delivered URIs`() { - val scope = AwtNucleusApplicationScope(RecordingApplicationScope(), emptyArray()) + val scope = TaoNucleusApplicationScope(RecordingApplicationScope(), emptyArray()) val received = mutableListOf() scope.onDeepLink { received.add(it) } val uri = URI("nucleus-test://scope/${System.nanoTime()}") @@ -77,7 +77,7 @@ class NucleusApplicationScopeTest { val key = "nucleus.aot.mode" val previous = System.getProperty(key) val compose = RecordingApplicationScope() - val scope = AwtNucleusApplicationScope(compose, emptyArray()) + val scope = TaoNucleusApplicationScope(compose, emptyArray()) try { System.setProperty(key, "off") var timedOut = false @@ -94,7 +94,7 @@ class NucleusApplicationScopeTest { fun `aotTraining arms once and invokes onTimeout in training mode`() { val key = "nucleus.aot.mode" val previous = System.getProperty(key) - val scope = AwtNucleusApplicationScope(RecordingApplicationScope(), emptyArray()) + val scope = TaoNucleusApplicationScope(RecordingApplicationScope(), emptyArray()) val first = CountDownLatch(1) val second = CountDownLatch(1) try { @@ -120,11 +120,16 @@ class NucleusApplicationScopeTest { } } - private class RecordingApplicationScope : ApplicationScope { + private class RecordingApplicationScope : TaoApplicationScope { var exited: Boolean = false override fun exitApplication() { exited = true } + + // Never read by the scope itself — only by app code reaching for the + // native application handle. + override val taoApplication: TaoApplication + get() = error("TaoApplication is not available in unit tests") } } diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusBackendTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusBackendTest.kt deleted file mode 100644 index daa4fd53e..000000000 --- a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusBackendTest.kt +++ /dev/null @@ -1,45 +0,0 @@ -package dev.nucleusframework.application - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class NucleusBackendTest { - @Test - fun `explicit backends are returned as-is`() { - assertEquals(NucleusBackend.Awt, resolveBackend(NucleusBackend.Awt)) - assertEquals(NucleusBackend.Tao, resolveBackend(NucleusBackend.Tao)) - } - - @Test - fun `auto prefers Tao when TaoApplication is on the classpath`() { - val taoPresent = taoBackendOnClasspath() - val resolved = resolveBackend(NucleusBackend.Auto) - assertEquals( - if (taoPresent) NucleusBackend.Tao else NucleusBackend.Awt, - resolved, - ) - assertTrue(resolved == NucleusBackend.Tao || resolved == NucleusBackend.Awt) - assertTrue(resolved != NucleusBackend.Auto) - } - - @Test - fun `enum lists every supported selector`() { - assertEquals( - setOf(NucleusBackend.Auto, NucleusBackend.Awt, NucleusBackend.Tao), - NucleusBackend.entries.toSet(), - ) - } - - private fun taoBackendOnClasspath(): Boolean = - try { - Class.forName( - "dev.nucleusframework.window.tao.TaoApplication", - false, - NucleusBackend::class.java.classLoader, - ) - true - } catch (_: ClassNotFoundException) { - false - } -} diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusWindowHostTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusWindowHostTest.kt index c33cb7b1c..9233ffdad 100644 --- a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusWindowHostTest.kt +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/NucleusWindowHostTest.kt @@ -1,7 +1,10 @@ +@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class) + package dev.nucleusframework.application import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.test.ExperimentalTestApi @@ -13,8 +16,10 @@ import androidx.compose.ui.window.WindowState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Test +import dev.nucleusframework.window.tao.v2.WindowState as WindowStateV2 @OptIn(ExperimentalTestApi::class) class NucleusWindowHostTest { @@ -63,6 +68,8 @@ class NucleusWindowHostTest { title = "Editor", visible = false, resizable = false, + minimizable = false, + maximizable = false, alwaysOnTop = true, undecorated = true, nativePopupLayers = true, @@ -84,6 +91,8 @@ class NucleusWindowHostTest { assertEquals("Editor", windowHost.title) assertFalse(windowHost.visible) assertFalse(windowHost.resizable) + assertFalse(windowHost.minimizable) + assertFalse(windowHost.maximizable) assertTrue(windowHost.alwaysOnTop) assertTrue(windowHost.undecorated) assertTrue(windowHost.nativePopupLayers) @@ -103,10 +112,56 @@ class NucleusWindowHostTest { assertTrue(dialogHost.closed) } + @Test + fun `hosted window v2 forwards the v2 clone state to the ambient host`() = + runComposeUiTest { + val windowHost = RecordingWindowHost() + val v2State = WindowStateV2() + setContent { + CompositionLocalProvider(LocalNucleusWindowHost provides windowHost) { + HostedWindow( + onCloseRequest = windowHost::close, + state = v2State, + title = "V2", + minSize = DpSize(320.dp, 240.dp), + maxSize = DpSize(1600.dp, 900.dp), + ) {} + } + } + waitForIdle() + assertSame(v2State, windowHost.v2State) + assertEquals("V2", windowHost.title) + assertEquals(DpSize(320.dp, 240.dp), windowHost.minSize) + assertEquals(DpSize(1600.dp, 900.dp), windowHost.maxSize) + } + + @Test + fun `hosted window v2 falls back to the v1 host surface when v2 is not overridden`() = + runComposeUiTest { + val windowHost = V1OnlyWindowHost() + val v2State = WindowStateV2() + setContent { + CompositionLocalProvider(LocalNucleusWindowHost provides windowHost) { + HostedWindow( + onCloseRequest = {}, + state = v2State, + title = "V2-fallback", + minSize = DpSize(320.dp, 240.dp), + ) {} + } + } + waitForIdle() + assertTrue(windowHost.hitV1) + assertEquals("V2-fallback", windowHost.title) + assertEquals(DpSize(320.dp, 240.dp), windowHost.minimumSize) + } + private class RecordingWindowHost : NucleusWindowHost { var title: String? = null var visible: Boolean = true var resizable: Boolean = true + var minimizable: Boolean = true + var maximizable: Boolean = true var alwaysOnTop: Boolean = false var undecorated: Boolean = false var nativePopupLayers: Boolean = false @@ -114,6 +169,9 @@ class NucleusWindowHostTest { var hiddenFromDock: Boolean = false var alwaysOnBottom: Boolean = false var minimumSize: DpSize? = null + var minSize: DpSize? = null + var maxSize: DpSize? = null + var v2State: WindowStateV2? = null var popupFor: NucleusWindow? = null lateinit var onCloseRequest: () -> Unit var closed: Boolean = false @@ -130,6 +188,8 @@ class NucleusWindowHostTest { title: String, icon: Painter?, resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, enabled: Boolean, focusable: Boolean, alwaysOnTop: Boolean, @@ -148,6 +208,8 @@ class NucleusWindowHostTest { this.title = title this.visible = visible this.resizable = resizable + this.minimizable = minimizable + this.maximizable = maximizable this.alwaysOnTop = alwaysOnTop this.undecorated = undecorated this.popupFor = popupFor @@ -157,6 +219,73 @@ class NucleusWindowHostTest { this.minimumSize = minimumSize this.alwaysOnBottom = alwaysOnBottom } + + @Composable + override fun Window( + onCloseRequest: () -> Unit, + state: WindowStateV2, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, + enabled: Boolean, + focusable: Boolean, + alwaysOnTop: Boolean, + undecorated: Boolean, + popupFor: NucleusWindow?, + nativePopupLayers: Boolean, + nativeContextMenu: Boolean, + hiddenFromDock: Boolean, + minSize: DpSize, + maxSize: DpSize, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + alwaysOnBottom: Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, + ) { + this.onCloseRequest = onCloseRequest + this.v2State = state + this.title = title + this.minSize = minSize + this.maxSize = maxSize + } + } + + private class V1OnlyWindowHost : NucleusWindowHost { + var hitV1: Boolean = false + var title: String? = null + var minimumSize: DpSize? = null + + @Composable + override fun Window( + onCloseRequest: () -> Unit, + state: WindowState, + visible: Boolean, + title: String, + icon: Painter?, + resizable: Boolean, + minimizable: Boolean, + maximizable: Boolean, + enabled: Boolean, + focusable: Boolean, + alwaysOnTop: Boolean, + undecorated: Boolean, + popupFor: NucleusWindow?, + nativePopupLayers: Boolean, + nativeContextMenu: Boolean, + hiddenFromDock: Boolean, + minimumSize: DpSize?, + onPreviewKeyEvent: (KeyEvent) -> Boolean, + onKeyEvent: (KeyEvent) -> Boolean, + alwaysOnBottom: Boolean, + content: @Composable NucleusDecoratedWindowScope.() -> Unit, + ) { + hitV1 = true + this.title = title + this.minimumSize = minimumSize + } } private class RecordingDialogHost : NucleusDialogHost { diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuE2EMain.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuE2EMain.kt new file mode 100644 index 000000000..f9fd243e5 --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/contextmenu/ContextMenuE2EMain.kt @@ -0,0 +1,159 @@ +@file:OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) + +package dev.nucleusframework.application.contextmenu + +import androidx.compose.foundation.ContextMenuArea +import androidx.compose.foundation.ContextMenuItem +import androidx.compose.foundation.ContextMenuState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isPrimaryPressed +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.rememberWindowState +import dev.nucleusframework.application.DecoratedWindow +import dev.nucleusframework.application.nucleusApplication +import java.util.logging.Handler +import java.util.logging.Level +import java.util.logging.LogRecord +import java.util.logging.Logger + +/** + * The loggers whose trace [main] forwards, held for the process's lifetime: + * `java.util.logging` keeps only a weak reference to a logger, so a collected + * one silently loses the configuration installed on it. + */ +private var tracedLoggers: List = emptyList() + +/** + * Process-level fixture for the compositor-driven context menu E2E + * (`scripts/context-menu-wayland-e2e.py`): one window painted a flat green, + * whose whole content is a [ContextMenuArea] using the OS-looking menu + * (`nativeContextMenu = true`, popups otherwise in-scene). Everything the + * driver needs to correlate with its screenshots goes to stdout, timestamped + * in milliseconds since start: pointer presses and releases as the scene sees + * them, every context menu status change, window focus flips, item clicks. + * + * Environment: `NUCLEUS_E2E_WINDOW_W` / `NUCLEUS_E2E_WINDOW_H` (dp, default + * 900×600). + */ +fun main(args: Array) { + val startNanos = System.nanoTime() + + fun log(message: String) { + val ms = (System.nanoTime() - startNanos) / 1_000_000 + println("[e2e $ms] $message") + System.out.flush() + } + // The popup layer's FINE trace, on the same clock as the lines above. + tracedLoggers = + listOf("dev.nucleusframework.window.tao.popup", "dev.nucleusframework.window.tao.scene").map { name -> + Logger.getLogger(name).apply { + level = Level.FINE + useParentHandlers = false + addHandler( + object : Handler() { + override fun publish(record: LogRecord) = log("LOG ${record.message}") + + override fun flush() = Unit + + override fun close() = Unit + }.apply { level = Level.ALL }, + ) + } + } + val width = System.getenv("NUCLEUS_E2E_WINDOW_W")?.toIntOrNull() ?: 900 + val height = System.getenv("NUCLEUS_E2E_WINDOW_H")?.toIntOrNull() ?: 600 + nucleusApplication(args, enableSingleInstance = false) { + DecoratedWindow( + onCloseRequest = ::exitApplication, + state = rememberWindowState(size = DpSize(width.dp, height.dp)), + title = "context-menu-e2e", + // NUCLEUS_E2E_NATIVE_CONTEXT_MENU=0 is the control: Compose's own + // in-scene menu, so a symptom can be attributed to the native + // surface or to Compose itself. + nativeContextMenu = System.getenv("NUCLEUS_E2E_NATIVE_CONTEXT_MENU") != "0", + ) { + val state = remember { ContextMenuState() } + val windowInfo = LocalWindowInfo.current + LaunchedEffect(Unit) { log("window content composed") } + LaunchedEffect(state) { + snapshotFlow { state.status }.collect { status -> + when (status) { + is ContextMenuState.Status.Open -> log("menu OPEN at ${status.rect.center}") + else -> log("menu CLOSED") + } + } + } + LaunchedEffect(windowInfo) { + snapshotFlow { windowInfo.isWindowFocused }.collect { log("window focused=$it") } + } + Box( + Modifier + .fillMaxSize() + .background(Color(0xFF00FF00)) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(PointerEventPass.Initial) + if (event.type == PointerEventType.Press || event.type == PointerEventType.Release) { + val change = event.changes.first() + log( + "pointer ${event.type} at ${change.position} " + + "secondary=${event.buttons.isSecondaryPressed} " + + "primary=${event.buttons.isPrimaryPressed}", + ) + } + } + } + }, + ) { + ContextMenuArea( + items = { + listOf( + ContextMenuItem("Alpha") { log("item Alpha") }, + ContextMenuItem("Bravo") { log("item Bravo") }, + ContextMenuItem("Charlie") { log("item Charlie") }, + ContextMenuItem("Delta") { log("item Delta") }, + ) + }, + state = state, + ) { + Box(Modifier.fillMaxSize()) + } + // Text context menu path (NativeTextContextMenu): a field in the + // top-left corner, 20..420 × 20..60 dp. + var text by remember { mutableStateOf(TextFieldValue("right click in this field")) } + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = + Modifier + .offset(20.dp, 20.dp) + .size(400.dp, 40.dp) + .background(Color.White) + .padding(8.dp), + ) + } + } + } +} diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/filekit/FileKitE2EApp.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/filekit/FileKitE2EApp.kt new file mode 100644 index 000000000..c1f1f5e41 --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/filekit/FileKitE2EApp.kt @@ -0,0 +1,69 @@ +package dev.nucleusframework.application.filekit + +import androidx.compose.runtime.LaunchedEffect +import dev.nucleusframework.application.nucleusApplication +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.exceptions.FileKitNotInitializedException +import io.github.vinceglb.filekit.filesDir +import io.github.vinceglb.filekit.path +import java.io.File + +/** + * Child process of [main] in `FileKitE2EMain.kt`: boots a real [nucleusApplication] for one + * scenario (`args[0]`), prints what FileKit resolved as `KEY=value` lines, then exits. + * + * `absent` runs on a classpath without FileKit, so it must never reach [FileKitProbe]: that + * object is the only place referencing FileKit. + */ +fun main(args: Array) { + val scenario = args.single() + println("scenario=$scenario") + when (scenario) { + "preInitAppId" -> FileKitProbe.initAppId("user-chosen-id") + "preInitDirs" -> FileKitProbe.initDirs(File(System.getProperty("fileKitE2E.customDir"))) + } + + nucleusApplication( + enableSingleInstance = false, + exitProcessOnExit = true, + initializeFileKit = scenario != "optOut", + ) { + LaunchedEffect(Unit) { + if (scenario == "absent") { + val onClasspath = + Thread + .currentThread() + .contextClassLoader + .getResource("io/github/vinceglb/filekit/FileKit.class") != null + println("fileKitOnClasspath=$onClasspath") + } else { + FileKitProbe.report() + if (scenario == "initInContent") { + FileKitProbe.initAppId("content-id") + println("afterContentInit:") + FileKitProbe.report() + } + } + println("booted=true") + exitApplication() + } + } +} + +private object FileKitProbe { + fun initAppId(appId: String) = FileKit.init(appId = appId) + + fun initDirs(root: File) = FileKit.init(filesDir = File(root, "files"), cacheDir = File(root, "cache")) + + fun report() { + println("appId=${orUnset { FileKit.appId }}") + println("filesDir=${orUnset { File(FileKit.filesDir.path).canonicalPath }}") + } + + private inline fun orUnset(value: () -> String): String = + try { + value() + } catch (_: FileKitNotInitializedException) { + "" + } +} diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/filekit/FileKitE2EMain.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/filekit/FileKitE2EMain.kt new file mode 100644 index 000000000..83642497a --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/filekit/FileKitE2EMain.kt @@ -0,0 +1,115 @@ +package dev.nucleusframework.application.filekit + +import java.io.File +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import kotlin.system.exitProcess + +/** + * Process-level E2E for FileKit auto-initialization in `nucleusApplication`: every scenario is + * a fresh JVM running [FileKitE2EApp][dev.nucleusframework.application.filekit.main] — FileKit is + * a process-wide singleton, and the `absent` scenario needs a classpath without it. + * + * The children run with `nucleus.app.id = "My App"` (a space, passed through untouched) and with + * `APPDATA` / `HOME` / `XDG_DATA_HOME` pointed at a scratch directory, so FileKit never touches the + * real user profile. + * + * Run: `./gradlew :nucleus-application:fileKitE2E` + */ +fun main() { + val fullClasspath = System.getProperty("fileKitE2E.classpath") + val noFileKitClasspath = System.getProperty("fileKitE2E.classpathWithoutFileKit") + val scratch = Files.createTempDirectory("filekit-e2e").toFile().canonicalFile + val dataHome = File(scratch, "data").apply { mkdirs() } + val customDir = File(scratch, "custom") + val expectedAppId = "My App" + val expectedDefaultDir = expectedFilesDir(dataHome, expectedAppId).path + + val failures = mutableListOf() + + fun scenario( + name: String, + classpath: String, + vararg expected: Pair, + ) { + val output = runChild(name, classpath, dataHome, customDir) + val missing = expected.filter { (key, value) -> "$key=$value" !in output.lines } + val ok = output.exitCode == 0 && "booted=true" in output.lines && missing.isEmpty() + println("[${if (ok) "PASS" else "FAIL"}] $name") + if (!ok) { + failures += name + println(" exit=${output.exitCode} missing=${missing.map { "${it.first}=${it.second}" }}") + output.lines.forEach { println(" | $it") } + } + } + + scenario("absent", noFileKitClasspath, "fileKitOnClasspath" to "false") + scenario("uninitialized", fullClasspath, "appId" to expectedAppId, "filesDir" to expectedDefaultDir) + scenario("optOut", fullClasspath, "appId" to "", "filesDir" to "") + scenario("preInitAppId", fullClasspath, "appId" to "user-chosen-id") + scenario( + "preInitDirs", + fullClasspath, + "appId" to "", + "filesDir" to File(customDir, "files").canonicalPath, + ) + scenario( + "initInContent", + fullClasspath, + "appId" to expectedAppId, + "appId" to "content-id", + ) + + scratch.deleteRecursively() + println(if (failures.isEmpty()) "RESULT=PASS" else "RESULT=FAIL $failures") + exitProcess(if (failures.isEmpty()) 0 else 1) +} + +private class ChildOutput( + val exitCode: Int, + val lines: List, +) + +private fun runChild( + scenario: String, + classpath: String, + dataHome: File, + customDir: File, +): ChildOutput { + val java = + ProcessHandle + .current() + .info() + .command() + .get() + val process = + ProcessBuilder( + java, + "-cp", + classpath, + "-Dnucleus.app.id=My App", + "-DfileKitE2E.customDir=${customDir.path}", + "dev.nucleusframework.application.filekit.FileKitE2EAppKt", + scenario, + ).redirectErrorStream(true) + .apply { + environment()["APPDATA"] = dataHome.path + environment()["HOME"] = dataHome.path + environment()["XDG_DATA_HOME"] = dataHome.path + }.start() + val lines = process.inputStream.bufferedReader().readLines() + if (!process.waitFor(2, TimeUnit.MINUTES)) process.destroyForcibly() + return ChildOutput(process.exitValue(), lines) +} + +/** Where FileKit's JVM `filesDir` lands for [appId] with the redirected environment. */ +private fun expectedFilesDir( + dataHome: File, + appId: String, +): File { + val os = System.getProperty("os.name").lowercase() + return when { + "mac" in os -> File(dataHome, "Library/Application Support/$appId") + else -> File(dataHome, appId) // Windows: %APPDATA%\appId; Linux: $XDG_DATA_HOME/appId + }.canonicalFile +} diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/internal/FileKitIntegrationTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/internal/FileKitIntegrationTest.kt new file mode 100644 index 000000000..bb98af230 --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/internal/FileKitIntegrationTest.kt @@ -0,0 +1,32 @@ +package dev.nucleusframework.application.internal + +import dev.nucleusframework.core.runtime.NucleusApp +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.filesDir +import io.github.vinceglb.filekit.path +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class FileKitIntegrationTest { + // FileKit is a process-wide singleton with no reset, so the uninitialized case must come + // first and the whole sequence lives in one test. + @Test + fun `initializes FileKit only when the app has not`() { + initializeFileKitIfPresent() + assertEquals(NucleusApp.appId, FileKit.appId) + + val custom = Files.createTempDirectory("filekit-integration").toFile() + val filesDir = File(custom, "files") + FileKit.init(filesDir = filesDir, cacheDir = File(custom, "cache")) + initializeFileKitIfPresent() + assertEquals(filesDir.path, FileKit.filesDir.path) + + FileKit.init(appId = "app-chosen-id") + initializeFileKitIfPresent() + assertEquals("app-chosen-id", FileKit.appId) + + custom.deleteRecursively() + } +} diff --git a/nucleus-application/src/test/kotlin/dev/nucleusframework/application/internal/IdleGcControllerTest.kt b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/internal/IdleGcControllerTest.kt new file mode 100644 index 000000000..bf25c9f32 --- /dev/null +++ b/nucleus-application/src/test/kotlin/dev/nucleusframework/application/internal/IdleGcControllerTest.kt @@ -0,0 +1,115 @@ +package dev.nucleusframework.application.internal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class IdleGcControllerTest { + @Test + fun `registering an unfocused window does not schedule gc`() { + val c = IdleGcController() + c.register("w", focused = false, minimized = false) + assertEquals(IdleGcCommand.NoChange, c.update("w", focused = false, minimized = false)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `unfocus schedules a deferred collection`() { + val c = IdleGcController() + c.register("w", focused = true, minimized = false) + assertEquals(IdleGcCommand.Debounce, c.update("w", focused = false, minimized = false)) + assertTrue(c.shouldRunDeferredGc()) + } + + @Test + fun `refocus before the delay cancels deferred collection`() { + val c = IdleGcController() + c.register("w", focused = true, minimized = false) + assertEquals(IdleGcCommand.Debounce, c.update("w", focused = false, minimized = false)) + assertEquals(IdleGcCommand.Cancel, c.update("w", focused = true, minimized = false)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `minimize collects immediately`() { + val c = IdleGcController() + c.register("w", focused = true, minimized = false) + assertEquals(IdleGcCommand.CollectNow, c.update("w", focused = false, minimized = true)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `minimize of a still-focused window collects immediately`() { + val c = IdleGcController() + c.register("w", focused = true, minimized = false) + assertEquals(IdleGcCommand.CollectNow, c.update("w", focused = true, minimized = true)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `unfocus then minimize upgrades debounce to immediate`() { + val c = IdleGcController() + c.register("w", focused = true, minimized = false) + assertEquals(IdleGcCommand.Debounce, c.update("w", focused = false, minimized = false)) + assertEquals(IdleGcCommand.CollectNow, c.update("w", focused = false, minimized = true)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `second window still focused cancels idle gc`() { + val c = IdleGcController() + c.register("a", focused = true, minimized = false) + c.register("b", focused = false, minimized = false) + assertEquals(IdleGcCommand.Cancel, c.update("b", focused = true, minimized = false)) + assertEquals(IdleGcCommand.Cancel, c.update("a", focused = false, minimized = false)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `last focused window unfocusing schedules deferred collection`() { + val c = IdleGcController() + c.register("a", focused = true, minimized = false) + c.register("b", focused = false, minimized = false) + c.update("b", focused = true, minimized = false) + c.update("a", focused = false, minimized = false) + assertEquals(IdleGcCommand.Debounce, c.update("b", focused = false, minimized = false)) + assertTrue(c.shouldRunDeferredGc()) + } + + @Test + fun `minimize is skipped while another window is interacting`() { + val c = IdleGcController() + c.register("a", focused = true, minimized = false) + c.register("b", focused = false, minimized = false) + assertEquals(IdleGcCommand.Cancel, c.update("b", focused = false, minimized = true)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `dialog focus keeps the app interacting`() { + val c = IdleGcController() + c.register("window", focused = true, minimized = false) + c.register("dialog", focused = false, minimized = false) + c.update("window", focused = false, minimized = false) + assertEquals(IdleGcCommand.Cancel, c.update("dialog", focused = true, minimized = false)) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `unregistering the last window cancels pending collection`() { + val c = IdleGcController() + c.register("w", focused = true, minimized = false) + c.update("w", focused = false, minimized = false) + assertEquals(IdleGcCommand.Cancel, c.unregister("w")) + assertFalse(c.shouldRunDeferredGc()) + } + + @Test + fun `update after unregister is ignored`() { + val c = IdleGcController() + c.register("w", focused = true, minimized = false) + c.unregister("w") + assertEquals(IdleGcCommand.NoChange, c.update("w", focused = false, minimized = true)) + } +} diff --git a/plugin-build/gradle.properties b/plugin-build/gradle.properties index 5eab3053c..e6f9eae76 100644 --- a/plugin-build/gradle.properties +++ b/plugin-build/gradle.properties @@ -6,6 +6,9 @@ WEBSITE=https://github.com/NucleusFramework/Nucleus VCS_URL=https://github.com/NucleusFramework/Nucleus IMPLEMENTATION_CLASS=dev.nucleusframework.NucleusPlugin +# Same heap budget as the root build (see its gradle.properties): the included build gets +# its own daemon when it is invoked standalone. +org.gradle.jvmargs=-XX:MaxRAMPercentage=40 -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.configuration-cache=true org.gradle.caching=true diff --git a/plugin-build/gradle/wrapper/gradle-wrapper.jar b/plugin-build/gradle/wrapper/gradle-wrapper.jar index 1b33c55ba..5097068a8 100644 Binary files a/plugin-build/gradle/wrapper/gradle-wrapper.jar and b/plugin-build/gradle/wrapper/gradle-wrapper.jar differ diff --git a/plugin-build/gradle/wrapper/gradle-wrapper.properties b/plugin-build/gradle/wrapper/gradle-wrapper.properties index aaaabb3cb..0452e3cfc 100644 --- a/plugin-build/gradle/wrapper/gradle-wrapper.properties +++ b/plugin-build/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,9 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-rc-3-bin.zip networkTimeout=10000 +retries=0 +retryBackOffMs=500 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/plugin-build/gradlew b/plugin-build/gradlew index 23d15a936..249efbb03 100755 --- a/plugin-build/gradlew +++ b/plugin-build/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -172,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -212,7 +210,6 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" diff --git a/plugin-build/gradlew.bat b/plugin-build/gradlew.bat index db3a6ac20..3185a43f7 100644 --- a/plugin-build/gradlew.bat +++ b/plugin-build/gradlew.bat @@ -19,12 +19,39 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +@rem Catch executions from older scripts and ensure they exit cleanly. +@rem This can be removed once we can be reasonably confident that few people +@rem will be migrating directly to this new wrapper. +goto afterSafetyNet +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +:::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::: +goto exitWithErrorLevel +:afterSafetyNet set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @@ -45,13 +72,14 @@ set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% @@ -59,36 +87,26 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 +1>&2 echo. +1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +1>&2 echo. +1>&2 echo Please set the JAVA_HOME variable in your environment to match the +1>&2 echo location of your Java installation. -goto fail +"%COMSPEC%" /c exit 1 +goto exitWithErrorLevel :execute @rem Setup the command line -set CLASSPATH= -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem This label must not be changed. We rely on old scripts being able to jump to this point. +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/plugin-build/plugin/build.gradle.kts b/plugin-build/plugin/build.gradle.kts index a88a52671..d4e254aea 100644 --- a/plugin-build/plugin/build.gradle.kts +++ b/plugin-build/plugin/build.gradle.kts @@ -73,7 +73,7 @@ java { } // === Sandbox runtime shim jar + embedding === -val sandboxShimJar by tasks.registering(Jar::class) { +val sandboxShimJar = tasks.register("sandboxShimJar") { archiveFileName.set("nucleus-sandbox-shim.jar") // Nest under nucleus/sandbox/ so processResources places it at that path inside the // plugin JAR, resolvable via getResourceAsStream("/nucleus/sandbox/nucleus-sandbox-shim.jar"). diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmChannel.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmChannel.kt index 78b2b2000..305b2b18a 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmChannel.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmChannel.kt @@ -5,7 +5,7 @@ package dev.nucleusframework.desktop.application.dsl * (see [GraalvmToolchainSettings]). * * Both lines exist for either [GraalvmDistribution]: - * - **Innovation** releases (e.g. `25i3`) — newest compiler and runtime features, + * - **Innovation** releases (e.g. `25i4`) — newest compiler and runtime features, * short support window. Oracle GraalVM ships them via `gds.oracle.com`, Community * Edition under the `graal-*` tags of `graalvm/graalvm-ce-builds`. * - **LTS** releases (e.g. `25`) — long-term support line updated with quarterly @@ -16,7 +16,7 @@ enum class GraalvmChannel( val defaultVersion: String, ) { /** Latest innovation release. This is the default channel. */ - INNOVATION("25i3"), + INNOVATION("25i4"), /** Latest long-term-support release. */ LTS("25"), diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt index a08d3cb4a..13f8848a8 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/GraalvmSettings.kt @@ -100,8 +100,9 @@ abstract class GraalvmSettings // Garbage collector baked into the image (`--gc=`). Unlike the JVM, the collector is fixed // at build time. Leave unset to keep native-image's default (Serial GC, the right fit for a // desktop app's small heap). [NativeImageGarbageCollector.G1] is for heaps that outgrow it, - // and is Oracle GraalVM + Linux only — elsewhere it degrades to a warning instead of - // failing the build. [maxHeapSizePercent] follows the selected collector: it is baked as + // requires Oracle GraalVM — plus, outside Linux, GraalVM 25.4 or newer. An unsupported + // combination degrades to a warning instead of failing the build. + // [maxHeapSizePercent] follows the selected collector: it is baked as // `-R:MaximumHeapSizePercent` under Serial/Epsilon and as `-R:MaxRAMPercentage` under G1, // which does not know the former option. val garbageCollector: Property = objects.nullableProperty() @@ -202,7 +203,7 @@ abstract class GraalvmSettings * [distribution] still declares intent in that case, since it also gates the Oracle-only * tasks (`runWithPgoInstrument`). * - * "latest" versions ("25", "25i3") are sticky once downloaded; delete the corresponding + * "latest" versions ("25", "25i4") are sticky once downloaded; delete the corresponding * directory under [installDir] to pick up a newer build. */ abstract class GraalvmToolchainSettings @@ -227,7 +228,7 @@ abstract class GraalvmToolchainSettings /** * Explicit GraalVM version, overriding [channel]: an innovation release - * (`"25i3"`), a feature version tracking the latest CPU (`"25"`), or a pinned + * (`"25i4"`), a feature version tracking the latest CPU (`"25"`), or a pinned * patch release (`"25.0.1"`). */ val version: Property = objects.nullableProperty() diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplication.kt index bcbdd9040..5dfb09686 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplication.kt @@ -42,6 +42,35 @@ abstract class JvmApplication { */ abstract var garbageCollector: GarbageCollector? + /** + * Master switch for the desktop startup pack: Serial GC, compact heap + * (`-Xms32m`, `-XX:MaxRAMPercentage=25`), a single JAR in the jpackage + * image, idle GC (3s after last unfocus, immediately on minimize), and + * the current OpenJDK as the jpackage / jlink / `run` JDK (auto-downloaded, + * like the GraalVM toolchain). Idle GC also applies to GraalVM native images. + * + * `true` turns on every knob still unset in the [nucleusOptimization] + * configure block. An explicit [garbageCollector], [javaHome], or `-Xms` / + * `-XX:MaxRAMPercentage` in [jvmArgs] is left unchanged. + * + * Does not enable AOT; set [JvmApplicationDistributions.enableAotCache] + * separately. Does not change the Gradle compile JDK. + */ + abstract var nucleusOptimization: Boolean + + /** + * Per-knob overrides for [nucleusOptimization]. `null` follows the master + * boolean; `true` / `false` force that piece on or off. + * + * ``` + * nucleusOptimization = true + * nucleusOptimization { idleGc = false } + * + * nucleusOptimization { singleJar = true } + * ``` + */ + abstract fun nucleusOptimization(fn: Action) + abstract val nativeDistributions: JvmApplicationDistributions abstract fun nativeDistributions(fn: Action) diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplicationDistributions.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplicationDistributions.kt index bcc29ee3c..1acdf1354 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplicationDistributions.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplicationDistributions.kt @@ -32,6 +32,22 @@ abstract class JvmApplicationDistributions : AbstractDistributions() { var includeAllModules: Boolean = false + /** + * Omits the JRE's bundled fonts (`lib/fonts` from `java.desktop`) from the runtime image. + * + * Compose ships its own fonts, so the JDK copies are unused weight in the distributable. + * JetBrains Runtime bundles about 9 MB of them; many other JREs bundle none, and then this + * changes nothing. Set to `false` to keep the fonts, for an app that renders text through + * AWT or Swing. + * + * ```kotlin + * nativeDistributions { + * stripJreFonts = false + * } + * ``` + */ + var stripJreFonts: Boolean = true + /** Strip native libraries for non-target platforms from dependency JARs to reduce package size. */ var cleanupNativeLibs: Boolean = false @@ -58,11 +74,22 @@ abstract class JvmApplicationDistributions : AbstractDistributions() { } /** - * Whether any of the configured target formats require sandboxing - * (store formats like PKG, AppX, Flatpak) AND are compatible with the current OS. + * Whether [format] is built through the sandboxed (store) pipeline: AppX and Flatpak always + * are, PKG only when it targets the Mac App Store (`macOS { pkg { appStore } }`, the default). + * A Developer ID PKG shares the non-sandboxed pipeline with DMG. + */ + internal fun isSandboxed(format: TargetFormat): Boolean = + when (format) { + TargetFormat.Pkg -> macOS.pkg.appStore + else -> format.isAlwaysSandboxed + } + + /** + * Whether any of the configured target formats require sandboxing (see [isSandboxed]) + * AND are compatible with the current OS. */ internal val hasStoreFormats: Boolean - get() = targetFormats.any { it.isStoreFormat && it.isCompatibleWithCurrentOS } + get() = targetFormats.any { isSandboxed(it) && it.isCompatibleWithCurrentOS } val linux: LinuxPlatformSettings = objects.newInstance(LinuxPlatformSettings::class.java) @@ -121,6 +148,16 @@ abstract class JvmApplicationDistributions : AbstractDistributions() { fn.execute(publish) } + // --- Node.js used to run electron-builder --- + + /** Node.js acquisition for the electron-builder pipeline. See [NodeJsSettings]. */ + val nodejs: NodeJsSettings = objects.newInstance(NodeJsSettings::class.java) + + /** Configures [nodejs]. */ + fun nodejs(fn: Action) { + fn.execute(nodejs) + } + // --- Compression level for archive formats --- /** diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/MacAppExtensionSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/MacAppExtensionSettings.kt new file mode 100644 index 000000000..56aa7c89e --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/MacAppExtensionSettings.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2020-2022 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package dev.nucleusframework.desktop.application.dsl + +import org.gradle.api.Action +import java.io.File +import java.io.Serializable + +/** + * DSL block for embedding macOS app extensions (`.appex`) in the app bundle at + * `Contents/PlugIns/`. + * + * Nucleus copies each extension into the bundle and signs it with its OWN + * entitlements and provisioning profile, then seals the outer app without + * `--deep` so the extension keeps its distinct signature. This is what a macOS + * Network Extension needs (its own `com.apple.developer.networking.networkextension` + * entitlement, its own App Group, its own `embedded.provisionprofile`). + * + * Nucleus does not build the `.appex` — build it with Xcode or Kotlin/Native and + * point [MacAppExtension.appex] at the result. + * + * Caveats (from the Network Extension validation in #394): + * - Set `macOS { entitlementsFile }` to a plist **without** + * `com.apple.security.cs.allow-unsigned-executable-memory` and + * `com.apple.security.cs.disable-library-validation` (both are in Nucleus' default + * entitlements): a host app carrying them alongside a network extension has been + * reported not to launch. `com.apple.security.cs.allow-jit` is enough for the JVM. + * - The extension only exists inside a signed `.app`: `run` cannot exercise it. Use + * `runDistributable` (or `runReleaseDistributable`) for a dev loop with the extension. + * - Loading the extension at runtime needs an Apple-issued provisioning profile that + * grants `com.apple.developer.networking.networkextension`; ad-hoc builds only prove + * bundling and signing. + * + * ```kotlin + * macOS { + * appExtensions { + * extension("NetworkFilter") { + * appex(file("build/NetworkExtension/NetworkFilter.appex")) + * entitlements(file("packaging/networkextension.entitlements")) + * provisioningProfile(file("packaging/NetworkFilter.provisionprofile")) + * } + * } + * } + * ``` + */ +@Suppress("SerialVersionUIDInSerializableClass") // Gradle DSL bean, never deserialized across versions +class MacAppExtensionSettings : Serializable { + internal val extensions: MutableList = mutableListOf() + + /** + * Declares an app extension to embed. + * + * @param name identifier used for diagnostics only + */ + fun extension(name: String, fn: Action) { + val extension = MacAppExtension(name) + fn.execute(extension) + extensions.add(extension) + } +} + +/** + * A single macOS app extension (`.appex`) to embed under `Contents/PlugIns/`. + * + * The extension is signed with its own [entitlements] (and, when set, + * [provisioningProfile]), using the app's signing identity. The outer app is then + * re-sealed without `--deep` so the extension's signature is preserved. + */ +@Suppress("SerialVersionUIDInSerializableClass") // Gradle DSL bean, never deserialized across versions +class MacAppExtension( + /** Identifier used for diagnostics only. */ + val name: String, +) : Serializable { + internal var appex: File? = null + internal var entitlements: File? = null + internal var provisioningProfile: File? = null + + /** The prebuilt `.appex` bundle to embed. */ + fun appex(bundle: File) { + appex = bundle + } + + /** Entitlements plist applied to the extension (distinct from the app's). */ + fun entitlements(file: File) { + entitlements = file + } + + /** Provisioning profile embedded as `Contents/embedded.provisionprofile` inside the extension. */ + fun provisioningProfile(file: File) { + provisioningProfile = file + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollector.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollector.kt index 952841ed1..0ea3444ed 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollector.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollector.kt @@ -14,15 +14,15 @@ package dev.nucleusframework.desktop.application.dsl * Unlike the JVM, the collector is chosen at build time and cannot be switched at runtime. Leave * [GraalvmSettings.garbageCollector] unset to keep native-image's own default ([SERIAL]). * - * A collector unavailable on the resolved toolchain or platform ([isOracleOnly], [isLinuxOnly]) - * degrades to a warning and the Serial GC instead of failing the build, so the same repository - * still builds everywhere. + * A collector unavailable on the resolved toolchain or platform ([isOracleOnly], + * [nonLinuxMinVersion]) degrades to a warning and the Serial GC instead of failing the build, so + * the same repository still builds everywhere. */ enum class NativeImageGarbageCollector( internal val id: String, internal val maxHeapPercentOption: String, internal val isOracleOnly: Boolean = false, - internal val isLinuxOnly: Boolean = false, + internal val nonLinuxMinVersion: String? = null, ) { /** * `--gc=serial`: native-image's default. Single-threaded generational collector tuned for the @@ -35,10 +35,18 @@ enum class NativeImageGarbageCollector( * without visible pauses (roughly > 1–2 GB). Trades a larger image and a slower startup for * much shorter pauses under load. * - * Oracle GraalVM on Linux (AMD64/AArch64) only — GraalVM Community Edition, Liberica NIK and - * Mandrel reject `--gc=G1`, as do the macOS and Windows builds. + * Oracle GraalVM only: GraalVM Community Edition, Liberica NIK and Mandrel ship no G1 at all + * and fail the build with `Invalid option '--gc'. 'G1' is not an accepted value`. Linux + * (AMD64/AArch64) has it since 25.0; macOS and Windows only since **25.4** — 25.3 advertises + * `--gc=G1` and ships the header but not the static library, so the build dies at link time + * with `LNK1181: cannot open input file 'g1gc-cr.lib'`. */ - G1("G1", maxHeapPercentOption = "MaxRAMPercentage", isOracleOnly = true, isLinuxOnly = true), + G1( + "G1", + maxHeapPercentOption = "MaxRAMPercentage", + isOracleOnly = true, + nonLinuxMinVersion = "25.4", + ), /** * `--gc=epsilon`: allocates and never reclaims — the image dies with `OutOfMemoryError` once diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NodeJsSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NodeJsSettings.kt new file mode 100644 index 000000000..aebf325ab --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NodeJsSettings.kt @@ -0,0 +1,46 @@ +package dev.nucleusframework.desktop.application.dsl + +import dev.nucleusframework.internal.utils.notNullProperty +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.model.ObjectFactory +import org.gradle.api.provider.Property +import javax.inject.Inject + +/** + * Node.js acquisition for the electron-builder packaging pipeline. + * + * Every installer format goes through electron-builder, which the plugin provisions with + * `npm ci` against a lock file it embeds — so packaging needs a Node.js. By default the plugin + * downloads one from `nodejs.org` on first use and caches it under + * `/nucleus/nodejs`: nothing has to be installed on the build machine, and + * every machine packages with the same Node.js. + * + * Overrides, in order of precedence: + * 1. the `compose.electronBuilder.nodePath` Gradle property (the node binary, or its directory), + * 2. a `NUCLEUS_NODE_HOME` environment variable pointing at an installation, + * 3. this block, + * 4. `node` on `PATH`, used when [autoDownload] is `false` or the download fails. + * + * Floating versions are sticky once downloaded; delete the corresponding directory under + * [installDir] to pick up a newer release. + */ +abstract class NodeJsSettings + @Inject + constructor( + objects: ObjectFactory, + ) { + /** + * Download and cache Node.js automatically. Defaults to `true`; `false` falls back to the + * `node` and `npm` found on `PATH`. + */ + val autoDownload: Property = objects.notNullProperty(true) + + /** + * Node.js version: a major line tracking its newest release (`"22"`, the default), the + * newest LTS (`"lts"`), or a pinned release (`"22.11.0"`). + */ + val version: Property = objects.notNullProperty("22") + + /** Where downloaded Node.js installations are cached. Defaults to `/nucleus/nodejs`. */ + val installDir: DirectoryProperty = objects.directoryProperty() + } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NucleusOptimizationSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NucleusOptimizationSettings.kt new file mode 100644 index 000000000..485ce19c0 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/NucleusOptimizationSettings.kt @@ -0,0 +1,55 @@ +package dev.nucleusframework.desktop.application.dsl + +/** + * Per-knob overrides for [JvmApplication.nucleusOptimization]. + * + * `null` (the default) follows the master boolean. `true` / `false` force that + * knob on or off, independently of the master and of the other knobs. + * + * Does not cover AOT ([JvmApplicationDistributions.enableAotCache]) or ProGuard. + * + * ``` + * nucleus.application { + * nucleusOptimization = true + * nucleusOptimization { idleGc = false } + * } + * + * nucleus.application { + * nucleusOptimization { singleJar = true } + * } + * ``` + */ +abstract class NucleusOptimizationSettings { + /** + * Serial GC when [JvmApplication.garbageCollector] is unset. + * An explicit collector always wins. + */ + var serialGc: Boolean? = null + + /** + * `-Xms32m` and `-XX:MaxRAMPercentage=25`, unless already present in + * [JvmApplication.jvmArgs]. + */ + var compactHeap: Boolean? = null + + /** + * Flatten runtime JARs (or [ProguardSettings.joinOutputJars] when ProGuard + * is on) so the jpackage image contains a single JAR. + */ + var singleJar: Boolean? = null + + /** + * Request a GC 3s after the last window loses focus, or immediately when a + * window is minimized. + */ + var idleGc: Boolean? = null + + /** + * Package and run the app with the current OpenJDK feature release, + * auto-downloaded and cached under `/nucleus/jdk` like + * the GraalVM toolchain. Intel macs and Windows ARM get BellSoft Liberica + * JDK (Oracle dropped those ports). An explicit [JvmApplication.javaHome] always + * wins. Does not change the Gradle compile JDK. + */ + var lastJdk: Boolean? = null +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PkgSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PkgSettings.kt new file mode 100644 index 000000000..5c5c885f0 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PkgSettings.kt @@ -0,0 +1,66 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package dev.nucleusframework.desktop.application.dsl + +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.model.ObjectFactory +import javax.inject.Inject + +/** + * macOS PKG installer settings, scoped under `nativeDistributions { macOS { pkg { ... } } }`. + * + * A PKG is built for one of two distribution channels, selected by [appStore]: + * - **Mac App Store** (default): the app goes through the sandboxed pipeline (sandbox entitlements, + * "3rd Party Mac Developer" certificates, provisioning profile), the installer is re-signed with + * `productsign`, and nothing is notarized — the `.pkg` is uploaded with Transporter. + * - **Developer ID** (`appStore = false`): the app goes through the same non-sandboxed pipeline as + * DMG (Developer ID Application, hardened runtime), electron-builder signs the installer with the + * matching "Developer ID Installer" certificate, and `notarizePkg` notarizes and staples the + * `.pkg`. This is the channel for MDM deployment (Jamf, …) and manual installs outside the store, + * and the only one that accepts [preInstall] / [postInstall] scripts. + * + * ```kotlin + * macOS { + * pkg { + * appStore = false + * preInstall.set(file("packaging/macos/preinstall")) + * postInstall.set(file("packaging/macos/postinstall")) + * } + * } + * ``` + */ +@Suppress("AbstractClassCanBeConcreteClass") // Required abstract for Gradle ObjectFactory.newInstance() +abstract class PkgSettings { + @get:Inject + internal abstract val objects: ObjectFactory + + /** + * Whether the PKG targets the Mac App Store (`true`, the default) or direct Developer ID + * distribution (`false`). See the class documentation for what each channel changes. + */ + var appStore: Boolean = true + + /** + * Script the macOS Installer runs as root **before** the payload is copied. Staged as the + * package's top-level `preinstall` script (`pkgbuild --scripts`) whatever the source file is + * named; it must start with a shebang. Receives the standard Installer arguments: `$1` package + * path, `$2` install target, `$3` target volume, `$4` startup disk. + * + * It runs **once**. electron-builder declares install scripts both per bundle and at the top + * level, which makes Installer run them twice; Nucleus stages a small entry point that collapses + * that back to a single call, so the script does not have to be idempotent. + * + * Requires `appStore = false`: the Mac App Store rejects installer packages that carry install + * scripts (validation error 90254). + */ + val preInstall: RegularFileProperty = objects.fileProperty() + + /** Script the Installer runs as root **after** the payload is copied. Same rules as [preInstall]. */ + val postInstall: RegularFileProperty = objects.fileProperty() + + internal val hasScripts: Boolean + get() = preInstall.isPresent || postInstall.isPresent +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt index a94cc9957..10bd798db 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/PlatformSettings.kt @@ -93,18 +93,36 @@ abstract class JvmMacOSPlatformSettings : AbstractMacOSPlatformSettings() { var setDockNameSameAsPackageName: Boolean = true /** - * Previously used to enable App Store signing for PKG builds. + * PKG installer settings: distribution channel (Mac App Store or Developer ID) and install + * scripts. See [PkgSettings]. + */ + val pkg: PkgSettings = objects.newInstance(PkgSettings::class.java) + + /** Configures the PKG installer, see [PkgSettings]. */ + fun pkg(fn: Action) { + fn.execute(pkg) + } + + /** + * Whether a PKG targets the Mac App Store. Alias of `pkg { appStore = ... }`, see + * [PkgSettings.appStore]. * - * This property is now ignored — PKG is always treated as an App Store format. - * Store-specific signing (sandbox entitlements, "3rd Party Mac Developer" certificates, - * provisioning profiles, `productsign`) is applied automatically when the target format - * is [TargetFormat.Pkg]. + * Deprecated at ERROR level on purpose: this property used to be **ignored** and defaulted to + * `false`, so silently aliasing it would flip an existing `appStore = false` build from the Mac + * App Store to the Developer ID channel — a different pipeline, a different certificate and a + * different installer signature. Migrating is a one-line edit that has to be deliberate. */ @Deprecated( - "PKG is always built for the App Store. This property is ignored and will be removed in a future release.", - level = DeprecationLevel.WARNING, + "Use pkg { appStore = ... }. Note the meaning changed: this property was previously ignored " + + "(PKG was always App Store), so review which channel you want before migrating.", + ReplaceWith("pkg.appStore"), + level = DeprecationLevel.ERROR, ) - var appStore: Boolean = false + var appStore: Boolean + get() = pkg.appStore + set(value) { + pkg.appStore = value + } val entitlementsFile: RegularFileProperty = objects.fileProperty() val runtimeEntitlementsFile: RegularFileProperty = objects.fileProperty() var pkgPackageVersion: String? = null @@ -147,6 +165,29 @@ abstract class JvmMacOSPlatformSettings : AbstractMacOSPlatformSettings() { fn.execute(launchAgents) } + /** + * Configures macOS app extensions (`.appex`) to embed under `Contents/PlugIns/`, + * each signed with its own entitlements and provisioning profile. + * + * ```kotlin + * macOS { + * appExtensions { + * extension("NetworkFilter") { + * appex(file("build/NetworkExtension/NetworkFilter.appex")) + * entitlements(file("packaging/networkextension.entitlements")) + * provisioningProfile(file("packaging/NetworkFilter.provisionprofile")) + * } + * } + * } + * ``` + */ + val appExtensions: MacAppExtensionSettings = MacAppExtensionSettings() + + /** Configures [appExtensions]. See [MacAppExtensionSettings] for the caveats. */ + fun appExtensions(fn: Action) { + fn.execute(appExtensions) + } + internal val infoPlistSettings = InfoPlistSettings() fun infoPlist(fn: Action) { diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/ProguardSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/ProguardSettings.kt index 7d429075c..ee48c7cf1 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/ProguardSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/ProguardSettings.kt @@ -12,7 +12,7 @@ import org.gradle.api.model.ObjectFactory import org.gradle.api.provider.Property import javax.inject.Inject -private const val DEFAULT_PROGUARD_VERSION = "7.9.1" +private const val DEFAULT_PROGUARD_VERSION = "7.10.0" abstract class ProguardSettings @Inject diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/SandboxingSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/SandboxingSettings.kt index a35f58c15..0c72e381e 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/SandboxingSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/SandboxingSettings.kt @@ -8,9 +8,11 @@ package dev.nucleusframework.desktop.application.dsl /** * Sandboxed (store) distribution settings, scoped under `nativeDistributions { sandboxing { ... } }`. * - * Active only when at least one store target format is configured - * ([TargetFormat.Pkg], [TargetFormat.AppX], [TargetFormat.Flatpak]) and compatible with the - * current OS — the same trigger as the rest of the sandboxed pipeline. + * Active only when at least one store target format is configured and compatible with the current + * OS — the same trigger as the rest of the sandboxed pipeline. Those are [TargetFormat.AppX], + * [TargetFormat.Flatpak], and [TargetFormat.Pkg] **only when it targets the Mac App Store** + * (`macOS { pkg { appStore = true } }`, the default). A Developer ID PKG is built like a DMG, so + * nothing here applies to it. * * The sandboxed pipeline replaces native libs inside dependency JARs with markers and rewrites * `System.load(String)` / `Runtime.load(String)` call sites to a runtime shim that loads the @@ -36,4 +38,4 @@ abstract class SandboxingSettings { fun keepNativeLibsInJars(vararg substrings: String) { keepNativeLibsInJars.addAll(substrings.toList()) } -} \ No newline at end of file +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/TargetFormat.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/TargetFormat.kt index e1c907166..240238dd9 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/TargetFormat.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/dsl/TargetFormat.kt @@ -48,9 +48,25 @@ enum class TargetFormat( val isCompatibleWithCurrentOS: Boolean by lazy { isCompatibleWith(currentOS) } - /** Whether this format is a store format that requires sandboxing (App Store, Windows Store, Flatpak). */ + /** + * Whether this format is always built through the sandboxed (store) pipeline: AppX (Windows + * Store) and Flatpak. PKG is sandboxed only when it targets the Mac App Store, which is a DSL + * decision — see `JvmApplicationDistributions.isSandboxed`. + */ + internal val isAlwaysSandboxed: Boolean + get() = this == AppX || this == Flatpak + + /** + * Whether this format was always built through the sandboxed pipeline. PKG no longer is: it + * depends on `macOS { pkg { appStore } }`, which this property cannot see. + */ + @Deprecated( + "A PKG is a store format only when macOS { pkg { appStore = true } }, so the answer is no " + + "longer a property of the format alone. Branch on the DSL instead.", + level = DeprecationLevel.ERROR, + ) val isStoreFormat: Boolean - get() = this in setOf(Pkg, AppX, Flatpak) + get() = this == Pkg || isAlwaysSandboxed /** * Whether this format supports auto-update but electron-builder does not generate latest-*.yml for it. @@ -60,6 +76,26 @@ enum class TargetFormat( val needsPluginUpdateYml: Boolean get() = this == Msi || this == Portable + /** + * The extension of the artifact listed in this format's update manifest, for the formats whose + * manifest the plugin writes itself when electron-builder did not — always for [needsPluginUpdateYml], + * and for the others when no `publish` provider is configured (electron-builder then writes none), + * so the packaging output is a complete local update feed either way. `null` for formats without a + * self-contained artifact to list (NSIS-Web's packages live on its publish host). + */ + internal val updateArtifactExtension: String? + get() = + when (this) { + Nsis, Exe, Portable -> "exe" + Msi -> "msi" + Dmg -> "dmg" + AppImage -> "AppImage" + Deb -> "deb" + Rpm -> "rpm" + Zip -> if (targetOS == OS.MacOS) "zip" else null + else -> null + } + /** * Whether this format publishes a per-channel auto-update manifest (`.yml`), * generated either by electron-builder (NSIS, NSIS-Web, DMG, ZIP-on-macOS, AppImage, DEB, RPM) diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/ApplyNucleusOptimization.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/ApplyNucleusOptimization.kt new file mode 100644 index 000000000..4544d9dfb --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/ApplyNucleusOptimization.kt @@ -0,0 +1,71 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.desktop.application.dsl.GarbageCollector +import org.gradle.api.Project + +internal const val OPTIMIZED_XMS = "-Xms32m" +internal const val OPTIMIZED_MAX_RAM_PERCENTAGE = "-XX:MaxRAMPercentage=25" + +/** + * Runtime flag read by `nucleus-application` to arm idle GC. Keep in sync with `NucleusOptimization`. + * Also baked into `nucleus-app.properties` as [NUCLEUS_IDLE_GC_RESOURCE_KEY], since a native image + * has no launcher `.cfg` to carry the `-D`. + */ +internal const val NUCLEUS_IDLE_GC_PROPERTY = "nucleus.optimization.idleGc" +internal const val OPTIMIZED_IDLE_GC_FLAG = "-D$NUCLEUS_IDLE_GC_PROPERTY=true" +internal const val NUCLEUS_IDLE_GC_RESOURCE_KEY = "optimization.idleGc" + +internal val JvmApplicationData.optSerialGc: Boolean + get() = nucleusOptimizationSettings.serialGc ?: nucleusOptimization + +internal val JvmApplicationData.optCompactHeap: Boolean + get() = nucleusOptimizationSettings.compactHeap ?: nucleusOptimization + +internal val JvmApplicationData.optSingleJar: Boolean + get() = nucleusOptimizationSettings.singleJar ?: nucleusOptimization + +internal val JvmApplicationData.optIdleGc: Boolean + get() = nucleusOptimizationSettings.idleGc ?: nucleusOptimization + +internal val JvmApplicationData.optLastJdk: Boolean + get() = nucleusOptimizationSettings.lastJdk ?: nucleusOptimization + +/** + * Applies [JvmApplicationData.nucleusOptimization] JVM flags without clobbering an + * explicit collector or heap flags already on [app]. + */ +internal fun applyNucleusOptimization(app: JvmApplicationData) { + if (app.optSerialGc && app.garbageCollector == null) { + app.garbageCollector = GarbageCollector.SERIAL + } + if (app.optCompactHeap) { + if (app.jvmArgs.none { it.startsWith("-Xms") }) { + app.jvmArgs.add(OPTIMIZED_XMS) + } + if (app.jvmArgs.none { it.startsWith("-XX:MaxRAMPercentage") }) { + app.jvmArgs.add(OPTIMIZED_MAX_RAM_PERCENTAGE) + } + } + if (app.optIdleGc && app.jvmArgs.none { it.startsWith("-D$NUCLEUS_IDLE_GC_PROPERTY=") }) { + app.jvmArgs.add(OPTIMIZED_IDLE_GC_FLAG) + } +} + +/** + * Points packaging / `run` at an auto-downloaded current OpenJDK when + * [JvmApplicationData.optLastJdk] is on. An explicit `javaHome` wins. The + * [org.gradle.api.provider.ValueSource] stays lazy — listing tasks does not + * download the JDK. + */ +internal fun applyNucleusOptimizationJdk( + project: Project, + app: JvmApplicationData, +) { + if (!app.optLastJdk || app.hasCustomJavaHome || app.javaHomeOverride != null) return + app.javaHomeOverride = + project.providers.of(NucleusJdkToolchainValueSource::class.java) { spec -> + spec.parameters.installBaseDir.set( + project.gradle.gradleUserHomeDir.resolve("nucleus/jdk").absolutePath, + ) + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/GraalvmToolchainProvisioner.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/GraalvmToolchainProvisioner.kt index a297d3b7e..3ab7d562c 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/GraalvmToolchainProvisioner.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/GraalvmToolchainProvisioner.kt @@ -12,15 +12,10 @@ import org.gradle.api.provider.Property import org.gradle.api.provider.ValueSource import org.gradle.api.provider.ValueSourceParameters import org.gradle.process.ExecOperations -import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException -import java.io.RandomAccessFile -import java.net.HttpURLConnection -import java.net.URI import java.nio.file.Files import java.nio.file.StandardCopyOption -import java.security.MessageDigest import javax.inject.Inject /** @@ -39,11 +34,29 @@ internal fun isOracleGraalvmInstallation(javaHome: File): Boolean = line.contains("Oracle", ignoreCase = true) } +/** + * The GraalVM version of [javaHome] (`GRAALVM_VERSION="25.4.4.1.1"` → `"25.4.4.1.1"`), or `null` + * when the `release` file is missing or carries no such entry — the case for a plain JDK. + * + * This is not `JAVA_VERSION`: the same build reports `25.0.4.1.1` there, so the GraalVM release + * line (25.3 vs 25.4) is only readable from this entry. + */ +internal fun graalvmVersionOf(javaHome: File): String? = + javaHome + .resolve("release") + .takeIf { it.isFile } + ?.readLines() + .orEmpty() + .firstOrNull { it.startsWith("GRAALVM_VERSION=") } + ?.substringAfter('=') + ?.trim('"') + ?.takeIf { it.isNotBlank() } + /** * What GraalVM toolchain to provision for the current build machine. * * @param distribution GraalVM Community Edition (the default) or Oracle GraalVM. - * @param version GraalVM version: an innovation release (`"25i3"`), a feature + * @param version GraalVM version: an innovation release (`"25i4"`), a feature * version tracking the latest CPU (`"25"`), or a pinned patch release (`"25.0.1"`). * @param macosIntelFallback use Liberica NIK on macOS x64, which neither distribution * ships any more (dropped after 25.0.1). @@ -100,8 +113,8 @@ internal abstract class GraalvmToolchainValueSource : * - GraalVM Community Edition (the default) from the `graalvm/graalvm-ce-builds` GitHub * releases, resolved through the GitHub API since the innovation asset names embed a * base version that is not derivable from the requested version alone - * (`graalvm-community-jdk-25i3-25.0.4.1_macos-aarch64_bin.tar.gz`). - * - Oracle GraalVM innovation releases (`25i3`) from + * (`graalvm-community-jdk-25i4-25.0.4.1.1_macos-aarch64_bin.tar.gz`). + * - Oracle GraalVM innovation releases (`25i4`) from * `https://gds.oracle.com/download/graal//latest/graalvm-jdk--_-_bin.` * - Oracle GraalVM LTS/latest (`25`) and pinned (`25.0.1`) releases from * `https://download.oracle.com/graalvm//{latest,archive}/graalvm-jdk-_-_bin.` @@ -119,12 +132,6 @@ internal abstract class GraalvmToolchainValueSource : @Suppress("TooManyFunctions") internal object GraalvmToolchainProvisioner { private const val MARKER_FILE = ".nucleus-provisioned" - private const val CONNECT_TIMEOUT_MS = 30_000 - private const val READ_TIMEOUT_MS = 60_000 - private const val MAX_REDIRECTS = 5 - private const val DOWNLOAD_BUFFER_SIZE = 1 shl 16 - private const val HTTP_FIRST_REDIRECT = 300 - private const val HTTP_FIRST_ERROR = 400 private const val BITNESS_64 = 64 private const val BELLSOFT_NIK_API = "https://api.bell-sw.com/v1/nik/releases?os=macos&output=json" private const val GRAALVM_CE_RELEASES_API = @@ -144,13 +151,9 @@ internal object GraalvmToolchainProvisioner { val installDir = File(request.installBaseDir, id) readMarker(installDir)?.let { return it } - request.installBaseDir.mkdirs() - // Guard against concurrent Gradle builds provisioning the same toolchain. - RandomAccessFile(File(request.installBaseDir, "$id.lock"), "rw").use { lockFile -> - lockFile.channel.lock().use { - readMarker(installDir)?.let { return it } - return downloadAndInstall(request, id, installDir, execOperations, logger) - } + // Guard against concurrent builds and parallel tasks provisioning the same toolchain. + return ToolchainDownloads.withInstallLock(request.installBaseDir, id) { + readMarker(installDir) ?: downloadAndInstall(request, id, installDir, execOperations, logger) } } @@ -308,7 +311,7 @@ internal object GraalvmToolchainProvisioner { * A pinned patch release ("25.0.2") maps to a deterministic tag and asset name and is * resolved offline. Floating versions need the API: for the LTS line ("25") the newest * patch is unknown, and innovation assets embed a base version that is not derivable from - * the requested version (`graalvm-community-jdk-25i3-25.0.4.1_…` under tag `graal-25.3.4.1`). + * the requested version (`graalvm-community-jdk-25i4-25.0.4.1.1_…` under tag `graal-25.4.4.1.1`). */ private fun resolveCommunityDownload(request: GraalvmToolchainRequest): DownloadSource { check(!(request.os == OS.Windows && request.arch == Arch.Arm64)) { @@ -334,11 +337,11 @@ internal object GraalvmToolchainProvisioner { val prefix = if (version.contains('i')) { - // Innovation release ("25i3") — the asset appends the base version. + // Innovation release ("25i4") — the asset appends the base version. "$GRAALVM_CE_ASSET_PREFIX$version-" } else { // Feature version tracking the latest CPU ("25"); the trailing dot keeps - // "25" from also matching the "25i3" innovation assets. + // "25" from also matching the "25i4" innovation assets. "$GRAALVM_CE_ASSET_PREFIX$version." } val chosen = @@ -421,7 +424,7 @@ internal object GraalvmToolchainProvisioner { val ext = if (request.os == OS.Windows) "zip" else "tar.gz" val url = when { - // Innovation releases ("25i3") are distributed through GDS only. + // Innovation releases ("25i4") are distributed through GDS only. version.contains('i') -> { val base = version.substringBefore('i') "https://gds.oracle.com/download/graal/$version/latest/" + @@ -512,7 +515,7 @@ internal object GraalvmToolchainProvisioner { private fun javaFeatureVersion(version: String): Int = version.takeWhile(Char::isDigit).toIntOrNull() ?: error( - "Invalid graalvm.toolchain.version '$version' — expected e.g. \"25\", \"25.0.1\" or \"25i3\"", + "Invalid graalvm.toolchain.version '$version' — expected e.g. \"25\", \"25.0.1\" or \"25i4\"", ) private fun archToken(arch: Arch): String = @@ -534,38 +537,12 @@ internal object GraalvmToolchainProvisioner { val (algorithm, expected) = when { source.sha1 != null -> "SHA-1" to source.sha1 - source.sha256Url != null -> { - val text = - runCatching { fetchText(source.sha256Url) }.getOrElse { - // Some networks filter the checksum side-file while allowing the - // archive itself; integrity failure would still surface in tar. - logger.warn( - "[graalvm] Could not fetch checksum ${source.sha256Url} (${it.message}) — " + - "skipping verification", - ) - return - } - "SHA-256" to text.trim().substringBefore(' ') - } + source.sha256Url != null -> + "SHA-256" to + (ToolchainDownloads.fetchOptionalChecksum(source.sha256Url, "[graalvm]", logger) ?: return) else -> return } - val actual = archive.digest(algorithm) - check(actual.equals(expected, ignoreCase = true)) { - "Checksum mismatch for ${source.url}: expected $expected, got $actual" - } - } - - private fun File.digest(algorithm: String): String { - val digest = MessageDigest.getInstance(algorithm) - inputStream().use { input -> - val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE) - while (true) { - val read = input.read(buffer) - if (read < 0) break - digest.update(buffer, 0, read) - } - } - return digest.digest().joinToString("") { "%02x".format(it) } + ToolchainDownloads.verifyChecksum(archive, source.url, algorithm, expected) } private fun download( @@ -574,9 +551,7 @@ internal object GraalvmToolchainProvisioner { request: GraalvmToolchainRequest, ) { try { - openConnection(url).inputStream.use { input -> - dest.outputStream().use { output -> input.copyTo(output, DOWNLOAD_BUFFER_SIZE) } - } + ToolchainDownloads.download(url, dest) } catch (e: IOException) { val macIntelHint = if (request.os == OS.MacOS && request.arch == Arch.X64) { @@ -592,37 +567,7 @@ internal object GraalvmToolchainProvisioner { private fun fetchText( url: String, headers: Map = emptyMap(), - ): String = openConnection(url, headers).inputStream.use { it.readBytes().decodeToString() } - - /** Opens a connection following redirects across hosts (HttpURLConnection won't by itself). */ - // Redirect handling has three distinct failure modes worth reporting separately. - @Suppress("ThrowsCount") - private fun openConnection( - url: String, - headers: Map = emptyMap(), - ): HttpURLConnection { - var current = url - repeat(MAX_REDIRECTS) { - val connection = URI(current).toURL().openConnection() as HttpURLConnection - connection.connectTimeout = CONNECT_TIMEOUT_MS - connection.readTimeout = READ_TIMEOUT_MS - connection.instanceFollowRedirects = true - headers.forEach { (name, value) -> connection.setRequestProperty(name, value) } - val code = connection.responseCode - when { - code in HTTP_FIRST_REDIRECT until HTTP_FIRST_ERROR -> { - val location = - connection.getHeaderField("Location") - ?: throw IOException("Redirect without Location header from $current") - connection.disconnect() - current = location - } - code >= HTTP_FIRST_ERROR -> throw IOException("HTTP $code from $current") - else -> return connection - } - } - throw IOException("Too many redirects for $url") - } + ): String = ToolchainDownloads.fetchText(url, headers) /** * Extracts with the system `tar`, which preserves permissions and symlinks (Gradle's @@ -634,16 +579,5 @@ internal object GraalvmToolchainProvisioner { archive: File, destDir: File, execOperations: ExecOperations, - ) { - destDir.mkdirs() - val output = ByteArrayOutputStream() - val result = - execOperations.exec { spec -> - spec.commandLine("tar", "-xf", archive.absolutePath, "-C", destDir.absolutePath) - spec.standardOutput = output - spec.errorOutput = output - spec.isIgnoreExitValue = true - } - check(result.exitValue == 0) { "tar failed extracting ${archive.name}: $output" } - } + ) = ToolchainDownloads.extract(archive, destDir, execOperations) } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationContext.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationContext.kt index 736157088..ed816de8a 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationContext.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationContext.kt @@ -12,6 +12,7 @@ import dev.nucleusframework.internal.javaSourceSets import dev.nucleusframework.internal.mppExt import dev.nucleusframework.internal.utils.OS import dev.nucleusframework.internal.utils.Target +import dev.nucleusframework.internal.utils.currentArch import dev.nucleusframework.internal.utils.currentOS import dev.nucleusframework.internal.utils.jdkArch import dev.nucleusframework.internal.utils.joinDashLowercaseNonEmpty @@ -50,8 +51,19 @@ internal data class JvmApplicationContext( runtimeFiles.configureUsageBy(this, fn) } - /** Architecture of the configured JDK (may differ from the Gradle daemon's arch when cross-building). */ - val targetArch by lazy { jdkArch(java.io.File(app.javaHome)) } + /** + * Architecture of the configured JDK (may differ from the Gradle daemon's + * arch when cross-building). The auto-downloaded OpenJDK 27 matches the + * host, so we must not realize [JvmApplicationData.javaHomeOverride] here + * — that would download the JDK at configuration time. + */ + val targetArch by lazy { + if (app.javaHomeOverride != null) { + currentArch + } else { + jdkArch(java.io.File(app.javaHome)) + } + } /** Target combining the current OS with the configured JDK's architecture. */ val targetTarget by lazy { Target(currentOS, targetArch) } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationData.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationData.kt index 2063705ec..23ca76026 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationData.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationData.kt @@ -7,6 +7,7 @@ package dev.nucleusframework.desktop.application.internal import dev.nucleusframework.desktop.application.dsl.GarbageCollector import dev.nucleusframework.desktop.application.dsl.GraalvmSettings +import dev.nucleusframework.desktop.application.dsl.NucleusOptimizationSettings import dev.nucleusframework.desktop.application.dsl.JvmApplicationBuildTypes import dev.nucleusframework.desktop.application.dsl.JvmApplicationDistributions import dev.nucleusframework.internal.utils.new @@ -38,11 +39,24 @@ internal open class JvmApplicationData set(value) { customJavaHome = value } + + internal val hasCustomJavaHome: Boolean + get() = customJavaHome != null + + /** + * Lazy JDK home used by packaging / `run`. When [optLastJdk] is on + * this is a [NucleusJdkToolchainValueSource]; otherwise it reads + * [javaHome]. + */ + internal var javaHomeOverride: Provider? = null + val javaHomeProvider: Provider - get() = providers.provider { javaHome } + get() = javaHomeOverride ?: providers.provider { javaHome } val args: MutableList = ArrayList() val jvmArgs: MutableList = ArrayList() var garbageCollector: GarbageCollector? = null + var nucleusOptimization: Boolean = false + val nucleusOptimizationSettings: NucleusOptimizationSettings = objects.new() val nativeDistributions: JvmApplicationDistributions = objects.new() val buildTypes: JvmApplicationBuildTypes = objects.new() val graalvm: GraalvmSettings = objects.new() diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationInternal.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationInternal.kt index 7acd8db1e..bc1b6e7cf 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationInternal.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/JvmApplicationInternal.kt @@ -6,9 +6,11 @@ package dev.nucleusframework.desktop.application.internal import dev.nucleusframework.desktop.application.dsl.GarbageCollector + import dev.nucleusframework.desktop.application.dsl.GraalvmSettings import dev.nucleusframework.desktop.application.dsl.JvmApplication import dev.nucleusframework.desktop.application.dsl.JvmApplicationBuildTypes +import dev.nucleusframework.desktop.application.dsl.NucleusOptimizationSettings import dev.nucleusframework.desktop.application.dsl.JvmApplicationDistributions import dev.nucleusframework.internal.utils.new import dev.nucleusframework.desktop.application.dsl.AdditionalLauncher @@ -76,6 +78,12 @@ internal open class JvmApplicationInternal final override var garbageCollector: GarbageCollector? by data::garbageCollector + final override var nucleusOptimization: Boolean by data::nucleusOptimization + + final override fun nucleusOptimization(fn: Action) { + fn.execute(data.nucleusOptimizationSettings) + } + final override val nativeDistributions: JvmApplicationDistributions by data::nativeDistributions final override fun nativeDistributions(fn: Action) { diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrder.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrder.kt new file mode 100644 index 000000000..8978977d4 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrder.kt @@ -0,0 +1,67 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.logging.Logger +import java.io.File + +/** + * Puts the `app.classpath=` entries of jpackage launcher `.cfg` files back in classpath order. + * + * jpackage has no classpath option: it lists every file of `--input` (sorted by name) after the + * main jar. When two JARs define the same classes, the one that sorts first wins at run time, + * while `./gradlew run` resolves them in Gradle's runtime-classpath order — so a packaged app + * could load different classes than the one tested. Seen with Jewel: the IntelliJ icon + * libraries pull `kotlinx-coroutines-core-jvm-1.10.2-intellij-2`, which sorts before + * `kotlinx-coroutines-core-jvm-1.11.0` and made the packaged app fail with `NoSuchMethodError`. + */ +internal object LauncherClasspathOrder { + private const val CLASSPATH_PREFIX = "app.classpath=" + + /** + * Rewrites every launcher `.cfg` under [appImageRoot] so its classpath follows [order] + * (JAR file names, first wins). Entries not in [order] keep their relative place, after it. + * + * @return number of `.cfg` files rewritten + */ + fun apply( + appImageRoot: File, + order: List, + logger: Logger, + ): Int { + if (order.isEmpty() || !appImageRoot.exists()) return 0 + var rewritten = 0 + appImageRoot + .walkTopDown() + .filter { it.isFile && it.extension.equals("cfg", ignoreCase = true) && it.name != "jvm.cfg" } + .forEach { cfg -> + val text = cfg.readText() + val reordered = reorder(text, order) ?: return@forEach + cfg.writeText(reordered) + rewritten++ + logger.info("Restored classpath order in ${cfg.name}") + } + return rewritten + } + + /** [cfgText] with its classpath in [order], or `null` when it already is. */ + internal fun reorder( + cfgText: String, + order: List, + ): String? { + val lineSeparator = if (cfgText.contains("\r\n")) "\r\n" else "\n" + val lines = cfgText.split(lineSeparator) + val slots = lines.indices.filter { lines[it].trimStart().startsWith(CLASSPATH_PREFIX) } + if (slots.size < 2) return null + + val rank = order.withIndex().associate { (index, name) -> name to index } + val entries = slots.map { lines[it].trim().removePrefix(CLASSPATH_PREFIX) } + // sortedBy is stable: unknown entries (rank MAX) keep jpackage's relative order. + val sorted = entries.sortedBy { rank[fileName(it)] ?: Int.MAX_VALUE } + if (sorted == entries) return null + + val out = lines.toMutableList() + slots.forEachIndexed { i, slot -> out[slot] = CLASSPATH_PREFIX + sorted[i] } + return out.joinToString(lineSeparator) + } + + private fun fileName(entry: String): String = entry.substringAfterLast('/').substringAfterLast('\\') +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/MacPkgScripts.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/MacPkgScripts.kt new file mode 100644 index 000000000..8d9cacb14 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/MacPkgScripts.kt @@ -0,0 +1,124 @@ +/* + * Copyright 2020-2026 JetBrains s.r.o. and respective authors and developers. + * Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE.txt file. + */ + +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.GradleException +import java.io.File + +/** + * Stages the user's PKG install scripts where electron-builder's PKG target picks them up. + * + * electron-builder resolves `pkg.scripts` against its build-resources directory (`/build`) + * and hands the directory to `pkgbuild --scripts`, which runs the files named exactly `preinstall` + * and `postinstall` as the package's top-level scripts. + * + * **It also declares them a second time.** For every file whose name contains `preinstall` / + * `postinstall`, electron-builder sets `BundlePreInstallScriptPath` / `BundlePostInstallScriptPath` + * in the component property list, so the generated `PackageInfo` carries both a bundle-level and a + * top-level entry and macOS Installer runs each script **twice** — verified on a real install. To + * spare every app that papercut, the staged `preinstall` / `postinstall` are small shims: the app's + * own script is staged under a name electron-builder does not scan for, and the shim runs it once, + * on the top-level pass. + */ +internal object MacPkgScripts { + /** Directory name under electron-builder's build resources, and the value written to `pkg.scripts`. */ + const val SCRIPTS_DIR = "pkg-scripts" + + private const val PRE_INSTALL = "preinstall" + private const val POST_INSTALL = "postinstall" + + /** + * Names the app's own scripts are staged under. They must not contain the substrings + * `preinstall` / `postinstall`, or electron-builder would point the bundle-level entry at them + * and the deduplication below would be bypassed. + */ + private fun userScriptName(topLevelName: String) = + when (topLevelName) { + PRE_INSTALL -> "nucleus-app-pre" + else -> "nucleus-app-post" + } + + /** + * Runs the app's script exactly once. + * + * Installer passes the install location as `$2` to a top-level script and the installed bundle + * path to a bundle-level one, so the pass to skip is the one whose `$2` is the `.app` itself. + */ + private fun shim(userScript: String) = + """ + #!/bin/sh + # Generated by Nucleus. electron-builder declares this script twice in PackageInfo (once per + # bundle, once top level), so macOS Installer would run the app's script twice. The per-bundle + # pass receives the installed bundle as ${'$'}2 — skip it and run on the top-level pass only. + case "${'$'}2" in + *.app|*.app/) exit 0 ;; + esac + exec "${'$'}(dirname "${'$'}0")/$userScript" "${'$'}@" + """.trimIndent() + "\n" + + /** + * Copies [preInstall] / [postInstall] into `/pkg-scripts`, each behind a + * deduplicating shim, exec bit set. The directory is always wiped first so a script from a + * previous run cannot leak into a build that no longer declares it. Returns the staged + * directory, `null` when no script is configured. + * + * Fails when a script is declared for an App Store PKG (Apple rejects packages with install + * scripts, validation error 90254), when a declared file is missing, or when it has no shebang + * (Installer executes the file directly). + */ + fun stage( + buildResourcesDir: File, + preInstall: File?, + postInstall: File?, + appStore: Boolean, + ): File? { + val scriptsDir = buildResourcesDir.resolve(SCRIPTS_DIR) + scriptsDir.deleteRecursively() + + val scripts = + listOfNotNull( + preInstall?.let { PRE_INSTALL to it }, + postInstall?.let { POST_INSTALL to it }, + ) + if (scripts.isEmpty()) return null + if (appStore) { + fail( + "macOS { pkg { preInstall / postInstall } } requires pkg { appStore = false }: " + + "the Mac App Store rejects installer packages that carry install scripts (error 90254).", + ) + } + + scriptsDir.mkdirs() + for ((topLevelName, source) in scripts) { + validateScript(topLevelName, source) + + val userScriptName = userScriptName(topLevelName) + val userScript = scriptsDir.resolve(userScriptName) + source.copyTo(userScript, overwrite = true) + userScript.setExecutable(true, false) + + val entryPoint = scriptsDir.resolve(topLevelName) + entryPoint.writeText(shim(userScriptName)) + entryPoint.setExecutable(true, false) + } + return scriptsDir + } + + private fun validateScript( + name: String, + source: File, + ) { + if (!source.isFile) fail("PKG $name script not found: ${source.absolutePath}") + if (!source.readText().startsWith("#!")) { + fail( + "PKG $name script must start with a shebang (e.g. #!/bin/sh), " + + "the Installer executes it directly: ${source.absolutePath}", + ) + } + } + + private fun fail(message: String): Nothing = throw GradleException(message) +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/NodeToolchainProvisioner.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/NodeToolchainProvisioner.kt new file mode 100644 index 000000000..cbcdd015c --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/NodeToolchainProvisioner.kt @@ -0,0 +1,287 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.desktop.application.dsl.NodeJsSettings +import dev.nucleusframework.desktop.application.internal.ToolchainDownloads.fetchText +import dev.nucleusframework.desktop.application.tasks.AbstractElectronBuilderPackageTask +import dev.nucleusframework.internal.utils.Arch +import dev.nucleusframework.internal.utils.OS +import groovy.json.JsonSlurper +import org.gradle.api.Project +import org.gradle.api.logging.Logger +import org.gradle.process.ExecOperations +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** Environment variable pointing at a Node.js installation to use instead of downloading one. */ +internal const val NODE_HOME_ENV = "NUCLEUS_NODE_HOME" + +/** + * What Node.js toolchain to provision for the current build machine. + * + * @param version a major line tracking its newest release (`"22"`), the newest LTS (`"lts"`), + * or a pinned release (`"22.11.0"`). + */ +internal data class NodeToolchainRequest( + val version: String, + val os: OS, + val arch: Arch, + val installBaseDir: File, +) + +/** + * A provisioned Node.js installation: the directory the archive unpacked to, plus the two + * executables the electron-builder pipeline runs. + */ +internal data class NodeInstallation( + val home: File, + val node: File, + val npm: File, +) + +/** + * Downloads and caches the Node.js used to provision and run electron-builder, so packaging + * needs nothing installed on the build machine — the same deal [GraalvmToolchainProvisioner] + * gives native-image and [NucleusJdkToolchainProvisioner] gives jpackage. + * + * Archives come from `https://nodejs.org/dist/v/`, verified against the `SHASUMS256.txt` + * published alongside them. Each installation is unpacked under `//` with a + * marker file recording its home directory; once provisioned, resolution is a single marker-file + * read (no network). Floating versions ("22", "lts") are sticky — delete the directory to pick up + * a newer release. + * + * A [NODE_HOME_ENV] environment variable pointing at a usable installation bypasses the download. + * + * Unlike the JDK toolchains this one is provisioned at execution time, from the packaging task + * itself: nothing in the task graph needs the path at configuration time. + */ +internal object NodeToolchainProvisioner { + private const val MARKER_FILE = ".nucleus-provisioned" + private const val NODE_DIST_BASE = "https://nodejs.org/dist" + private const val NODE_INDEX_URL = "$NODE_DIST_BASE/index.json" + private const val LTS_VERSION = "lts" + + fun provision( + request: NodeToolchainRequest, + execOperations: ExecOperations, + logger: Logger, + ): NodeInstallation { + environmentOverride(logger)?.let { return it } + + val id = installationId(request) + val installDir = File(request.installBaseDir, id) + readMarker(installDir)?.let { return it } + + // Guard against concurrent builds and parallel tasks provisioning the same toolchain. + return ToolchainDownloads.withInstallLock(request.installBaseDir, id) { + readMarker(installDir) ?: downloadAndInstall(request, id, installDir, execOperations, logger) + } + } + + /** The install directory name: the request's version, not the resolved one, so it stays sticky. */ + internal fun installationId(request: NodeToolchainRequest): String = + "node-${request.version}-${platformToken(request.os)}-${archToken(request.arch)}" + + /** Node's own platform token, as it appears in the archive names. */ + internal fun platformToken(os: OS): String = + when (os) { + OS.Windows -> "win" + OS.MacOS -> "darwin" + OS.Linux -> "linux" + } + + /** Node's own architecture token — `arm64`, not the `aarch64` the JDK archives use. */ + internal fun archToken(arch: Arch): String = + when (arch) { + Arch.X64 -> "x64" + Arch.Arm64 -> "arm64" + } + + /** Archive name for a fully resolved version (`v22.11.0`). */ + internal fun archiveName( + version: String, + os: OS, + arch: Arch, + ): String { + val ext = if (os == OS.Windows) "zip" else "tar.gz" + return "node-$version-${platformToken(os)}-${archToken(arch)}.$ext" + } + + /** + * Resolves [requested] to a concrete `v`-prefixed release, hitting `index.json` only for the + * floating forms ("22", "lts"). A pinned version resolves offline. + */ + internal fun resolveVersion( + requested: String, + index: () -> String, + ): String { + val normalized = requested.removePrefix("v") + if (normalized.count { it == '.' } == 2) return "v$normalized" + + @Suppress("UNCHECKED_CAST") + val releases = JsonSlurper().parseText(index()) as List> + val matching = + releases.filter { release -> + val version = release["version"] as? String ?: return@filter false + if (normalized.equals(LTS_VERSION, ignoreCase = true)) { + release["lts"] != false + } else { + majorOf(version) == normalized.toIntOrNull() + } + } + // index.json is published newest-first, but sort rather than trust the order. + return matching.maxWithOrNull(compareBy(versionOrder) { versionKey(it["version"] as String) }) + ?.get("version") as? String + ?: error( + "No Node.js release matches '$requested'. Set nativeDistributions { nodejs { version } } " + + "to a released version, or point at a local install with the " + + "'${NucleusProperties.ELECTRON_BUILDER_NODE_PATH}' Gradle property.", + ) + } + + /** Resolves the executables inside an unpacked (or user-supplied) Node.js home. */ + internal fun installationAt(home: File): NodeInstallation? { + val windows = home.resolve("node.exe") + if (windows.isFile) { + return NodeInstallation(home, windows, home.resolve("npm.cmd")) + } + val unix = home.resolve("bin/node") + if (unix.isFile) { + return NodeInstallation(home, unix, home.resolve("bin/npm")) + } + return null + } + + internal fun majorOf(version: String): Int? = version.removePrefix("v").substringBefore('.').toIntOrNull() + + private fun environmentOverride(logger: Logger): NodeInstallation? { + val home = System.getenv(NODE_HOME_ENV)?.takeIf { it.isNotBlank() } ?: return null + val installation = installationAt(File(home)) + if (installation == null) { + logger.warn("[nodejs] Ignoring $NODE_HOME_ENV=$home — no node executable found there") + return null + } + logger.info("[nodejs] Using $NODE_HOME_ENV=${installation.home}") + return installation + } + + private fun readMarker(installDir: File): NodeInstallation? { + val marker = File(installDir, MARKER_FILE).takeIf { it.isFile } ?: return null + val home = File(installDir, marker.readText().trim()) + return installationAt(home) + } + + private fun downloadAndInstall( + request: NodeToolchainRequest, + id: String, + installDir: File, + execOperations: ExecOperations, + logger: Logger, + ): NodeInstallation { + val version = resolveVersion(request.version) { fetchText(NODE_INDEX_URL) } + val name = archiveName(version, request.os, request.arch) + val url = "$NODE_DIST_BASE/$version/$name" + + logger.lifecycle("[nodejs] Downloading Node.js ${version.removePrefix("v")} from $url") + val archive = File(request.installBaseDir, "$id.download") + val extractDir = File(request.installBaseDir, "$id.extract") + try { + try { + ToolchainDownloads.download(url, archive) + } catch (e: IOException) { + throw IOException("Failed to download Node.js from $url: ${e.message}", e) + } + verifyChecksum(archive, version, name, logger) + + extractDir.deleteRecursively() + ToolchainDownloads.extract(archive, extractDir, execOperations) + + val topDir = + extractDir.listFiles()?.singleOrNull { it.isDirectory } + ?: error("Unexpected archive layout for $url: expected a single top-level directory") + checkNotNull(installationAt(topDir)) { "Downloaded Node.js archive $name contains no node executable" } + + installDir.deleteRecursively() + installDir.mkdirs() + Files.move( + topDir.toPath(), + installDir.toPath().resolve(topDir.name), + StandardCopyOption.ATOMIC_MOVE, + ) + File(installDir, MARKER_FILE).writeText(topDir.name) + + val installation = + checkNotNull(installationAt(File(installDir, topDir.name))) { + "Node.js was installed to $installDir but its node executable is missing" + } + logger.lifecycle("[nodejs] Node.js ${version.removePrefix("v")} installed to ${installation.home}") + return installation + } finally { + archive.delete() + extractDir.deleteRecursively() + } + } + + /** + * Verifies the archive against the release's `SHASUMS256.txt`, which lists every artifact of + * that release as ` `. + */ + private fun verifyChecksum( + archive: File, + version: String, + archiveName: String, + logger: Logger, + ) { + val url = "$NODE_DIST_BASE/$version/SHASUMS256.txt" + val sums = + runCatching { fetchText(url) }.getOrElse { + logger.warn("[nodejs] Could not fetch checksums $url (${it.message}) — skipping verification") + return + } + val expected = + sums + .lineSequence() + .firstOrNull { it.trim().endsWith(" $archiveName") } + ?.trim() + ?.substringBefore(' ') + if (expected == null) { + logger.warn("[nodejs] $url lists no entry for $archiveName — skipping verification") + return + } + ToolchainDownloads.verifyChecksum(archive, archiveName, "SHA-256", expected) + } + + private fun versionKey(version: String): List = + version + .removePrefix("v") + .split('.') + .map { it.takeWhile(Char::isDigit).toIntOrNull() ?: 0 } + + private val versionOrder: Comparator> = + Comparator { left, right -> + val size = maxOf(left.size, right.size) + for (index in 0 until size) { + val comparison = (left.getOrElse(index) { 0 }).compareTo(right.getOrElse(index) { 0 }) + if (comparison != 0) return@Comparator comparison + } + 0 + } +} + +/** + * Copies the `nodejs { }` DSL onto a packaging task. The cache directory defaults to + * `/nucleus/nodejs`, next to the GraalVM and JDK toolchains. + */ +internal fun AbstractElectronBuilderPackageTask.configureNodeJs( + project: Project, + nodejs: NodeJsSettings, +) { + nodeAutoDownload.set(nodejs.autoDownload) + nodeVersion.set(nodejs.version) + nodeInstallDir.set( + nodejs.installDir + .map { it.asFile.absolutePath } + .orElse(project.gradle.gradleUserHomeDir.resolve("nucleus/nodejs").absolutePath), + ) +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/NucleusJdkToolchainProvisioner.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/NucleusJdkToolchainProvisioner.kt new file mode 100644 index 000000000..0d5abde9f --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/NucleusJdkToolchainProvisioner.kt @@ -0,0 +1,295 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.internal.utils.Arch +import dev.nucleusframework.internal.utils.OS +import dev.nucleusframework.internal.utils.currentArch +import dev.nucleusframework.internal.utils.currentOS +import org.gradle.api.logging.Logger +import org.gradle.api.logging.Logging +import org.gradle.api.provider.Property +import org.gradle.api.provider.ValueSource +import org.gradle.api.provider.ValueSourceParameters +import org.gradle.process.ExecOperations +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption +import javax.inject.Inject + +/** + * Current OpenJDK used as the jpackage / jlink / `run` JDK when + * [dev.nucleusframework.desktop.application.dsl.NucleusOptimizationSettings.lastJdk] + * is on. + * + * Pin from https://jdk.java.net/27/ (GA 2026-09-15). The last RC (build 35) was + * promoted unchanged; the install id dropped the `-rc-b35` suffix so existing + * caches re-provision under a stable GA directory. + */ +internal const val OPENJDK_27_FEATURE = 27 +internal const val OPENJDK_27_BUILD = 35 +internal const val OPENJDK_27_HASH = "55ce5470a6294008af0057ff4626d0e5" +internal const val OPENJDK_27_INSTALL_ID = "openjdk-27" + +private const val OPENJDK_27_DOWNLOAD_BASE = + "https://download.java.net/java/GA/jdk27/$OPENJDK_27_HASH/$OPENJDK_27_BUILD/GPL" + +/** BellSoft Liberica JDK 27 for platforms Oracle does not publish. */ +internal const val LIBERICA_27_MACOS_X64_URL = + "https://github.com/bell-sw/Liberica/releases/download/27+36/bellsoft-jdk27+36-macos-amd64.tar.gz" +internal const val LIBERICA_27_WINDOWS_AARCH64_URL = + "https://github.com/bell-sw/Liberica/releases/download/27+36/bellsoft-jdk27+36-windows-aarch64.zip" +private const val LIBERICA_27_MACOS_X64_SHA1 = "00c2e885219f9454a08aae944175758c8c2d3831" +private const val LIBERICA_27_WINDOWS_AARCH64_SHA1 = "a0f9353138c99b090c101d452fea9373d7ac9523" +private const val LIBERICA_27_INSTALL_ID = "liberica-jdk-27" + +internal data class NucleusJdkToolchainRequest( + val os: OS, + val arch: Arch, + val installBaseDir: File, +) + +/** + * Configuration-cache-safe entry point to [NucleusJdkToolchainProvisioner]. + * Stays lazy so `gradlew tasks` / an IDE sync never downloads the JDK. + */ +internal abstract class NucleusJdkToolchainValueSource : + ValueSource { + interface Params : ValueSourceParameters { + val installBaseDir: Property + } + + @get:Inject + abstract val execOperations: ExecOperations + + override fun obtain(): String { + val request = + NucleusJdkToolchainRequest( + os = currentOS, + arch = currentArch, + installBaseDir = File(parameters.installBaseDir.get()), + ) + return NucleusJdkToolchainProvisioner + .provision( + request, + execOperations, + Logging.getLogger(NucleusJdkToolchainProvisioner::class.java), + ).absolutePath + } +} + +/** + * Downloads and caches OpenJDK 27 for the JVM packaging toolchain, mirroring + * [GraalvmToolchainProvisioner] for native-image. + * + * `NUCLEUS_JDK_HOME` pointing at a valid JDK 27 installation bypasses the + * download. macOS Intel and Windows aarch64 fall back to BellSoft Liberica + * JDK 27 (Oracle dropped those ports). + */ +@Suppress("TooManyFunctions") +internal object NucleusJdkToolchainProvisioner { + private const val MARKER_FILE = ".nucleus-provisioned" + private const val ENV_JDK_HOME = "NUCLEUS_JDK_HOME" + + fun provision( + request: NucleusJdkToolchainRequest, + execOperations: ExecOperations, + logger: Logger, + ): File { + environmentOverride(logger)?.let { return it } + + val id = installationId(request) + val installDir = File(request.installBaseDir, id) + readMarker(installDir)?.let { return it } + + return ToolchainDownloads.withInstallLock(request.installBaseDir, id) { + readMarker(installDir) ?: downloadAndInstall(request, id, installDir, execOperations, logger) + } + } + + internal fun downloadUrl( + os: OS, + arch: Arch, + ): String = + when { + os == OS.MacOS && arch == Arch.X64 -> LIBERICA_27_MACOS_X64_URL + os == OS.Windows && arch == Arch.Arm64 -> LIBERICA_27_WINDOWS_AARCH64_URL + else -> "$OPENJDK_27_DOWNLOAD_BASE/${artifactName(os, arch)}" + } + + internal fun installationId(request: NucleusJdkToolchainRequest): String { + val vendor = + if (usesLibericaFallback(request.os, request.arch)) { + LIBERICA_27_INSTALL_ID + } else { + OPENJDK_27_INSTALL_ID + } + return "$vendor-${request.os.id}-${archToken(request.arch)}" + } + + internal fun usesLibericaFallback( + os: OS, + arch: Arch, + ): Boolean = + (os == OS.MacOS && arch == Arch.X64) || + (os == OS.Windows && arch == Arch.Arm64) + + internal fun archToken(arch: Arch): String = + when (arch) { + Arch.X64 -> "x64" + Arch.Arm64 -> "aarch64" + } + + private fun artifactName( + os: OS, + arch: Arch, + ): String { + val ext = if (os == OS.Windows) "zip" else "tar.gz" + return "openjdk-${OPENJDK_27_FEATURE}_${os.id}-${archToken(arch)}_bin.$ext" + } + + private fun libericaSha1( + os: OS, + arch: Arch, + ): String = + when { + os == OS.MacOS && arch == Arch.X64 -> LIBERICA_27_MACOS_X64_SHA1 + os == OS.Windows && arch == Arch.Arm64 -> LIBERICA_27_WINDOWS_AARCH64_SHA1 + else -> error("No Liberica pin for ${os.id}-${archToken(arch)}") + } + + private fun environmentOverride(logger: Logger): File? { + val env = System.getenv(ENV_JDK_HOME)?.takeIf { it.isNotBlank() } ?: return null + val root = File(env) + val home = root.resolve("Contents/Home").takeIf { it.isDirectory } ?: root + if (javaBinary(home) == null) { + logger.warn( + "[nucleusOptimization] $ENV_JDK_HOME is set to $env but contains no bin/java — ignoring it", + ) + return null + } + val feature = javaFeatureVersion(home) + if (feature != OPENJDK_27_FEATURE) { + logger.warn( + "[nucleusOptimization] $ENV_JDK_HOME ($home) is JDK $feature, expected " + + "$OPENJDK_27_FEATURE — ignoring it and downloading OpenJDK $OPENJDK_27_FEATURE", + ) + return null + } + logger.lifecycle("[nucleusOptimization] Using $ENV_JDK_HOME toolchain: $home") + return home + } + + private fun javaFeatureVersion(javaHome: File): Int? { + val release = javaHome.resolve("release") + if (!release.isFile) return null + val raw = + release + .readLines() + .firstOrNull { it.startsWith("JAVA_VERSION=") } + ?.substringAfter("JAVA_VERSION=") + ?.trim('"') + ?: return null + return raw.takeWhile { it.isDigit() }.toIntOrNull() + } + + private fun readMarker(installDir: File): File? { + val marker = File(installDir, MARKER_FILE) + if (!marker.isFile) return null + val home = File(installDir, marker.readText().trim()) + return home.takeIf { it.isDirectory && javaBinary(it) != null } + } + + private fun downloadAndInstall( + request: NucleusJdkToolchainRequest, + id: String, + installDir: File, + execOperations: ExecOperations, + logger: Logger, + ): File { + val url = downloadUrl(request.os, request.arch) + val description = + if (usesLibericaFallback(request.os, request.arch)) { + "Liberica JDK $OPENJDK_27_FEATURE (${request.os.id}-${archToken(request.arch)})" + } else { + "OpenJDK $OPENJDK_27_FEATURE+$OPENJDK_27_BUILD " + + "(${request.os.id}-${archToken(request.arch)})" + } + logger.lifecycle("[nucleusOptimization] Downloading $description from $url") + val archive = File(request.installBaseDir, "$id.download") + val extractDir = File(request.installBaseDir, "$id.extract") + try { + download(url, archive) + verifyChecksum(archive, url, request, logger) + + extractDir.deleteRecursively() + extract(archive, extractDir, execOperations) + + val topDir = + extractDir.listFiles()?.singleOrNull { it.isDirectory } + ?: error("Unexpected archive layout for $url: expected a single top-level directory") + val homeRelative = + if (topDir.resolve("Contents/Home").isDirectory) { + "${topDir.name}/Contents/Home" + } else { + topDir.name + } + checkNotNull(javaBinary(File(extractDir, homeRelative))) { + "Downloaded toolchain $description contains no bin/java ($topDir)" + } + + installDir.deleteRecursively() + installDir.mkdirs() + Files.move( + topDir.toPath(), + installDir.toPath().resolve(topDir.name), + StandardCopyOption.ATOMIC_MOVE, + ) + File(installDir, MARKER_FILE).writeText(homeRelative) + + val home = File(installDir, homeRelative) + logger.lifecycle("[nucleusOptimization] $description installed to $home") + return home + } finally { + archive.delete() + extractDir.deleteRecursively() + } + } + + private fun javaBinary(home: File): File? = + listOf("java", "java.exe") + .map { home.resolve("bin/$it") } + .firstOrNull { it.isFile } + + private fun verifyChecksum( + archive: File, + url: String, + request: NucleusJdkToolchainRequest, + logger: Logger, + ) { + if (usesLibericaFallback(request.os, request.arch)) { + ToolchainDownloads.verifyChecksum(archive, url, "SHA-1", libericaSha1(request.os, request.arch)) + return + } + val sha256Url = "$url.sha256" + val expected = + ToolchainDownloads.fetchOptionalChecksum(sha256Url, "[nucleusOptimization]", logger) ?: return + ToolchainDownloads.verifyChecksum(archive, sha256Url, "SHA-256", expected) + } + + private fun download( + url: String, + dest: File, + ) { + try { + ToolchainDownloads.download(url, dest) + } catch (e: IOException) { + throw IOException("Failed to download JDK $OPENJDK_27_FEATURE from $url: ${e.message}", e) + } + } + + private fun extract( + archive: File, + destDir: File, + execOperations: ExecOperations, + ) = ToolchainDownloads.extract(archive, destDir, execOperations) +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/ToolchainDownloads.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/ToolchainDownloads.kt new file mode 100644 index 000000000..9d7d5c488 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/ToolchainDownloads.kt @@ -0,0 +1,166 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.logging.Logger +import org.gradle.process.ExecOperations +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.net.HttpURLConnection +import java.net.URI +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** + * Shared download / verify / extract plumbing for the toolchains the plugin provisions itself: + * GraalVM ([GraalvmToolchainProvisioner]), the packaging JDK ([NucleusJdkToolchainProvisioner]) + * and Node.js ([NodeToolchainProvisioner]). + * + * Each provisioner keeps its own resolution logic (where an archive lives, how its checksum is + * published) — only the transport is shared. + */ +internal object ToolchainDownloads { + private const val CONNECT_TIMEOUT_MS = 30_000 + private const val READ_TIMEOUT_MS = 60_000 + private const val MAX_REDIRECTS = 5 + private const val DOWNLOAD_BUFFER_SIZE = 1 shl 16 + private const val HTTP_FIRST_REDIRECT = 300 + private const val HTTP_FIRST_ERROR = 400 + + /** One monitor per lock file, so threads of this JVM queue up instead of colliding. */ + private val inProcessLocks = ConcurrentHashMap() + + /** + * Runs [action] while holding the install lock `/.lock`, against both other + * Gradle processes (a file lock) and other threads of this one. The file lock alone is not + * enough: parallel tasks in one daemon share the JVM, and a second `FileChannel.lock()` there + * throws `OverlappingFileLockException` instead of waiting. + */ + fun withInstallLock( + installBaseDir: File, + id: String, + action: () -> T, + ): T { + installBaseDir.mkdirs() + val lockFile = File(installBaseDir, "$id.lock") + val monitor = inProcessLocks.computeIfAbsent(lockFile.canonicalPath) { Any() } + return synchronized(monitor) { + RandomAccessFile(lockFile, "rw").use { file -> + file.channel.lock().use { action() } + } + } + } + + /** Downloads [url] into [dest]. Throws [IOException] with the URL in the message. */ + fun download( + url: String, + dest: File, + ) { + openConnection(url).inputStream.use { input -> + dest.outputStream().use { output -> input.copyTo(output, DOWNLOAD_BUFFER_SIZE) } + } + } + + /** Fetches [url] as text — checksum side-files, JSON indexes, discovery APIs. */ + fun fetchText( + url: String, + headers: Map = emptyMap(), + ): String = openConnection(url, headers).inputStream.use { it.readBytes().decodeToString() } + + /** Hex digest of this file under [algorithm] ("SHA-256", "SHA-1"). */ + fun File.digest(algorithm: String): String { + val digest = MessageDigest.getInstance(algorithm) + inputStream().use { input -> + val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + + /** Fails the build unless [archive] hashes to [expected] under [algorithm]. */ + fun verifyChecksum( + archive: File, + source: String, + algorithm: String, + expected: String, + ) { + val actual = archive.digest(algorithm) + check(actual.equals(expected, ignoreCase = true)) { + "Checksum mismatch for $source: expected $expected, got $actual" + } + } + + /** + * Reads a checksum published as a side-file next to the archive, or `null` when it cannot be + * fetched — some networks filter the side-file while allowing the archive itself, and an + * integrity failure would still surface when `tar` chokes on the payload. + */ + fun fetchOptionalChecksum( + url: String, + logTag: String, + logger: Logger, + ): String? = + runCatching { fetchText(url) } + .map { it.trim().substringBefore(' ') } + .getOrElse { + logger.warn("$logTag Could not fetch checksum $url (${it.message}) — skipping verification") + null + } + + /** Opens a connection following redirects across hosts (HttpURLConnection won't by itself). */ + // Redirect handling has three distinct failure modes worth reporting separately. + @Suppress("ThrowsCount") + fun openConnection( + url: String, + headers: Map = emptyMap(), + ): HttpURLConnection { + var current = url + repeat(MAX_REDIRECTS) { + val connection = URI(current).toURL().openConnection() as HttpURLConnection + connection.connectTimeout = CONNECT_TIMEOUT_MS + connection.readTimeout = READ_TIMEOUT_MS + connection.instanceFollowRedirects = true + headers.forEach { (name, value) -> connection.setRequestProperty(name, value) } + val code = connection.responseCode + when { + code in HTTP_FIRST_REDIRECT until HTTP_FIRST_ERROR -> { + val location = + connection.getHeaderField("Location") + ?: throw IOException("Redirect without Location header from $current") + connection.disconnect() + current = location + } + code >= HTTP_FIRST_ERROR -> throw IOException("HTTP $code from $current") + else -> return connection + } + } + throw IOException("Too many redirects for $url") + } + + /** + * Extracts with the system `tar`, which preserves permissions and symlinks (Gradle's + * tarTree does not) and is available on Linux, macOS and Windows 10+ (bsdtar, which + * also handles zip). Runs through [ExecOperations] so it stays legal at configuration + * time under the configuration cache. + */ + fun extract( + archive: File, + destDir: File, + execOperations: ExecOperations, + ) { + destDir.mkdirs() + val output = ByteArrayOutputStream() + val result = + execOperations.exec { spec -> + spec.commandLine("tar", "-xf", archive.absolutePath, "-C", destDir.absolutePath) + spec.standardOutput = output + spec.errorOutput = output + spec.isIgnoreExitValue = true + } + check(result.exitValue == 0) { "tar failed extracting ${archive.name}: $output" } + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlGenerator.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlGenerator.kt index 352c3e9d8..1a2e5c5e8 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlGenerator.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlGenerator.kt @@ -23,13 +23,18 @@ internal object UpdateYmlGenerator { /** * Generates the auto-update YML file if it does not already exist. - * When electron-builder natively generates the file (e.g. for NSIS), this is a no-op. + * When electron-builder natively generates the file (e.g. for NSIS with a publish provider), + * this is a no-op. + * + * @param artifactExtension when set, only files with this extension are listed — the output + * directory also holds build leftovers (`nucleus-installer.nsh`, …) that are no artifact. */ fun generateIfMissing( outputDir: File, ymlFilename: String, version: String, logger: Logger, + artifactExtension: String? = null, ) { val ymlFile = File(outputDir, ymlFilename) if (ymlFile.exists()) { @@ -37,11 +42,13 @@ internal object UpdateYmlGenerator { return } - val installerFiles = outputDir.listFiles { f -> + val candidates = outputDir.listFiles { f -> f.isFile && !f.name.startsWith(".") && - f.extension.lowercase() !in SKIP_EXTENSIONS + f.extension.lowercase() !in SKIP_EXTENSIONS && + (artifactExtension == null || f.extension.equals(artifactExtension, ignoreCase = true)) }?.sortedBy { it.name } ?: emptyList() + val installerFiles = currentArtifacts(candidates, version) if (installerFiles.isEmpty()) { logger.warn("No installer files found in ${outputDir.absolutePath}, skipping update YML generation") @@ -81,6 +88,24 @@ internal object UpdateYmlGenerator { logger.lifecycle("Generated auto-update metadata: ${ymlFile.name}") } + /** + * The artifacts of this packaging run among [candidates]. electron-builder does not clean its + * output directory, so the installer of a previous version is still there after a version bump; + * listed first, it would be what every client downloads as the new version. The artifacts whose + * name carries [version] are kept, or, for an artifact name without a version, the newest one. + */ + internal fun currentArtifacts( + candidates: List, + version: String, + ): List { + val versioned = candidates.filter { VERSION_BOUNDARY.replace("{v}", Regex.escape(version)).toRegex().containsMatchIn(it.name) } + if (versioned.isNotEmpty()) return versioned + return listOfNotNull(candidates.maxByOrNull { it.lastModified() }) + } + + /** [version] as a whole component of a file name: `1.1.0` must not match in `11.1.0` or `1.1.0.1`. */ + private const val VERSION_BOUNDARY = """(? diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlPublish.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlPublish.kt index af9bb3a49..946e2db86 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlPublish.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlPublish.kt @@ -115,6 +115,11 @@ internal object UpdateYmlPublish { * * Returns an empty list when no manifests are found (e.g. only non-updatable formats ran). */ + /** Deletes the update manifests in [outputDir], so a new packaging run cannot inherit stale ones. */ + fun deleteManifests(outputDir: File) { + outputDir.listFiles()?.filter { it.isFile && UPDATE_YML_NAME.matches(it.name) }?.forEach(File::delete) + } + fun discoverAndMerge(outputDirs: List): List { val byName = LinkedHashMap>() for (dir in outputDirs) { diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdaterLaunchSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdaterLaunchSettings.kt new file mode 100644 index 000000000..8391d45aa --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/UpdaterLaunchSettings.kt @@ -0,0 +1,54 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.provider.ProviderFactory + +/** + * The updater's launch-time test switches (`nucleus.updater.feedUrl`, `nucleus.updater.simulate*`, + * read by `updater-runtime`) given to Gradle as `-Pnucleus.updater.…=…`, which `run` forwards to the + * app as system properties and `runDistributable` as environment variables. + */ +internal object UpdaterLaunchSettings { + private const val PREFIX = "nucleus.updater." + + /** Settings of `serveUpdateFeed` itself, not the app's. */ + private const val SERVE_PREFIX = "nucleus.updater.serve." + + fun systemProperties(providers: ProviderFactory): Map = + providers + .gradlePropertiesPrefixedBy(PREFIX) + .get() + .filterKeys { !it.startsWith(SERVE_PREFIX) } + + fun environment(providers: ProviderFactory): Map = + systemProperties(providers).mapKeys { (key, _) -> environmentName(key) } + + /** `nucleus.updater.simulate.justUpdatedFrom` → `NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM`, as the runtime reads it. */ + fun environmentName(key: String): String = + key + .replace(CAMEL_HUMP, "$1_$2") + .replace('.', '_') + .uppercase() + + /** `serveUpdateFeed`'s `-Pnucleus.updater.serve.`. */ + fun serveSetting( + providers: ProviderFactory, + name: String, + ): String? = providers.gradleProperty(SERVE_PREFIX + name).orNull?.trim()?.takeIf { it.isNotEmpty() } + + /** `2000000`, `512k`, `2m` → bytes. */ + fun parseByteRate(value: String): Long? { + val trimmed = value.trim().lowercase() + val multiplier = + when (trimmed.lastOrNull()) { + 'k' -> KIB + 'm' -> MIB + else -> 1L + } + val digits = if (multiplier == 1L) trimmed else trimmed.dropLast(1) + return digits.toDoubleOrNull()?.let { (it * multiplier).toLong() }?.takeIf { it > 0 } + } + + private const val KIB = 1024L + private const val MIB = 1024L * 1024 + private val CAMEL_HUMP = Regex("([a-z0-9])([A-Z])") +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayout.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayout.kt new file mode 100644 index 000000000..b609a8f88 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayout.kt @@ -0,0 +1,96 @@ +package dev.nucleusframework.desktop.application.internal + +import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * Lays a Windows jpackage app image out for hot updates (every NSIS installer): + * + * ``` + * .exe launcher, stays at the root + * app\.cfg rewritten to point into the version directory + * versions\\runtime\ was runtime\ + * versions\\app\ was app\ (everything but the .cfg files) + * ``` + * + * The jpackage launcher reads `app\.cfg` next to itself at every start and nothing more, so + * a new version can be installed next to a running one — nothing the running JVM holds open is + * overwritten — and the rewritten `.cfg` makes the next start pick it up. The `.cfg` names the + * runtime with `app.runtime` and every `$APPDIR` reference becomes `$ROOTDIR\versions\\app`. + * + * Must match `UpdateHandoff` / `WindowsHotUpdate` in the runtime, which recognize the layout from + * `java.home` and read the installed version back from `app.runtime`. + */ +internal object WindowsHotUpdateLayout { + internal const val VERSIONS_DIR_NAME = "versions" + private const val APP_DIR_NAME = "app" + private const val RUNTIME_DIR_NAME = "runtime" + private const val APPLICATION_SECTION = "[Application]" + private const val RUNTIME_KEY = "app.runtime" + private const val APPDIR_MACRO = "\$APPDIR" + private const val ROOTDIR_MACRO = "\$ROOTDIR" + + /** + * Rewrites [appImageDir] in place. Returns `false`, leaving it untouched, when it is not a + * jpackage image (a GraalVM native image has no `.cfg` nor `runtime\`) or is already versioned. + */ + fun apply( + appImageDir: File, + version: String, + ): Boolean { + val appDir = File(appImageDir, APP_DIR_NAME) + val runtimeDir = File(appImageDir, RUNTIME_DIR_NAME) + val cfgFiles = appDir.listFiles { file -> file.isFile && file.extension.equals("cfg", ignoreCase = true) } + if (cfgFiles.isNullOrEmpty() || !runtimeDir.isDirectory) return false + if (File(appImageDir, VERSIONS_DIR_NAME).exists()) return false + + val versionName = versionDirName(version) + val versionDir = File(appImageDir, "$VERSIONS_DIR_NAME/$versionName") + val versionAppDir = File(versionDir, APP_DIR_NAME).apply { mkdirs() } + move(runtimeDir, File(versionDir, RUNTIME_DIR_NAME)) + appDir.listFiles()?.filter { it !in cfgFiles }?.forEach { move(it, File(versionAppDir, it.name)) } + + val versionRoot = "$ROOTDIR_MACRO\\$VERSIONS_DIR_NAME\\$versionName" + cfgFiles.forEach { cfg -> cfg.writeText(rewriteCfg(cfg.readText(), versionRoot)) } + return true + } + + /** A version string made safe as a directory name (it names `versions\`). */ + internal fun versionDirName(version: String): String = + version + .trim() + .replace(Regex("[^A-Za-z0-9._+-]"), "_") + .trimEnd('.') + .ifEmpty { "current" } + + /** Points a launcher `.cfg` at `\app` and `\runtime`. */ + internal fun rewriteCfg( + cfg: String, + versionRoot: String, + ): String { + val lineSeparator = if (cfg.contains("\r\n")) "\r\n" else "\n" + val lines = + cfg + .lines() + .filterNot { it.trim().startsWith("$RUNTIME_KEY=") } + .map { it.replace(APPDIR_MACRO, "$versionRoot\\$APP_DIR_NAME") } + .toMutableList() + val runtimeLine = "$RUNTIME_KEY=$versionRoot\\$RUNTIME_DIR_NAME" + val section = lines.indexOfFirst { it.trim() == APPLICATION_SECTION } + if (section >= 0) { + lines.add(section + 1, runtimeLine) + } else { + lines.addAll(0, listOf(APPLICATION_SECTION, runtimeLine, "")) + } + return lines.joinToString(lineSeparator) + } + + private fun move( + source: File, + target: File, + ) { + target.parentFile.mkdirs() + Files.move(source.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE) + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateNsis.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateNsis.kt new file mode 100644 index 000000000..2f7953bd5 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateNsis.kt @@ -0,0 +1,93 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.logging.Logger +import java.io.File + +/** + * NSIS hooks that let electron-builder's installer run as a hot update, next to a running app laid + * out by [WindowsHotUpdateLayout]. + * + * The updater runs the installer with `NUCLEUS_HOT_UPDATE=1` in its environment (inherited by the + * old version's uninstaller, which the installer runs first). In that mode: + * - `customCheckAppRunning` does not close the running app — by default electron-builder kills + * every process started from the install directory; + * - `customRemoveFiles` (uninstaller) keeps the old version's files — they are in use, and the new + * version deletes the retired `versions\` once the old process has exited. + * + * Without the variable (a manual install, an uninstall, a classic update) both reproduce + * electron-builder's default bodies, copied from the pinned 26.x templates + * (`allowOnlyOneInstallerInstance.nsh` / `uninstaller.nsh`). Defining `customCheckAppRunning` + * makes the template skip `getProcessInfo.nsh` and `Var pid`, which the default body needs, so + * they are declared here. + * + * Both macros are guarded with `!ifmacrondef`: a user include script defining its own wins (and + * [warnOnConflicts] says hot updates are then up to it). + */ +internal object WindowsHotUpdateNsis { + private val HOOKS = listOf("customCheckAppRunning", "customRemoveFiles") + + val MACROS: String = + """ + |; --- Nucleus hot update (see WindowsHotUpdateNsis) --- + |!ifmacrondef customCheckAppRunning + | !include "getProcessInfo.nsh" + | Var pid + | + | !macro customCheckAppRunning + | ReadEnvStr ${'$'}R0 NUCLEUS_HOT_UPDATE + | ${'$'}{if} ${'$'}R0 != "1" + | !insertmacro IS_POWERSHELL_AVAILABLE + | !insertmacro _CHECK_APP_RUNNING + | ${'$'}{endIf} + | !macroend + |!endif + | + |!ifmacrondef customRemoveFiles + | !macro customRemoveFiles + | ReadEnvStr ${'$'}R0 NUCLEUS_HOT_UPDATE + | ${'$'}{if} ${'$'}R0 == "1" + | ${'$'}{andIf} ${'$'}{isUpdated} + | DetailPrint "Hot update: the running version keeps its files" + | ${'$'}{else} + | ${'$'}{if} ${'$'}{isUpdated} + | CreateDirectory "${'$'}PLUGINSDIR\old-install" + | + | Push "" + | Call un.atomicRMDir + | Pop ${'$'}R0 + | + | ${'$'}{if} ${'$'}R0 != 0 + | DetailPrint "File is busy, aborting: ${'$'}R0" + | + | Push "" + | Call un.restoreFiles + | Pop ${'$'}R0 + | + | Abort `Can't rename "${'$'}INSTDIR" to "${'$'}PLUGINSDIR\old-install".` + | ${'$'}{endif} + | ${'$'}{endif} + | + | SetOutPath ${'$'}TEMP + | RMDir /r ${'$'}INSTDIR + | ${'$'}{endIf} + | !macroend + |!endif + | + """.trimMargin() + + /** Warns when [userInclude] defines a hook the hot update needs, since it then takes over. */ + fun warnOnConflicts( + userInclude: File, + logger: Logger, + ) { + val text = runCatching { userInclude.readText() }.getOrDefault("") + val overridden = HOOKS.filter { Regex("""!macro\s+$it\b""").containsMatchIn(text) } + if (overridden.isNotEmpty()) { + logger.warn( + "nsis.includeScript defines ${overridden.joinToString()}; Nucleus keeps yours, so hot " + + "updates only work if it honours NUCLEUS_HOT_UPDATE=1 (leave the running app and " + + "its files alone); otherwise the app updates the classic way, closing during the install.", + ) + } + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt index 959aa1996..df131872f 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureGraalvmApplication.kt @@ -4,14 +4,18 @@ package dev.nucleusframework.desktop.application.internal import dev.nucleusframework.desktop.application.dsl.FileAssociation import dev.nucleusframework.desktop.application.dsl.GraalvmSettings +import dev.nucleusframework.desktop.application.dsl.MacAppExtension import dev.nucleusframework.desktop.application.dsl.NativeImageMarch import dev.nucleusframework.desktop.application.dsl.PackagingBackend +import dev.nucleusframework.desktop.application.dsl.TargetFormat import dev.nucleusframework.desktop.application.dsl.UrlProtocol import dev.nucleusframework.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistListValue import dev.nucleusframework.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistMapValue import dev.nucleusframework.desktop.application.internal.InfoPlistBuilder.InfoPlistValue.InfoPlistStringValue +import dev.nucleusframework.desktop.application.internal.files.nucleusNativeDir import dev.nucleusframework.desktop.application.tasks.AbstractElectronBuilderPackageTask import dev.nucleusframework.desktop.application.tasks.AbstractNotarizationTask +import dev.nucleusframework.desktop.application.tasks.AbstractUnpackNucleusNativesTask import dev.nucleusframework.desktop.tasks.AbstractUnpackDefaultApplicationResourcesTask import dev.nucleusframework.internal.kotlinJvmExtOrNull import dev.nucleusframework.internal.mppExtOrNull @@ -103,6 +107,29 @@ private fun JvmApplicationContext.copyGraalvmAppResources( } } +/** + * Copies the Nucleus JNI libraries the image was compiled without next to the executable, where + * `GraalVmInitializer` points `java.library.path`. + */ +private fun JvmApplicationContext.copyGraalvmNucleusNatives( + unpackNucleusNatives: TaskProvider, + into: Provider, + extraDepends: List> = emptyList(), + doNotTrack: Boolean = false, +): TaskProvider = + tasks.register( + taskNameAction = "copy", + taskNameObject = "graalvmNucleusNatives", + ) { + description = "Copy the Nucleus JNI libraries next to the native executable" + extraDepends.forEach { dependsOn(it) } + if (doNotTrack) { + doNotTrackState("Output directory is modified by downstream strip/codesign tasks") + } + from(unpackNucleusNatives.flatMap { it.libsDir }) + into(into) + } + @Suppress("LongMethod", "CyclomaticComplexMethod") internal fun JvmApplicationContext.configureGraalvmApplication() { val graalvm = app.graalvm @@ -241,6 +268,19 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { val uberJarTaskName = "package${buildType.classifier.uppercaseFirstChar()}UberJarForCurrentOS" val packageUberJar = project.tasks.named(uberJarTaskName, Jar::class.java) + // The image is compiled from a copy without the Nucleus JNI libraries, which ship next to + // the executable instead (see AbstractUnpackNucleusNativesTask). + val unpackNucleusNatives = + tasks.register( + taskNameAction = "unpack", + taskNameObject = "graalvmNucleusNatives", + ) { + uberJar.set(packageUberJar.flatMap { it.archiveFile }) + platformDir.set(nucleusNativeDir(currentOS, currentArch)) + strippedJar.set(appTmpDir.map { it.file("graalvm/nucleus-natives/app.jar") }) + libsDir.set(appTmpDir.map { it.dir("graalvm/nucleus-natives/libs") }) + } + // ── runWithNativeAgent ── // Agent writes to a temp dir, then automatically merges into the real config // without overwriting manually enriched entries (e.g. allDeclaredFields). @@ -839,7 +879,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { ) { description = "Compile the application into a GraalVM native image" - dependsOn(packageUberJar) + dependsOn(unpackNucleusNatives) dependsOn(generatePlatformMetadata) dependsOn(resolveReachabilityMetadata) dependsOn(analyzeStaticMetadata) @@ -848,7 +888,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { compileStubs?.let { dependsOn(it) } generateWindowsResources?.let { dependsOn(it) } - val uberJarFile = packageUberJar.flatMap { it.archiveFile } + val uberJarFile = unpackNucleusNatives.flatMap { it.strippedJar } val outputDir = nativeCompileDir.get().asFile outputs.dir(outputDir) @@ -1065,6 +1105,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { requested = resolvedGarbageCollector, isOracleGraalvm = oracleGraalvm, isLinux = currentOS == OS.Linux, + graalvmVersion = graalvmVersionOf(File(resolvedGraalvmHome)), graalvmHome = resolvedGraalvmHome, ) gcResolution.warning?.let { logger.warn(it) } @@ -1271,6 +1312,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { imageName, unpackDefaultResources, packageUberJar, + unpackNucleusNatives, ) OS.Windows -> configureWindowsGraalvmPackaging( @@ -1280,6 +1322,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { nativeCompileDir, imageName, packageUberJar, + unpackNucleusNatives, ) OS.Linux -> configureLinuxGraalvmPackaging( @@ -1289,6 +1332,7 @@ internal fun JvmApplicationContext.configureGraalvmApplication() { nativeCompileDir, imageName, packageUberJar, + unpackNucleusNatives, ) } @@ -1438,6 +1482,7 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging( imageName: org.gradle.api.provider.Provider, unpackDefaultResources: TaskProvider, packageUberJar: TaskProvider, + unpackNucleusNatives: TaskProvider, ): TaskProvider { val appBundleName = resolvedMacBundleNameProvider().map { "$it.app" } val appBundleDir = @@ -1552,13 +1597,22 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging( into(appBundleDir.map { it.dir("MacOS/lib") }) } + // Stripped, patched and signed with the other dylibs of MacOS/, which is java.library.path. + val copyNucleusNatives = + copyGraalvmNucleusNatives( + unpackNucleusNatives, + into = appBundleDir.map { it.dir("MacOS") }, + extraDepends = listOf(cleanAppBundle), + doNotTrack = true, + ) + val stripDylibs = tasks.register( taskNameAction = "strip", taskNameObject = "graalvmDylibs", ) { description = "Strip debug symbols from dylibs" - dependsOn(copyAwtDylibs) + dependsOn(copyAwtDylibs, copyNucleusNatives) doLast { val macosDir = appBundleDir.get().dir("MacOS").asFile @@ -1896,6 +1950,29 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging( commandLine("codesign", "--force", "--deep", "--sign", "-", bundleDir.get().asFile.absolutePath) } + // Embed and (ad-hoc) sign app extensions into Contents/PlugIns after the bundle is sealed, + // then re-seal the outer bundle without --deep so each extension keeps its own entitlements. + val macAppExtensions = app.nativeDistributions.macOS.appExtensions.extensions + val embedAppExtensions = + if (macAppExtensions.isNotEmpty()) { + tasks.register( + taskNameAction = "embed", + taskNameObject = "graalvmAppExtensions", + ) { + description = "Embed and sign macOS app extensions (.appex) into the .app bundle" + dependsOn(codesignBundle) + for (extension in macAppExtensions) { + extension.appex?.let { inputs.dir(it) } + extension.entitlements?.let { inputs.file(it) } + extension.provisioningProfile?.let { inputs.file(it) } + } + val bundleDir = appTmpDir.map { it.dir("graalvm/output/${appBundleName.get()}") }.get().asFile + commandLine("bash", "-c", buildGraalvmAppExtensionEmbedScript(bundleDir, macAppExtensions)) + } + } else { + null + } + return tasks.register( taskNameAction = "package", taskNameObject = "graalvmNative", @@ -1918,6 +1995,48 @@ private fun JvmApplicationContext.configureMacOsGraalvmPackaging( copyIcon, ) copyFileAssociationIcons?.let { dependsOn(it) } + embedAppExtensions?.let { dependsOn(it) } + } +} + +/** + * Builds the bash script that embeds each `.appex` into the GraalVM `.app` bundle's + * `Contents/PlugIns/`, signs it (ad-hoc) with its own entitlements inside-out, and re-seals + * the outer bundle without `--deep`. GraalVM native images are always ad-hoc signed. + */ +private fun buildGraalvmAppExtensionEmbedScript( + bundleDir: File, + extensions: List, +): String { + fun quote(file: File): String = "'" + file.absolutePath.replace("'", "'\\''") + "'" + + val plugInsDir = File(bundleDir, "Contents/PlugIns") + return buildString { + appendLine("set -euo pipefail") + appendLine("mkdir -p ${quote(plugInsDir)}") + for (extension in extensions) { + val source = + extension.appex + ?: error("appExtension '${extension.name}': no .appex file configured (call appex(...))") + val dest = File(plugInsDir, source.name) + val frameworks = File(dest, "Contents/Frameworks") + val entitlementsArg = extension.entitlements?.let { " --entitlements ${quote(it)}" } ?: "" + + appendLine("rm -rf ${quote(dest)}") + appendLine("cp -R ${quote(source)} ${quote(plugInsDir)}/") + extension.provisioningProfile?.let { profile -> + appendLine("cp ${quote(profile)} ${quote(File(dest, "Contents/embedded.provisionprofile"))}") + } + // Sign nested frameworks first (inside-out), then the extension bundle. + appendLine( + "if [ -d ${quote(frameworks)} ]; then find ${quote(frameworks)} -type f " + + "-exec codesign --force --options runtime$entitlementsArg --sign - {} +; fi", + ) + appendLine("codesign --force --options runtime$entitlementsArg --sign - ${quote(dest)}") + } + // Re-seal the outer bundle (no --deep) so the nested extension signatures are preserved. + appendLine("codesign --force --options runtime --sign - ${quote(bundleDir)}") + appendLine("codesign --verify --deep --strict --verbose=2 ${quote(bundleDir)}") } } @@ -1933,6 +2052,7 @@ private fun JvmApplicationContext.configureWindowsGraalvmPackaging( nativeCompileDir: org.gradle.api.provider.Provider, imageName: org.gradle.api.provider.Provider, packageUberJar: TaskProvider, + unpackNucleusNatives: TaskProvider, ): TaskProvider { val outputDir = graalvmOutputDir.map { it.dir(resolvedPackageNameProvider().get()) } @@ -2069,13 +2189,14 @@ private fun JvmApplicationContext.configureWindowsGraalvmPackaging( } val copyAppResources = copyGraalvmAppResources(into = outputDir) + val copyNucleusNatives = copyGraalvmNucleusNatives(unpackNucleusNatives, into = outputDir) return tasks.register( taskNameAction = "package", taskNameObject = "graalvmNative", ) { description = "Build native image and package with DLLs" - dependsOn(copyBinary, copyAppResources) + dependsOn(copyBinary, copyAppResources, copyNucleusNatives) if (!graalvm.headless.get()) { dependsOn(copyAwtDlls, copyJvmDll, copyJawtToBin, copySkikoLib, copyFontConfig) } @@ -2095,6 +2216,7 @@ private fun JvmApplicationContext.configureLinuxGraalvmPackaging( nativeCompileDir: org.gradle.api.provider.Provider, imageName: org.gradle.api.provider.Provider, packageUberJar: TaskProvider, + unpackNucleusNatives: TaskProvider, ): TaskProvider { val headless = graalvm.headless.get() val outputDir = graalvmOutputDir.map { it.dir(resolvedPackageNameProvider().get()) } @@ -2197,13 +2319,15 @@ private fun JvmApplicationContext.configureLinuxGraalvmPackaging( commandLine("patchelf", "--set-rpath", "\$ORIGIN", binary.get().asFile.absolutePath) } + val copyNucleusNatives = copyGraalvmNucleusNatives(unpackNucleusNatives, into = outputDir, doNotTrack = true) + val fixSoRpath = tasks.register( taskNameAction = "fix", taskNameObject = "graalvmSoRpath", ) { description = "Set RPATH to \$ORIGIN on companion .so libs so inter-library deps resolve" - dependsOn(copyAwtSoLibs, copyJvmSo) + dependsOn(copyAwtSoLibs, copyJvmSo, copyNucleusNatives) val dir = outputDir.get().asFile.absolutePath commandLine("bash", "-c", "for f in '$dir'/*.so; do patchelf --set-rpath '\$ORIGIN' \"\$f\"; done") } @@ -2214,7 +2338,7 @@ private fun JvmApplicationContext.configureLinuxGraalvmPackaging( taskNameObject = "graalvmSoLibs", ) { description = "Strip debug symbols from .so libs" - dependsOn(copyAwtSoLibs, copyJvmSo, fixSoRpath) + dependsOn(copyAwtSoLibs, copyJvmSo, copyNucleusNatives, fixSoRpath) commandLine("bash", "-c", "strip --strip-debug '${outputDir.get().asFile.absolutePath}'/*.so") } @@ -2240,7 +2364,7 @@ private fun JvmApplicationContext.configureLinuxGraalvmPackaging( taskNameObject = "graalvmNative", ) { description = "Build native image and package with .so libs" - dependsOn(copyBinary, copyAppResources, fixRpath, stripBinary) + dependsOn(copyBinary, copyAppResources, copyNucleusNatives, fixRpath, stripBinary) if (!headless) { dependsOn( copyAwtSoLibs, @@ -2265,7 +2389,22 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging( ) { val ebFormats = app.nativeDistributions.targetFormats - .filter { it.backend == PackagingBackend.ELECTRON_BUILDER && !it.isStoreFormat } + .filter { it.backend == PackagingBackend.ELECTRON_BUILDER && !app.nativeDistributions.isSandboxed(it) } + + val droppedStoreFormats = + app.nativeDistributions.targetFormats + .filter { app.nativeDistributions.isSandboxed(it) && it.isCompatibleWithCurrentOS } + if (droppedStoreFormats.isNotEmpty()) { + // info, not warn: the configuration is legitimate and nothing is lost overall — the JVM + // packagePkg still builds the store package. Only the GraalVM-native variant is skipped, + // and warning on every configuration would fire on any project combining the two. + project.logger.info( + "GraalVM native image does not support the sandboxed (store) pipeline, so no " + + "packageGraalvm task is registered for ${droppedStoreFormats.joinToString { it.name }}; " + + "the JVM package task still builds it. For a native PKG use " + + "macOS { pkg { appStore = false } } (Developer ID).", + ) + } for (targetFormat in ebFormats) { val packageFormat = @@ -2316,8 +2455,13 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging( val mac = app.nativeDistributions.macOS nonValidatedMacSigningSettings = mac.signing nonValidatedMacBundleID.set(mac.bundleID) - // PKG is always treated as App Store — ignore the deprecated user setting. - macAppStore.set(targetFormat.isStoreFormat) + // Sandboxed formats are filtered out above, so a PKG reaching this point is + // always Developer ID — the GraalVM pipeline does not build store packages. + macAppStore.set(false) + if (targetFormat == TargetFormat.Pkg) { + macPkgPreInstall.set(mac.pkg.preInstall) + macPkgPostInstall.set(mac.pkg.postInstall) + } macEntitlementsFile.set( mac.entitlementsFile.orElse( unpackDefaultResources.flatMap { it.resources.defaultEntitlements }, @@ -2328,11 +2472,19 @@ private fun JvmApplicationContext.configureGraalvmElectronBuilderPackaging( unpackDefaultResources.flatMap { it.resources.defaultEntitlements }, ), ) + macAppExtensions.set(mac.appExtensions.extensions) + macAppExtensionFiles.from( + mac.appExtensions.extensions.flatMap { + listOfNotNull(it.appex, it.entitlements, it.provisioningProfile) + }, + ) } } executableName.set(imageName) + runtimeAppId.set(resolvedAppIdProvider()) customNodePath.set(NucleusProperties.electronBuilderNodePath(project.providers)) + configureNodeJs(project, app.nativeDistributions.nodejs) publishMode.set(NucleusProperties.electronBuilderPublishMode(project.providers)) linuxAfterInstall.set(app.nativeDistributions.linux.afterInstall) linuxAfterRemove.set(app.nativeDistributions.linux.afterRemove) diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt index 025eab8b9..a6c58d11f 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/configureJvmApplication.kt @@ -10,7 +10,10 @@ package dev.nucleusframework.desktop.application.internal import dev.nucleusframework.desktop.application.dsl.AotCacheCompatibility import dev.nucleusframework.desktop.application.dsl.AotCacheSettings import dev.nucleusframework.desktop.application.dsl.PackagingBackend +import dev.nucleusframework.desktop.application.dsl.PkgSettings import dev.nucleusframework.desktop.application.dsl.TargetFormat +import dev.nucleusframework.desktop.application.internal.files.nucleusNativeDir +import dev.nucleusframework.desktop.application.internal.transforms.configureLcdTextDefaultTransform import dev.nucleusframework.desktop.application.internal.validation.validateMacBundleName import dev.nucleusframework.desktop.application.internal.validation.validatePackageVersions import dev.nucleusframework.desktop.application.tasks.AbstractCheckNativeDistributionRuntime @@ -27,6 +30,7 @@ import dev.nucleusframework.desktop.application.tasks.AbstractPatchMacJvmTask import dev.nucleusframework.desktop.application.tasks.AbstractProguardTask import dev.nucleusframework.desktop.application.tasks.AbstractRunAppXTask import dev.nucleusframework.desktop.application.tasks.AbstractRunDistributableTask +import dev.nucleusframework.desktop.application.tasks.AbstractServeUpdateFeedTask import dev.nucleusframework.desktop.application.tasks.AbstractStripNativeLibsFromJarsTask import dev.nucleusframework.desktop.application.tasks.AbstractSuggestModulesTask import dev.nucleusframework.desktop.tasks.AbstractJarsFlattenTask @@ -78,6 +82,9 @@ internal const val NUCLEUS_TASK_GROUP = "nucleus" // todo: file associations // todo: use workers internal fun JvmApplicationContext.configureJvmApplication() { + applyNucleusOptimization(app) + applyNucleusOptimizationJdk(project, app) + if (app.isDefaultConfigurationEnabled) { configureDefaultApp() } @@ -86,6 +93,10 @@ internal fun JvmApplicationContext.configureJvmApplication() { registerCleanNativeLibsTransform(project) } + // LCD / ClearType text on Windows (#875): patch Compose's hardcoded + // grayscale PlatformDefault at build time — see LcdTextDefaultTransform. + configureLcdTextDefaultTransform(project) + validatePackageVersions() validateMacBundleName() val commonTasks = configureCommonJvmDesktopTasks() @@ -146,6 +157,8 @@ private fun JvmApplicationContext.configureCommonJvmDesktopTasks(): CommonJvmDes val taskId = appxSettings.startupTaskId ?: "SlackStartup" startupTaskId.set(taskId) } + // Native images have no launcher .cfg for the idle-GC -D flag; bake it here too. + idleGc.set(project.provider { app.optIdleGc }) outputDir.set(appTmpDir.dir("app-properties")) } @@ -277,6 +290,7 @@ private fun JvmApplicationContext.configureCommonJvmDesktopTasks(): CommonJvmDes modules.set(provider { app.nativeDistributions.modules }) includeAllModules.set(provider { app.nativeDistributions.includeAllModules }) javaRuntimePropertiesFile.set(checkRuntime.flatMap { it.javaRuntimePropertiesFile }) + stripJreFonts.set(provider { app.nativeDistributions.stripJreFonts }) destinationDir.set(appTmpDir.dir("runtime")) } @@ -325,8 +339,8 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm val allEbFormats = app.nativeDistributions.targetFormats .filter { it.backend == PackagingBackend.ELECTRON_BUILDER } - val nonStoreFormats = allEbFormats.filter { !it.isStoreFormat } - val storeFormats = allEbFormats.filter { it.isStoreFormat } + val nonStoreFormats = allEbFormats.filter { !app.nativeDistributions.isSandboxed(it) } + val storeFormats = allEbFormats.filter { app.nativeDistributions.isSandboxed(it) } // Strip native libs from JARs for the sandboxed pipeline (store formats only). val stripNativeLibsFromJars = @@ -375,6 +389,14 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm } } + val flattenJars = + tasks.register( + taskNameAction = "flatten", + taskNameObject = "Jars", + ) { + configureFlattenJars(this, runProguard) + } + // === Non-sandboxed pipeline (direct distribution formats: DMG, ZIP, NSIS, etc.) === val createDistributable = @@ -390,6 +412,7 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm checkRuntime = commonTasks.checkRuntime, unpackDefaultResources = commonTasks.unpackDefaultResources, runProguard = runProguard, + flattenJars = flattenJars, patchCaCertificates = commonTasks.patchCaCertificates, sandboxed = false, ) @@ -453,7 +476,7 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm packageFormat } - // === Sandboxed pipeline (store formats: PKG, AppX, Flatpak) === + // === Sandboxed pipeline (store formats: App Store PKG, AppX, Flatpak) === val storeNotarizeTasks = mutableListOf>() @@ -474,6 +497,7 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm checkRuntime = commonTasks.checkRuntime, unpackDefaultResources = commonTasks.unpackDefaultResources, runProguard = runProguard, + flattenJars = flattenJars, stripNativeLibs = stripNativeLibsFromJars, patchCaCertificates = commonTasks.patchCaCertificates, sandboxed = true, @@ -555,6 +579,8 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm val mergeUpdateYml: TaskProvider? = registerUpdateYmlMergeIfNeeded(nonStoreFormats, nonStorePackageFormats) + registerServeUpdateFeedIfNeeded(nonStoreFormats, nonStorePackageFormats) + val notarizeForCurrentOS = if (allNotarizeTasks.isNotEmpty()) { tasks.register( @@ -591,14 +617,6 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm } } - val flattenJars = - tasks.register( - taskNameAction = "flatten", - taskNameObject = "Jars", - ) { - configureFlattenJars(this, runProguard) - } - val packageUberJarForCurrentOS = tasks.register( taskNameAction = "package", @@ -613,7 +631,9 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm taskNameAction = "run", taskNameObject = "distributable", args = listOf(createDistributable), - ) + ) { + environment.putAll(UpdaterLaunchSettings.environment(project.providers)) + } if (generateAotCache != null) { runDistributable.dependsOn(generateAotCache) } @@ -653,7 +673,7 @@ private fun JvmApplicationContext.configurePackagingTasks(commonTasks: CommonJvm val patchMacJvmTask: TaskProvider? = if (currentOS == OS.MacOS && app.nativeDistributions.macOS.macOsSdkVersion != null) { registerPatchMacJvmTask( - javaHome = app.javaHome, + javaHome = app.javaHomeProvider, minVersion = app.nativeDistributions.macOS.minimumSystemVersion ?: "10.13", sdkVersion = app.nativeDistributions.macOS.macOsSdkVersion!!, ) @@ -689,6 +709,35 @@ private fun AbstractGenerateAotCacheTask.applyAotCacheSettings(settings: AotCach extraTrainingJvmArgs.set(settings.extraTrainingJvmArgs.toList()) } +/** + * Registers `serveUpdateFeed`, which packages the auto-updatable formats of the current OS and + * serves them over loopback HTTP, so an installed copy of the app can update to this build with + * nothing published. Returns null when no auto-updatable format targets the current OS. + */ +private fun JvmApplicationContext.registerServeUpdateFeedIfNeeded( + nonStoreFormats: List, + nonStorePackageFormats: List>, +): TaskProvider? { + val updatableTasks = + nonStoreFormats.zip(nonStorePackageFormats) + .filter { (format, _) -> format.isCompatibleWithCurrentOS && format.producesUpdateManifest } + .map { (_, task) -> task } + if (updatableTasks.isEmpty()) return null + + return tasks.register( + taskNameAction = "serve", + taskNameObject = "updateFeed", + ) { + dependsOn(updatableTasks) + perFormatOutputDirs.from(updatableTasks.map { provider -> provider.flatMap { it.destinationDir } }) + val providers = project.providers + UpdaterLaunchSettings.serveSetting(providers, "port")?.toIntOrNull()?.let(port::set) + UpdaterLaunchSettings.serveSetting(providers, "throttle")?.let(UpdaterLaunchSettings::parseByteRate)?.let(throttleBytesPerSecond::set) + UpdaterLaunchSettings.serveSetting(providers, "latency")?.toLongOrNull()?.let(latencyMillis::set) + UpdaterLaunchSettings.serveSetting(providers, "timeout")?.toLongOrNull()?.let(timeoutSeconds::set) + } +} + private fun JvmApplicationContext.registerUpdateYmlMergeIfNeeded( nonStoreFormats: List, nonStorePackageFormats: List>, @@ -752,7 +801,11 @@ private fun JvmApplicationContext.configureProguardTask( dontobfuscate.set(settings.obfuscate.map { !it }) dontoptimize.set(settings.optimize.map { !it }) - joinOutputJars.set(settings.joinOutputJars) + joinOutputJars.set( + settings.joinOutputJars.map { enabled -> + enabled || app.optSingleJar + }, + ) dependsOn(unpackDefaultResources) defaultComposeRulesFile.set(unpackDefaultResources.flatMap { it.resources.defaultComposeProguardRules }) @@ -793,6 +846,7 @@ private fun JvmApplicationContext.configurePackageTask( checkRuntime: TaskProvider? = null, unpackDefaultResources: TaskProvider, runProguard: Provider? = null, + flattenJars: TaskProvider? = null, stripNativeLibs: TaskProvider? = null, patchCaCertificates: TaskProvider? = null, sandboxed: Boolean = false, @@ -855,9 +909,10 @@ private fun JvmApplicationContext.configurePackageTask( val strippedOutputDir = stripNativeLibs.flatMap { it.outputDir } packageTask.files.from( strippedOutputDir.map { dir -> - dir.asFileTree.matching { it.exclude(".main-jar-name") } + dir.asFileTree.matching { it.exclude(".main-jar-name", ".classpath-order") } }, ) + packageTask.classpathOrderFile.set(strippedOutputDir.map { it.file(".classpath-order") }) val strippedMainJarName = stripNativeLibs.flatMap { it.mainJarName } packageTask.launcherMainJar.fileProvider( strippedOutputDir.zip(strippedMainJarName) { dir, mainJarName -> @@ -882,6 +937,14 @@ private fun JvmApplicationContext.configurePackageTask( packageTask.mangleJarFilesNames.set(false) packageTask.packageFromUberJar.set(runProguard.flatMap { it.joinOutputJars }) } + app.optSingleJar && flattenJars != null -> { + packageTask.dependsOn(flattenJars) + val flattened = flattenJars.flatMap { it.flattenedJar } + packageTask.files.from(flattened) + packageTask.launcherMainJar.set(flattened) + packageTask.mangleJarFilesNames.set(false) + packageTask.packageFromUberJar.set(true) + } else -> { packageTask.useAppRuntimeFiles { (runtimeJars, mainJar) -> files.from(runtimeJars) @@ -892,6 +955,7 @@ private fun JvmApplicationContext.configurePackageTask( packageTask.launcherMainClass.set(app.mainClass) packageTask.sandboxingEnabled.set(sandboxed) + packageTask.nucleusNativeDir.set(nucleusNativeDir(currentOS, targetArch)) packageTask.launcherJvmArgs.set( provider { val executableTypeArg = "-D$APP_EXECUTABLE_TYPE=${packageTask.targetFormat.executableTypeValue}" @@ -930,6 +994,7 @@ private fun JvmApplicationContext.configureElectronBuilderPackageTask( ) packageTask.packageName.set(packageNameProvider) + packageTask.runtimeAppId.set(resolvedAppIdProvider()) packageTask.executableName.set( project.provider { val dist = app.nativeDistributions @@ -964,6 +1029,7 @@ private fun JvmApplicationContext.configureElectronBuilderPackageTask( packageTask.startupWMClass.set(startupWMClass) } packageTask.customNodePath.set(NucleusProperties.electronBuilderNodePath(project.providers)) + packageTask.configureNodeJs(project, app.nativeDistributions.nodejs) packageTask.publishMode.set(NucleusProperties.electronBuilderPublishMode(project.providers)) packageTask.appxStoreLogo.set(app.nativeDistributions.windows.appx.storeLogo) packageTask.appxSquare44x44Logo.set(app.nativeDistributions.windows.appx.square44x44Logo) @@ -980,9 +1046,16 @@ private fun JvmApplicationContext.configureElectronBuilderPackageTask( val mac = app.nativeDistributions.macOS packageTask.nonValidatedMacSigningSettings = mac.signing packageTask.nonValidatedMacBundleID.set(mac.bundleID) - // PKG is always treated as App Store — ignore the deprecated user setting for store formats. - packageTask.macAppStore.set(packageTask.targetFormat.isStoreFormat) - val sandboxed = packageTask.targetFormat.isStoreFormat + // A PKG is sandboxed (App Store) or not (Developer ID) by DSL choice; AppX/Flatpak always are. + val sandboxed = app.nativeDistributions.isSandboxed(packageTask.targetFormat) + packageTask.macAppStore.set(sandboxed) + // Only the PKG task reads the install scripts. Wiring them everywhere would make a typo in + // the path fail packageDmg / packageZip too, since Gradle checks every @InputFile exists. + if (packageTask.targetFormat == TargetFormat.Pkg) { + validatePkgScripts(mac.pkg) + packageTask.macPkgPreInstall.set(mac.pkg.preInstall) + packageTask.macPkgPostInstall.set(mac.pkg.postInstall) + } val defaultAppEntitlements = if (sandboxed) { unpackDefaultResources.get { defaultSandboxEntitlements } @@ -1001,6 +1074,12 @@ private fun JvmApplicationContext.configureElectronBuilderPackageTask( packageTask.macRuntimeEntitlementsFile.set( mac.runtimeEntitlementsFile.orElse(defaultRuntimeEntitlements), ) + packageTask.macAppExtensions.set(mac.appExtensions.extensions) + packageTask.macAppExtensionFiles.from( + mac.appExtensions.extensions.flatMap { + listOfNotNull(it.appex, it.entitlements, it.provisioningProfile) + }, + ) } } @@ -1029,6 +1108,20 @@ private fun TaskProvider Provider, ) = flatMap { fn(it.resources) } +/** + * Fails at configuration time on a PKG channel contradiction, rather than after minutes of + * packaging: the Mac App Store rejects installer packages carrying install scripts (error 90254). + * File-level checks (existence, shebang) stay in `MacPkgScripts` at execution time. + */ +internal fun validatePkgScripts(pkg: PkgSettings) { + if (pkg.appStore && pkg.hasScripts) { + error( + "macOS { pkg { preInstall / postInstall } } requires pkg { appStore = false }: " + + "the Mac App Store rejects installer packages that carry install scripts (error 90254).", + ) + } +} + internal fun JvmApplicationContext.configurePlatformSettings( packageTask: AbstractJPackageTask, defaultResources: TaskProvider, @@ -1066,10 +1159,10 @@ internal fun JvmApplicationContext.configurePlatformSettings( } }, ) - // The jpackage task always builds a RawAppImage, so targetFormat.isStoreFormat - // is always false. Use the sandboxed flag instead: sandboxed distributable feeds - // store formats (PKG) and must pass --mac-app-store to jpackage so it searches - // for the correct certificate type ("3rd Party Mac Developer Application"). + // The jpackage task always builds a RawAppImage, so the format says nothing about + // the channel. Use the sandboxed flag instead: the sandboxed distributable feeds + // the store formats (App Store PKG) and must pass --mac-app-store to jpackage so it + // searches for the correct certificate type ("3rd Party Mac Developer Application"). packageTask.macAppStore.set(sandboxed) packageTask.macAppCategory.set(mac.appCategory) packageTask.macMinimumSystemVersion.set(mac.minimumSystemVersion) @@ -1103,6 +1196,12 @@ internal fun JvmApplicationContext.configurePlatformSettings( packageTask.urlProtocols.set(app.nativeDistributions.protocols) packageTask.macLayeredIcons.set(mac.layeredIconDir) packageTask.macLaunchAgents.set(mac.launchAgents.agents) + packageTask.macAppExtensions.set(mac.appExtensions.extensions) + packageTask.macAppExtensionFiles.from( + mac.appExtensions.extensions.flatMap { + listOfNotNull(it.appex, it.entitlements, it.provisioningProfile) + }, + ) } } } @@ -1117,11 +1216,9 @@ private fun JvmApplicationContext.configureRunTask( exec.dependsOn(prepareAppResources) exec.mainClass.set(app.mainClass) - exec.executable(javaExecutable(app.javaHome)) if (currentOS == OS.MacOS) { val sdkVersion = app.nativeDistributions.macOS.macOsSdkVersion if (sdkVersion != null && patchMacJvmTask != null) { - val javaHome = app.javaHome exec.dependsOn(patchMacJvmTask) // Route the fork through a vtool-patched copy of the JDK so AppKit // gates Liquid Glass on. `javaLauncher` is finalized before @@ -1140,12 +1237,14 @@ private fun JvmApplicationContext.configureRunTask( .asFile val patchedJavaHomeFile = patchedBinFile.parentFile.parentFile exec.javaLauncher.set( - ExternalJavaLauncher( - javaBinary = patchedBinFile, - javaHome = patchedJavaHomeFile, - objects = project.objects, - metadataJavaHome = java.io.File(javaHome), - ), + app.javaHomeProvider.map { home -> + ExternalJavaLauncher( + javaBinary = patchedBinFile, + javaHome = patchedJavaHomeFile, + objects = project.objects, + metadataJavaHome = java.io.File(home), + ) + }, ) // `executable` isn't Provider-aware in Gradle 9, but it isn't // finalized before `doFirst` either — align it with the launcher @@ -1153,7 +1252,11 @@ private fun JvmApplicationContext.configureRunTask( exec.doFirst { (it as JavaExec).executable(patchedBinFile.absolutePath) } + } else { + configureRunJavaHome(exec) } + } else { + configureRunJavaHome(exec) } exec.jvmArgs = arrayListOf().apply { @@ -1163,6 +1266,8 @@ private fun JvmApplicationContext.configureRunTask( app.garbageCollector?.let { addAll(it.jvmArgs) } add("-D$APP_EXECUTABLE_TYPE=$EXECUTABLE_TYPE_DEV") add("-D$APP_ID=${resolvedAppIdProvider().get()}") + // ./gradlew run -Pnucleus.updater.simulate=update / -Pnucleus.updater.feedUrl=

+ UpdaterLaunchSettings.systemProperties(project.providers).forEach { (key, value) -> add("-D$key=$value") } if (currentOS == OS.MacOS) { val dockName = @@ -1290,8 +1395,24 @@ private fun sandboxingJvmArgs(resourcesPath: String): List = * tasks of all build types since inputs (javaHome, SDK/min version) are * identical at the project level. */ +private fun JvmApplicationContext.configureRunJavaHome(exec: JavaExec) { + if (app.javaHomeOverride != null) { + exec.javaLauncher.set( + app.javaHomeProvider.map { home -> + ExternalJavaLauncher( + javaBinary = java.io.File(javaExecutable(home)), + javaHome = java.io.File(home), + objects = project.objects, + ) + }, + ) + } else { + exec.executable(javaExecutable(app.javaHome)) + } +} + private fun JvmApplicationContext.registerPatchMacJvmTask( - javaHome: String, + javaHome: Provider, minVersion: String, sdkVersion: String, ): TaskProvider { diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt index 29437455c..36b6ff866 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderConfigGenerator.kt @@ -16,6 +16,8 @@ import dev.nucleusframework.desktop.application.dsl.NsisSettings import dev.nucleusframework.desktop.application.dsl.PublishSettings import dev.nucleusframework.desktop.application.dsl.SnapSettings import dev.nucleusframework.desktop.application.dsl.TargetFormat +import dev.nucleusframework.desktop.application.internal.MacPkgScripts +import dev.nucleusframework.desktop.application.internal.validation.stripAppleCertificatePrefix import dev.nucleusframework.internal.utils.Arch import dev.nucleusframework.internal.utils.OS import dev.nucleusframework.internal.utils.currentOS @@ -70,7 +72,7 @@ internal class ElectronBuilderConfigGenerator { executableName: String? = null, dmgBackgroundOverride: File? = null, dmgWindowOverride: DmgWindowOverride? = null, - nsisProtocolInclude: File? = null, + nsisInclude: File? = null, macBundleName: String? = null, ): String { val yaml = StringBuilder() @@ -140,7 +142,7 @@ internal class ElectronBuilderConfigGenerator { targetArch, windowsIconOverride, executableName, - nsisProtocolInclude, + nsisInclude, ) OS.Linux -> generateLinuxConfig( @@ -174,7 +176,7 @@ internal class ElectronBuilderConfigGenerator { return yaml.toString() } - private fun generateMacConfig( + internal fun generateMacConfig( yaml: StringBuilder, distributions: JvmApplicationDistributions, targetFormat: TargetFormat, @@ -196,6 +198,15 @@ internal class ElectronBuilderConfigGenerator { ) appendIfNotNull(yaml, " minimumSystemVersion", distributions.macOS.minimumSystemVersion) + // electron-builder never notarizes the PKG. App Store binaries are not Developer ID, so + // notarytool would return "Invalid" — and without this it submits the .app anyway whenever + // APPLE_ID / APPLE_API_KEY / APPLE_KEYCHAIN_PROFILE are in the environment (#650). A + // Developer ID PKG is notarized and stapled as a whole by the notarizePkg task, which + // covers the embedded .app. + if (targetFormat == TargetFormat.Pkg) { + yaml.appendLine(" notarize: false") + } + // When not signing, disable signature-related features if (distributions.macOS.signing.sign.orNull != true) { yaml.appendLine(" identity: null") @@ -212,10 +223,10 @@ internal class ElectronBuilderConfigGenerator { if (distributions.macOS.signing.sign.orNull != true) { yaml.appendLine(" identity: null") } else { - val installerIdentity = resolveInstallerIdentity(distributions.macOS) - if (installerIdentity != null) { - yaml.appendLine(" identity: \"$installerIdentity\"") - } + appendIfNotNull(yaml, " identity", resolveInstallerIdentity(distributions.macOS)) + } + if (distributions.macOS.pkg.hasScripts) { + yaml.appendLine(" scripts: \"${MacPkgScripts.SCRIPTS_DIR}\"") } } else -> {} @@ -295,7 +306,7 @@ internal class ElectronBuilderConfigGenerator { targetArch: Arch, windowsIconOverride: File?, executableName: String?, - nsisProtocolInclude: File?, + nsisInclude: File?, ) { yaml.appendLine("win:") yaml.appendLine(" target:") @@ -320,7 +331,7 @@ internal class ElectronBuilderConfigGenerator { yaml, distributions.windows.nsis, " ", - nsisProtocolInclude, + nsisInclude, menuCategoryDefault = distributions.windows.menuGroup, ) } @@ -330,7 +341,7 @@ internal class ElectronBuilderConfigGenerator { yaml, distributions.windows.nsis, " ", - nsisProtocolInclude, + nsisInclude, menuCategoryDefault = distributions.windows.menuGroup, ) } @@ -452,7 +463,7 @@ internal class ElectronBuilderConfigGenerator { yaml: StringBuilder, nsis: NsisSettings, indent: String, - protocolInclude: File? = null, + nsisInclude: File? = null, menuCategoryDefault: String? = null, ) { yaml.appendLine("${indent}oneClick: ${nsis.oneClick}") @@ -469,7 +480,7 @@ internal class ElectronBuilderConfigGenerator { yaml.appendLine("${indent}deleteAppDataOnUninstall: ${nsis.deleteAppDataOnUninstall}") yaml.appendLine("${indent}warningsAsErrors: false") - appendNsisFileSettings(yaml, nsis, indent, protocolInclude) + appendNsisFileSettings(yaml, nsis, indent, nsisInclude) if (nsis.multiLanguageInstaller) { yaml.appendLine("${indent}multiLanguageInstaller: true") @@ -486,7 +497,7 @@ internal class ElectronBuilderConfigGenerator { yaml: StringBuilder, nsis: NsisSettings, indent: String, - protocolInclude: File? = null, + nsisInclude: File? = null, ) { appendIfNotNull( yaml, @@ -512,10 +523,11 @@ internal class ElectronBuilderConfigGenerator { appendIfNotNull( yaml, "${indent}include", - nsis.includeScript.orNull - ?.asFile - ?.absolutePath - ?: protocolInclude?.absolutePath, + // The generated include chains the user's own script, so it wins when present. + nsisInclude?.absolutePath + ?: nsis.includeScript.orNull + ?.asFile + ?.absolutePath, ) appendIfNotNull( yaml, @@ -793,16 +805,21 @@ internal class ElectronBuilderConfigGenerator { } /** - * Resolves the PKG installer signing identity. + * Resolves the identity electron-builder hands to `productbuild --sign` for the PKG installer. * - * PKG is always treated as an App Store format, so signing is handled post-build - * via `productsign` with the "3rd Party Mac Developer Installer" certificate. - * This always returns `null` because electron-builder's `pkg.ts` hardcodes - * `certType = "Developer ID Installer"`, making it impossible to match a - * "3rd Party Mac Developer Installer" certificate at build time. + * - App Store PKG: `null`. electron-builder's `pkg.ts` hardcodes `certType = "Developer ID + * Installer"`, so it can never match a "3rd Party Mac Developer Installer" certificate; the + * package task re-signs the installer with `productsign` after the build instead. + * - Developer ID PKG: the configured signing identity with any certificate-type prefix stripped. + * electron-builder prepends the type itself when it looks the certificate up, and rejects a + * qualifier that already carries one. */ - @Suppress("UnusedParameter", "FunctionOnlyReturningConstant") - private fun resolveInstallerIdentity(macOS: JvmMacOSPlatformSettings): String? = null + private fun resolveInstallerIdentity(macOS: JvmMacOSPlatformSettings): String? { + if (macOS.pkg.appStore) return null + return macOS.signing.identity.orNull + ?.takeIf { it.isNotBlank() } + ?.stripAppleCertificatePrefix() + } private fun fpmArgs( distributions: JvmApplicationDistributions, diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderToolManager.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderToolManager.kt index 1b5fc2f53..a3b1c0cb6 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderToolManager.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderToolManager.kt @@ -61,7 +61,7 @@ internal class ElectronBuilderToolManager( * builds: left unpinned, the same plugin + sources produce different artifacts on different * days. See #266. */ - internal const val ELECTRON_BUILDER_VERSION = "26.15.5" + internal const val ELECTRON_BUILDER_VERSION = "26.16.1" /** Classpath directory holding the pinned toolchain manifest and its lock file. */ internal const val TOOLCHAIN_RESOURCE_DIR = "/nucleus/electron-builder" diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/files/nucleusNativeLibs.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/files/nucleusNativeLibs.kt new file mode 100644 index 000000000..bb8cd48b0 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/files/nucleusNativeLibs.kt @@ -0,0 +1,96 @@ +package dev.nucleusframework.desktop.application.internal.files + +import dev.nucleusframework.internal.utils.Arch +import dev.nucleusframework.internal.utils.OS +import java.io.File +import java.util.zip.ZipFile + +/** + * Directory each Nucleus runtime module lists its JNI libraries in, one file per module holding + * one `nucleus/native//` JAR entry per line. Only listed entries are moved out of the + * JARs: anything else under `nucleus/native/` (an application's own libraries, a third-party + * library's) may be read as a resource and stays untouched. + */ +private const val NUCLEUS_NATIVE_LIBRARIES_DIR = "META-INF/nucleus/native-libraries/" + +/** + * Resource shipped by `core-runtime` once its `NativeLibraryLoader` reads + * [NUCLEUS_NATIVE_LIBRARY_PATH]. An older runtime can only extract its libraries from the JARs, + * so without this marker on the classpath they must stay there. + */ +internal const val NUCLEUS_BUNDLED_NATIVES_MARKER = "META-INF/nucleus/bundled-native-libraries" + +/** System property naming the directory the packaged application's Nucleus libraries sit in. */ +internal const val NUCLEUS_NATIVE_LIBRARY_PATH = "nucleus.native.libraryPath" + +/** The `nucleus/native//` a runtime module stores [os]/[arch]'s libraries in. */ +internal fun nucleusNativeDir( + os: OS, + arch: Arch, +): String { + val osDir = + when (os) { + OS.Windows -> "win32" + OS.MacOS -> "darwin" + OS.Linux -> "linux" + } + val archDir = + when (arch) { + Arch.X64 -> "x64" + Arch.Arm64 -> "aarch64" + } + return "$osDir-$archDir" +} + +/** Reads the central directory only, so scanning every runtime JAR stays cheap. */ +internal fun File.hasZipEntry(predicate: (String) -> Boolean): Boolean = + ZipFile(this).use { zip -> zip.entries().asSequence().any { predicate(it.name) } } + +/** The JAR entries the Nucleus modules packed into this JAR declare as their JNI libraries. */ +internal fun File.nucleusNativeEntries(): Set = + ZipFile(this).use { zip -> + zip + .entries() + .asSequence() + .filter { !it.isDirectory && it.name.startsWith(NUCLEUS_NATIVE_LIBRARIES_DIR) } + .flatMap { entry -> zip.getInputStream(entry).bufferedReader().use { it.readLines() } } + .map(String::trim) + .filter { it.isNotEmpty() && !it.startsWith("#") } + .toSet() + } + +/** + * Rewrites [sourceJar] to [targetJar], moving the [platformDir] libraries listed in + * [nucleusEntries] into [libsDir] and dropping the other platforms' listed ones, so the + * application ships each Nucleus library once, loose, instead of six copies inside the JAR that + * the runtime would extract to the user's cache on first use. Every other entry is copied as is. + * + * @return [targetJar] followed by the extracted libraries + */ +internal fun unpackNucleusNativeLibs( + sourceJar: File, + targetJar: File, + libsDir: File, + platformDir: String, + nucleusEntries: Set, +): List { + val platformRoot = "nucleus/native/$platformDir/" + val outputFiles = mutableListOf(targetJar) + + targetJar.parentFile.mkdirs() + libsDir.mkdirs() + transformJar(sourceJar, targetJar) { entry, zin, zout -> + val name = entry.name + when { + entry.isDirectory || name !in nucleusEntries -> copyZipEntry(entry, zin, zout) + name.startsWith(platformRoot) -> { + val lib = libsDir.resolve(name.removePrefix(platformRoot)) + zin.copyTo(lib) + outputFiles += lib + } + // Another platform's library: never loaded by this application + else -> Unit + } + } + return outputFiles +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/nativeImageGcArgs.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/nativeImageGcArgs.kt index 0661c7e4b..72b530f8d 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/nativeImageGcArgs.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/nativeImageGcArgs.kt @@ -15,23 +15,32 @@ internal data class NativeImageGcResolution( /** * Drops a garbage collector the current toolchain or platform cannot build with, so a project - * pinning `--gc=G1` still builds on GraalVM CE, macOS and Windows (with a warning) instead of - * failing on an unknown native-image option. + * pinning `--gc=G1` still builds on GraalVM CE, or on a macOS / Windows toolchain older than the + * release that first shipped it (with a warning), instead of failing native-image. + * + * @param graalvmVersion the toolchain's `GRAALVM_VERSION` ([graalvmVersionOf]). An unreadable + * version is treated as too old off Linux, since the build would fail rather than warn. */ internal fun resolveNativeImageGc( requested: NativeImageGarbageCollector?, isOracleGraalvm: Boolean, isLinux: Boolean, + graalvmVersion: String?, graalvmHome: String, ): NativeImageGcResolution { if (requested == null) return NativeImageGcResolution(gc = null, warning = null) + val minimum = requested.nonLinuxMinVersion val unsupportedReason = when { requested.isOracleOnly && !isOracleGraalvm -> "${requested.flag} requires Oracle GraalVM (current toolchain: $graalvmHome)" - requested.isLinuxOnly && !isLinux -> - "${requested.flag} is only supported on Linux" + minimum != null && !isLinux && graalvmVersion == null -> + "${requested.flag} requires GraalVM $minimum or newer outside Linux, and the " + + "version of $graalvmHome could not be read" + minimum != null && !isLinux && !isAtLeastVersion(graalvmVersion!!, minimum) -> + "${requested.flag} requires GraalVM $minimum or newer outside Linux " + + "(current toolchain: $graalvmVersion)" else -> return NativeImageGcResolution(gc = requested, warning = null) } @@ -43,6 +52,25 @@ internal fun resolveNativeImageGc( ) } +/** + * Compares two dotted GraalVM versions component by component, a missing component counting as 0 + * (`"25.4" >= "25.4"`, `"25.3.4.1" < "25.4"`). Non-numeric components compare as 0, so an + * unexpected qualifier never promotes a toolchain past the minimum. + */ +private fun isAtLeastVersion( + version: String, + minimum: String, +): Boolean { + val actual = version.split('.') + val required = minimum.split('.') + for (i in 0 until maxOf(actual.size, required.size)) { + val a = actual.getOrNull(i)?.toIntOrNull() ?: 0 + val r = required.getOrNull(i)?.toIntOrNull() ?: 0 + if (a != r) return a > r + } + return true +} + /** * Builds the collector selection and the baked default heap ceiling. * diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/transforms/LcdTextDefaultTransform.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/transforms/LcdTextDefaultTransform.kt new file mode 100644 index 000000000..e1274e55e --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/transforms/LcdTextDefaultTransform.kt @@ -0,0 +1,399 @@ +package dev.nucleusframework.desktop.application.internal.transforms + +import org.gradle.api.Project +import org.gradle.api.artifacts.transform.CacheableTransform +import org.gradle.api.artifacts.transform.InputArtifact +import org.gradle.api.artifacts.transform.TransformAction +import org.gradle.api.artifacts.transform.TransformOutputs +import org.gradle.api.artifacts.transform.TransformParameters +import org.gradle.api.attributes.Attribute +import org.gradle.api.attributes.LibraryElements +import org.gradle.api.file.FileSystemLocation +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Classpath +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.ClassWriter +import org.objectweb.asm.Label +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import java.io.File +import java.util.jar.JarFile +import java.util.jar.JarOutputStream +import java.util.zip.ZipEntry + +/** + * Enables LCD / ClearType text on Windows (Compose issue #875) by patching + * `FontRasterizationSettings.PlatformDefault` in `ui-text-desktop` at build + * time. + * + * Compose hardcodes grayscale (`FontSmoothing.AntiAlias`) as the Windows + * default — its own source comments it wants ClearType but cannot query the + * OS. There is no runtime hook (no CompositionLocal, no system property), so + * this artifact transform rewrites the single choke point every paragraph + * falls back to: `FontRasterizationSettings.Companion.getPlatformDefault()`. + * The original getter is kept (renamed) and a wrapper is generated that, on + * Windows, returns `SubpixelAntiAlias` settings unless the app opts out with + * `-Dnucleus.text.lcd=false`; every other OS delegates to the original. + * + * The subpixel request alone never causes fringes: Skia only rasterizes LCD + * glyphs on surfaces whose `SurfaceProps` carry a known pixel geometry, and + * the Tao backend attaches geometry only to opaque Windows window surfaces + * (queried from the OS ClearType settings — see `decorated-window-tao` + * `LcdText.kt`). Transparent windows, popups, and offscreen surfaces keep an + * unknown geometry and Skia falls back to grayscale there. + * + * Because the patch is plain bytecode on the classpath, it needs no runtime + * reflection and works identically under HotSpot, ProGuard, and GraalVM + * native-image. + */ +@CacheableTransform +internal abstract class LcdTextDefaultTransform : TransformAction { + /** The jar being transformed; only `ui-text-desktop-*.jar` is rewritten. */ + @get:Classpath + @get:InputArtifact + abstract val inputArtifact: Provider + + override fun transform(outputs: TransformOutputs) { + val input = inputArtifact.get().asFile + if (!input.name.startsWith(UI_TEXT_ARTIFACT_PREFIX) || input.extension != "jar") { + // Identity: hand the original artifact through without copying. + outputs.file(inputArtifact) + return + } + val output = outputs.file("${input.nameWithoutExtension}$PATCHED_JAR_SUFFIX.jar") + LcdTextClassPatcher.patchJar(input, output) + } +} + +private const val UI_TEXT_ARTIFACT_PREFIX = "ui-text-desktop" +private const val PATCHED_JAR_SUFFIX = "-nucleus-lcd" + +/** + * Marks jars whose `FontRasterizationSettings.PlatformDefault` has been + * patched by [LcdTextDefaultTransform]. Runtime classpaths request `true`, + * plain jars default to `false`, and the transform bridges the two. + */ +private val LCD_TEXT_PATCHED: Attribute = + Attribute.of("dev.nucleusframework.lcd-text-default", Boolean::class.javaObjectType) + +/** Gradle property that skips the whole build-time patch when set to `false`. */ +private const val PATCH_OPT_OUT_PROPERTY = "nucleus.text.lcd.patch" + +/** + * Registers [LcdTextDefaultTransform] and requests the patched variant on + * every non-test runtime classpath of [project] (`runtimeClasspath`, + * `jvmRuntimeClasspath`, …) — which is what `run`, packaging, ProGuard, and + * the GraalVM native-image classpath all resolve. + * + * Configuration exclusions and the KMP jar-variant pinning mirror + * `registerCleanNativeLibsTransform`, which needed them for exactly this + * attribute-on-runtimeClasspath pattern: Android configurations resolve + * dexing directory variants, and the Compose Hot Reload dev classpaths + * consume custom-usage project variants — both fail resolution when an + * extra requested attribute is added. + * + * Build-time opt-out: `-Pnucleus.text.lcd.patch=false` (the runtime + * `-Dnucleus.text.lcd=false` only disables the already-patched default). + */ +internal fun configureLcdTextDefaultTransform(project: Project) { + val enabled = + project.providers + .gradleProperty(PATCH_OPT_OUT_PROPERTY) + .map { it != "false" } + .getOrElse(true) + if (!enabled) return + + project.dependencies.registerTransform(LcdTextDefaultTransform::class.java) { spec -> + spec.from.attribute(LCD_TEXT_PATCHED, false) + spec.to.attribute(LCD_TEXT_PATCHED, true) + } + + // KMP desktop runtime classpaths resolve project dependencies to their + // `classes`/`resources` directory sub-variants, which carry no LCD + // attribute — requesting it would make artifact selection ambiguous. + // Pinning the jar LibraryElements restores plain-JVM resolution (same + // reasoning as registerCleanNativeLibsTransform). + val isMultiplatform = project.plugins.hasPlugin("org.jetbrains.kotlin.multiplatform") + val jarLibraryElements = + project.objects.named(LibraryElements::class.java, LibraryElements.JAR) + + project.configurations.configureEach { configuration -> + val name = configuration.name + if (name.endsWith("RuntimeClasspath", ignoreCase = true) && !name.contains("Test", ignoreCase = true)) { + val isAndroid = configuration.attributes.keySet().any { it.name.startsWith("com.android") } + val isHotReload = name.contains("HotReload", ignoreCase = true) + if (!isAndroid && !isHotReload) { + configuration.attributes.attribute(LCD_TEXT_PATCHED, true) + if (isMultiplatform) { + configuration.attributes.attribute( + LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE, + jarLibraryElements, + ) + } + } + } + } + + project.dependencies.artifactTypes.configureEach { artifactType -> + if (artifactType.name == "jar") { + artifactType.attributes.attribute(LCD_TEXT_PATCHED, false) + } + } +} + +/** + * The ASM surgery for [LcdTextDefaultTransform]: renames the original + * `getPlatformDefault()` and generates a caching wrapper in its place. + */ +internal object LcdTextClassPatcher { + private const val FRS = "androidx/compose/ui/text/FontRasterizationSettings" + private const val COMPANION = "$FRS\$Companion" + private const val COMPANION_ENTRY = "$COMPANION.class" + private const val GETTER = "getPlatformDefault" + private const val GETTER_DESC = "()L$FRS;" + private const val ORIGINAL = "nucleus\$originalPlatformDefault" + private const val CACHE_FIELD = "nucleus\$lcdDefault" + private const val CACHE_FIELD_DESC = "L$FRS;" + private const val FONT_SMOOTHING = "androidx/compose/ui/text/FontSmoothing" + private const val FONT_HINTING = "androidx/compose/ui/text/FontHinting" + private const val CTOR_DESC = "(L$FONT_SMOOTHING;L$FONT_HINTING;ZZ)V" + + /** System property that disables the patched ClearType default at runtime. */ + private const val OPT_OUT_PROPERTY = "nucleus.text.lcd" + + /** + * Rewrites [input] into [output], patching the Companion class and + * verifying every member the generated wrapper references (constructor, + * enum fields) still exists in the artifact — so a Compose layout change + * fails the build instead of throwing `NoSuchMethodError` at the app's + * first text layout. + */ + fun patchJar( + input: File, + output: File, + ) { + var patched = false + var ctorPresent = false + var smoothingPresent = false + var hintingPresent = false + JarFile(input).use { jar -> + JarOutputStream(output.outputStream().buffered()).use { out -> + for (entry in jar.entries()) { + val bytes = jar.getInputStream(entry).use { it.readBytes() } + out.putNextEntry(ZipEntry(entry.name)) + when (entry.name) { + COMPANION_ENTRY -> { + out.write(patchCompanion(bytes)) + patched = true + } + "$FRS.class" -> { + ctorPresent = hasMethod(bytes, "", CTOR_DESC) + out.write(bytes) + } + "$FONT_SMOOTHING.class" -> { + smoothingPresent = hasField(bytes, "SubpixelAntiAlias") + out.write(bytes) + } + "$FONT_HINTING.class" -> { + hintingPresent = hasField(bytes, "Normal") + out.write(bytes) + } + else -> out.write(bytes) + } + out.closeEntry() + } + } + } + val missing = + buildList { + if (!patched) add(COMPANION_ENTRY) + if (!ctorPresent) add("FontRasterizationSettings.$CTOR_DESC") + if (!smoothingPresent) add("FontSmoothing.SubpixelAntiAlias") + if (!hintingPresent) add("FontHinting.Normal") + } + check(missing.isEmpty()) { + "Nucleus LCD text patch: ${missing.joinToString()} not found in ${input.name}. " + + "The Compose ui-text layout changed — update LcdTextDefaultTransform " + + "or disable the patch with -Pnucleus.text.lcd.patch=false." + } + } + + private fun hasMethod( + classBytes: ByteArray, + name: String, + descriptor: String, + ): Boolean { + var found = false + ClassReader(classBytes).accept( + object : ClassVisitor(Opcodes.ASM9) { + override fun visitMethod( + access: Int, + methodName: String, + methodDescriptor: String, + signature: String?, + exceptions: Array?, + ): MethodVisitor? { + if (methodName == name && methodDescriptor == descriptor) found = true + return null + } + }, + ClassReader.SKIP_CODE, + ) + return found + } + + private fun hasField( + classBytes: ByteArray, + name: String, + ): Boolean { + var found = false + ClassReader(classBytes).accept( + object : ClassVisitor(Opcodes.ASM9) { + override fun visitField( + access: Int, + fieldName: String, + descriptor: String, + signature: String?, + value: Any?, + ): org.objectweb.asm.FieldVisitor? { + if (fieldName == name) found = true + return null + } + }, + ClassReader.SKIP_CODE, + ) + return found + } + + /** Patches the Companion class bytes; fails loudly if the getter is missing. */ + fun patchCompanion(classBytes: ByteArray): ByteArray { + val reader = ClassReader(classBytes) + val writer = + object : ClassWriter(reader, COMPUTE_FRAMES) { + // COMPUTE_FRAMES only merges identical reference types here; never + // load application classes to compute a common supertype. + override fun getCommonSuperClass( + type1: String, + type2: String, + ): String = if (type1 == type2) type1 else "java/lang/Object" + } + var renamed = false + val visitor = + object : ClassVisitor(Opcodes.ASM9, writer) { + override fun visitMethod( + access: Int, + name: String, + descriptor: String, + signature: String?, + exceptions: Array?, + ): MethodVisitor { + if (name == GETTER && descriptor == GETTER_DESC) { + renamed = true + return super.visitMethod(access, ORIGINAL, descriptor, signature, exceptions) + } + return super.visitMethod(access, name, descriptor, signature, exceptions) + } + + override fun visitEnd() { + cv + .visitField( + Opcodes.ACC_PRIVATE or Opcodes.ACC_STATIC or + Opcodes.ACC_VOLATILE or Opcodes.ACC_SYNTHETIC, + CACHE_FIELD, + CACHE_FIELD_DESC, + null, + null, + ).visitEnd() + generateWrapper(cv) + super.visitEnd() + } + } + reader.accept(visitor, 0) + check(renamed) { + "Nucleus LCD text patch: method $GETTER$GETTER_DESC not found in " + + "FontRasterizationSettings\$Companion. The Compose ui-text API " + + "changed — update LcdTextDefaultTransform or disable the patch " + + "with -Pnucleus.text.lcd.patch=false." + } + return writer.toByteArray() + } + + // Generates: + // public final FontRasterizationSettings getPlatformDefault() { + // FontRasterizationSettings v = nucleus$lcdDefault; + // if (v != null) return v; + // v = (os.name startsWith "Windows" && !"false".equals(getProperty("nucleus.text.lcd"))) + // ? new FontRasterizationSettings(SubpixelAntiAlias, Normal, true, false) + // : nucleus$originalPlatformDefault(); + // nucleus$lcdDefault = v; // benign race: idempotent value + // return v; + // } + @Suppress("LongMethod") + private fun generateWrapper(cv: ClassVisitor) { + val mv = cv.visitMethod(Opcodes.ACC_PUBLIC or Opcodes.ACC_FINAL, GETTER, GETTER_DESC, null, null) + val compute = Label() + val fallback = Label() + val store = Label() + mv.visitCode() + mv.visitFieldInsn(Opcodes.GETSTATIC, COMPANION, CACHE_FIELD, CACHE_FIELD_DESC) + mv.visitVarInsn(Opcodes.ASTORE, 1) + mv.visitVarInsn(Opcodes.ALOAD, 1) + mv.visitJumpInsn(Opcodes.IFNULL, compute) + mv.visitVarInsn(Opcodes.ALOAD, 1) + mv.visitInsn(Opcodes.ARETURN) + mv.visitLabel(compute) + mv.visitLdcInsn("os.name") + mv.visitLdcInsn("") + mv.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/lang/System", + "getProperty", + "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + false, + ) + mv.visitLdcInsn("Windows") + mv.visitMethodInsn( + Opcodes.INVOKEVIRTUAL, + "java/lang/String", + "startsWith", + "(Ljava/lang/String;)Z", + false, + ) + mv.visitJumpInsn(Opcodes.IFEQ, fallback) + mv.visitLdcInsn("false") + mv.visitLdcInsn(OPT_OUT_PROPERTY) + mv.visitMethodInsn( + Opcodes.INVOKESTATIC, + "java/lang/System", + "getProperty", + "(Ljava/lang/String;)Ljava/lang/String;", + false, + ) + mv.visitMethodInsn( + Opcodes.INVOKEVIRTUAL, + "java/lang/String", + "equals", + "(Ljava/lang/Object;)Z", + false, + ) + mv.visitJumpInsn(Opcodes.IFNE, fallback) + mv.visitTypeInsn(Opcodes.NEW, FRS) + mv.visitInsn(Opcodes.DUP) + mv.visitFieldInsn(Opcodes.GETSTATIC, FONT_SMOOTHING, "SubpixelAntiAlias", "L$FONT_SMOOTHING;") + mv.visitFieldInsn(Opcodes.GETSTATIC, FONT_HINTING, "Normal", "L$FONT_HINTING;") + mv.visitInsn(Opcodes.ICONST_1) + mv.visitInsn(Opcodes.ICONST_0) + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, FRS, "", CTOR_DESC, false) + mv.visitJumpInsn(Opcodes.GOTO, store) + mv.visitLabel(fallback) + mv.visitVarInsn(Opcodes.ALOAD, 0) + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, COMPANION, ORIGINAL, GETTER_DESC, false) + mv.visitLabel(store) + mv.visitInsn(Opcodes.DUP) + mv.visitFieldInsn(Opcodes.PUTSTATIC, COMPANION, CACHE_FIELD, CACHE_FIELD_DESC) + mv.visitInsn(Opcodes.ARETURN) + mv.visitMaxs(0, 0) + mv.visitEnd() + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt index 6c26a8504..012199eaf 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/internal/validation/ValidatedMacOSSigningSettings.kt @@ -33,19 +33,7 @@ internal data class ValidatedMacOSSigningSettings( /** Identity with all known certificate-type prefixes stripped. */ val bareIdentityName: String - get() { - val knownPrefixes = - listOf( - "Developer ID Application: ", - "3rd Party Mac Developer Application: ", - "Developer ID Installer: ", - "3rd Party Mac Developer Installer: ", - ) - return knownPrefixes - .firstOrNull { identity.startsWith(it) } - ?.let { identity.removePrefix(it) } - ?: identity - } + get() = identity.stripAppleCertificatePrefix() /** Team ID extracted from the identity string, e.g. "NAME (XXXXXXX)" → "XXXXXXX". */ val teamID: String? @@ -107,3 +95,18 @@ private val ERR_UNKNOWN_SIGN_ID = """.trimMargin() private val TEAM_ID_REGEX = Regex("\\(([A-Z0-9]+)\\)\\s*$") + +private val APPLE_CERTIFICATE_PREFIXES = + listOf( + "Developer ID Application: ", + "3rd Party Mac Developer Application: ", + "Developer ID Installer: ", + "3rd Party Mac Developer Installer: ", + ) + +/** + * Strips a known certificate-type prefix ("Developer ID Application: ", "3rd Party Mac Developer + * Installer: ", …) from a signing identity, leaving the bare "NAME (TEAMID)" qualifier. + */ +internal fun String.stripAppleCertificatePrefix(): String = + APPLE_CERTIFICATE_PREFIXES.firstOrNull { startsWith(it) }?.let { removePrefix(it) } ?: this diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt index edec38fd8..e18eaf436 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractElectronBuilderPackageTask.kt @@ -7,6 +7,7 @@ package dev.nucleusframework.desktop.application.tasks import dev.nucleusframework.desktop.application.dsl.CompressionLevel import dev.nucleusframework.desktop.application.dsl.JvmApplicationDistributions +import dev.nucleusframework.desktop.application.dsl.MacAppExtension import dev.nucleusframework.desktop.application.dsl.MacOSSigningSettings import dev.nucleusframework.desktop.application.dsl.ReleaseChannel import dev.nucleusframework.desktop.application.dsl.TargetFormat @@ -15,9 +16,13 @@ import dev.nucleusframework.desktop.application.internal.UpdateYmlPublish import dev.nucleusframework.desktop.application.internal.UpdateYmlGenerator import dev.nucleusframework.desktop.application.internal.LinuxSigner import dev.nucleusframework.desktop.application.internal.LinuxUpdateHelper +import dev.nucleusframework.desktop.application.internal.MacPkgScripts import dev.nucleusframework.desktop.application.internal.MacDmgLzma import dev.nucleusframework.desktop.application.internal.MacSigner import dev.nucleusframework.desktop.application.internal.MacSignerImpl +import dev.nucleusframework.desktop.application.internal.NodeToolchainProvisioner +import dev.nucleusframework.desktop.application.internal.NodeToolchainRequest +import dev.nucleusframework.desktop.application.internal.NucleusProperties import dev.nucleusframework.desktop.application.internal.NoCertificateSigner import dev.nucleusframework.desktop.application.internal.WindowsKitsLocator import dev.nucleusframework.desktop.application.internal.electronbuilder.ElectronBuilderConfigGenerator @@ -29,6 +34,9 @@ import dev.nucleusframework.desktop.application.internal.files.isDylibPath import dev.nucleusframework.desktop.application.internal.MACOS_DMG_TITLE_BAR_HEIGHT import dev.nucleusframework.desktop.application.internal.padDmgBackgroundForTitleBar import dev.nucleusframework.desktop.application.internal.readImageDimensions +import dev.nucleusframework.desktop.application.internal.WindowsHotUpdateLayout +import dev.nucleusframework.desktop.application.internal.WindowsHotUpdateNsis +import dev.nucleusframework.desktop.application.internal.sanitizeFileName import dev.nucleusframework.desktop.application.internal.updateExecutableTypeInAppImage import dev.nucleusframework.desktop.application.internal.validation.ValidatedMacOSSigningSettings import dev.nucleusframework.desktop.application.internal.validation.validate @@ -44,13 +52,16 @@ import net.coobird.thumbnailator.Thumbnails import net.coobird.thumbnailator.filters.Canvas import net.coobird.thumbnailator.geometry.Positions import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.logging.Logger +import org.gradle.api.provider.ListProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Nested import org.gradle.api.tasks.Optional @@ -101,6 +112,8 @@ abstract class AbstractElectronBuilderPackageTask private const val APPX_SQUARE150_LOGO_SIZE = 150 private const val APPX_WIDE_LOGO_WIDTH = 310 private const val APPX_WIDE_LOGO_HEIGHT = 150 + private const val DEFAULT_PACKAGE_VERSION = "1.0.0" + private val NSIS_FORMATS = setOf(TargetFormat.Nsis, TargetFormat.NsisWeb, TargetFormat.Exe) } @get:InputDirectory @@ -113,6 +126,16 @@ abstract class AbstractElectronBuilderPackageTask @get:Input val packageName: Property = objects.notNullProperty() + /** + * The runtime's `NucleusApp.appId`. On Windows it names the app's data directory under + * `%APPDATA%` (the one `nucleusApplication` hands to FileKit), which + * `deleteAppDataOnUninstall` must remove even when electron-builder derives other names — + * a GraalVM `imageName` that is not the package name. + */ + @get:Input + @get:Optional + val runtimeAppId: Property = objects.nullableProperty() + @get:Input @get:Optional val packageVersion: Property = objects.nullableProperty() @@ -121,6 +144,18 @@ abstract class AbstractElectronBuilderPackageTask @get:Optional val customNodePath: Property = objects.nullableProperty() + /** Download and cache Node.js instead of requiring one on `PATH`. See `nodejs { }`. */ + @get:Internal + val nodeAutoDownload: Property = objects.notNullProperty(true) + + /** Node.js version to provision: `"22"`, `"lts"` or a pinned `"22.11.0"`. */ + @get:Internal + val nodeVersion: Property = objects.notNullProperty("22") + + /** Where provisioned Node.js installations are cached. */ + @get:Internal + val nodeInstallDir: Property = objects.nullableProperty() + @get:Input @get:Optional val publishMode: Property = objects.nullableProperty() @@ -224,10 +259,30 @@ abstract class AbstractElectronBuilderPackageTask @get:Optional internal val nonValidatedMacBundleID: Property = objects.nullableProperty() + @get:Internal + internal val macAppExtensions: ListProperty = + objects.listProperty(MacAppExtension::class.java).convention(emptyList()) + + // Tracks the .appex payload + per-extension entitlements/profiles for up-to-date checks. + @get:InputFiles + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + internal val macAppExtensionFiles: ConfigurableFileCollection = objects.fileCollection() + @get:Input @get:Optional val macAppStore: Property = objects.nullableProperty() + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + val macPkgPreInstall: RegularFileProperty = objects.fileProperty() + + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + val macPkgPostInstall: RegularFileProperty = objects.fileProperty() + @get:Optional @get:Nested internal var nonValidatedMacSigningSettings: MacOSSigningSettings? = null @@ -265,6 +320,9 @@ abstract class AbstractElectronBuilderPackageTask logger.info("Resolved app image directory: ${originalAppDir.absolutePath}") val outputDir = destinationDir.ioFile.apply { mkdirs() } + // A manifest left by a previous run describes a previous artifact; electron-builder + // rewrites its own, and generateUpdateYmlIfNeeded() only fills a missing one. + UpdateYmlPublish.deleteManifests(outputDir) // Create a task-private copy of the app image so parallel tasks don't // interfere when modifying .cfg files or signing the bundle. On macOS the copy is @@ -275,10 +333,10 @@ abstract class AbstractElectronBuilderPackageTask bundleSilentUpdateArtifacts(workingAppDir, dist) ensureLinuxExecutableAlias(workingAppDir) updateExecutableTypeInAppImage(workingAppDir, targetFormat, logger, packageVersion.orNull) + val hotUpdateLayout = applyWindowsHotUpdateLayout(workingAppDir, dist) ensureMacAdHocSigning(workingAppDir, targetFormat) - val node = detectNode() - val npm = detectNpm() + val (node, npm) = resolveNodeJs() validateNodeVersion(node) val linuxIconOverride = prepareLinuxIconSet(outputDir) @@ -296,6 +354,9 @@ abstract class AbstractElectronBuilderPackageTask hasExplicitWindowsIcon = hasExplicitWindowsIcon, ) } + if (targetFormat == TargetFormat.Pkg) { + stagePkgScripts(outputDir) + } val configFile = generateConfig( distributions = dist, @@ -305,6 +366,7 @@ abstract class AbstractElectronBuilderPackageTask windowsIconOverride = windowsIconOverride, linuxAfterInstallTemplate = linuxAfterInstallTemplate, linuxAfterRemoveTemplate = linuxAfterRemoveTemplate, + hotUpdateLayout = hotUpdateLayout, ) ensureProjectPackageMetadata(outputDir, dist) @@ -323,7 +385,7 @@ abstract class AbstractElectronBuilderPackageTask currentOs = currentOS, currentArchitecture = currentArch, logger = logger, - ) + isolatedCacheEnv(outputDir) + ) + isolatedCacheEnv(outputDir) + pkgInstallerSigningEnv() toolManager.invoke( ElectronBuilderInvocation( configFile = configFile, @@ -341,6 +403,7 @@ abstract class AbstractElectronBuilderPackageTask if (targetFormat == TargetFormat.Pkg) { signPkgInstaller(outputDir) + verifyDeveloperIdPkgSignature(outputDir) } // Must run before signLinuxPackage(): rebuilding the .deb archive to recompress its @@ -365,11 +428,11 @@ abstract class AbstractElectronBuilderPackageTask outputDir: File, dist: JvmApplicationDistributions, ) { - if (!targetFormat.needsPluginUpdateYml) return + val extension = targetFormat.updateArtifactExtension ?: return val channel = resolveUpdateChannel(dist) val ymlFilename = targetFormat.updateYmlFilename(channel) val version = packageVersion.orNull ?: "0.0.0" - UpdateYmlGenerator.generateIfMissing(outputDir, ymlFilename, version, logger) + UpdateYmlGenerator.generateIfMissing(outputDir, ymlFilename, version, logger, artifactExtension = extension) } private fun resolveUpdateChannel(dist: JvmApplicationDistributions): ReleaseChannel { @@ -397,24 +460,55 @@ abstract class AbstractElectronBuilderPackageTask return flag } - private fun detectNode(): File = - NodeJsDetector.detectNode( - customNodePath = customNodePath.orNull, - logger = logger, - ) ?: throw GradleException( - "node not found. Node.js 18+ is required for electron-builder packaging. " + - "Install Node.js or set the 'compose.electronBuilder.nodePath' Gradle property.", - ) + /** + * Resolves the `node` and `npm` electron-builder runs with: the explicitly configured + * installation, else the one the plugin provisions itself, else whatever is on `PATH`. + */ + private fun resolveNodeJs(): Pair { + customNodePath.orNull?.let { return detectOnPath(it) } + if (!nodeAutoDownload.get()) return detectOnPath(customNodePath = null) + + val installation = + runCatching { + NodeToolchainProvisioner.provision( + request = + NodeToolchainRequest( + version = nodeVersion.get(), + os = currentOS, + arch = currentArch, + installBaseDir = File(nodeInstallDir.get()), + ), + execOperations = execOperations, + logger = logger, + ) + }.getOrElse { failure -> + // An offline machine with a usable Node.js installed should still package. + logger.warn( + "Could not provision Node.js ($failure) — falling back to the one on PATH. " + + "Set nativeDistributions { nodejs { autoDownload = false } } to silence this.", + ) + return detectOnPath(customNodePath = null) + } + return installation.node to installation.npm + } - private fun detectNpm(): File = - NodeJsDetector.detectNpm( - customNodePath = customNodePath.orNull, - logger = logger, - ) ?: throw GradleException( - "npm not found. It provisions the pinned electron-builder toolchain from the " + - "plugin's package-lock.json. Install Node.js 18+ (npm ships with it) or set " + - "the 'compose.electronBuilder.nodePath' Gradle property.", - ) + private fun detectOnPath(customNodePath: String?): Pair { + val node = + NodeJsDetector.detectNode(customNodePath, logger) ?: throw GradleException( + "node not found. Node.js 18+ is required for electron-builder packaging. " + + "Enable nativeDistributions { nodejs { autoDownload } } to let the plugin " + + "download one, install Node.js, or set the " + + "'${NucleusProperties.ELECTRON_BUILDER_NODE_PATH}' Gradle property.", + ) + val npm = + NodeJsDetector.detectNpm(customNodePath, logger) ?: throw GradleException( + "npm not found next to ${node.absolutePath}. It provisions the pinned " + + "electron-builder toolchain from the plugin's package-lock.json. Install " + + "Node.js 18+ (npm ships with it) or set the " + + "'${NucleusProperties.ELECTRON_BUILDER_NODE_PATH}' Gradle property.", + ) + return node to npm + } private fun validateNodeVersion(node: File) { val version = NodeJsDetector.getNodeVersion(node) ?: return @@ -434,6 +528,7 @@ abstract class AbstractElectronBuilderPackageTask windowsIconOverride: File?, linuxAfterInstallTemplate: File?, linuxAfterRemoveTemplate: File?, + hotUpdateLayout: Boolean, ): File { val configGenerator = ElectronBuilderConfigGenerator() val resolvedArch = Arch.entries.first { it.id == targetArch.get() } @@ -467,7 +562,7 @@ abstract class AbstractElectronBuilderPackageTask ) } - val nsisProtocolInclude = generateProtocolNsisInclude(distributions, outputDir) + val nsisInclude = generateNsisInclude(distributions, outputDir, hotUpdateLayout) val configContent = configGenerator.generateConfig( @@ -483,7 +578,7 @@ abstract class AbstractElectronBuilderPackageTask executableName = resolveExecutableName(), dmgBackgroundOverride = dmgBackgroundOverride, dmgWindowOverride = dmgWindowOverride, - nsisProtocolInclude = nsisProtocolInclude, + nsisInclude = nsisInclude, macBundleName = macBundleName.orNull, ) val configFile = File(outputDir, "electron-builder.yml") @@ -493,31 +588,109 @@ abstract class AbstractElectronBuilderPackageTask } /** - * Generates an NSIS include script that registers the declared URL protocol handlers - * (deep linking) in the Windows registry at install time. + * Lays the Windows app image out for hot updates (`versions\\`, see + * [WindowsHotUpdateLayout]) when the target is an NSIS installer. + * Returns whether the layout was applied, which is what the NSIS include keys its hot + * update support on. + */ + private fun applyWindowsHotUpdateLayout( + appDir: File, + distributions: JvmApplicationDistributions, + ): Boolean { + if (currentOS != OS.Windows || targetFormat !in NSIS_FORMATS) return false + val version = packageVersion.orNull?.takeIf { it.isNotBlank() } ?: DEFAULT_PACKAGE_VERSION + val applied = WindowsHotUpdateLayout.apply(appDir, version) + if (applied) { + logger.info( + "Laid the app image out for hot updates " + + "(versions\\${WindowsHotUpdateLayout.versionDirName(version)})", + ) + } else { + logger.info("Hot update layout skipped: not a jpackage app image") + } + return applied + } + + /** + * Generates the NSIS include script passed to electron-builder, or null when nothing needs + * one. It chains, in order: the user's `nsis.includeScript`, the URL protocol registration and + * app data removal (only without a user script, see [nucleusNsisMacros]) and the hot update hooks + * ([WindowsHotUpdateNsis]) when [hotUpdateLayout] applies. + */ + private fun generateNsisInclude( + distributions: JvmApplicationDistributions, + outputDir: File, + hotUpdateLayout: Boolean, + ): File? { + if (currentOS != OS.Windows || targetFormat !in NSIS_FORMATS) return null + val userInclude = + distributions.windows.nsis.includeScript.orNull + ?.asFile + val nucleusMacros = nucleusNsisMacros(distributions, hasUserInclude = userInclude != null) + if (nucleusMacros == null && !hotUpdateLayout) return null + + val script = + buildString { + if (userInclude != null) { + if (hotUpdateLayout) WindowsHotUpdateNsis.warnOnConflicts(userInclude, logger) + appendLine("!include \"${userInclude.absolutePath}\"") + appendLine() + } + nucleusMacros?.let { appendLine(it) } + if (hotUpdateLayout) append(WindowsHotUpdateNsis.MACROS) + } + + val nshFile = File(outputDir, "nucleus-installer.nsh") + nshFile.parentFile.mkdirs() + // Write with a UTF-8 BOM so makensis detects the encoding and keeps non-ASCII + // protocol names (e.g. Hebrew) intact. NSIS treats '#' as a comment, so a + // "#pragma" directive would be inert — the BOM is the supported mechanism. + nshFile.writeText("$script", Charsets.UTF_8) + logger.info("Generated NSIS include script at ${nshFile.absolutePath}") + return nshFile + } + + /** + * Builds the NSIS macros that register the declared URL protocol handlers (deep linking) + * in the Windows registry at install time. * * electron-builder's `protocols` field only registers schemes on macOS (Info.plist) and * Linux (.desktop `x-scheme-handler`); the NSIS target ignores it. Windows therefore needs * explicit registry writes, which we emit via the `customInstall`/`customUnInstall` hooks. * - * Returns null (no registration) when the current OS is not Windows, the target is not an - * NSIS-family installer, no protocols are declared, or the user already supplied a custom - * NSIS include script (which must not be overridden). + * With `deleteAppDataOnUninstall`, the same `customUnInstall` also removes + * `%APPDATA%\` (see [appendAppDataRemoval]). Both live in one macro because + * NSIS allows a single `customUnInstall`. + * + * Returns the macros, or null when there is nothing to emit or the user already supplied a + * custom NSIS include script (whose own macros must not be overridden). */ - private fun generateProtocolNsisInclude( + private fun nucleusNsisMacros( distributions: JvmApplicationDistributions, - outputDir: File, - ): File? { - if (currentOS != OS.Windows) return null - if (distributions.protocols.isEmpty()) return null - if (targetFormat !in setOf(TargetFormat.Nsis, TargetFormat.NsisWeb, TargetFormat.Exe)) return null - - if (distributions.windows.nsis.includeScript.orNull != null) { - logger.warn( - "URL protocol handlers are declared but a custom nsis.includeScript is set; " + - "skipping automatic protocol registration. Register the schemes yourself " + - "in a customInstall macro inside your include script.", - ) + hasUserInclude: Boolean, + ): String? { + val appDataDir = + runtimeAppId.orNull + ?.takeIf { distributions.windows.nsis.deleteAppDataOnUninstall } + ?.let { appDataDirNameOrNull(it) } + if (distributions.protocols.isEmpty() && appDataDir == null) return null + + if (hasUserInclude) { + if (distributions.protocols.isNotEmpty()) { + logger.warn( + "URL protocol handlers are declared but a custom nsis.includeScript is set; " + + "skipping automatic protocol registration. Register the schemes yourself " + + "in a customInstall macro inside your include script.", + ) + } + if (appDataDir != null) { + logger.warn( + "deleteAppDataOnUninstall is set but a custom nsis.includeScript is set; " + + "%APPDATA%\\$appDataDir (NucleusApp.appId) is only removed if electron-builder " + + "derives the same name. Remove it yourself in a customUnInstall macro " + + "inside your include script.", + ) + } return null } @@ -537,52 +710,50 @@ abstract class AbstractElectronBuilderPackageTask .filter { it.isNotEmpty() } .map { scheme -> scheme to (friendlyName ?: scheme) } }.distinctBy { it.first } - if (handlers.isEmpty()) return null + if (handlers.isEmpty() && appDataDir == null) return null // SHELL_CONTEXT resolves to HKLM (per-machine) or HKCU (per-user) automatically. // ${APP_EXECUTABLE_FILENAME} is provided by electron-builder's NSIS template. val script = buildString { - appendLine("!macro customInstall") - for ((scheme, friendlyName) in handlers) { - val key = "Software\\Classes\\$scheme" - appendLine(" DetailPrint \"Registering $scheme:// URL handler\"") - appendLine(" DeleteRegKey SHELL_CONTEXT \"$key\"") - appendLine(" WriteRegStr SHELL_CONTEXT \"$key\" \"\" \"URL:$friendlyName\"") - appendLine(" WriteRegStr SHELL_CONTEXT \"$key\" \"URL Protocol\" \"\"") - appendLine( - " WriteRegStr SHELL_CONTEXT \"$key\\DefaultIcon\" \"\" " + - "\"\$INSTDIR\\\${APP_EXECUTABLE_FILENAME},0\"", - ) - appendLine( - " WriteRegStr SHELL_CONTEXT \"$key\\shell\\open\\command\" \"\" " + - "'\"\$INSTDIR\\\${APP_EXECUTABLE_FILENAME}\" \"%1\"'", - ) + if (handlers.isNotEmpty()) { + appendLine("!macro customInstall") + for ((scheme, friendlyName) in handlers) { + val key = "Software\\Classes\\$scheme" + appendLine(" DetailPrint \"Registering $scheme:// URL handler\"") + appendLine(" DeleteRegKey SHELL_CONTEXT \"$key\"") + appendLine(" WriteRegStr SHELL_CONTEXT \"$key\" \"\" \"URL:$friendlyName\"") + appendLine(" WriteRegStr SHELL_CONTEXT \"$key\" \"URL Protocol\" \"\"") + appendLine( + " WriteRegStr SHELL_CONTEXT \"$key\\DefaultIcon\" \"\" " + + "\"\$INSTDIR\\\${APP_EXECUTABLE_FILENAME},0\"", + ) + appendLine( + " WriteRegStr SHELL_CONTEXT \"$key\\shell\\open\\command\" \"\" " + + "'\"\$INSTDIR\\\${APP_EXECUTABLE_FILENAME}\" \"%1\"'", + ) + } + appendLine("!macroend") + appendLine() } - appendLine("!macroend") - appendLine() appendLine("!macro customUnInstall") - // Guard against auto-update: the new installer runs before the old uninstaller, - // so unconditional cleanup would drop a just-registered scheme. - appendLine(" \${ifNot} \${isUpdated}") - for ((scheme, _) in handlers) { - appendLine(" DeleteRegKey SHELL_CONTEXT \"Software\\Classes\\$scheme\"") + if (handlers.isNotEmpty()) { + // Guard against auto-update: the new installer runs before the old uninstaller, + // so unconditional cleanup would drop a just-registered scheme. + appendLine(" \${ifNot} \${isUpdated}") + for ((scheme, _) in handlers) { + appendLine(" DeleteRegKey SHELL_CONTEXT \"Software\\Classes\\$scheme\"") + } + appendLine(" \${endIf}") } - appendLine(" \${endIf}") + if (appDataDir != null) appendAppDataRemoval(appDataDir) appendLine("!macroend") } - val nshFile = File(outputDir, "nucleus-protocols.nsh") - nshFile.parentFile.mkdirs() - // Write with a UTF-8 BOM so makensis detects the encoding and keeps non-ASCII - // protocol names (e.g. Hebrew) intact. NSIS treats '#' as a comment, so a - // "#pragma" directive would be inert — the BOM is the supported mechanism. - nshFile.writeText("$script", Charsets.UTF_8) logger.info( - "Generated NSIS protocol registration script at ${nshFile.absolutePath} " + - "for schemes: ${handlers.joinToString { it.first }}", + "NSIS macros: schemes ${handlers.joinToString { it.first }}; app data ${appDataDir.orEmpty()}", ) - return nshFile + return script } private fun exportPackagingMetadata( @@ -737,12 +908,12 @@ abstract class AbstractElectronBuilderPackageTask if (currentOS != OS.MacOS) return if (!appDir.isDirectory) return - // For PKG (App Store), re-sign the .app with proper entitlements after .cfg modification. - // The jpackage task signed the app, but updateExecutableTypeInAppImage() modified .cfg - // files which invalidated the code signature. We must re-sign before electron-builder - // packages it into the PKG. - if (targetFormat == TargetFormat.Pkg) { - resignAppForPkg(appDir) + // For an App Store PKG, re-sign the .app with the store entitlements after .cfg + // modification. The jpackage task signed the app, but updateExecutableTypeInAppImage() + // modified .cfg files which invalidated the code signature. We must re-sign before + // electron-builder packages it into the PKG. A Developer ID PKG takes the DMG path below. + if (targetFormat == TargetFormat.Pkg && macAppStore.orNull == true) { + resignAppForAppStorePkg(appDir) return } @@ -771,6 +942,15 @@ abstract class AbstractElectronBuilderPackageTask spec.isIgnoreExitValue = false } + // The blanket `--deep` above re-signs embedded extensions ad-hoc, dropping their + // own entitlements. When extensions are configured, re-sign them with their + // entitlements and re-seal the outer bundle (without --deep) to preserve them. + // NoCertificateSigner only signs on Apple Silicon; on Intel the --deep result stands. + if (signer != null && currentArch == Arch.Arm64 && macAppExtensions.get().isNotEmpty()) { + signAppExtensions(appDir, signer) + signer.sign(appDir, macEntitlementsFile.orNull?.asFile, forceEntitlements = true) + } + logger.info("Ad-hoc signature applied successfully") } @@ -837,31 +1017,76 @@ abstract class AbstractElectronBuilderPackageTask } } + // Re-sign embedded app extensions (Contents/PlugIns) with their own entitlements + // before sealing the outer bundle. The jpackage task embedded them; the copy that + // electron-builder packages must carry a valid nested signature. + signAppExtensions(appDir, signer) + // Re-sign the entire app bundle signer.sign(appDir, appEntitlements, forceEntitlements = true) } /** - * Re-signs the .app bundle for PKG builds (always App Store). - * Delegates to [resignApp] for the core signing, then augments entitlements - * with application-identifier and team-identifier for App Store submissions. + * Re-signs each configured app extension found under `Contents/PlugIns/` with its own + * entitlements, inside-out. Mirrors the embedding done by the jpackage task; here the + * `.appex` already exists in the bundle copy and only needs a fresh signature. */ - private fun resignAppForPkg(appDir: File) { - resignApp(appDir, "PKG format") - - // For App Store builds, re-sign the bundle with augmented entitlements - // (application-identifier + team-identifier required by TestFlight / Transporter, error 90886). - if (macAppStore.orNull == true) { - val signer = macSigner ?: return - val appEntitlements = macEntitlementsFile.orNull?.asFile - // augmentEntitlementsForAppStore returns null when settings is null (NoCertificateSigner / - // unsigned builds). Fall back to the original entitlements so the app is never re-signed - // without them — which would silently strip sandbox entitlements from the bundle. - val bundleEntitlements = augmentEntitlementsForAppStore(appEntitlements, signer.settings) - signer.sign(appDir, bundleEntitlements ?: appEntitlements, forceEntitlements = true) + private fun signAppExtensions( + appDir: File, + signer: MacSigner, + ) { + val extensions = macAppExtensions.get() + if (extensions.isEmpty()) return + + val plugInsDir = appDir.resolve("Contents/PlugIns") + for (extension in extensions) { + val appexName = extension.appex?.name ?: continue + val appex = plugInsDir.resolve(appexName) + if (!appex.exists()) continue + signBundleInsideOut(appex, extension.entitlements, signer) } } + /** + * Signs a nested bundle (e.g. an `.appex`) inside-out: nested executables/dylibs in its + * `Contents/Frameworks` first, then the bundle itself with its [entitlements]. + */ + private fun signBundleInsideOut( + bundle: File, + entitlements: File?, + signer: MacSigner, + ) { + val frameworks = bundle.resolve("Contents/Frameworks") + if (frameworks.exists()) { + frameworks.walk().forEach { file -> + val path = file.toPath() + if (path.isRegularFile(LinkOption.NOFOLLOW_LINKS) && + (path.isExecutable() || file.name.isDylibPath) + ) { + signer.sign(file, entitlements) + } + } + } + signer.sign(bundle, entitlements, forceEntitlements = true) + } + + /** + * Re-signs the .app bundle for an App Store PKG. Delegates to [resignApp] for the core + * signing, then re-signs the bundle with entitlements augmented with application-identifier + * and team-identifier (required by TestFlight / Transporter, error 90886). + */ + private fun resignAppForAppStorePkg(appDir: File) { + resignApp(appDir, "App Store PKG format") + + val signer = macSigner ?: return + val appEntitlements = macEntitlementsFile.orNull?.asFile + // augmentEntitlementsForAppStore returns null when settings is null (NoCertificateSigner / + // unsigned builds). Fall back to the original entitlements so the app is never re-signed + // without them — which would silently strip sandbox entitlements from the bundle. + val bundleEntitlements = augmentEntitlementsForAppStore(appEntitlements, signer.settings) + signer.sign(appDir, bundleEntitlements ?: appEntitlements, forceEntitlements = true) + } + /** * Returns a copy of [entitlements] with `com.apple.application-identifier` and * `com.apple.developer.team-identifier` injected, which Apple requires for @@ -905,11 +1130,12 @@ abstract class AbstractElectronBuilderPackageTask } /** - * Signs the PKG installer for App Store distribution using `productsign`. + * Signs an App Store PKG installer with `productsign`. * - * PKG is always treated as an App Store format. electron-builder creates an - * unsigned PKG (installer identity is always null), and this method re-signs - * it with the correct "3rd Party Mac Developer Installer" certificate. + * electron-builder's PKG target only knows the "Developer ID Installer" certificate type, so + * for the store channel the config hands it no identity, it produces an unsigned PKG, and + * this method re-signs it with the "3rd Party Mac Developer Installer" certificate. A + * Developer ID PKG is signed by electron-builder itself and skips this step. */ private fun signPkgInstaller(outputDir: File) { if (currentOS != OS.MacOS) return @@ -958,6 +1184,81 @@ abstract class AbstractElectronBuilderPackageTask logger.lifecycle("Signed PKG installer: ${pkgFile.name}") } + /** + * Stages `macOS { pkg { preInstall / postInstall } }` under electron-builder's build + * resources directory (`/build`, the same root as the AppX assets), see + * [MacPkgScripts]. + */ + private fun stagePkgScripts(outputDir: File) { + val staged = + MacPkgScripts.stage( + buildResourcesDir = outputDir.resolve("build"), + preInstall = macPkgPreInstall.orNull?.asFile, + postInstall = macPkgPostInstall.orNull?.asFile, + appStore = macAppStore.orNull == true, + ) + if (staged != null) { + logger.info("Staged PKG install scripts: ${staged.listFiles()?.map { it.name }}") + } + } + + /** + * electron-builder signs a Developer ID PKG itself (`productbuild --sign`) and looks the + * "Developer ID Installer" certificate up in the keychain named by `CSC_KEYCHAIN`, so a + * keychain configured in the signing DSL must be handed over; without it only the default + * keychain search list is consulted. + */ + private fun pkgInstallerSigningEnv(): Map { + if (currentOS != OS.MacOS || targetFormat != TargetFormat.Pkg || macAppStore.orNull == true) { + return emptyMap() + } + val keychain = macSigner?.settings?.keychain ?: return emptyMap() + return mapOf("CSC_KEYCHAIN" to keychain.absolutePath) + } + + /** + * electron-builder silently emits an unsigned PKG when it finds no "Developer ID Installer" + * certificate matching the configured identity. When signing is configured for a Developer + * ID PKG, fail loudly instead of shipping an installer Gatekeeper will refuse. + */ + private fun verifyDeveloperIdPkgSignature(outputDir: File) { + if (currentOS != OS.MacOS || macAppStore.orNull == true) return + val settings = macSigner?.settings ?: return + val pkgFile = + outputDir + .listFiles() + ?.firstOrNull { it.isFile && it.extension == "pkg" } + ?: return + + var output = "" + val result = + runExternalTool( + tool = File("/usr/sbin/pkgutil"), + args = listOf("--check-signature", pkgFile.absolutePath), + checkExitCodeIsNormal = false, + processStdout = { output = it }, + ) + if (output.contains("no signature")) { + val keychainHint = settings.keychain?.let { " in keychain ${it.absolutePath}" } ?: "" + throw GradleException( + "${pkgFile.name} is not signed: electron-builder found no \"Developer ID Installer\" " + + "certificate matching '${settings.bareIdentityName}'$keychainHint. Import the " + + "Developer ID Installer certificate of the same team, or set " + + "macOS { pkg { appStore = true } } for the Mac App Store channel.\n$output", + ) + } + if (result.exitValue != 0) { + // Signed, but the chain did not validate — an expired certificate or a keychain + // missing the Apple intermediate. Report it as such instead of "no certificate". + logger.warn( + "${pkgFile.name} carries a signature that pkgutil could not validate. " + + "Check the certificate chain (expiry, Apple WWDR intermediate).\n$output", + ) + return + } + logger.lifecycle("Verified Developer ID signature of ${pkgFile.name}") + } + /** * Post-processes the DMG electron-builder just produced by recompressing it with LZMA (ULMO). * @@ -1847,8 +2148,10 @@ abstract class AbstractElectronBuilderPackageTask outputDir: File, distributions: JvmApplicationDistributions, ) { + // Always rewritten: the file is ours, and electron-builder derives the npm name (installer + // file name, the %APPDATA% dir NSIS deleteAppDataOnUninstall removes) from it, so a copy + // left by an earlier build would keep a stale packageName. val packageJson = File(outputDir, "package.json") - if (packageJson.exists()) return val normalizedName = (executableName.orNull ?: packageName.get()).toNpmPackageName() val normalizedVersion = packageVersion.orNull?.takeIf { it.isNotBlank() } ?: "1.0.0" @@ -1935,11 +2238,14 @@ abstract class AbstractElectronBuilderPackageTask ".electron-builder-cache", ELECTRON_BUILDER_TOOL_DIR_NAME, ".app-image", + // electron-builder's build-resources dir: staged AppX assets and PKG install + // scripts. Leaving it behind would publish a root-run script next to the .pkg. + "build", ) ) { val dir = File(outputDir, dirName) - if (dir.isDirectory) { - dir.deleteRecursively() + if (dir.isDirectory && !dir.deleteRecursivelyClearingReadOnly()) { + logger.warn("Failed to delete build temporary ${dir.absolutePath}") } } File(outputDir, ".npmrc-user").delete() @@ -2178,16 +2484,69 @@ private fun deleteWithRetry( for (attempt in 1..DELETE_MAX_RETRIES) { // Kill processes that may lock files inside the directory killProcessesIn(dir, logger) - if (dir.deleteRecursively()) return + if (dir.deleteRecursivelyClearingReadOnly()) return logger.warn("Failed to delete ${dir.absolutePath} (attempt $attempt/$DELETE_MAX_RETRIES)") if (attempt < DELETE_MAX_RETRIES) Thread.sleep(DELETE_RETRY_DELAY_MS) } // Last resort: try once more and throw if it still fails - if (dir.exists() && !dir.deleteRecursively()) { + if (dir.exists() && !dir.deleteRecursivelyClearingReadOnly()) { error("Cannot delete ${dir.absolutePath} after $DELETE_MAX_RETRIES attempts. Is a process locking files?") } } +/** + * [appId] when it is a plain file name — the only form safe to append to `$APPDATA\` in an + * `RMDir /r`: an empty name, `.`, `..` or a path separator would target `%APPDATA%` itself or + * beyond. Anything electron-builder's sanitizer would rewrite is refused as well. + */ +internal fun appDataDirNameOrNull(appId: String): String? = + appId.takeIf { it.isNotEmpty() && sanitizeFileName(it) == it } + +/** + * Emits the removal of `%APPDATA%\` under the exact condition electron-builder's + * `uninstaller.nsh` removes its own app data directories: `--delete-app-data`, or + * `deleteAppDataOnUninstall` outside an update. It has to be re-evaluated here because the + * template computes `$isDeleteAppData` only after `customUnInstall` has run, and the later + * `customUnInstallSection` hook is never reached by a one-click uninstaller (`quitSuccess`). + */ +internal fun StringBuilder.appendAppDataRemoval(dirName: String) { + val nsisDirName = dirName.replace("$", "$$") + appendLine(" # Nucleus: NucleusApp.appId data directory (deleteAppDataOnUninstall)") + appendLine(" StrCpy \$R2 \"0\"") + appendLine(" ClearErrors") + appendLine(" \${GetParameters} \$R0") + appendLine(" \${GetOptions} \$R0 \"--delete-app-data\" \$R1") + appendLine(" \${if} \${Errors}") + appendLine(" \${ifNot} \${isUpdated}") + appendLine(" StrCpy \$R2 \"1\"") + appendLine(" \${endIf}") + appendLine(" \${else}") + appendLine(" StrCpy \$R2 \"1\"") + appendLine(" \${endIf}") + appendLine(" \${if} \$R2 == \"1\"") + appendLine(" \${if} \$installMode == \"all\"") + appendLine(" SetShellVarContext current") + appendLine(" \${endIf}") + appendLine(" RMDir /r \"\$APPDATA\\$nsisDirName\"") + appendLine(" \${if} \$installMode == \"all\"") + appendLine(" SetShellVarContext all") + appendLine(" \${endIf}") + appendLine(" \${endIf}") +} + +/** + * [File.deleteRecursively] that first clears the read-only flag of every entry. Windows refuses to + * delete a read-only file, and jpackage's launcher `.exe` is one — [copyAppImage] keeps that + * attribute (`COPY_ATTRIBUTES`), so the plain delete left `.app-image` behind and the next build + * failed to replace it. Symbolic links are left alone: clearing the flag would follow them. + */ +internal fun File.deleteRecursivelyClearingReadOnly(): Boolean { + walkBottomUp() + .filter { !Files.isSymbolicLink(it.toPath()) && !it.canWrite() } + .forEach { it.setWritable(true) } + return deleteRecursively() +} + /** * On Windows, kills any running processes whose executable path is inside [dir]. */ diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt index 8585033a6..9c048ddfe 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractGenerateAppPropertiesTask.kt @@ -1,5 +1,6 @@ package dev.nucleusframework.desktop.application.tasks +import dev.nucleusframework.desktop.application.internal.NUCLEUS_IDLE_GC_RESOURCE_KEY import org.gradle.api.DefaultTask import org.gradle.api.file.DirectoryProperty import org.gradle.api.provider.Property @@ -43,6 +44,10 @@ abstract class AbstractGenerateAppPropertiesTask : DefaultTask() { @get:Optional abstract val startupTaskId: Property + @get:Input + @get:Optional + abstract val idleGc: Property + @get:OutputDirectory abstract val outputDir: DirectoryProperty @@ -60,6 +65,7 @@ abstract class AbstractGenerateAppPropertiesTask : DefaultTask() { appAumid.orNull?.let { props["app.aumid"] = it } startupWmClass.orNull?.let { props["startup.wm.class"] = it } startupTaskId.orNull?.let { props["startup.task.id"] = it } + if (idleGc.getOrElse(false)) props[NUCLEUS_IDLE_GC_RESOURCE_KEY] = "true" // Use the OutputStream overload (not Writer): it escapes any non-Latin1 // character (e.g. Hebrew app names) as \uXXXX, so the file round-trips diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJLinkTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJLinkTask.kt index eb441383e..f6449f7d6 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJLinkTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJLinkTask.kt @@ -37,6 +37,10 @@ abstract class AbstractJLinkTask : AbstractJvmToolOperationTask("jlink") { @get:PathSensitive(PathSensitivity.NONE) val javaRuntimePropertiesFile: RegularFileProperty = objects.fileProperty() + /** When true, `jlink` drops `java.desktop`'s `lib/fonts` from the runtime image. */ + @get:Input + val stripJreFonts: Property = objects.notNullProperty(true) + @get:Input internal val stripDebug: Property = objects.notNullProperty(true) @@ -57,7 +61,10 @@ abstract class AbstractJLinkTask : AbstractJvmToolOperationTask("jlink") { super.makeArgs(tmpDir).apply { val modulesToInclude = if (includeAllModules.get()) { + // JEP 493 JDKs (no jmods/) refuse to link an image containing jdk.jlink, + // and a shipped app never needs it (#673). JvmRuntimeProperties.readFromFile(javaRuntimePropertiesFile.ioFile).availableModules + .filterNot { it == "jdk.jlink" } } else { modules.get() } @@ -69,6 +76,7 @@ abstract class AbstractJLinkTask : AbstractJvmToolOperationTask("jlink") { cliArg("--no-header-files", noHeaderFiles) cliArg("--no-man-pages", noManPages) cliArg("--strip-native-commands", stripNativeCommands) + cliArg("--exclude-files=glob:/java.desktop/lib/fonts/**", stripJreFonts) cliArg("--compress", compressionLevel.orNull?.id) cliArg("--output", destinationDir) diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt index a0292da17..82611a3f5 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractJPackageTask.kt @@ -7,6 +7,7 @@ package dev.nucleusframework.desktop.application.tasks import dev.nucleusframework.desktop.application.dsl.FileAssociation import dev.nucleusframework.desktop.application.dsl.LaunchAgentDefinition +import dev.nucleusframework.desktop.application.dsl.MacAppExtension import dev.nucleusframework.desktop.application.dsl.MacOSSigningSettings import dev.nucleusframework.desktop.application.internal.LaunchAgentPlistGenerator import dev.nucleusframework.desktop.application.dsl.TargetFormat @@ -21,21 +22,28 @@ import dev.nucleusframework.desktop.application.internal.MacAssetsTool import dev.nucleusframework.desktop.application.internal.MacSigner import dev.nucleusframework.desktop.application.internal.MacSignerImpl import dev.nucleusframework.desktop.application.internal.NoCertificateSigner +import dev.nucleusframework.desktop.application.internal.LauncherClasspathOrder import dev.nucleusframework.desktop.application.internal.PathingJarClasspath import dev.nucleusframework.desktop.application.internal.PlistKeys import dev.nucleusframework.desktop.application.internal.SKIKO_LIBRARY_PATH import dev.nucleusframework.desktop.application.internal.cliArg import dev.nucleusframework.desktop.application.internal.files.FileCopyingProcessor import dev.nucleusframework.desktop.application.internal.files.MacJarSignFileCopyingProcessor +import dev.nucleusframework.desktop.application.internal.files.NUCLEUS_BUNDLED_NATIVES_MARKER +import dev.nucleusframework.desktop.application.internal.files.NUCLEUS_NATIVE_LIBRARY_PATH import dev.nucleusframework.desktop.application.internal.files.SimpleFileCopyingProcessor +import dev.nucleusframework.desktop.application.internal.files.nucleusNativeEntries import dev.nucleusframework.desktop.application.internal.files.copyTo import dev.nucleusframework.desktop.application.internal.files.copyZipEntry import dev.nucleusframework.desktop.application.internal.files.findOutputFileOrDir +import dev.nucleusframework.desktop.application.internal.files.hasZipEntry import dev.nucleusframework.desktop.application.internal.files.isDylibPath import dev.nucleusframework.desktop.application.internal.files.isJarFile import dev.nucleusframework.desktop.application.internal.files.mangledName import dev.nucleusframework.desktop.application.internal.files.normalizedPath +import dev.nucleusframework.desktop.application.internal.files.nucleusNativeDir import dev.nucleusframework.desktop.application.internal.files.transformJar +import dev.nucleusframework.desktop.application.internal.files.unpackNucleusNativeLibs import dev.nucleusframework.desktop.application.internal.javaOption import dev.nucleusframework.desktop.application.internal.renameMacAppBundle import dev.nucleusframework.desktop.application.internal.validation.validate @@ -144,6 +152,16 @@ abstract class AbstractJPackageTask @get:Input val packageFromUberJar: Property = objects.notNullProperty(false) + /** + * Classpath order of [files] when they come from a directory and so carry none (the + * sandboxed strip task's output): one file name per line, first wins. Unset: [files] is + * already in classpath order. See [LauncherClasspathOrder]. + */ + @get:InputFile + @get:Optional + @get:PathSensitive(PathSensitivity.NONE) + val classpathOrderFile: RegularFileProperty = objects.fileProperty() + @get:InputFile @get:Optional @get:PathSensitive(PathSensitivity.ABSOLUTE) @@ -281,6 +299,16 @@ abstract class AbstractJPackageTask internal val macLaunchAgents: ListProperty = objects.listProperty(LaunchAgentDefinition::class.java).convention(emptyList()) + @get:Internal + internal val macAppExtensions: ListProperty = + objects.listProperty(MacAppExtension::class.java).convention(emptyList()) + + // Tracks the .appex payload + per-extension entitlements/profiles for up-to-date checks. + @get:InputFiles + @get:Optional + @get:PathSensitive(PathSensitivity.RELATIVE) + internal val macAppExtensionFiles: ConfigurableFileCollection = objects.fileCollection() + @get:Input @get:Optional val macOsSdkVersion: Property = objects.nullableProperty() @@ -288,6 +316,11 @@ abstract class AbstractJPackageTask @get:Input val sandboxingEnabled: Property = objects.notNullProperty(false) + /** The `nucleus/native//` matching the packaged runtime's platform, e.g. `win32-x64`. */ + @get:Input + internal val nucleusNativeDir: Property = + objects.notNullProperty(nucleusNativeDir(currentOS, currentArch)) + @get:Nested internal val additionalLaunchers: ListProperty = objects.listProperty(AdditionalLauncher::class.java) @@ -356,6 +389,13 @@ abstract class AbstractJPackageTask @get:LocalState protected val skikoDir: Provider = project.layout.buildDirectory.dir("compose/tmp/skiko") + @get:LocalState + protected val nucleusNativesDir: Provider = + project.layout.buildDirectory.dir("compose/tmp/nucleus-natives/$name") + + /** Whether the Nucleus libraries were moved out of the JARs; decided in [prepareWorkingDir]. */ + private var bundleNucleusNatives = false + @get:Internal private val libsDir: Provider = workingDir.map { @@ -377,6 +417,13 @@ abstract class AbstractJPackageTask it.file("libs-mapping.txt") } + /** The Nucleus library entries the libs in [libsDir] were laid out with (none: not bundled). */ + @get:Internal + private val nucleusNativesLayoutFile: Provider = + workingDir.map { + it.file("nucleus-natives-bundled.txt") + } + @get:Internal private val libsMapping = FilesMapping() @@ -421,6 +468,9 @@ abstract class AbstractJPackageTask else -> appDir() } javaOption("-D$SKIKO_LIBRARY_PATH=$skikoPath") + if (bundleNucleusNatives) { + javaOption("-D$NUCLEUS_NATIVE_LIBRARY_PATH=${appDir()}") + } if (currentOS == OS.MacOS) { macDockName.orNull?.let { dockName -> javaOption("-Xdock:name=$dockName") @@ -463,7 +513,10 @@ abstract class AbstractJPackageTask } } - private fun invalidateMappedLibs(inputChanges: InputChanges): Set { + private fun invalidateMappedLibs( + inputChanges: InputChanges, + layoutChanged: Boolean, + ): Set { val outdatedLibs = HashSet() val libsDirFile = libsDir.ioFile @@ -474,7 +527,7 @@ abstract class AbstractJPackageTask fileOperations.clearDirs(libsDirFile) } - if (inputChanges.isIncremental) { + if (inputChanges.isIncremental && !layoutChanged) { val allChanges = inputChanges.getFileChanges(files).asSequence() try { @@ -527,18 +580,49 @@ abstract class AbstractJPackageTask // skiko can be bundled to the main uber jar by proguard fun File.isMainUberJar() = packageFromUberJar.get() && name == launcherMainJar.ioFile.name - val outdatedLibs = invalidateMappedLibs(inputChanges) + // Moving the libraries out of the JARs is only safe when the runtime on the classpath + // knows to look for them next to the JARs. The sandboxed pipeline has its own layout. + val jars = files.files.filter { it.isJarFile } + bundleNucleusNatives = + !sandboxingEnabled.get() && + jars.any { jar -> jar.hasZipEntry { it == NUCLEUS_BUNDLED_NATIVES_MARKER } } + // Only the libraries the Nucleus modules list are moved, wherever they sit (a module may + // list a dependency's); every other entry, and any JAR without one, stays untouched. + val nucleusEntries: Set = + if (bundleNucleusNatives) jars.flatMapTo(sortedSetOf()) { it.nucleusNativeEntries() } else emptySet() + val layout = nucleusEntries.joinToString("\n") + val layoutFile = nucleusNativesLayoutFile.ioFile + val layoutChanged = !layoutFile.exists() || layoutFile.readText() != layout + + fun File.withNucleusNativesUnpacked(): List { + if (!isJarFile || !hasZipEntry { it in nucleusEntries }) return listOf(this) + val unpackDir = nucleusNativesDir.ioFile.resolve(mangledName()) + fileOperations.clearDirs(unpackDir) + return unpackNucleusNativeLibs( + sourceJar = this, + targetJar = unpackDir.resolve(name), + libsDir = unpackDir, + platformDir = nucleusNativeDir.get(), + nucleusEntries = nucleusEntries, + ) + } + + val outdatedLibs = invalidateMappedLibs(inputChanges, layoutChanged) for (sourceFile in outdatedLibs) { assert(sourceFile.exists()) { "Lib file does not exist: $sourceFile" } - libsMapping[sourceFile] = + val unpackedFiles = if (isSkikoForCurrentOS(sourceFile) || sourceFile.isMainUberJar()) { - val unpackedFiles = unpackSkikoForCurrentOS(sourceFile, skikoDir.ioFile, fileOperations) - unpackedFiles.map { copyFileToLibsDir(it) } + unpackSkikoForCurrentOS(sourceFile, skikoDir.ioFile, fileOperations) } else { - listOf(copyFileToLibsDir(sourceFile)) + listOf(sourceFile) } + libsMapping[sourceFile] = + unpackedFiles + .flatMap { it.withNucleusNativesUnpacked() } + .map { copyFileToLibsDir(it) } } + layoutFile.writeText(layout) // todo: incremental copy fileOperations.clearDirs(packagedResourcesDir) @@ -627,6 +711,10 @@ abstract class AbstractJPackageTask override fun checkResult(result: ExecResult) { super.checkResult(result) + // Before signing (macOS) and the pathing-jar collapse (Linux), which both keep the order. + if (targetFormat == TargetFormat.RawAppImage) { + LauncherClasspathOrder.apply(destinationDir.ioFile, launcherClasspathOrder(), logger) + } modifyRuntimeOnMacOsIfNeeded() // Linux only: shrink the jpackage launcher's serialized classpath so the parent // process's single pipe read cannot short-read (JDK-8380085 / Nucleus #454). @@ -641,6 +729,25 @@ abstract class AbstractJPackageTask logger.lifecycle("The distribution is written to ${outputFile.canonicalPath}") } + /** The file names jpackage copied into `--input`, in classpath order, main JAR first. */ + private fun launcherClasspathOrder(): List { + val sources = files.files.toList() + val rank = + classpathOrderFile.orNull + ?.asFile + ?.takeIf { it.isFile } + ?.readLines() + ?.filter { it.isNotBlank() } + ?.withIndex() + ?.associate { (index, name) -> name.trim() to index } + val ordered = if (rank == null) sources else sources.sortedBy { rank[it.name] ?: Int.MAX_VALUE } + val mainJar = libsMapping[launcherMainJar.ioFile].orEmpty().filter { it.isJarFile } + return (mainJar + ordered.flatMap { libsMapping[it].orEmpty() }) + .filter { it.isJarFile } + .map { it.name } + .distinct() + } + /** Bundle directory name jpackage's macOS output is renamed to, without the `.app` suffix. */ private val macAppDirName: String get() = macBundleName.orNull?.takeIf { it.isNotBlank() } ?: packageName.get() @@ -727,6 +834,10 @@ abstract class AbstractJPackageTask } } + // Embed and sign app extensions (.appex) into Contents/PlugIns before sealing the app. + embedAndSignAppExtensions(appDir, macSigner) + warnIfHostEntitlementsBlockExtensions(appEntitlementsFile) + macSigner.sign(runtimeDir, runtimeEntitlementsFile, forceEntitlements = true) macSigner.sign(appDir, appEntitlementsFile, forceEntitlements = true) @@ -740,6 +851,90 @@ abstract class AbstractJPackageTask } } + /** + * Copies each configured app extension into `Contents/PlugIns/`, embeds its own + * provisioning profile, and signs it inside-out with its own entitlements. The outer + * app is sealed afterwards (without `--deep`), which preserves these signatures. + */ + private fun embedAndSignAppExtensions( + appDir: File, + macSigner: MacSigner, + ) { + val extensions = macAppExtensions.get() + if (extensions.isEmpty()) return + + val plugInsDir = appDir.resolve("Contents/PlugIns") + for (extension in extensions) { + val source = + extension.appex + ?: error("appExtension '${extension.name}': no .appex file configured (call appex(...))") + check(source.exists()) { + "appExtension '${extension.name}': .appex not found at ${source.absolutePath}" + } + plugInsDir.mkdirs() + val dest = plugInsDir.resolve(source.name) + dest.deleteRecursively() + // `cp -R`, not `copyRecursively`: Kotlin's copy streams file contents and drops the + // POSIX mode, so the extension's executable lost its +x and launchd could not spawn + // it (#394). cp keeps the mode bits and any framework symlinks intact. + runExternalTool(File("/bin/cp"), listOf("-R", source.absolutePath, plugInsDir.absolutePath)) + + // Embed the extension's own provisioning profile. + extension.provisioningProfile?.copyTo( + target = dest.resolve("Contents/embedded.provisionprofile"), + overwrite = true, + ) + + // Sign the extension inside-out with its OWN entitlements. + signBundleInsideOut(dest, extension.entitlements, macSigner) + } + } + + /** + * The default entitlements relax the hardened runtime for the JVM. A host app that ships a + * network extension has been reported not to launch with these keys (#394); the fix is a + * custom `entitlementsFile` without them — modern JDKs only need `allow-jit`. + */ + private fun warnIfHostEntitlementsBlockExtensions(appEntitlementsFile: File?) { + if (macAppExtensions.get().isEmpty() || appEntitlementsFile == null) return + val offending = + listOf( + "com.apple.security.cs.allow-unsigned-executable-memory", + "com.apple.security.cs.disable-library-validation", + ).filter { appEntitlementsFile.readText().contains(it) } + if (offending.isNotEmpty()) { + logger.warn( + "macOS app extensions are embedded but the host entitlements ($appEntitlementsFile) " + + "still grant ${offending.joinToString()}. Apps hosting a Network Extension have " + + "been reported to fail to launch with these keys; set macOS { entitlementsFile } " + + "to a plist without them (allow-jit is enough for the JVM).", + ) + } + } + + /** + * Signs a nested bundle (e.g. an `.appex`) inside-out: nested executables/dylibs in its + * `Contents/Frameworks` first, then the bundle itself with its [entitlements]. + */ + private fun signBundleInsideOut( + bundle: File, + entitlements: File?, + macSigner: MacSigner, + ) { + val frameworks = bundle.resolve("Contents/Frameworks") + if (frameworks.exists()) { + frameworks.walk().forEach { file -> + val path = file.toPath() + if (path.isRegularFile(LinkOption.NOFOLLOW_LINKS) && + (path.isExecutable() || file.name.isDylibPath) + ) { + macSigner.sign(file, entitlements) + } + } + } + macSigner.sign(bundle, entitlements, forceEntitlements = true) + } + /** * Moves native libraries from `Contents/app/resources/` to `Contents/Frameworks/` * (Apple convention for sandboxed apps) and signs them. diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractRunDistributableTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractRunDistributableTask.kt index 9575b1762..01b21b01e 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractRunDistributableTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractRunDistributableTask.kt @@ -11,6 +11,7 @@ import dev.nucleusframework.internal.utils.currentOS import dev.nucleusframework.internal.utils.executableName import dev.nucleusframework.internal.utils.ioFile import org.gradle.api.file.Directory +import org.gradle.api.provider.MapProperty import org.gradle.api.provider.Provider import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory @@ -37,6 +38,10 @@ abstract class AbstractRunDistributableTask @get:Input internal val packageName: Provider = createApplicationImage.flatMap { it.packageName } + /** Extra environment for the app, e.g. the updater test switches (`-Pnucleus.updater.*`). */ + @get:Input + val environment: MapProperty = objects.mapProperty(String::class.java, String::class.java) + @TaskAction fun run() { val appDir = @@ -66,6 +71,7 @@ abstract class AbstractRunDistributableTask .exec { spec -> spec.workingDir(workingDir) spec.executable(workingDir.resolve(executable).absolutePath) + spec.environment(environment.get()) }.assertNormalExitValue() } } diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractServeUpdateFeedTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractServeUpdateFeedTask.kt new file mode 100644 index 000000000..ab297a412 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractServeUpdateFeedTask.kt @@ -0,0 +1,224 @@ +package dev.nucleusframework.desktop.application.tasks + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import dev.nucleusframework.desktop.application.internal.UpdateYmlPublish +import dev.nucleusframework.desktop.tasks.AbstractNucleusTask +import dev.nucleusframework.internal.utils.notNullProperty +import dev.nucleusframework.internal.utils.nullableProperty +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import java.io.File +import java.io.IOException +import java.io.OutputStream +import java.io.RandomAccessFile +import java.net.InetAddress +import java.net.InetSocketAddress +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Serves the packaged update of the current OS over loopback HTTP, as a release host would, so an + * installed copy of the app can update to it with nothing published: + * + * ``` + * ./gradlew serveUpdateFeed # packages, then serves http://127.0.0.1:8421 + * NUCLEUS_UPDATER_FEED_URL=http://127.0.0.1:8421 + * ``` + * + * The feed is the union of the per-format packaging outputs (the same manifests the release would + * publish, merged when several formats share one), with the artifacts, block maps and detached + * signatures next to them. Byte ranges are served, so differential downloads work as in production. + * `-Pnucleus.updater.serve.throttle=` and `-Pnucleus.updater.serve.latency=` + * slow it down, to watch the app's progress UI; `-Pnucleus.updater.serve.timeout=` stops + * it on its own (otherwise it serves until the build is cancelled). + */ +@DisableCachingByDefault(because = "Runs a server, not a cacheable build step") +abstract class AbstractServeUpdateFeedTask : AbstractNucleusTask() { + /** Output directories of the current OS's auto-updatable package tasks. */ + @get:Internal + val perFormatOutputDirs: ConfigurableFileCollection = objects.fileCollection() + + @get:Input + val port: Property = objects.notNullProperty().apply { set(DEFAULT_PORT) } + + @get:Input + @get:Optional + val throttleBytesPerSecond: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val latencyMillis: Property = objects.nullableProperty() + + @get:Input + @get:Optional + val timeoutSeconds: Property = objects.nullableProperty() + + @TaskAction + fun serve() { + val dirs = perFormatOutputDirs.files.filter(File::isDirectory) + val manifests = UpdateYmlPublish.discoverAndMerge(dirs).associate { it.fileName to it.content.toByteArray() } + if (manifests.isEmpty()) { + throw GradleException( + "No update manifest in ${dirs.joinToString()}: package an auto-updatable format first " + + "(NSIS, MSI, DMG, macOS ZIP, AppImage, DEB, RPM).", + ) + } + val executor = Executors.newCachedThreadPool { runnable -> Thread(runnable, "nucleus-update-feed").apply { isDaemon = true } } + val server = HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port.get()), 0) + server.executor = executor + server.createContext("/") { exchange -> handle(exchange, dirs, manifests) } + server.start() + val url = "http://127.0.0.1:${server.address.port}" + logger.lifecycle( + buildString { + appendLine("Serving the update feed at $url") + manifests.forEach { (name, content) -> + val version = String(content).lineSequence().firstOrNull { it.startsWith("version:") }?.substringAfter(':')?.trim() + appendLine(" $name → $version") + } + appendLine("Point the app at it with NUCLEUS_UPDATER_FEED_URL=$url") + appendLine(" (an installed app must set UpdaterConfig.allowLaunchOverrides; ./gradlew run -Pnucleus.updater.feedUrl=$url always works)") + append("Cancel the build (Ctrl+C) to stop.") + }, + ) + try { + val timeout = timeoutSeconds.orNull + if (timeout != null) Thread.sleep(TimeUnit.SECONDS.toMillis(timeout)) else Thread.sleep(Long.MAX_VALUE) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + server.stop(0) + executor.shutdownNow() + logger.lifecycle("Update feed stopped.") + } + } + + private fun handle( + exchange: HttpExchange, + dirs: List, + manifests: Map, + ) { + try { + latencyMillis.orNull?.let(Thread::sleep) + val name = exchange.requestURI.path.trimStart('/') + val range = exchange.requestHeaders.getFirst("Range") + logger.lifecycle("${exchange.requestMethod} /$name${range?.let { " [$it]" }.orEmpty()}") + if (exchange.requestMethod !in setOf("GET", "HEAD") || '/' in name || '\\' in name || name.startsWith("..")) { + exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1) + return + } + manifests[name]?.let { body -> + exchange.responseHeaders.add("Content-Type", "text/yaml") + exchange.sendResponseHeaders(HTTP_OK, body.size.toLong()) + exchange.responseBody.write(body) + return + } + val file = dirs.map { File(it, name) }.firstOrNull(File::isFile) + if (file == null) { + exchange.sendResponseHeaders(HTTP_NOT_FOUND, -1) + return + } + serveFile(exchange, file, range) + } catch (_: IOException) { + // The client went away. + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + exchange.close() + } + } + + private fun serveFile( + exchange: HttpExchange, + file: File, + range: String?, + ) { + val length = file.length() + val requested = range?.let { parseRange(it, length) } + exchange.responseHeaders.add("Accept-Ranges", "bytes") + if (requested == UNSATISFIABLE) { + exchange.responseHeaders.add("Content-Range", "bytes */$length") + exchange.sendResponseHeaders(HTTP_RANGE_NOT_SATISFIABLE, -1) + return + } + val (start, endInclusive) = requested ?: (0L to length - 1) + val count = endInclusive - start + 1 + val status = if (requested != null) HTTP_PARTIAL_CONTENT else HTTP_OK + if (requested != null) exchange.responseHeaders.add("Content-Range", "bytes $start-$endInclusive/$length") + if (exchange.requestMethod == "HEAD") { + exchange.responseHeaders.add("Content-Length", count.toString()) + exchange.sendResponseHeaders(status, -1) + return + } + exchange.sendResponseHeaders(status, if (count == 0L) -1 else count) + if (count > 0) copy(file, start, count, exchange.responseBody) + } + + private fun copy( + file: File, + start: Long, + count: Long, + out: OutputStream, + ) { + val rate = throttleBytesPerSecond.orNull?.takeIf { it > 0 } + val chunk = rate?.let { (it / THROTTLE_TICKS_PER_SECOND).coerceIn(1, BUFFER_SIZE.toLong()).toInt() } ?: BUFFER_SIZE + val buffer = ByteArray(chunk) + val began = System.nanoTime() + var sent = 0L + RandomAccessFile(file, "r").use { input -> + input.seek(start) + while (sent < count) { + val read = input.read(buffer, 0, minOf(chunk.toLong(), count - sent).toInt()) + if (read < 0) break + out.write(buffer, 0, read) + sent += read + if (rate != null) { + val aheadMillis = (sent * NANOS_PER_SECOND / rate - (System.nanoTime() - began)) / NANOS_PER_MILLI + if (aheadMillis > 0) Thread.sleep(aheadMillis) + } + } + } + } + + internal companion object { + const val DEFAULT_PORT = 8421 + private const val HTTP_OK = 200 + private const val HTTP_PARTIAL_CONTENT = 206 + private const val HTTP_NOT_FOUND = 404 + private const val HTTP_RANGE_NOT_SATISFIABLE = 416 + private const val BUFFER_SIZE = 64 * 1024 + private const val THROTTLE_TICKS_PER_SECOND = 20 + private const val NANOS_PER_SECOND = 1_000_000_000L + private const val NANOS_PER_MILLI = 1_000_000L + private val UNSATISFIABLE = -1L to -1L + + /** Parses a single `bytes=a-b`, `bytes=a-` or `bytes=-n` range; `null` for anything else. */ + fun parseRange( + header: String, + length: Long, + ): Pair? { + val spec = header.trim() + if (!spec.startsWith("bytes=") || ',' in spec) return null + val parts = spec.removePrefix("bytes=").split('-', limit = 2) + if (parts.size != 2) return null + val (first, last) = parts + val range = + if (first.isBlank()) { + val suffix = last.trim().toLongOrNull() ?: return null + (length - suffix).coerceAtLeast(0) to length - 1 + } else { + val begin = first.trim().toLongOrNull() ?: return null + val end = last.trim().takeIf { it.isNotEmpty() }?.toLongOrNull() ?: (length - 1) + begin to minOf(end, length - 1) + } + return if (range.first > range.second || range.first >= length) UNSATISFIABLE else range + } + } +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt index 71446c008..c182fa652 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractStripNativeLibsFromJarsTask.kt @@ -100,6 +100,8 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { // Inject the runtime shim JAR onto the app classpath (fixed name, not mangled). SandboxJarRewriter.injectShimJar(outDir) + // The output is a directory, which loses the input order: record it for the package task. + val classpathOrder = mutableListOf(SandboxMarkers.SHIM_JAR_NAME) logger.lifecycle("Sandboxing: injected runtime shim JAR '{}'", SandboxMarkers.SHIM_JAR_NAME) for (file in inputJars.files) { @@ -107,6 +109,7 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { val outputFileName = file.mangledName() val outputFile = outDir.resolve(outputFileName) + classpathOrder += outputFileName // Track the mangled name of the main JAR for downstream tasks if (file.name == expectedMainJarName) { @@ -139,6 +142,8 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { rewrittenClassCount += result.rewrittenClasses } + outDir.resolve(CLASSPATH_ORDER_FILE).writeText(classpathOrder.joinToString("\n", postfix = "\n")) + // Emit the manifest next to the extracted native libs (packaged into app resources). val manifestFile = manifestDir.resolve(SandboxMarkers.MANIFEST_FILENAME) manifest.store( @@ -158,5 +163,6 @@ abstract class AbstractStripNativeLibsFromJarsTask : AbstractNucleusTask() { private companion object { const val MAIN_JAR_META_FILE = ".main-jar-name" + const val CLASSPATH_ORDER_FILE = ".classpath-order" } } \ No newline at end of file diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractUnpackNucleusNativesTask.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractUnpackNucleusNativesTask.kt new file mode 100644 index 000000000..bfe7ec291 --- /dev/null +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/desktop/application/tasks/AbstractUnpackNucleusNativesTask.kt @@ -0,0 +1,57 @@ +package dev.nucleusframework.desktop.application.tasks + +import dev.nucleusframework.desktop.application.internal.files.nucleusNativeEntries +import dev.nucleusframework.desktop.application.internal.files.unpackNucleusNativeLibs +import dev.nucleusframework.desktop.tasks.AbstractNucleusTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault + +/** + * Splits the uber JAR the GraalVM native image is compiled from: the Nucleus JNI libraries of + * [platformDir] go to [libsDir], to be shipped next to the executable, and [strippedJar] is the + * same JAR without any library a Nucleus module lists, so native-image embeds none of them. + * Everything else — including other `nucleus/native/` entries — is left as it is. + * + * Embedded libraries could only be loaded by extracting them to the user's cache on first launch; + * next to the executable, `GraalVmInitializer`'s `java.library.path` resolves them directly. + */ +@DisableCachingByDefault(because = "Rewrites a local JAR; fast and not worth caching") +abstract class AbstractUnpackNucleusNativesTask : AbstractNucleusTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val uberJar: RegularFileProperty + + /** The `nucleus/native//` of the image's platform, e.g. `win32-x64`. */ + @get:Input + abstract val platformDir: Property + + @get:OutputFile + abstract val strippedJar: RegularFileProperty + + @get:OutputDirectory + abstract val libsDir: DirectoryProperty + + /** Writes [strippedJar] and refills [libsDir] from [uberJar]. */ + @TaskAction + fun unpack() { + val libs = libsDir.get().asFile + libs.deleteRecursively() + val source = uberJar.get().asFile + unpackNucleusNativeLibs( + sourceJar = source, + targetJar = strippedJar.get().asFile, + libsDir = libs, + platformDir = platformDir.get(), + nucleusEntries = source.nucleusNativeEntries(), + ) + } +} diff --git a/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package-lock.json b/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package-lock.json index 9669c670a..c0c018cf6 100644 --- a/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package-lock.json +++ b/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { - "electron-builder": "26.15.5" + "electron-builder": "26.16.1" } }, "node_modules/@electron/asar": { @@ -36,9 +36,9 @@ "license": "MIT" }, "node_modules/@electron/asar/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -255,18 +255,18 @@ "license": "MIT" }, "node_modules/@electron/universal/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/@electron/universal/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -298,6 +298,7 @@ "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -313,11 +314,12 @@ } }, "node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -392,26 +394,29 @@ } }, "node_modules/@noble/hashes": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", - "integrity": "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": "^14.21.3 || >=16" }, "funding": { "url": "https://paulmillr.com/funding/" } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.0.tgz", - "integrity": "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q==", + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.5.tgz", + "integrity": "sha512-Ez3wLKVjaxdsLcgeWN4OE31QkM7oBOgKuuBJxldRYAkfYw2C+8zJPcSd/SThnbhszsEOlAmoaS5kIIkN29fGKQ==", "license": "MIT", "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14" } }, "node_modules/@peculiar/json-schema": { @@ -527,12 +532,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", - "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", "license": "MIT", "dependencies": { - "undici-types": "~8.3.0" + "undici-types": "~8.9.0" } }, "node_modules/@types/responselike": { @@ -545,9 +550,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", - "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -612,9 +617,9 @@ } }, "node_modules/app-builder-lib": { - "version": "26.15.5", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.5.tgz", - "integrity": "sha512-CJdzqy4YXQQdn+ivw1ssuY4yBTgVaBtniB2Dnjc6JsM9mbXoZ4shbuuysjenZloMOEIKEqkuRxltNQyG/NP/pA==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.16.1.tgz", + "integrity": "sha512-FhaO6YOup01ZfQW0Z6gt3AyukJjv1gW4uFK47jTgwcHZKqyN/fSlK2LqPf9tAeZYLP2bRJLDzeOkRImsw2X4Pg==", "license": "MIT", "dependencies": { "@electron/asar": "3.4.1", @@ -625,13 +630,13 @@ "@electron/rebuild": "^4.0.4", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", - "@noble/hashes": "^2.2.0", + "@noble/hashes": "^1.8.0", "@peculiar/webcrypto": "^1.7.1", "@types/fs-extra": "9.0.13", "ajv": "^8.18.0", "asn1js": "^3.0.10", "async-exit-hook": "^2.0.1", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chromium-pickle-js": "^0.2.0", "ci-info": "4.3.1", @@ -639,7 +644,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.15.3", + "electron-publish": "26.16.0", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -663,8 +668,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.15.5", - "electron-builder-squirrel-windows": "26.15.5" + "dmg-builder": "26.16.1", + "electron-builder-squirrel-windows": "26.16.1" } }, "node_modules/app-builder-lib/node_modules/ci-info": { @@ -782,9 +787,9 @@ "optional": true }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -800,9 +805,9 @@ "license": "MIT" }, "node_modules/builder-util": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.15.3.tgz", - "integrity": "sha512-q2hn7Mbo2nFNkVekPiHFx6Nfo3hURmES3tfBn+k5Pqxl2RkmP3QGqZUhH/q9Pch/4G05NRhPjDlVj1O8q4Txvw==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.16.0.tgz", + "integrity": "sha512-RLyJhB7Si3YkzKR9ubQslWuXW3Vhs3CGe1i+SeixBZ0qTd1mk3XBmssvY22TlB6CS5blyko8Gu1JzpYk8UkYAg==", "license": "MIT", "dependencies": { "@types/debug": "^4.1.6", @@ -1023,7 +1028,8 @@ "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -1182,9 +1188,9 @@ "license": "MIT" }, "node_modules/dir-compare/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -1204,14 +1210,13 @@ } }, "node_modules/dmg-builder": { - "version": "26.15.5", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.15.5.tgz", - "integrity": "sha512-Ts58Bs9QVCPhkhvkz9V1JwVoIwmbA06szZTM7W/ihzoDjHlf7KJo1Ci9nFknoFUC8uDeYgtbu5HW8eAeZ5qeSA==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.16.1.tgz", + "integrity": "sha512-pnI/3Qb24Uk+rMTgIUrsVUKosVgwmBUdF8Zeb8TexOSbpq8MWc7v6l+n+FrEqVkjNZwzBN+XpDS9ENgZ/rkWAw==", "license": "MIT", - "peer": true, "dependencies": { - "app-builder-lib": "26.15.5", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } @@ -1282,17 +1287,17 @@ } }, "node_modules/electron-builder": { - "version": "26.15.5", - "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.15.5.tgz", - "integrity": "sha512-ii+Befxc8diyoQv9iUchEzBAvFef4vrY/l2NID1wdZL2WCTLe80sYQz7Alc+yswWPpgowUdpsI5HtomE2Lj/Mg==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/electron-builder/-/electron-builder-26.16.1.tgz", + "integrity": "sha512-LrLK65QX5PUYYODXqp23FKrV7CILTtVY7mrJckNknO9jLNSMiqFkKbSMiDRw4CjOADMPVDdWLxY4mezOZWswxg==", "license": "MIT", "dependencies": { - "app-builder-lib": "26.15.5", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", - "dmg-builder": "26.15.5", + "dmg-builder": "26.16.1", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", @@ -1307,26 +1312,26 @@ } }, "node_modules/electron-builder-squirrel-windows": { - "version": "26.15.5", - "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.5.tgz", - "integrity": "sha512-+7D6F08V26p8dLLu2rK4MReQR50lA5W6hEgtNmjm6xbLrAGCJSWFbQEca6oNRKnFGlfHGS1TfgHLmOL3HX+6DA==", + "version": "26.16.1", + "resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.16.1.tgz", + "integrity": "sha512-w0y44wSaT1l6R7CAGmeHn4nHPfvzDyCAU1xJyi1w9SbPYJpYn76SmHDzqHf8Y7l91cPWTdPYBpGQtB2T5mJ08A==", "license": "MIT", "peer": true, "dependencies": { - "app-builder-lib": "26.15.5", - "builder-util": "26.15.3", + "app-builder-lib": "26.16.1", + "builder-util": "26.16.0", "electron-winstaller": "5.4.0" } }, "node_modules/electron-publish": { - "version": "26.15.3", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz", - "integrity": "sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==", + "version": "26.16.0", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.16.0.tgz", + "integrity": "sha512-Vt3KzQIiw9BImvNOYtndg9Mjki+tl4+1sQiC/+G5j8khWaENOJFWodiB+sUl6yyHwtd37avehskdtPw7f8y/+Q==", "license": "MIT", "dependencies": { "@types/fs-extra": "^9.0.11", "aws4": "^1.13.2", - "builder-util": "26.15.3", + "builder-util": "26.16.0", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "form-data": "^4.0.5", @@ -1341,6 +1346,7 @@ "integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -1360,6 +1366,7 @@ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -1374,6 +1381,7 @@ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -1383,6 +1391,7 @@ "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -1504,9 +1513,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -1552,9 +1561,9 @@ "license": "MIT" }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1706,9 +1715,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2003,9 +2012,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -2223,6 +2232,7 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -2237,9 +2247,9 @@ "license": "MIT" }, "node_modules/node-abi": { - "version": "4.33.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.33.0.tgz", - "integrity": "sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==", + "version": "4.35.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.35.0.tgz", + "integrity": "sha512-ymk4aIzxdPopw2giv8Fs1Ec6vybGkjmyxUwVqhkI4MCy2tVfXdkOGGWieWVjL0THgH+7a8lRdevyupoYj3Js/Q==", "license": "MIT", "dependencies": { "semver": "^7.6.3" @@ -2420,11 +2430,10 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -2433,12 +2442,15 @@ } }, "node_modules/pkijs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", - "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.1.tgz", + "integrity": "sha512-Oo/NZcSWccq8KyoG7gLE9fnltgHns+pNCjCAp/WmjsUySi+sX7y4z4Xqu4fVb42CDHzRPl33fjzT15V1wvcyhA==", "license": "BSD-3-Clause", + "workspaces": [ + "website" + ], "dependencies": { - "@noble/hashes": "1.4.0", + "@noble/hashes": "1.8.0", "asn1js": "^3.0.6", "bytestreamjs": "^2.0.1", "pvtsutils": "^1.3.6", @@ -2449,18 +2461,6 @@ "node": ">=16.0.0" } }, - "node_modules/pkijs/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/plist": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.0.tgz", @@ -2481,6 +2481,7 @@ "integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==", "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -2497,6 +2498,7 @@ "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -2684,6 +2686,7 @@ "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -2931,6 +2934,7 @@ "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -3030,18 +3034,18 @@ } }, "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "license": "MIT" }, "node_modules/universalify": { diff --git a/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package.json b/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package.json index 198e6d681..b04896577 100644 --- a/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package.json +++ b/plugin-build/plugin/src/main/resources/nucleus/electron-builder/package.json @@ -5,6 +5,6 @@ "description": "Pinned electron-builder toolchain used by the Nucleus Gradle plugin.", "license": "MIT", "dependencies": { - "electron-builder": "26.15.5" + "electron-builder": "26.16.1" } } diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplicationDistributionsSandboxTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplicationDistributionsSandboxTest.kt new file mode 100644 index 000000000..84605b3b2 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/JvmApplicationDistributionsSandboxTest.kt @@ -0,0 +1,55 @@ +package dev.nucleusframework.desktop.application.dsl + +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Whether a format goes through the sandboxed (store) pipeline is a DSL decision for PKG: + * `macOS { pkg { appStore } }` selects the Mac App Store (default) or Developer ID distribution. + */ +class JvmApplicationDistributionsSandboxTest { + private fun newDistributions(): JvmApplicationDistributions = + ProjectBuilder.builder().build().objects.newInstance(JvmApplicationDistributions::class.java) + + @Test + fun `pkg targets the app store by default`() { + val distributions = newDistributions() + assertTrue(distributions.macOS.pkg.appStore) + assertTrue(distributions.isSandboxed(TargetFormat.Pkg)) + } + + @Test + fun `developer id pkg is not sandboxed`() { + val distributions = newDistributions() + distributions.macOS.pkg { it.appStore = false } + assertFalse(distributions.isSandboxed(TargetFormat.Pkg)) + } + + @Suppress("DEPRECATION_ERROR") + @Test + fun `deprecated appStore flag aliases pkg appStore`() { + val distributions = newDistributions() + distributions.macOS.appStore = false + assertFalse(distributions.macOS.pkg.appStore) + assertFalse(distributions.macOS.appStore) + assertFalse(distributions.isSandboxed(TargetFormat.Pkg)) + } + + @Test + fun `appx and flatpak are always sandboxed`() { + val distributions = newDistributions() + distributions.macOS.pkg.appStore = false + assertTrue(distributions.isSandboxed(TargetFormat.AppX)) + assertTrue(distributions.isSandboxed(TargetFormat.Flatpak)) + } + + @Test + fun `direct distribution formats are never sandboxed`() { + val distributions = newDistributions() + for (format in listOf(TargetFormat.Dmg, TargetFormat.Zip, TargetFormat.Msi, TargetFormat.Nsis, TargetFormat.Deb)) { + assertFalse(format.name, distributions.isSandboxed(format)) + } + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollectorIdsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollectorIdsTest.kt index 7561cc7ec..49363d58d 100644 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollectorIdsTest.kt +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/NativeImageGarbageCollectorIdsTest.kt @@ -2,6 +2,7 @@ package dev.nucleusframework.desktop.application.dsl import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -14,12 +15,12 @@ class NativeImageGarbageCollectorIdsTest { } @Test - fun `only G1 is restricted to Oracle GraalVM on Linux`() { + fun `only G1 is restricted to Oracle GraalVM, and off Linux to 25_4`() { assertTrue(NativeImageGarbageCollector.G1.isOracleOnly) - assertTrue(NativeImageGarbageCollector.G1.isLinuxOnly) + assertEquals("25.4", NativeImageGarbageCollector.G1.nonLinuxMinVersion) listOf(NativeImageGarbageCollector.SERIAL, NativeImageGarbageCollector.EPSILON).forEach { gc -> assertFalse("$gc should be unrestricted", gc.isOracleOnly) - assertFalse("$gc should be unrestricted", gc.isLinuxOnly) + assertNull("$gc should be unrestricted", gc.nonLinuxMinVersion) } } diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/TargetFormatStoreFormatTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/TargetFormatStoreFormatTest.kt deleted file mode 100644 index 81ffb62c6..000000000 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/dsl/TargetFormatStoreFormatTest.kt +++ /dev/null @@ -1,20 +0,0 @@ -package dev.nucleusframework.desktop.application.dsl - -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class TargetFormatStoreFormatTest { - @Test - fun `store formats are identified`() { - assertTrue(TargetFormat.Pkg.isStoreFormat) - assertTrue(TargetFormat.AppX.isStoreFormat) - assertTrue(TargetFormat.Flatpak.isStoreFormat) - } - - @Test - fun `non store formats are not marked as store formats`() { - assertFalse(TargetFormat.Dmg.isStoreFormat) - assertFalse(TargetFormat.Msi.isStoreFormat) - } -} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/ApplyNucleusOptimizationTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/ApplyNucleusOptimizationTest.kt new file mode 100644 index 000000000..2f8c9ed9d --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/ApplyNucleusOptimizationTest.kt @@ -0,0 +1,180 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.desktop.application.dsl.GarbageCollector +import org.gradle.api.Project +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class ApplyNucleusOptimizationTest { + @Test + fun `disabled does not touch collector or heap flags`() { + val app = applicationData() + applyNucleusOptimization(app) + assertNull(app.garbageCollector) + assertTrue(app.jvmArgs.isEmpty()) + } + + @Test + fun `enabled sets serial and heap when unset`() { + val app = applicationData() + app.nucleusOptimization = true + applyNucleusOptimization(app) + assertEquals(GarbageCollector.SERIAL, app.garbageCollector) + assertEquals( + listOf(OPTIMIZED_XMS, OPTIMIZED_MAX_RAM_PERCENTAGE, OPTIMIZED_IDLE_GC_FLAG), + app.jvmArgs.toList(), + ) + } + + @Test + fun `enabled keeps an explicit collector and existing heap flags`() { + val app = applicationData() + app.nucleusOptimization = true + app.garbageCollector = GarbageCollector.G1 + app.jvmArgs.add("-Xms64m") + app.jvmArgs.add("-XX:MaxRAMPercentage=40") + applyNucleusOptimization(app) + assertEquals(GarbageCollector.G1, app.garbageCollector) + assertEquals( + listOf("-Xms64m", "-XX:MaxRAMPercentage=40", OPTIMIZED_IDLE_GC_FLAG), + app.jvmArgs.toList(), + ) + } + + @Test + fun `enabled does not duplicate an existing runtime flag`() { + val app = applicationData() + app.nucleusOptimization = true + app.jvmArgs.add("-Dnucleus.optimization.idleGc=false") + applyNucleusOptimization(app) + assertEquals(1, app.jvmArgs.count { it.startsWith("-Dnucleus.optimization.idleGc=") }) + assertTrue(app.jvmArgs.contains("-Dnucleus.optimization.idleGc=false")) + } + + @Test + fun `master on idleGc off omits the runtime flag`() { + val app = applicationData() + app.nucleusOptimization = true + app.nucleusOptimizationSettings.idleGc = false + applyNucleusOptimization(app) + assertEquals(GarbageCollector.SERIAL, app.garbageCollector) + assertEquals(listOf(OPTIMIZED_XMS, OPTIMIZED_MAX_RAM_PERCENTAGE), app.jvmArgs.toList()) + assertFalse(app.optIdleGc) + assertTrue(app.optSingleJar) + } + + @Test + fun `master on serialGc off leaves collector unset`() { + val app = applicationData() + app.nucleusOptimization = true + app.nucleusOptimizationSettings.serialGc = false + applyNucleusOptimization(app) + assertNull(app.garbageCollector) + assertTrue(app.optCompactHeap) + assertTrue(app.optIdleGc) + assertFalse(app.optSerialGc) + } + + @Test + fun `only idleGc sets the runtime flag`() { + val app = applicationData() + app.nucleusOptimizationSettings.idleGc = true + applyNucleusOptimization(app) + assertNull(app.garbageCollector) + assertEquals(listOf(OPTIMIZED_IDLE_GC_FLAG), app.jvmArgs.toList()) + assertFalse(app.optSerialGc) + assertFalse(app.optCompactHeap) + assertFalse(app.optSingleJar) + } + + @Test + fun `only serialGc sets the collector`() { + val app = applicationData() + app.nucleusOptimizationSettings.serialGc = true + applyNucleusOptimization(app) + assertEquals(GarbageCollector.SERIAL, app.garbageCollector) + assertTrue(app.jvmArgs.isEmpty()) + } + + @Test + fun `only compactHeap sets heap flags`() { + val app = applicationData() + app.nucleusOptimizationSettings.compactHeap = true + applyNucleusOptimization(app) + assertNull(app.garbageCollector) + assertEquals(listOf(OPTIMIZED_XMS, OPTIMIZED_MAX_RAM_PERCENTAGE), app.jvmArgs.toList()) + assertFalse(app.optIdleGc) + } + + @Test + fun `only singleJar does not touch jvm flags`() { + val app = applicationData() + app.nucleusOptimizationSettings.singleJar = true + applyNucleusOptimization(app) + assertNull(app.garbageCollector) + assertTrue(app.jvmArgs.isEmpty()) + assertTrue(app.optSingleJar) + assertFalse(app.optIdleGc) + } + + @Test + fun `disabled does not provision a JDK`() { + val project = ProjectBuilder.builder().build() + val app = applicationData(project) + applyNucleusOptimizationJdk(project, app) + assertNull(app.javaHomeOverride) + assertFalse(app.optLastJdk) + } + + @Test + fun `master on provisions a lazy JDK home`() { + val project = ProjectBuilder.builder().build() + val app = applicationData(project) + app.nucleusOptimization = true + applyNucleusOptimizationJdk(project, app) + assertTrue(app.optLastJdk) + assertNotNull(app.javaHomeOverride) + } + + @Test + fun `explicit javaHome wins over JDK provisioning`() { + val project = ProjectBuilder.builder().build() + val app = applicationData(project) + app.nucleusOptimization = true + app.javaHome = "/custom/jdk" + applyNucleusOptimizationJdk(project, app) + assertNull(app.javaHomeOverride) + assertEquals("/custom/jdk", app.javaHome) + } + + @Test + fun `master on lastJdk off does not provision`() { + val project = ProjectBuilder.builder().build() + val app = applicationData(project) + app.nucleusOptimization = true + app.nucleusOptimizationSettings.lastJdk = false + applyNucleusOptimizationJdk(project, app) + assertFalse(app.optLastJdk) + assertNull(app.javaHomeOverride) + } + + @Test + fun `only lastJdk provisions without touching JVM flags`() { + val project = ProjectBuilder.builder().build() + val app = applicationData(project) + app.nucleusOptimizationSettings.lastJdk = true + applyNucleusOptimization(app) + applyNucleusOptimizationJdk(project, app) + assertNull(app.garbageCollector) + assertTrue(app.jvmArgs.isEmpty()) + assertNotNull(app.javaHomeOverride) + } + + private fun applicationData(project: Project = ProjectBuilder.builder().build()): JvmApplicationData = + project.objects.newInstance(JvmApplicationData::class.java) +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/GraalvmVersionOfTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/GraalvmVersionOfTest.kt new file mode 100644 index 000000000..1575348fb --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/GraalvmVersionOfTest.kt @@ -0,0 +1,47 @@ +package dev.nucleusframework.desktop.application.internal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class GraalvmVersionOfTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun javaHome(release: String?) = + tmp.newFolder().also { home -> + release?.let { home.resolve("release").writeText(it) } + } + + @Test + fun `reads the quoted GRAALVM_VERSION entry`() { + // Verbatim from Oracle GraalVM 25.4.4.1.1 for windows-x64: JAVA_VERSION is the JDK line + // (25.0.4.1.1) and must not be mistaken for the GraalVM one. + val home = + javaHome( + """ + IMPLEMENTOR="Oracle Corporation" + JAVA_VERSION="25.0.4.1.1" + GRAALVM_VERSION="25.4.4.1.1" + """.trimIndent(), + ) + assertEquals("25.4.4.1.1", graalvmVersionOf(home)) + } + + @Test + fun `a plain JDK with no GraalVM entry resolves to null`() { + assertNull(graalvmVersionOf(javaHome("""JAVA_VERSION="25.0.4""""))) + } + + @Test + fun `a missing release file resolves to null`() { + assertNull(graalvmVersionOf(javaHome(release = null))) + } + + @Test + fun `a blank version resolves to null`() { + assertNull(graalvmVersionOf(javaHome("""GRAALVM_VERSION="""""))) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrderTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrderTest.kt new file mode 100644 index 000000000..fc62eb5a9 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/LauncherClasspathOrderTest.kt @@ -0,0 +1,65 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.logging.Logging +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class LauncherClasspathOrderTest { + @get:Rule + val tmp = TemporaryFolder() + + private val fork = "kotlinx-coroutines-core-jvm-1.10.2-intellij-2-7b70.jar" + private val real = "kotlinx-coroutines-core-jvm-1.11.0-41a5.jar" + + private fun cfg( + separator: String, + vararg jars: String, + ) = ( + listOf("[Application]") + + jars.map { "app.classpath=\$APPDIR$separator$it" } + + listOf("app.mainclass=demo.MainKt", "", "[JavaOptions]", "java-options=-Dx=1", "") + ).joinToString("\r\n") + + @Test + fun `the jpackage name order is replaced by the classpath order`() { + val text = cfg("\\", "app.jar", fork, real, "zzz.jar") + val out = LauncherClasspathOrder.reorder(text, listOf("app.jar", "zzz.jar", real, fork))!! + assertEquals(cfg("\\", "app.jar", "zzz.jar", real, fork), out) + } + + @Test + fun `unknown entries keep their place after the known ones`() { + val text = cfg("/", "app.jar", "b.jar", "x.jar", "a.jar", "y.jar") + val out = LauncherClasspathOrder.reorder(text, listOf("app.jar", "a.jar", "b.jar"))!! + assertEquals(cfg("/", "app.jar", "a.jar", "b.jar", "x.jar", "y.jar"), out) + } + + @Test + fun `an ordered or single-entry classpath is left alone`() { + assertNull(LauncherClasspathOrder.reorder(cfg("/", "app.jar", real, fork), listOf("app.jar", real, fork))) + assertNull(LauncherClasspathOrder.reorder(cfg("/", "app.jar"), listOf("app.jar"))) + } + + @Test + fun `line endings and every other line survive`() { + val lf = cfg("/", "app.jar", fork, real).replace("\r\n", "\n") + val out = LauncherClasspathOrder.reorder(lf, listOf("app.jar", real, fork))!! + assertEquals(cfg("/", "app.jar", real, fork).replace("\r\n", "\n"), out) + } + + @Test + fun `every launcher cfg of the app image is rewritten, jvm cfg untouched`() { + val appDir = tmp.newFolder("App", "app") + val main = appDir.resolve("App.cfg").apply { writeText(cfg("\\", "app.jar", fork, real)) } + val extra = appDir.resolve("Tool.cfg").apply { writeText(cfg("\\", "app.jar", fork, real)) } + val jvm = tmp.newFolder("App", "runtime", "lib").resolve("jvm.cfg").apply { writeText("-server KNOWN\n") } + val count = LauncherClasspathOrder.apply(tmp.root, listOf("app.jar", real, fork), Logging.getLogger("test")) + assertEquals(2, count) + assertEquals(cfg("\\", "app.jar", real, fork), main.readText()) + assertEquals(cfg("\\", "app.jar", real, fork), extra.readText()) + assertEquals("-server KNOWN\n", jvm.readText()) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/MacPkgScriptsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/MacPkgScriptsTest.kt new file mode 100644 index 000000000..667935243 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/MacPkgScriptsTest.kt @@ -0,0 +1,119 @@ +package dev.nucleusframework.desktop.application.internal + +import org.gradle.api.GradleException +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class MacPkgScriptsTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun script( + name: String, + content: String = "#!/bin/sh\necho $name\n", + ): File = tmp.newFile(name).apply { writeText(content) } + + @Test + fun `stages an entry point per script plus the app's own copy, all executable`() { + val build = tmp.newFolder("build") + + val staged = MacPkgScripts.stage(build, script("pre.sh"), script("post.sh"), appStore = false) + + assertEquals(build.resolve("pkg-scripts"), staged) + assertEquals( + setOf("preinstall", "postinstall", "nucleus-app-pre", "nucleus-app-post"), + staged!!.list()!!.toSet(), + ) + staged.listFiles()!!.forEach { assertTrue(it.name, it.canExecute()) } + } + + @Test + fun `the app's script is copied verbatim under a name electron-builder does not scan`() { + val build = tmp.newFolder("build") + + val staged = MacPkgScripts.stage(build, script("pre.sh"), script("post.sh"), appStore = false)!! + + assertEquals("#!/bin/sh\necho pre.sh\n", staged.resolve("nucleus-app-pre").readText()) + assertEquals("#!/bin/sh\necho post.sh\n", staged.resolve("nucleus-app-post").readText()) + // electron-builder sets BundlePre/PostInstallScriptPath for any file whose name contains + // these substrings; matching here would re-introduce the double execution. + for (name in staged.list()!!.filter { it.startsWith("nucleus-app") }) { + assertFalse(name, name.contains("preinstall")) + assertFalse(name, name.contains("postinstall")) + } + } + + @Test + fun `the entry point skips the per-bundle pass and delegates on the top-level one`() { + val build = tmp.newFolder("build") + val staged = MacPkgScripts.stage(build, script("pre.sh"), null, appStore = false)!! + val entryPoint = staged.resolve("preinstall").readText() + + assertTrue(entryPoint, entryPoint.startsWith("#!/bin/sh")) + assertTrue(entryPoint, entryPoint.contains("*.app|*.app/) exit 0")) + assertTrue(entryPoint, entryPoint.contains("\"\$(dirname \"\$0\")/nucleus-app-pre\" \"\$@\"")) + } + + @Test + fun `stages a single script`() { + val build = tmp.newFolder("build") + + val staged = MacPkgScripts.stage(build, preInstall = null, postInstall = script("post.sh"), appStore = false) + + assertEquals(setOf("postinstall", "nucleus-app-post"), staged!!.list()!!.toSet()) + } + + @Test + fun `wipes a stale directory when no script is configured`() { + val build = tmp.newFolder("build") + build.resolve("pkg-scripts").mkdirs() + build.resolve("pkg-scripts/postinstall").writeText("#!/bin/sh\nstale\n") + + assertNull(MacPkgScripts.stage(build, preInstall = null, postInstall = null, appStore = true)) + assertFalse(build.resolve("pkg-scripts").exists()) + } + + @Test + fun `refuses scripts for an app store pkg`() { + val build = tmp.newFolder("build") + + val error = + assertThrows(GradleException::class.java) { + MacPkgScripts.stage(build, script("pre.sh"), null, appStore = true) + } + + assertTrue(error.message, error.message!!.contains("appStore = false")) + assertFalse(build.resolve("pkg-scripts").exists()) + } + + @Test + fun `refuses a missing script`() { + val build = tmp.newFolder("build") + + val error = + assertThrows(GradleException::class.java) { + MacPkgScripts.stage(build, null, File(build, "nope.sh"), appStore = false) + } + + assertTrue(error.message, error.message!!.contains("postinstall script not found")) + } + + @Test + fun `refuses a script without a shebang`() { + val build = tmp.newFolder("build") + + val error = + assertThrows(GradleException::class.java) { + MacPkgScripts.stage(build, script("pre.sh", content = "echo hi\n"), null, appStore = false) + } + + assertTrue(error.message, error.message!!.contains("shebang")) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NativeImageGcArgsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NativeImageGcArgsTest.kt index 0200ce4e9..53fc4ef03 100644 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NativeImageGcArgsTest.kt +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NativeImageGcArgsTest.kt @@ -43,6 +43,7 @@ class NativeImageGcArgsTest { requested = NativeImageGarbageCollector.EPSILON, isOracleGraalvm = false, isLinux = false, + graalvmVersion = null, graalvmHome = "/opt/graalvm-ce", ) assertEquals(NativeImageGarbageCollector.EPSILON, resolution.gc) @@ -50,12 +51,13 @@ class NativeImageGcArgsTest { } @Test - fun `G1 is kept on Oracle GraalVM for Linux`() { + fun `G1 is kept on Oracle GraalVM for Linux, whatever the version`() { val resolution = resolveNativeImageGc( requested = NativeImageGarbageCollector.G1, isOracleGraalvm = true, isLinux = true, + graalvmVersion = "25.3.4.1", graalvmHome = "/opt/graalvm-oracle", ) assertEquals(NativeImageGarbageCollector.G1, resolution.gc) @@ -69,25 +71,60 @@ class NativeImageGcArgsTest { requested = NativeImageGarbageCollector.G1, isOracleGraalvm = false, isLinux = true, + graalvmVersion = "25.4.4.1.1", graalvmHome = "/opt/graalvm-ce", ) assertNull(resolution.gc) assertNotNull(resolution.warning) assertTrue(resolution.warning!!.contains("requires Oracle GraalVM")) - assertTrue(resolution.warning!!.contains("/opt/graalvm-ce")) + assertTrue(resolution.warning.contains("/opt/graalvm-ce")) } @Test - fun `G1 is dropped off Linux`() { + fun `G1 is kept off Linux from 25_4 on`() { + listOf("25.4", "25.4.4.1.1", "26.0.1").forEach { version -> + val resolution = + resolveNativeImageGc( + requested = NativeImageGarbageCollector.G1, + isOracleGraalvm = true, + isLinux = false, + graalvmVersion = version, + graalvmHome = "/opt/graalvm-oracle", + ) + assertEquals("G1 should be kept on $version", NativeImageGarbageCollector.G1, resolution.gc) + assertNull(resolution.warning) + } + } + + @Test + fun `G1 is dropped off Linux before 25_4`() { + listOf("25.3.4.1", "25.0.1", "24.1.2").forEach { version -> + val resolution = + resolveNativeImageGc( + requested = NativeImageGarbageCollector.G1, + isOracleGraalvm = true, + isLinux = false, + graalvmVersion = version, + graalvmHome = "/opt/graalvm-oracle", + ) + assertNull("G1 should be dropped on $version", resolution.gc) + assertTrue(resolution.warning!!.contains("requires GraalVM 25.4 or newer outside Linux")) + assertTrue(resolution.warning.contains(version)) + } + } + + @Test + fun `G1 is dropped off Linux when the toolchain version cannot be read`() { val resolution = resolveNativeImageGc( requested = NativeImageGarbageCollector.G1, isOracleGraalvm = true, isLinux = false, + graalvmVersion = null, graalvmHome = "/opt/graalvm-oracle", ) assertNull(resolution.gc) - assertTrue(resolution.warning!!.contains("only supported on Linux")) + assertTrue(resolution.warning!!.contains("could not be read")) } @Test @@ -97,6 +134,7 @@ class NativeImageGcArgsTest { requested = null, isOracleGraalvm = true, isLinux = true, + graalvmVersion = "25.4.4.1.1", graalvmHome = "/opt/graalvm-oracle", ) assertNull(resolution.gc) diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NodeToolchainProvisionerTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NodeToolchainProvisionerTest.kt new file mode 100644 index 000000000..702a1c3dd --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NodeToolchainProvisionerTest.kt @@ -0,0 +1,92 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.internal.utils.Arch +import dev.nucleusframework.internal.utils.OS +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.nio.file.Files + +class NodeToolchainProvisionerTest { + private val index = + """ + [ + {"version": "v24.2.0", "lts": false}, + {"version": "v22.11.0", "lts": "Jod"}, + {"version": "v22.9.0", "lts": false}, + {"version": "v20.18.1", "lts": "Iron"} + ] + """.trimIndent() + + @Test + fun `a pinned version resolves without touching the network`() { + val resolved = + NodeToolchainProvisioner.resolveVersion("22.11.0") { error("index.json must not be fetched") } + assertEquals("v22.11.0", resolved) + } + + @Test + fun `a major line resolves to its newest release`() { + assertEquals("v22.11.0", NodeToolchainProvisioner.resolveVersion("22") { index }) + } + + @Test + fun `lts resolves to the newest release carrying an LTS codename`() { + assertEquals("v22.11.0", NodeToolchainProvisioner.resolveVersion("lts") { index }) + } + + @Test + fun `an unreleased line fails with an actionable message`() { + val failure = + assertThrows(IllegalStateException::class.java) { + NodeToolchainProvisioner.resolveVersion("19") { index } + } + assertTrue(failure.message!!.contains("nodejs { version }")) + } + + @Test + fun `windows downloads a zip and every other platform a tarball`() { + assertEquals( + "node-v22.11.0-win-x64.zip", + NodeToolchainProvisioner.archiveName("v22.11.0", OS.Windows, Arch.X64), + ) + assertEquals( + "node-v22.11.0-linux-arm64.tar.gz", + NodeToolchainProvisioner.archiveName("v22.11.0", OS.Linux, Arch.Arm64), + ) + assertEquals( + "node-v22.11.0-darwin-arm64.tar.gz", + NodeToolchainProvisioner.archiveName("v22.11.0", OS.MacOS, Arch.Arm64), + ) + } + + @Test + fun `the install id keeps the requested version so a floating line stays sticky`() { + val id = + NodeToolchainProvisioner.installationId( + NodeToolchainRequest(version = "22", os = OS.MacOS, arch = Arch.Arm64, installBaseDir = File(".")), + ) + assertEquals("node-22-darwin-arm64", id) + } + + @Test + fun `an installation is recognised by its node executable, on both layouts`() { + val root = Files.createTempDirectory("node-toolchain").toFile() + try { + val windows = File(root, "win").apply { mkdirs() } + File(windows, "node.exe").writeText("") + assertEquals(File(windows, "npm.cmd"), NodeToolchainProvisioner.installationAt(windows)!!.npm) + + val unix = File(root, "unix/bin").apply { mkdirs() }.parentFile + File(unix, "bin/node").writeText("") + assertEquals(File(unix, "bin/npm"), NodeToolchainProvisioner.installationAt(unix)!!.npm) + + assertNull(NodeToolchainProvisioner.installationAt(File(root, "empty"))) + } finally { + root.deleteRecursively() + } + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NucleusJdkToolchainProvisionerTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NucleusJdkToolchainProvisionerTest.kt new file mode 100644 index 000000000..b87d2735e --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/NucleusJdkToolchainProvisionerTest.kt @@ -0,0 +1,86 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.internal.utils.Arch +import dev.nucleusframework.internal.utils.OS +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class NucleusJdkToolchainProvisionerTest { + @Test + fun `download URL is the pinned OpenJDK 27 GA`() { + val url = NucleusJdkToolchainProvisioner.downloadUrl(OS.Windows, Arch.X64) + assertEquals( + "https://download.java.net/java/GA/jdk27/" + + "$OPENJDK_27_HASH/$OPENJDK_27_BUILD/GPL/" + + "openjdk-27_windows-x64_bin.zip", + url, + ) + assertTrue(url.contains("openjdk-27_")) + } + + @Test + fun `linux aarch64 uses the aarch64 token and tar gz`() { + val url = NucleusJdkToolchainProvisioner.downloadUrl(OS.Linux, Arch.Arm64) + assertTrue(url.endsWith("openjdk-27_linux-aarch64_bin.tar.gz")) + } + + @Test + fun `macos aarch64 is published`() { + val url = NucleusJdkToolchainProvisioner.downloadUrl(OS.MacOS, Arch.Arm64) + assertTrue(url.endsWith("openjdk-27_macos-aarch64_bin.tar.gz")) + } + + @Test + fun `install id is the GA pin so RC caches re-provision`() { + val id = + NucleusJdkToolchainProvisioner.installationId( + NucleusJdkToolchainRequest( + os = OS.Windows, + arch = Arch.X64, + installBaseDir = java.io.File("."), + ), + ) + assertEquals("openjdk-27-windows-x64", id) + } + + @Test + fun `macos x64 falls back to Liberica`() { + val url = NucleusJdkToolchainProvisioner.downloadUrl(OS.MacOS, Arch.X64) + assertEquals(LIBERICA_27_MACOS_X64_URL, url) + assertTrue(NucleusJdkToolchainProvisioner.usesLibericaFallback(OS.MacOS, Arch.X64)) + } + + @Test + fun `macos x64 install id is Liberica so Oracle caches are not reused`() { + val id = + NucleusJdkToolchainProvisioner.installationId( + NucleusJdkToolchainRequest( + os = OS.MacOS, + arch = Arch.X64, + installBaseDir = java.io.File("."), + ), + ) + assertEquals("liberica-jdk-27-macos-x64", id) + } + + @Test + fun `windows aarch64 falls back to Liberica`() { + val url = NucleusJdkToolchainProvisioner.downloadUrl(OS.Windows, Arch.Arm64) + assertEquals(LIBERICA_27_WINDOWS_AARCH64_URL, url) + assertTrue(NucleusJdkToolchainProvisioner.usesLibericaFallback(OS.Windows, Arch.Arm64)) + } + + @Test + fun `windows aarch64 install id is Liberica so Oracle caches are not reused`() { + val id = + NucleusJdkToolchainProvisioner.installationId( + NucleusJdkToolchainRequest( + os = OS.Windows, + arch = Arch.Arm64, + installBaseDir = java.io.File("."), + ), + ) + assertEquals("liberica-jdk-27-windows-aarch64", id) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/PkgScriptValidationTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/PkgScriptValidationTest.kt new file mode 100644 index 000000000..19e5b3cc1 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/PkgScriptValidationTest.kt @@ -0,0 +1,48 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.desktop.application.dsl.JvmApplicationDistributions +import dev.nucleusframework.desktop.application.dsl.PkgSettings +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The Mac App Store rejects installer packages carrying install scripts (error 90254), so the + * contradiction must surface at configuration time. [MacPkgScripts] repeats the check when the + * scripts are staged, but a build should never get that far. + */ +class PkgScriptValidationTest { + private fun pkgWithScript(appStore: Boolean): PkgSettings = + ProjectBuilder + .builder() + .build() + .objects + .newInstance(JvmApplicationDistributions::class.java) + .macOS + .pkg + .apply { + this.appStore = appStore + postInstall.set(File("postinstall")) + } + + @Test + fun `scripts on an app store pkg are a configuration error`() { + val error = assertThrows(IllegalStateException::class.java) { validatePkgScripts(pkgWithScript(appStore = true)) } + assertTrue(error.message, error.message!!.contains("appStore = false")) + } + + @Test + fun `scripts on a developer id pkg are accepted`() { + validatePkgScripts(pkgWithScript(appStore = false)) + } + + @Test + fun `an app store pkg without scripts is accepted`() { + val project = ProjectBuilder.builder().build() + val distributions = project.objects.newInstance(JvmApplicationDistributions::class.java) + assertTrue(distributions.macOS.pkg.appStore) + validatePkgScripts(distributions.macOS.pkg) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/ToolchainDownloadsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/ToolchainDownloadsTest.kt new file mode 100644 index 000000000..07d69a4af --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/ToolchainDownloadsTest.kt @@ -0,0 +1,42 @@ +package dev.nucleusframework.desktop.application.internal + +import org.junit.Assert.assertEquals +import org.junit.Test +import java.nio.file.Files +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +class ToolchainDownloadsTest { + @Test + fun `parallel callers in one JVM queue on the install lock instead of throwing`() { + val base = Files.createTempDirectory("toolchain-lock").toFile() + val threads = 8 + val pool = Executors.newFixedThreadPool(threads) + try { + val start = CountDownLatch(1) + val inside = AtomicInteger() + val maxInside = AtomicInteger() + val futures = + (1..threads).map { + pool.submit { + start.await() + ToolchainDownloads.withInstallLock(base, "node-22-linux-x64") { + maxInside.accumulateAndGet(inside.incrementAndGet(), ::maxOf) + Thread.sleep(20) + inside.decrementAndGet() + } + } + } + start.countDown() + // A bare FileChannel.lock() threw OverlappingFileLockException here for every thread + // but the first; get() rethrows it. + futures.forEach { it.get(30, TimeUnit.SECONDS) } + assertEquals(1, maxInside.get()) + } finally { + pool.shutdownNow() + base.deleteRecursively() + } + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlGeneratorTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlGeneratorTest.kt new file mode 100644 index 000000000..85e3f1702 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/UpdateYmlGeneratorTest.kt @@ -0,0 +1,89 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.desktop.application.dsl.TargetFormat +import org.gradle.api.logging.Logging +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class UpdateYmlGeneratorTest { + @get:Rule + val tmp = TemporaryFolder() + + private val logger = Logging.getLogger(UpdateYmlGeneratorTest::class.java) + + @Test + fun `a packaging output without a publish provider becomes a complete local feed`() { + val dir = tmp.newFolder("nsis") + File(dir, "app-1.1.0-win-x64-nsis.exe").writeBytes(ByteArray(1000) { it.toByte() }) + File(dir, "app-1.1.0-win-x64-nsis.exe.blockmap").writeBytes(ByteArray(10)) + File(dir, "nucleus-installer.nsh").writeText("; build leftover") + File(dir, "builder-debug.yml").writeText("x: 1") + File(dir, "package.json").writeText("{}") + + UpdateYmlGenerator.generateIfMissing(dir, "latest.yml", "1.1.0", logger, artifactExtension = "exe") + + val yml = File(dir, "latest.yml").readText() + assertTrue(yml, yml.startsWith("version: 1.1.0\n")) + assertTrue(yml, yml.contains(" - url: app-1.1.0-win-x64-nsis.exe\n")) + assertTrue(yml, yml.contains(" size: 1000\n")) + assertFalse("build leftovers are no artifact: $yml", yml.contains("nsh")) + assertEquals("one artifact listed", 1, Regex("- url:").findAll(yml).count()) + } + + @Test + fun `the installer of a previous version left in the output is not listed`() { + val dir = tmp.newFolder("bumped") + File(dir, "app-1.0.0-win-x64-nsis.exe").writeBytes(ByteArray(10)) + File(dir, "app-11.1.0-win-x64-nsis.exe").writeBytes(ByteArray(10)) + File(dir, "app-1.1.0.1-win-x64-nsis.exe").writeBytes(ByteArray(10)) + File(dir, "app-1.1.0-win-x64-nsis.exe").writeBytes(ByteArray(20)) + + UpdateYmlGenerator.generateIfMissing(dir, "latest.yml", "1.1.0", logger, artifactExtension = "exe") + + val urls = Regex("- url: (.*)").findAll(File(dir, "latest.yml").readText()).map { it.groupValues[1] }.toList() + assertEquals(listOf("app-1.1.0-win-x64-nsis.exe"), urls) + } + + @Test + fun `an artifact name without the version keeps the newest artifact`() { + val old = tmp.newFile("MyApp.exe.old.exe").apply { setLastModified(1_000_000) } + val current = tmp.newFile("MyApp.exe").apply { setLastModified(2_000_000) } + assertEquals(listOf(current), UpdateYmlGenerator.currentArtifacts(listOf(old, current), "2.0.0")) + assertEquals(emptyList(), UpdateYmlGenerator.currentArtifacts(emptyList(), "2.0.0")) + } + + @Test + fun `a new packaging run starts without the previous run's manifests`() { + val dir = tmp.newFolder("rerun") + listOf("latest.yml", "beta-mac.yml", "alpha-linux.yml").forEach { File(dir, it).writeText("version: 1.0.0\n") } + File(dir, "builder-debug.yml").writeText("x: 1") + File(dir, "app-1.0.0.exe").writeText("x") + UpdateYmlPublish.deleteManifests(dir) + assertEquals(setOf("builder-debug.yml", "app-1.0.0.exe"), dir.list()!!.toSet()) + } + + @Test + fun `a manifest electron-builder wrote is left alone`() { + val dir = tmp.newFolder("dmg") + File(dir, "app.dmg").writeBytes(ByteArray(3)) + File(dir, "latest-mac.yml").writeText("version: 9.9.9\n") + UpdateYmlGenerator.generateIfMissing(dir, "latest-mac.yml", "1.0.0", logger, artifactExtension = "dmg") + assertEquals("version: 9.9.9\n", File(dir, "latest-mac.yml").readText()) + } + + @Test + fun `every self-contained updatable format names its artifact`() { + assertEquals("exe", TargetFormat.Nsis.updateArtifactExtension) + assertEquals("msi", TargetFormat.Msi.updateArtifactExtension) + assertEquals("AppImage", TargetFormat.AppImage.updateArtifactExtension) + assertEquals("deb", TargetFormat.Deb.updateArtifactExtension) + assertNull("NSIS-Web's packages live on its publish host", TargetFormat.NsisWeb.updateArtifactExtension) + assertNull(TargetFormat.Flatpak.updateArtifactExtension) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/UpdaterLaunchSettingsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/UpdaterLaunchSettingsTest.kt new file mode 100644 index 000000000..b20fbf8a0 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/UpdaterLaunchSettingsTest.kt @@ -0,0 +1,39 @@ +package dev.nucleusframework.desktop.application.internal + +import dev.nucleusframework.desktop.application.tasks.AbstractServeUpdateFeedTask +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class UpdaterLaunchSettingsTest { + @Test + fun `settings map to the environment names the runtime reads`() { + assertEquals("NUCLEUS_UPDATER_FEED_URL", UpdaterLaunchSettings.environmentName("nucleus.updater.feedUrl")) + assertEquals("NUCLEUS_UPDATER_SIMULATE", UpdaterLaunchSettings.environmentName("nucleus.updater.simulate")) + assertEquals( + "NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM", + UpdaterLaunchSettings.environmentName("nucleus.updater.simulate.justUpdatedFrom"), + ) + } + + @Test + fun `byte rates accept plain, k and m suffixes`() { + assertEquals(2_000_000L, UpdaterLaunchSettings.parseByteRate("2000000")) + assertEquals(512L * 1024, UpdaterLaunchSettings.parseByteRate("512k")) + assertEquals(3L * 1024 * 1024 / 2, UpdaterLaunchSettings.parseByteRate("1.5M")) + assertNull(UpdaterLaunchSettings.parseByteRate("fast")) + assertNull(UpdaterLaunchSettings.parseByteRate("0")) + } + + @Test + fun `the feed server parses single byte ranges`() { + val parse = AbstractServeUpdateFeedTask.Companion::parseRange + assertEquals(10L to 19L, parse("bytes=10-19", 100)) + assertEquals(90L to 99L, parse("bytes=90-", 100)) + assertEquals(80L to 99L, parse("bytes=-20", 100)) + assertEquals("clamped to the end", 95L to 99L, parse("bytes=95-500", 100)) + assertEquals("unsatisfiable", -1L to -1L, parse("bytes=100-120", 100)) + assertNull("multi-range is served whole", parse("bytes=0-1,5-6", 100)) + assertNull(parse("items=0-1", 100)) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayoutTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayoutTest.kt new file mode 100644 index 000000000..037a0ca38 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/WindowsHotUpdateLayoutTest.kt @@ -0,0 +1,92 @@ +package dev.nucleusframework.desktop.application.internal + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class WindowsHotUpdateLayoutTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun jpackageImage(): File { + val image = tmp.newFolder("App") + File(image, "App.exe").writeText("launcher") + File(image, "runtime/bin").mkdirs() + File(image, "runtime/bin/jvm.dll").writeText("jvm") + File(image, "app/resources").mkdirs() + File(image, "app/app.jar").writeText("jar") + File(image, "app/nucleus_tao.dll").writeText("dll") + File(image, "app/App.cfg").writeText( + "[Application]\r\napp.classpath=\$APPDIR\\app.jar\r\napp.mainclass=MainKt\r\n\r\n" + + "[JavaOptions]\r\njava-options=-Dnucleus.native.libraryPath=\$APPDIR\r\n", + ) + return image + } + + @Test + fun `app image is moved under versions and the cfg points into it`() { + val image = jpackageImage() + + assertTrue(WindowsHotUpdateLayout.apply(image, "1.2.0")) + + assertTrue(File(image, "App.exe").isFile) + assertEquals(listOf("App.cfg"), File(image, "app").list()!!.toList()) + assertTrue(File(image, "versions/1.2.0/runtime/bin/jvm.dll").isFile) + assertTrue(File(image, "versions/1.2.0/app/app.jar").isFile) + assertTrue(File(image, "versions/1.2.0/app/nucleus_tao.dll").isFile) + assertTrue(File(image, "versions/1.2.0/app/resources").isDirectory) + assertFalse(File(image, "runtime").exists()) + assertEquals( + "[Application]\r\n" + + "app.runtime=\$ROOTDIR\\versions\\1.2.0\\runtime\r\n" + + "app.classpath=\$ROOTDIR\\versions\\1.2.0\\app\\app.jar\r\n" + + "app.mainclass=MainKt\r\n\r\n" + + "[JavaOptions]\r\n" + + "java-options=-Dnucleus.native.libraryPath=\$ROOTDIR\\versions\\1.2.0\\app\r\n", + File(image, "app/App.cfg").readText(), + ) + } + + @Test + fun `an image without cfg or runtime is left untouched`() { + val image = tmp.newFolder("Native") + File(image, "App.exe").writeText("native image") + + assertFalse(WindowsHotUpdateLayout.apply(image, "1.2.0")) + assertFalse(File(image, "versions").exists()) + } + + @Test + fun `an already versioned image is left untouched`() { + val image = jpackageImage() + WindowsHotUpdateLayout.apply(image, "1.2.0") + val cfg = File(image, "app/App.cfg").readText() + + assertFalse(WindowsHotUpdateLayout.apply(image, "1.3.0")) + assertEquals(cfg, File(image, "app/App.cfg").readText()) + } + + @Test + fun `an existing app runtime entry is replaced`() { + val cfg = "[Application]\napp.runtime=\$APPDIR\\..\\runtime\napp.mainclass=MainKt\n" + + val rewritten = WindowsHotUpdateLayout.rewriteCfg(cfg, "\$ROOTDIR\\versions\\2.0.0") + + assertEquals( + "[Application]\napp.runtime=\$ROOTDIR\\versions\\2.0.0\\runtime\napp.mainclass=MainKt\n", + rewritten, + ) + } + + @Test + fun `version directory names are sanitized`() { + assertEquals("1.2.0-beta.1", WindowsHotUpdateLayout.versionDirName("1.2.0-beta.1")) + assertEquals("1.2.0_build_7", WindowsHotUpdateLayout.versionDirName("1.2.0 build/7")) + assertEquals("1.2", WindowsHotUpdateLayout.versionDirName("1.2.")) + assertEquals("current", WindowsHotUpdateLayout.versionDirName(" ")) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt index 8e0bb0127..7dcf38b39 100644 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderMsiConfigTest.kt @@ -30,7 +30,7 @@ class ElectronBuilderMsiConfigTest { targetArch = Arch.X64, windowsIconOverride = null, executableName = "nucleusdemo", - nsisProtocolInclude = null, + nsisInclude = null, ) return yaml.toString() } diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt index 3ab9682d6..45ffab165 100644 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderNsisConfigTest.kt @@ -30,7 +30,7 @@ class ElectronBuilderNsisConfigTest { targetArch = Arch.X64, windowsIconOverride = null, executableName = "nucleusdemo", - nsisProtocolInclude = null, + nsisInclude = null, ) return yaml.toString() } diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderPkgConfigTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderPkgConfigTest.kt new file mode 100644 index 000000000..1c17f0eab --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderPkgConfigTest.kt @@ -0,0 +1,88 @@ +package dev.nucleusframework.desktop.application.internal.electronbuilder + +import dev.nucleusframework.desktop.application.dsl.JvmApplicationDistributions +import dev.nucleusframework.desktop.application.dsl.TargetFormat +import dev.nucleusframework.internal.utils.Arch +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * The PKG target serves two channels. App Store: electron-builder gets no installer identity (its + * `pkg.ts` only knows "Developer ID Installer") and the package task re-signs with `productsign`; + * its binaries are not Developer ID, so notarytool would reject them as `Invalid` (#650) and the + * config must opt out of electron-builder's own notarization. Developer ID: electron-builder signs + * the installer itself from the bare identity, and `notarizePkg` notarizes the `.pkg`. + */ +class ElectronBuilderPkgConfigTest { + private fun newDistributions(): JvmApplicationDistributions = + ProjectBuilder.builder().build().objects.newInstance(JvmApplicationDistributions::class.java) + + private fun renderMac( + targetFormat: TargetFormat, + distributions: JvmApplicationDistributions = newDistributions(), + ): String { + val yaml = StringBuilder() + ElectronBuilderConfigGenerator().generateMacConfig( + yaml = yaml, + distributions = distributions, + targetFormat = targetFormat, + targetArch = Arch.Arm64, + ) + return yaml.toString() + } + + private fun signedDistributions(appStore: Boolean): JvmApplicationDistributions = + newDistributions().apply { + macOS.signing.sign.set(true) + macOS.signing.identity.set("Developer ID Application: Acme Corp (TEAM1234)") + macOS.pkg.appStore = appStore + } + + // missingDelimiterValue keeps the negative assertions honest: without it a dropped `pkg:` block + // would return the whole document and every "does not contain" assertion would pass vacuously. + private fun String.pkgSection(): String = substringAfter("\npkg:\n", missingDelimiterValue = "") + + @Test + fun `pkg disables electron-builder notarization for both channels`() { + assertTrue(renderMac(TargetFormat.Pkg).contains(" notarize: false")) + assertTrue(renderMac(TargetFormat.Pkg, signedDistributions(appStore = false)).contains(" notarize: false")) + } + + @Test + fun `dmg keeps electron-builder notarization`() { + val yaml = renderMac(TargetFormat.Dmg) + assertFalse(yaml, yaml.contains("notarize:")) + } + + @Test + fun `unsigned pkg disables installer signing`() { + val yaml = renderMac(TargetFormat.Pkg) + assertTrue(yaml, yaml.pkgSection().contains(" identity: null")) + } + + @Test + fun `app store pkg leaves installer signing to productsign`() { + val yaml = renderMac(TargetFormat.Pkg, signedDistributions(appStore = true)) + assertFalse(yaml, yaml.pkgSection().contains("identity:")) + } + + @Test + fun `developer id pkg hands the bare installer identity to electron-builder`() { + val yaml = renderMac(TargetFormat.Pkg, signedDistributions(appStore = false)) + assertTrue(yaml, yaml.pkgSection().contains(" identity: \"Acme Corp (TEAM1234)\"")) + assertFalse(yaml, yaml.pkgSection().contains("Developer ID")) + } + + @Test + fun `pkg declares the staged scripts directory only when a script is configured`() { + val distributions = newDistributions().apply { macOS.pkg.appStore = false } + assertFalse(renderMac(TargetFormat.Pkg, distributions).contains("scripts:")) + + distributions.macOS.pkg.postInstall.set(File("postinstall")) + val yaml = renderMac(TargetFormat.Pkg, distributions) + assertTrue(yaml, yaml.pkgSection().contains(" scripts: \"pkg-scripts\"")) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderRpmConfigTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderRpmConfigTest.kt index 0a688e589..9256fb2aa 100644 --- a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderRpmConfigTest.kt +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/electronbuilder/ElectronBuilderRpmConfigTest.kt @@ -37,6 +37,9 @@ class ElectronBuilderRpmConfigTest { return yaml.toString() } + /** [path] as the generator writes it inside a double-quoted YAML string (Windows backslashes escaped). */ + private fun yamlQuoted(path: String): String = "\"${path.replace("\\", "\\\\")}\"" + @Test fun `rpm config passes --rpm-auto-add-directories to fpm`() { val yaml = renderLinux(distributions(), TargetFormat.Rpm) @@ -78,7 +81,7 @@ class ElectronBuilderRpmConfigTest { assertTrue(yaml, yaml.contains("fpm:")) assertTrue(yaml, yaml.contains("--before-install")) - assertTrue(yaml, yaml.contains(beforeInstall.absolutePath)) + assertTrue(yaml, yaml.contains(yamlQuoted(beforeInstall.absolutePath))) assertFalse(yaml, yaml.contains("--rpm-auto-add-directories")) } @@ -95,6 +98,6 @@ class ElectronBuilderRpmConfigTest { assertTrue(yaml, yaml.contains("--rpm-auto-add-directories")) assertTrue(yaml, yaml.contains("--before-remove")) - assertTrue(yaml, yaml.contains(beforeRemove.absolutePath)) + assertTrue(yaml, yaml.contains(yamlQuoted(beforeRemove.absolutePath))) } } diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/files/NucleusNativeLibsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/files/NucleusNativeLibsTest.kt new file mode 100644 index 000000000..dbb0dab1d --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/files/NucleusNativeLibsTest.kt @@ -0,0 +1,159 @@ +package dev.nucleusframework.desktop.application.internal.files + +import dev.nucleusframework.internal.utils.Arch +import dev.nucleusframework.internal.utils.OS +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +class NucleusNativeLibsTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun jar(vararg entries: String): File = namedJar("module.jar", *entries) + + private fun namedJar( + name: String, + vararg entries: String, + ): File = + tmp.newFile(name).apply { + ZipOutputStream(outputStream()).use { zip -> + for (entry in entries) { + zip.putNextEntry(ZipEntry(entry)) + if (!entry.endsWith("/")) zip.write(entry.toByteArray()) + zip.closeEntry() + } + } + } + + private fun File.entryNames(): List = + ZipFile(this).use { zip -> zip.entries().asSequence().map { it.name }.toList() } + + @Test + fun `platform dirs follow the runtime resource layout`() { + assertEquals("win32-x64", nucleusNativeDir(OS.Windows, Arch.X64)) + assertEquals("darwin-aarch64", nucleusNativeDir(OS.MacOS, Arch.Arm64)) + assertEquals("linux-aarch64", nucleusNativeDir(OS.Linux, Arch.Arm64)) + } + + private fun jarWithManifest( + manifestEntries: List, + vararg entries: String, + ): File = + tmp.newFile("nucleus-module.jar").apply { + ZipOutputStream(outputStream()).use { zip -> + zip.putNextEntry(ZipEntry("META-INF/nucleus/native-libraries/nucleus.foo")) + zip.write(manifestEntries.joinToString("\n", postfix = "\n").toByteArray()) + zip.closeEntry() + for (entry in entries) { + zip.putNextEntry(ZipEntry(entry)) + if (!entry.endsWith("/")) zip.write(entry.toByteArray()) + zip.closeEntry() + } + } + } + + @Test + fun `moves the listed current platform libraries out and drops the other listed ones`() { + val source = + jarWithManifest( + listOf( + "nucleus/native/win32-x64/nucleus_foo.dll", + "nucleus/native/win32-x64/libGLESv2.dll", + "nucleus/native/win32-aarch64/nucleus_foo.dll", + "nucleus/native/linux-x64/libnucleus_foo.so", + ), + "dev/nucleusframework/Foo.class", + "nucleus/native/win32-x64/", + "nucleus/native/win32-x64/nucleus_foo.dll", + "nucleus/native/win32-x64/libGLESv2.dll", + "nucleus/native/win32-aarch64/nucleus_foo.dll", + "nucleus/native/linux-x64/libnucleus_foo.so", + "META-INF/MANIFEST.MF", + ) + val out = tmp.newFolder("out") + + val files = + unpackNucleusNativeLibs( + source, + out.resolve(source.name), + out, + "win32-x64", + source.nucleusNativeEntries(), + ) + + assertEquals( + listOf( + "META-INF/nucleus/native-libraries/nucleus.foo", + "dev/nucleusframework/Foo.class", + "nucleus/native/win32-x64/", + "META-INF/MANIFEST.MF", + ), + files.first().entryNames(), + ) + assertEquals(setOf("nucleus_foo.dll", "libGLESv2.dll"), files.drop(1).map { it.name }.toSet()) + assertEquals("nucleus/native/win32-x64/nucleus_foo.dll", out.resolve("nucleus_foo.dll").readText()) + } + + @Test + fun `leaves unlisted nucleus native entries untouched`() { + // An application's own library, read as a resource (e.g. through FFM), must stay in its JAR + val source = + jarWithManifest( + listOf("nucleus/native/darwin-aarch64/libnucleus_foo.dylib"), + "nucleus/native/darwin-aarch64/libnucleus_foo.dylib", + "nucleus/native/darwin-aarch64/libapp_bridge.dylib", + "nucleus/native/darwin-x64/libapp_bridge.dylib", + ) + val out = tmp.newFolder("out") + + val files = + unpackNucleusNativeLibs( + source, + out.resolve(source.name), + out, + "darwin-aarch64", + source.nucleusNativeEntries(), + ) + + assertEquals( + listOf( + "META-INF/nucleus/native-libraries/nucleus.foo", + "nucleus/native/darwin-aarch64/libapp_bridge.dylib", + "nucleus/native/darwin-x64/libapp_bridge.dylib", + ), + files.first().entryNames(), + ) + assertEquals(listOf("libnucleus_foo.dylib"), files.drop(1).map { it.name }) + } + + @Test + fun `moves the libraries another module lists out of a jar without a manifest`() { + // decorated-window-tao lists ANGLE, which ships in its own artifact + val nucleusEntries = + jarWithManifest(listOf("nucleus/native/win32-x64/libGLESv2.dll")).nucleusNativeEntries() + val angle = namedJar("angle.jar", "nucleus/native/win32-x64/libGLESv2.dll", "nucleus/native/win32-x64/NOTICE") + val out = tmp.newFolder("out") + + val files = unpackNucleusNativeLibs(angle, out.resolve(angle.name), out, "win32-x64", nucleusEntries) + + assertEquals(listOf("nucleus/native/win32-x64/NOTICE"), files.first().entryNames()) + assertEquals(listOf("libGLESv2.dll"), files.drop(1).map { it.name }) + } + + @Test + fun `jars without a manifest declare no nucleus libraries`() { + assertTrue(jar("nucleus/native/linux-x64/libapp.so").nucleusNativeEntries().isEmpty()) + assertEquals( + setOf("nucleus/native/linux-x64/libnucleus_foo.so"), + jarWithManifest(listOf("", "# comment", "nucleus/native/linux-x64/libnucleus_foo.so")) + .nucleusNativeEntries(), + ) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/transforms/LcdTextDefaultTransformTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/transforms/LcdTextDefaultTransformTest.kt new file mode 100644 index 000000000..c8adc669b --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/internal/transforms/LcdTextDefaultTransformTest.kt @@ -0,0 +1,110 @@ +package dev.nucleusframework.desktop.application.internal.transforms + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File +import java.net.URLClassLoader +import java.nio.file.Files + +/** + * Regression canary for the LCD/ClearType bytecode patch: runs + * [LcdTextClassPatcher] against the *real* `ui-text-desktop` artifacts — the + * Compose version the plugin ships with AND the one the main repo's + * consumers resolve (see `test-analysis-libraries.gradle.kts`) — loads each + * patched jar, and checks all three runtime paths of the generated + * `getPlatformDefault()` wrapper. If a Compose bump changes the class + * layout, `patchJar` throws and this test fails loudly. + */ +class LcdTextDefaultTransformTest { + @Test + fun `patched default is SubpixelAntiAlias on Windows`() { + forEachPatchedJar { loader -> + withSystemProperties(osName = "Windows 11", lcdProperty = null) { + assertEquals("SubpixelAntiAlias", loader.platformDefaultSmoothing()) + } + } + } + + @Test + fun `patched default caches and stays stable across calls`() { + forEachPatchedJar { loader -> + withSystemProperties(osName = "Windows 11", lcdProperty = null) { + assertEquals("SubpixelAntiAlias", loader.platformDefaultSmoothing()) + assertEquals("SubpixelAntiAlias", loader.platformDefaultSmoothing()) + } + } + } + + @Test + fun `opt-out property falls back to the original grayscale default`() { + forEachPatchedJar { loader -> + withSystemProperties(osName = "Windows 11", lcdProperty = "false") { + assertEquals("AntiAlias", loader.platformDefaultSmoothing()) + } + } + } + + @Test + fun `non-Windows platforms delegate to the original default`() { + forEachPatchedJar { loader -> + withSystemProperties(osName = "Linux", lcdProperty = null) { + assertEquals("AntiAlias", loader.platformDefaultSmoothing()) + } + } + } + + /** Runs [block] with a fresh classloader over every patched jar. */ + private fun forEachPatchedJar(block: (URLClassLoader) -> Unit) { + for (jar in patchedJars) { + URLClassLoader(arrayOf(jar.toURI().toURL()), javaClass.classLoader).use { loader -> + block(loader) + } + } + } + + private fun URLClassLoader.platformDefaultSmoothing(): String { + val frsClass = loadClass("androidx.compose.ui.text.FontRasterizationSettings") + val companion = frsClass.getField("Companion").get(null) + val settings = companion.javaClass.getMethod("getPlatformDefault").invoke(companion) + return settings.javaClass.getMethod("getSmoothing").invoke(settings).toString() + } + + private fun withSystemProperties( + osName: String, + lcdProperty: String?, + block: () -> T, + ): T { + val previousOs = System.getProperty("os.name") + val previousLcd = System.getProperty("nucleus.text.lcd") + System.setProperty("os.name", osName) + if (lcdProperty != null) System.setProperty("nucleus.text.lcd", lcdProperty) + try { + return block() + } finally { + System.setProperty("os.name", previousOs) + if (previousLcd != null) { + System.setProperty("nucleus.text.lcd", previousLcd) + } else { + System.clearProperty("nucleus.text.lcd") + } + } + } + + private companion object { + val patchedJars: List by lazy { + val sourceJars = + checkNotNull(System.getProperty("test.lcd.uitext.jars")) { + "test.lcd.uitext.jars system property not set (see test-analysis-libraries.gradle.kts)" + }.split(File.pathSeparator).map(::File) + assertTrue("no ui-text-desktop jars resolved", sourceJars.isNotEmpty()) + sourceJars.map { sourceJar -> + assertTrue("ui-text-desktop jar missing: $sourceJar", sourceJar.isFile) + val output = Files.createTempFile("ui-text-desktop-patched", ".jar").toFile() + output.deleteOnExit() + LcdTextClassPatcher.patchJar(sourceJar, output) + output + } + } + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/DeleteRecursivelyClearingReadOnlyTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/DeleteRecursivelyClearingReadOnlyTest.kt new file mode 100644 index 000000000..9b51e4896 --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/DeleteRecursivelyClearingReadOnlyTest.kt @@ -0,0 +1,21 @@ +package dev.nucleusframework.desktop.application.tasks + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DeleteRecursivelyClearingReadOnlyTest { + @Test + fun `deletes a tree holding read-only files`() { + val root = Files.createTempDirectory("delete-read-only").toFile() + val launcher = File(root, "My App/My App.exe").apply { parentFile.mkdirs() } + launcher.writeText("launcher") + File(root, "My App/app/app.jar").apply { parentFile.mkdirs() }.writeText("jar") + assertTrue(launcher.setReadOnly()) + + assertTrue(root.deleteRecursivelyClearingReadOnly()) + assertFalse(root.exists()) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/NsisAppDataRemovalTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/NsisAppDataRemovalTest.kt new file mode 100644 index 000000000..db19e890f --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/NsisAppDataRemovalTest.kt @@ -0,0 +1,40 @@ +package dev.nucleusframework.desktop.application.tasks + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NsisAppDataRemovalTest { + @Test + fun `plain file names are kept verbatim`() { + assertEquals("ZstdDemo", appDataDirNameOrNull("ZstdDemo")) + assertEquals("My App", appDataDirNameOrNull("My App")) + assertEquals("com.example.app", appDataDirNameOrNull("com.example.app")) + } + + @Test + fun `names that would escape the app data directory are refused`() { + // Each of these would make `RMDir /r "$APPDATA\"` hit %APPDATA% itself or beyond. + for (unsafe in listOf("", ".", "..", "a\\b", "a/b", "..\\Local", "C:", "name.", "name ")) { + assertNull("'$unsafe' must be refused", appDataDirNameOrNull(unsafe)) + } + } + + @Test + fun `removal mirrors electron-builder's delete-app-data condition`() { + val script = buildString { appendAppDataRemoval("My App") } + + assertTrue(script.contains("RMDir /r \"\$APPDATA\\My App\"")) + assertTrue(script.contains("\${GetOptions} \$R0 \"--delete-app-data\" \$R1")) + assertTrue(script.contains("\${ifNot} \${isUpdated}")) + assertTrue(script.contains("SetShellVarContext current")) + } + + @Test + fun `dollar signs are escaped for NSIS`() { + val script = buildString { appendAppDataRemoval("A\$B") } + + assertTrue(script.contains("RMDir /r \"\$APPDATA\\A\$\$B\"")) + } +} diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/StripJreFontsTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/StripJreFontsTest.kt new file mode 100644 index 000000000..3d85b062b --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/desktop/application/tasks/StripJreFontsTest.kt @@ -0,0 +1,51 @@ +package dev.nucleusframework.desktop.application.tasks + +import dev.nucleusframework.desktop.application.dsl.JvmApplicationDistributions +import org.gradle.testfixtures.ProjectBuilder +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class StripJreFontsTest { + @Test + fun `distributions strip jre fonts by default`() { + val distributions = + ProjectBuilder.builder().build().objects.newInstance(JvmApplicationDistributions::class.java) + + assertTrue(distributions.stripJreFonts) + } + + @Test + fun `jlink excludes jre fonts unless stripJreFonts is false`() { + val stripped = jlinkArgs(stripJreFonts = null) + val kept = jlinkArgs(stripJreFonts = false) + + assertTrue(stripped.contains(JRE_FONTS_EXCLUDE)) + assertFalse(kept.contains(JRE_FONTS_EXCLUDE)) + assertTrue(stripped.contains("--strip-debug")) + assertTrue(kept.contains("--add-modules")) + } + + private fun jlinkArgs(stripJreFonts: Boolean?): List { + val project = ProjectBuilder.builder().build() + val task = + project.tasks.register("createRuntimeImage", JLinkArgsProbe::class.java) { + it.includeAllModules.set(false) + it.modules.set(listOf("java.base", "java.desktop")) + if (stripJreFonts != null) { + it.stripJreFonts.set(stripJreFonts) + } + }.get() + return task.args(project.file("tmp")) + } + + private companion object { + const val JRE_FONTS_EXCLUDE = "--exclude-files=glob:/java.desktop/lib/fonts/**" + } +} + +/** Gradle instantiates this abstract task so the test can read [AbstractJLinkTask.makeArgs]. */ +abstract class JLinkArgsProbe : AbstractJLinkTask() { + fun args(tmpDir: File): List = makeArgs(tmpDir) +} diff --git a/plugin-build/plugin/test-analysis-libraries.gradle.kts b/plugin-build/plugin/test-analysis-libraries.gradle.kts index fc8990fd1..f68bf6ab6 100644 --- a/plugin-build/plugin/test-analysis-libraries.gradle.kts +++ b/plugin-build/plugin/test-analysis-libraries.gradle.kts @@ -1,4 +1,4 @@ -val testAnalysisLibraries: Configuration by configurations.creating { +val testAnalysisLibraries: Configuration = configurations.create("testAnalysisLibraries") { isCanBeResolved = true isCanBeConsumed = false isTransitive = false @@ -71,7 +71,7 @@ dependencies { testAnalysisLibraries("org.jctools:jctools-core:2.1.2") } -val testZayitLibraries: Configuration by configurations.creating { +val testZayitLibraries: Configuration = configurations.create("testZayitLibraries") { isCanBeResolved = true isCanBeConsumed = false isTransitive = false @@ -102,7 +102,38 @@ dependencies { testZayitLibraries("org.jetbrains.kotlin:kotlin-stdlib:2.3.20") } -val testOracleRepo: Configuration by configurations.creating { +// Real ui-text-desktop jars for LcdTextDefaultTransformTest — the LCD patch is +// bytecode surgery, so the regression test must run against the actual +// artifact shapes users resolve: the Compose version the plugin ships with AND +// the version the main repo's consumers/examples use (parsed from the root +// version catalog; a bump there is exactly when the class layout may drift). +val testLcdPatchLibraries: Configuration = configurations.create("testLcdPatchLibraries") { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false +} + +val testLcdPatchLibrariesConsumer: Configuration = configurations.create("testLcdPatchLibrariesConsumer") { + isCanBeResolved = true + isCanBeConsumed = false + isTransitive = false +} + +val lcdPluginComposeVersion = project.findProperty("compose.version")?.toString() ?: "1.10.0" +val lcdConsumerComposeVersion = + rootDir + .resolve("../gradle/libs.versions.toml") + .takeIf { it.isFile } + ?.readLines() + ?.firstNotNullOfOrNull { Regex("""^compose\s*=\s*"([^"]+)"""").find(it)?.groupValues?.get(1) } + ?: lcdPluginComposeVersion + +dependencies { + testLcdPatchLibraries("org.jetbrains.compose.ui:ui-text-desktop:$lcdPluginComposeVersion") + testLcdPatchLibrariesConsumer("org.jetbrains.compose.ui:ui-text-desktop:$lcdConsumerComposeVersion") +} + +val testOracleRepo: Configuration = configurations.create("testOracleRepo") { isCanBeResolved = true isCanBeConsumed = false isTransitive = false @@ -115,6 +146,13 @@ dependencies { tasks.withType { maxHeapSize = "1g" systemProperty("test.analysis.libraries", testAnalysisLibraries.asPath) + systemProperty( + "test.lcd.uitext.jars", + (testLcdPatchLibraries.files + testLcdPatchLibrariesConsumer.files) + .map { it.absolutePath } + .distinct() + .joinToString(java.io.File.pathSeparator), + ) systemProperty("test.oracle.repo.zip", testOracleRepo.singleFile.absolutePath) systemProperty("test.zayit.libraries", testZayitLibraries.asPath) val zayitMetadataDir = diff --git a/scheduler/api/scheduler.api b/scheduler/api/scheduler.api index 6d55a8638..a049918c6 100644 --- a/scheduler/api/scheduler.api +++ b/scheduler/api/scheduler.api @@ -204,6 +204,7 @@ public abstract class dev/nucleusframework/scheduler/RetryPolicy { public final class dev/nucleusframework/scheduler/RetryPolicy$ExponentialBackoff : dev/nucleusframework/scheduler/RetryPolicy { public static final field MAX_SHIFT I + public fun ()V public synthetic fun (JIILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-UwyO8pc ()J @@ -218,6 +219,7 @@ public final class dev/nucleusframework/scheduler/RetryPolicy$ExponentialBackoff } public final class dev/nucleusframework/scheduler/RetryPolicy$Linear : dev/nucleusframework/scheduler/RetryPolicy { + public fun ()V public synthetic fun (JIILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1-UwyO8pc ()J diff --git a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt index 6b1258d79..77fdeee6d 100644 --- a/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt +++ b/scheduler/src/main/kotlin/dev/nucleusframework/scheduler/DesktopTaskScheduler.kt @@ -82,9 +82,9 @@ public object DesktopTaskScheduler { */ @JvmStatic public fun enqueue(request: TaskRequest): Boolean { - if (ExecutableRuntime.isPkg()) { + if (Platform.Current == Platform.MacOS && ExecutableRuntime.isSandboxed()) { logger.severe( - "DesktopTaskScheduler is not supported in sandboxed Mac App Store builds (.pkg). " + + "DesktopTaskScheduler is not supported in sandboxed Mac App Store builds. " + "Use the service-management-macos module with SMAppService instead.", ) return false diff --git a/scripts/context-menu-wayland-e2e.py b/scripts/context-menu-wayland-e2e.py new file mode 100755 index 000000000..c0f76b8f5 --- /dev/null +++ b/scripts/context-menu-wayland-e2e.py @@ -0,0 +1,476 @@ +#!/usr/bin/python3 +"""Compositor-driven E2E for the Linux context menu flyout on native Wayland. + +Boots a nested `gnome-shell --headless` (Mutter, the compositor the bug +reports come from), launches `ContextMenuE2EMainKt` on it, drives a real +pointer through `org.gnome.Mutter.RemoteDesktop`, and reads the result back +from `org.gnome.Shell.Screenshot` captures plus the fixture's own stdout log. + +Scenarios (each prints PASS/FAIL, exit code is the number of failures): + latency first frame of the menu within LATENCY_BUDGET_MS of the press + once the menu shows once per right click (no show/hide/show flicker) + bottom a menu opened near the bottom of a window sitting at the bottom of + the screen is fully visible (flipped or slid on screen) + repeat open / dismiss / open / dismiss / open all show a menu + reopen three right clicks in a row each move the menu + +Prerequisites: `./gradlew :nucleus-application:contextMenuE2EClasspath`, +GNOME Shell with --headless (Ubuntu 26.04), python3-gi, Pillow. +""" +import json +import os +import signal +import subprocess +import sys +import tempfile +import threading +import time + +import gi + +gi.require_version("Gio", "2.0") +gi.require_version("GLib", "2.0") +from gi.repository import Gio, GLib # noqa: E402 +from PIL import Image # noqa: E402 + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CLASSPATH_FILE = os.path.join(REPO, "nucleus-application/build/e2e/context-menu-classpath.txt") +MAIN_CLASS = "dev.nucleusframework.application.contextmenu.ContextMenuE2EMainKt" +JAVA = os.environ.get("NUCLEUS_E2E_JAVA", "/usr/lib/jvm/java-17-openjdk-amd64/bin/java") +WAYLAND_NAME = "nucleus-cm-e2e" +MONITOR_W, MONITOR_H = 1600, 1000 +WINDOW_W, WINDOW_H = 900, 600 +TITLE = "context-menu-e2e" +EDGE_INSET_PX = 10 # rounded corners and anti-aliased frame edges are not menu pixels +LATENCY_BUDGET_MS = 250 +LATENCY_SAMPLES = 4 +BTN_LEFT, BTN_RIGHT = 0x110, 0x111 +WORK = tempfile.mkdtemp(prefix="nucleus-cm-e2e-") + + +def log(msg): + print(f"[driver {time.strftime('%H:%M:%S')}] {msg}", flush=True) + + +# ── nested shell ───────────────────────────────────────────────────────────── + +def start_shell(): + bus_file = os.path.join(WORK, "bus") + shell_log = open(os.path.join(WORK, "shell.log"), "w") + cmd = [ + "dbus-run-session", "--", "sh", "-c", + f"echo $DBUS_SESSION_BUS_ADDRESS > {bus_file}; exec gnome-shell --headless " + f"--virtual-monitor {MONITOR_W}x{MONITOR_H} --wayland-display={WAYLAND_NAME} --unsafe-mode", + ] + env = dict(os.environ) + env.pop("WAYLAND_DISPLAY", None) + env.pop("DISPLAY", None) + socket = os.path.join(os.environ["XDG_RUNTIME_DIR"], WAYLAND_NAME) + # A previous run killed mid-way leaves the socket and its lock behind, and + # Mutter then refuses to create its own. + for stale in (socket, socket + ".lock"): + if os.path.exists(stale): + os.remove(stale) + # Own process group: dbus-run-session does not forward SIGTERM to the shell. + proc = subprocess.Popen(cmd, stdout=shell_log, stderr=subprocess.STDOUT, env=env, start_new_session=True) + deadline = time.time() + 40 + while time.time() < deadline: + if os.path.exists(socket) and os.path.exists(bus_file) and os.path.getsize(bus_file) > 0: + break + if proc.poll() is not None: + raise SystemExit(f"gnome-shell exited early, see {shell_log.name}") + time.sleep(0.2) + else: + raise SystemExit("gnome-shell headless did not come up") + address = open(bus_file).read().strip() + # The Shell registers its D-Bus names a little after the socket appears. + bus = None + while time.time() < deadline: + try: + bus = Gio.DBusConnection.new_for_address_sync( + address, + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, + None, None, + ) + bus.call_sync("org.gnome.Shell", "/org/gnome/Shell", "org.gnome.Shell", "Eval", + GLib.Variant("(s)", ("1",)), None, Gio.DBusCallFlags.NONE, 5000, None) + break + except GLib.Error: + time.sleep(0.5) + else: + raise SystemExit("org.gnome.Shell never answered") + log(f"nested shell up: WAYLAND_DISPLAY={WAYLAND_NAME} bus={address}") + return proc, bus, address + + +class Shell: + def __init__(self, bus): + self.bus = bus + + def call(self, dest, path, iface, method, params=None, timeout=10000): + return self.bus.call_sync(dest, path, iface, method, params, None, Gio.DBusCallFlags.NONE, timeout, None) + + def eval(self, js): + ok, result = self.call("org.gnome.Shell", "/org/gnome/Shell", "org.gnome.Shell", "Eval", + GLib.Variant("(s)", (js,))).unpack() + if not ok: + raise RuntimeError(f"Eval failed: {result}") + # Eval JSON-encodes its result; a JS expression that already returned a + # JSON string therefore comes back double-encoded. + value = json.loads(result) if result else None + if isinstance(value, str): + try: + value = json.loads(value) + except ValueError: + pass + return value + + def windows(self): + return self.eval( + "JSON.stringify(global.get_window_actors().map(a => { const w = a.meta_window; " + "const r = w.get_frame_rect(); const b = w.get_buffer_rect(); " + "return {title: w.get_title(), type: w.get_window_type(), " + "x: r.x, y: r.y, w: r.width, h: r.height, bx: b.x, by: b.y, bw: b.width, bh: b.height}; }))" + ) + + def find_window(self, title, timeout=60): + deadline = time.time() + timeout + while time.time() < deadline: + for w in self.windows() or []: + if w["title"] == title and w["w"] > 1: + return w + time.sleep(0.25) + raise SystemExit(f"window {title!r} never appeared; windows={self.windows()}") + + def move_window(self, title, x, y): + self.eval( + "(() => { const w = global.get_window_actors().map(a => a.meta_window)" + f".find(w => w.get_title() === {json.dumps(title)}); w.move_frame(true, {x}, {y}); return 'ok'; }})()" + ) + + def screenshot(self, path): + ok, used = self.call("org.gnome.Shell.Screenshot", "/org/gnome/Shell/Screenshot", + "org.gnome.Shell.Screenshot", "Screenshot", + GLib.Variant("(bbs)", (False, False, path))).unpack() + if not ok: + raise RuntimeError("screenshot failed") + return Image.open(used).convert("RGB") + + +class Pointer: + """org.gnome.Mutter.RemoteDesktop pointer: the only injection Mutter accepts on Wayland.""" + + def __init__(self, shell): + self.shell = shell + rd = "org.gnome.Mutter.RemoteDesktop" + sc = "org.gnome.Mutter.ScreenCast" + (self.session,) = shell.call(rd, "/org/gnome/Mutter/RemoteDesktop", rd, "CreateSession").unpack() + (session_id,) = shell.call(rd, self.session, "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", (rd + ".Session", "SessionId"))).unpack() + (sc_session,) = shell.call( + sc, "/org/gnome/Mutter/ScreenCast", sc, "CreateSession", + GLib.Variant("(a{sv})", ({"remote-desktop-session-id": GLib.Variant("s", session_id)},)), + ).unpack() + shell.call(rd, self.session, rd + ".Session", "Start") + (self.stream,) = shell.call( + sc, sc_session, sc + ".Session", "RecordMonitor", + GLib.Variant("(sa{sv})", ("Meta-0", {"cursor-mode": GLib.Variant("u", 1)})), + ).unpack() + self.rd = rd + log(f"remote desktop session {self.session} stream {self.stream}") + + def move(self, x, y): + self.shell.call(self.rd, self.session, self.rd + ".Session", "NotifyPointerMotionAbsolute", + GLib.Variant("(sdd)", (self.stream, float(x), float(y)))) + + def button(self, code, pressed): + self.shell.call(self.rd, self.session, self.rd + ".Session", "NotifyPointerButton", + GLib.Variant("(ib)", (code, pressed))) + + def click(self, x, y, code=BTN_LEFT, hold_ms=60): + self.move(x, y) + time.sleep(0.05) + self.button(code, True) + time.sleep(hold_ms / 1000) + self.button(code, False) + + +# ── the app under test ─────────────────────────────────────────────────────── + +class App: + def __init__(self, wayland, bus_address): + classpath = open(CLASSPATH_FILE).read().strip() + env = dict(os.environ) + env.update({ + "WAYLAND_DISPLAY": wayland, + "GDK_BACKEND": "wayland", + "DBUS_SESSION_BUS_ADDRESS": bus_address, + "NUCLEUS_E2E_WINDOW_W": str(WINDOW_W), + "NUCLEUS_E2E_WINDOW_H": str(WINDOW_H), + }) + env.pop("DISPLAY", None) + if os.environ.get("E2E_WAYLAND_DEBUG"): + env["WAYLAND_DEBUG"] = "1" + self.lines = [] + self.log_path = os.path.join(WORK, "app.log") + self.proc = subprocess.Popen([JAVA, "-cp", classpath, MAIN_CLASS], env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + self.start = time.time() + threading.Thread(target=self._pump, daemon=True).start() + + def _pump(self): + with open(self.log_path, "w") as out: + for line in self.proc.stdout: + self.lines.append((time.time(), line.rstrip())) + out.write(line) + out.flush() + + def since(self, t): + return [l for (ts, l) in self.lines if ts >= t and l.startswith("[e2e")] + + def stop(self): + self.proc.terminate() + try: + self.proc.wait(10) + except subprocess.TimeoutExpired: + self.proc.kill() + + +# ── pixel analysis ─────────────────────────────────────────────────────────── + +TEXT_FIELD_DP = (20, 20, 420, 60) # the fixture's white text field, window-relative + + +def menu_bbox(img, region, win=None): + """Bounding box of non-green pixels inside region=(x0,y0,x1,y1), or None. + + The fixture's text field is white too; its rectangle is skipped. + """ + x0, y0, x1, y1 = region + skip = None + if win is not None: + fx0, fy0, fx1, fy1 = TEXT_FIELD_DP + skip = (win["x"] + fx0 - 2, win["y"] + fy0 - 2, win["x"] + fx1 + 2, win["y"] + fy1 + 2) + crop = img.crop((x0, y0, x1, y1)) + px = crop.load() + xs, ys = [], [] + w, h = crop.size + for y in range(0, h, 2): + for x in range(0, w, 2): + if skip and skip[0] <= x0 + x < skip[2] and skip[1] <= y0 + y < skip[3]: + continue + r, g, b = px[x, y] + if abs(r) > 70 or abs(255 - g) > 70 or abs(b) > 70: + xs.append(x) + ys.append(y) + if len(xs) < 40: # a few stray pixels are not a menu + return None + return (x0 + min(xs), y0 + min(ys), x0 + max(xs) + 1, y0 + max(ys) + 1) + + +def menu_bbox_win(img, region, win): + return menu_bbox(img, region, win) + + +def content_region(win, to_screen_bottom=False): + x0, y0 = win["x"] + EDGE_INSET_PX, win["y"] + EDGE_INSET_PX + x1 = win["x"] + win["w"] - EDGE_INSET_PX + y1 = MONITOR_H if to_screen_bottom else win["y"] + win["h"] - EDGE_INSET_PX + return (x0, y0, x1, y1) + + +def observe(shell, region, win, seconds, period=0.04): + """Samples screenshots for `seconds`; returns [(t_rel_ms, bbox or None)].""" + samples = [] + start = time.time() + n = 0 + while time.time() - start < seconds: + path = os.path.join(WORK, f"shot-{int(start)}-{n}.png") + n += 1 + img = shell.screenshot(path) + samples.append((int((time.time() - start) * 1000), menu_bbox(img, region, win), path)) + time.sleep(period) + return samples + + +def app_ms(lines, needle): + """Timestamp (ms, app clock) of the first fixture line containing needle.""" + for line in lines: + if needle in line: + return int(line.split("]")[0].split(" ")[1]) + return None + + +# ── scenarios ──────────────────────────────────────────────────────────────── + +class Report: + def __init__(self): + self.failures = 0 + + def check(self, name, ok, detail): + print(f"{'PASS' if ok else 'FAIL'} {name}: {detail}", flush=True) + if not ok: + self.failures += 1 + + +def run(): + shell_proc, bus, address = start_shell() + shell = Shell(bus) + app = App(WAYLAND_NAME, address) + report = Report() + try: + win = shell.find_window(TITLE) + log(f"window: {win}") + pointer = Pointer(shell) + # Wake the app's input path and make sure the window is focused/active. + cx, cy = win["x"] + win["w"] // 2, win["y"] + win["h"] // 2 + pointer.click(cx, cy) + time.sleep(0.5) + t0 = time.time() + pointer.click(cx, cy) + time.sleep(0.5) + report.check("input reaches the window", any("pointer Press" in l for l in app.since(t0)), + f"log={app.since(t0)}") + + # ── latency + once, window in the middle of the screen ────────────── + shell.move_window(TITLE, (MONITOR_W - win["w"]) // 2, (MONITOR_H - win["h"]) // 2) + time.sleep(0.6) + win = shell.find_window(TITLE) + region = content_region(win) + cx, cy = win["x"] + win["w"] // 2, win["y"] + win["h"] // 2 + # Latency over several menus, from the app's own trace: screenshots are + # heavy enough to starve the compositor's frame callbacks, so measuring + # the first menu while sampling pixels measures the driver, not the app. + latencies = [] + for _ in range(LATENCY_SAMPLES): + t = time.time() + pointer.click(cx, cy, BTN_RIGHT) + time.sleep(0.6) + trace = app.since(t) + press = app_ms(trace, "pointer Press") + present = app_ms(trace, "first present") or app_ms(trace, "menu OPEN") + latencies.append((present - press) if (press is not None and present is not None) else None) + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.5) + report.check("latency", all(v is not None and v <= LATENCY_BUDGET_MS for v in latencies), + f"press→first present per menu: {latencies} ms (budget {LATENCY_BUDGET_MS})") + stalls = [l for l in app.since(t0) if "frame stalled" in l] + report.check("no frame stall while opening a menu", not stalls, f"stalls={stalls[:6]}") + + pointer.move(cx, cy) + time.sleep(0.1) + t_press = time.time() + pointer.button(BTN_RIGHT, True) + time.sleep(0.05) + pointer.button(BTN_RIGHT, False) + samples = observe(shell, region, win, 1.6) + visible = [(t, b) for (t, b, _) in samples] + first = next((t for (t, b) in visible if b), None) + report.check("visible on screen after the press", first is not None, + f"first screenshot with the menu at {first} ms; trace:\n " + "\n ".join(app.since(t_press))) + # show / hide / show within the window is the double display. + pattern = [] + for (_, b) in visible: + v = bool(b) + if not pattern or pattern[-1] != v: + pattern.append(v) + report.check("once", pattern.count(True) <= 1 and (not pattern or pattern[-1] is True), + f"visibility pattern={pattern} log={app.since(t_press)}") + ref = next((b for (_, b) in reversed(visible) if b), None) + ref_h = (ref[3] - ref[1]) if ref else None + log(f"reference menu bbox={ref} height={ref_h}") + # dismiss with a left click far from the menu + t_dismiss = time.time() + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.5) + gone = menu_bbox_win(shell.screenshot(os.path.join(WORK, "after-dismiss.png")), region, win) is None + report.check("dismiss on outside click", gone, f"log={app.since(t_dismiss)}") + + # ── bottom: window flush with the screen bottom, click near its bottom ─ + shell.move_window(TITLE, (MONITOR_W - win["w"]) // 2, MONITOR_H - win["h"]) + time.sleep(0.6) + win = shell.find_window(TITLE) + log(f"window at bottom: {win}") + region = content_region(win, to_screen_bottom=True) + bx, by = win["x"] + win["w"] // 2, min(win["y"] + win["h"] - 30, MONITOR_H - 30) + t_press = time.time() + pointer.click(bx, by, BTN_RIGHT) + time.sleep(0.8) + shot = shell.screenshot(os.path.join(WORK, "bottom.png")) + bbox = menu_bbox(shot, region, win) + ok = bbox is not None and bbox[3] < MONITOR_H - 1 and (ref_h is None or abs((bbox[3] - bbox[1]) - ref_h) <= 4) + report.check("bottom", ok, + f"menu bbox={bbox} reference height={ref_h} screen height={MONITOR_H} " + f"click=({bx},{by}) log={app.since(t_press)}") + pointer.click(win["x"] + 40, win["y"] + 80) + time.sleep(0.5) + + # ── repeat: open / dismiss ×3 in the middle ───────────────────────── + shell.move_window(TITLE, (MONITOR_W - win["w"]) // 2, (MONITOR_H - win["h"]) // 2) + time.sleep(0.6) + win = shell.find_window(TITLE) + region = content_region(win) + cx, cy = win["x"] + win["w"] // 2, win["y"] + win["h"] // 2 + for i in range(3): + t_press = time.time() + pointer.click(cx, cy, BTN_RIGHT) + time.sleep(0.7) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"repeat-{i}.png")), region, win) + report.check(f"repeat #{i + 1} shows", bbox is not None, f"bbox={bbox} log={app.since(t_press)}") + t_dismiss = time.time() + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.6) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"repeat-{i}-closed.png")), region, win) + report.check(f"repeat #{i + 1} dismisses", bbox is None, f"bbox={bbox} log={app.since(t_dismiss)}") + + # ── reopen: three right clicks in a row, no dismiss in between ───── + points = [(cx - 200, cy - 100), (cx + 100, cy), (cx - 50, cy + 120)] + for i, (px, py) in enumerate(points): + t_press = time.time() + pointer.click(px, py, BTN_RIGHT) + time.sleep(0.7) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"reopen-{i}.png")), region, win) + near = bbox is not None and abs(bbox[0] - px) < 40 and abs(bbox[1] - py) < 40 + report.check(f"reopen #{i + 1} shows at the click", near, + f"click=({px},{py}) bbox={bbox} log={app.since(t_press)}") + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.4) + + # ── textfield: the text context menu path, as in the demo ─────────── + tx, ty = win["x"] + 200, win["y"] + 40 + t_press = time.time() + pointer.click(tx, ty, BTN_RIGHT) + time.sleep(0.8) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, "textfield.png")), region, win) + report.check("textfield shows", bbox is not None, f"bbox={bbox} log={app.since(t_press)}") + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.6) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, "textfield-closed.png")), region, win) + report.check("textfield dismisses", bbox is None, f"bbox={bbox}") + + # ── hold: a right click held longer than the menu takes to appear ─── + for i in range(2): + t_press = time.time() + pointer.click(cx, cy, BTN_RIGHT, hold_ms=350) + time.sleep(0.6) + trace = app.since(t_press) + bbox = menu_bbox_win(shell.screenshot(os.path.join(WORK, f"hold-{i}.png")), region, win) + released = any("pointer Release" in l for l in trace) + report.check(f"hold #{i + 1} shows and the window sees the release", bbox is not None and released, + f"bbox={bbox} log={trace}") + pointer.click(win["x"] + 40, win["y"] + win["h"] - 40) + time.sleep(0.6) + finally: + log(f"artifacts in {WORK}") + app.stop() + os.killpg(shell_proc.pid, signal.SIGTERM) + try: + shell_proc.wait(10) + except subprocess.TimeoutExpired: + os.killpg(shell_proc.pid, signal.SIGKILL) + print(f"failures={report.failures}", flush=True) + return report.failures + + +if __name__ == "__main__": + sys.exit(min(run(), 100)) diff --git a/scripts/updater-dev-testing-e2e.ps1 b/scripts/updater-dev-testing-e2e.ps1 new file mode 100644 index 000000000..19204b91b --- /dev/null +++ b/scripts/updater-dev-testing-e2e.ps1 @@ -0,0 +1,284 @@ +<# +.SYNOPSIS + End-to-end check of the updater's dev-testing switches on a real, installed NSIS app: updating + without publishing anything, simulating updates, and refusing both when the app does not opt in. + +.DESCRIPTION + Uses examples/hot-update-demo, which ships a production GitHubProvider (never contacted here), + sets UpdaterConfig.allowLaunchOverrides unless HOT_UPDATE_DEMO_ALLOW_OVERRIDES=0, and logs every + step of its update flow to %TEMP%\hot-update-demo.log. Each scenario reinstalls the old version + silently into -InstallDir, starts it with the switch under test, and reads the log. + + Scenarios (all by default): + file-feed NUCLEUS_UPDATER_FEED_URL=: the + installed app updates to it and restarts on it. + file-url-feed the same through a file: URL of a copy in a directory whose name has + spaces and non-ASCII characters. + http-feed ./gradlew serveUpdateFeed (throttled, so the download reports progress + along the way) and NUCLEUS_UPDATER_FEED_URL=http://127.0.0.1:. + http-feed-cached the same again: the update cache now holds the new installer, so the + download must be differential (block map + range requests over the task). + locked HOT_UPDATE_DEMO_ALLOW_OVERRIDES=0: the redirect is ignored, nothing updates. + simulate NUCLEUS_UPDATER_SIMULATE=update: a simulated update is offered, downloaded, + and its install skipped; the app keeps running on its version. + simulate-error NUCLEUS_UPDATER_SIMULATE=checksum-error: the download fails as a + tampered artifact would. + simulate-updated NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM=0.9.0: the post-update event. + run-simulate ./gradlew run -Pnucleus.updater.simulate=download-error (unpackaged). + run-feed ./gradlew run -Pnucleus.updater.feedUrl=: an unpackaged + run checks and downloads, and skips the install (the installed app is + left alone). + + Build the fixtures first: + ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.0.0 (copy the .exe aside) + ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.1.0 + +.EXAMPLE + powershell -File scripts/updater-dev-testing-e2e.ps1 -OldInstaller v1\HotUpdateDemo-1.0.0-win-x64-nsis.exe ` + -OldVersion 1.0.0 -NewVersion 1.1.0 +#> +param( + [Parameter(Mandatory)] [string] $OldInstaller, + [Parameter(Mandatory)] [string] $OldVersion, + [Parameter(Mandatory)] [string] $NewVersion, + [string] $RepoRoot = '', + # Defaults to the hot-update-demo NSIS packaging output: the directory is the feed. + [string] $NewFeedDir = '', + [string[]] $Scenario = @('file-feed', 'file-url-feed', 'http-feed', 'http-feed-cached', 'locked', 'simulate', + 'simulate-error', 'simulate-updated', 'run-simulate', 'run-feed'), + [string] $InstallDir = "$env:TEMP\nucleus-updater-dev-e2e\install", + [string] $ReportDir = "$env:TEMP\nucleus-updater-dev-e2e", + [int] $Port = 8431, + [int] $TimeoutSeconds = 120 +) + +$ErrorActionPreference = 'Stop' +# Script-relative defaults: $PSScriptRoot is not set yet while Windows PowerShell binds parameters. +if (-not $RepoRoot) { $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path } +# `powershell -File` passes `-Scenario a,b` as the single string "a,b". +$Scenario = @($Scenario | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) +if (-not $NewFeedDir) { $NewFeedDir = Join-Path $RepoRoot 'examples\hot-update-demo\build\compose\binaries\main\nsis' } +$exeName = 'HotUpdateDemo.exe' +$logFile = "$env:TEMP\hot-update-demo.log" +$gradlew = Join-Path $RepoRoot 'gradlew.bat' +$switches = 'NUCLEUS_UPDATER_FEED_URL', 'NUCLEUS_UPDATER_SIMULATE', 'NUCLEUS_UPDATER_SIMULATE_DURATION', + 'NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM', 'HOT_UPDATE_DEMO_ALLOW_OVERRIDES', 'HOT_UPDATE_DEMO_FEED', 'JAVA_TOOL_OPTIONS' +New-Item -ItemType Directory -Force -Path $ReportDir | Out-Null +if (-not (Get-ChildItem $NewFeedDir -Filter 'latest.yml' -ErrorAction SilentlyContinue)) { throw "No latest.yml in $NewFeedDir" } +if (-not (Test-Path $OldInstaller)) { throw "No old installer at $OldInstaller" } + +function Stop-App { + Get-CimInstance Win32_Process | Where-Object { + ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallDir, 'OrdinalIgnoreCase')) -or + ($_.CommandLine -and $_.CommandLine -match 'hotupdatedemo\.MainKt') + } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + Start-Sleep -Milliseconds 500 +} + +function Clear-Switches { foreach ($name in $switches) { Remove-Item "Env:$name" -ErrorAction SilentlyContinue } } + +function Install-Old { + Stop-App + if (Test-Path $InstallDir) { + $uninstaller = Get-ChildItem $InstallDir -Filter 'Uninstall *.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($uninstaller) { Start-Process $uninstaller.FullName -ArgumentList '/S' -Wait } + Remove-Item $InstallDir -Recurse -Force -ErrorAction SilentlyContinue + } + Start-Process (Resolve-Path $OldInstaller) -ArgumentList '/S', "/D=$InstallDir" -Wait + if (-not (Test-Path (Join-Path $InstallDir $exeName))) { throw "The old version was not installed into $InstallDir" } +} + +function Installed-Versions { @(Get-ChildItem (Join-Path $InstallDir 'versions') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name) } + +function Read-Log { @(Get-Content $logFile -Encoding UTF8 -ErrorAction SilentlyContinue) } + +# Waits until a log line matches every pattern in turn (in order), or the timeout. +function Wait-Log([string[]] $patterns, [int] $seconds = $TimeoutSeconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline) { + $lines = Read-Log; $i = 0 + foreach ($line in $lines) { if ($i -lt $patterns.Count -and $line -match $patterns[$i]) { $i++ } } + if ($i -eq $patterns.Count) { return $true } + Start-Sleep -Milliseconds 300 + } + return $false +} + +function Start-Installed { + Remove-Item $logFile -ErrorAction SilentlyContinue + Start-Process (Join-Path $InstallDir $exeName) | Out-Null +} + +function Start-Gradle([string[]] $arguments, [string] $name) { + $out = Join-Path $ReportDir "$name.gradle.log" + Start-Process -FilePath $gradlew -ArgumentList ($arguments + '--console=plain') -WorkingDirectory $RepoRoot ` + -RedirectStandardOutput $out -RedirectStandardError "$out.err" -PassThru -WindowStyle Hidden +} + +function Stop-FeedServer { + Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | + ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue } +} + +function Wait-Feed([int] $seconds) { + $deadline = (Get-Date).AddSeconds($seconds) + while ((Get-Date) -lt $deadline) { + try { Invoke-WebRequest "http://127.0.0.1:$Port/latest.yml" -UseBasicParsing -TimeoutSec 2 | Out-Null; return $true } catch { Start-Sleep 1 } + } + return $false +} + +$old = [regex]::Escape($OldVersion); $new = [regex]::Escape($NewVersion) +$updatedPatterns = @("started version=$old ", 'installAndRestart ', "started version=$new ", "updated from $old to $new") +$results = [ordered]@{} + +foreach ($name in $Scenario) { + Write-Host "=== $name" + Clear-Switches + $failures = @() + try { + switch ($name) { + 'file-feed' { + Install-Old + $env:NUCLEUS_UPDATER_FEED_URL = (Resolve-Path $NewFeedDir).Path + Start-Installed + if (-not (Wait-Log $updatedPatterns)) { $failures += 'the installed app did not update from the local directory' } + if ((Installed-Versions) -notcontains $NewVersion) { $failures += "versions\$NewVersion is not installed: $(Installed-Versions)" } + } + 'file-url-feed' { + Install-Old + $odd = Join-Path $ReportDir "feed dir ünïcødé" + Remove-Item $odd -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Force -Path $odd | Out-Null + Get-ChildItem $NewFeedDir -File | Where-Object { $_.Name -match '\.(exe|yml|blockmap)$' } | Copy-Item -Destination $odd + $env:NUCLEUS_UPDATER_FEED_URL = ([System.Uri] (Resolve-Path $odd).Path).AbsoluteUri + Write-Host "feed=$env:NUCLEUS_UPDATER_FEED_URL" + Start-Installed + if (-not (Wait-Log $updatedPatterns)) { $failures += 'the installed app did not update from the file: URL' } + } + { $_ -in 'http-feed', 'http-feed-cached' } { + if ($_ -eq 'http-feed') { + # A cold cache: the whole installer crosses the (throttled) link. + Remove-Item "$env:LOCALAPPDATA\nucleus\updates" -Recurse -Force -ErrorAction SilentlyContinue + } + Install-Old + Stop-FeedServer + # The cached run repackages the new version: electron-builder output is not byte-for-byte + # reproducible, so the cached installer is a real, slightly different, delta basis. + $repackage = if ($_ -eq 'http-feed-cached') { @(':examples:hot-update-demo:packageNsis', '--rerun') } else { @() } + $gradle = Start-Gradle ($repackage + @(':examples:hot-update-demo:serveUpdateFeed', "-PhotUpdateDemoVersion=$NewVersion", + "-Pnucleus.updater.serve.port=$Port", '-Pnucleus.updater.serve.throttle=6m', + '-Pnucleus.updater.serve.timeout=240')) $_ + if (-not (Wait-Feed 300)) { throw "serveUpdateFeed did not come up (see $ReportDir\$_.gradle.log)" } + $env:NUCLEUS_UPDATER_FEED_URL = "http://127.0.0.1:$Port" + Start-Installed + if (-not (Wait-Log $updatedPatterns)) { $failures += 'the installed app did not update from serveUpdateFeed' } + $downloaded = Read-Log | Where-Object { $_ -match 'downloaded .* differential=(\w+) reports=(\d+)' } | Select-Object -First 1 + Write-Host "download: $downloaded" + if ($downloaded -match 'differential=(\w+) reports=(\d+)') { + $differential = $Matches[1]; $reports = [int]$Matches[2] + if ($_ -eq 'http-feed') { + if ($differential -ne 'false') { $failures += 'a cold-cache update should be a full download' } + if ($reports -lt 10) { $failures += "a throttled download should report progress along the way, got $reports reports" } + } elseif ($differential -ne 'true') { + $failures += 'with the new installer cached, the update should be differential' + } + } else { $failures += 'no download line in the app log' } + $served = Get-Content (Join-Path $ReportDir "$_.gradle.log") -ErrorAction SilentlyContinue + if (-not ($served -match 'GET /latest\.yml')) { $failures += 'serveUpdateFeed never served latest.yml' } + if ($_ -eq 'http-feed-cached' -and -not ($served -match '\[bytes=')) { $failures += 'no range request reached serveUpdateFeed' } + Stop-FeedServer + $gradle | Stop-Process -Force -ErrorAction SilentlyContinue + } + 'locked' { + Install-Old + $env:HOT_UPDATE_DEMO_ALLOW_OVERRIDES = '0' + $env:NUCLEUS_UPDATER_FEED_URL = (Resolve-Path $NewFeedDir).Path + Start-Installed + # The refused redirect leaves the production provider (a GitHub repo that does not exist). + $expected = @("started version=$old ", 'updater feedOverride=null simulation=null', 'no update \(Error') + if (-not (Wait-Log $expected 60)) { $failures += 'the updater did not keep its production provider' } + Start-Sleep -Seconds 10 + $log = Read-Log + if ($log -match "started version=$new ") { $failures += 'the app updated although it does not allow launch overrides' } + if ($log -match 'downloaded ') { $failures += 'something was downloaded' } + if ((Installed-Versions) -contains $NewVersion) { $failures += "versions\$NewVersion appeared" } + } + 'simulate' { + Install-Old + $env:NUCLEUS_UPDATER_SIMULATE = 'update' + $env:NUCLEUS_UPDATER_SIMULATE_DURATION = '2' + Start-Installed + $expected = @("started version=$old ", 'simulation=UpdateSimulation\(scenario=UPDATE_AVAILABLE', 'downloaded simulated-update-', 'installAndRestart returned') + if (-not (Wait-Log $expected 60)) { $failures += 'the simulated update did not play through' } + Start-Sleep -Seconds 3 + $log = Read-Log + # -match on an array filters it and leaves $Matches alone: match the line itself. + $downloadLine = @($log | Where-Object { $_ -match 'downloaded simulated-update-' })[0] + if (-not ($downloadLine -match 'reports=(\d+)') -or [int]$Matches[1] -lt 10) { + $failures += "the simulated download reported too little progress: $downloadLine" + } + if ($log -match "started version=$new ") { $failures += 'a simulated update restarted the app' } + $alive = Get-Process -Name 'HotUpdateDemo' -ErrorAction SilentlyContinue + if (-not $alive) { $failures += 'the app exited after a simulated install' } + if ((Installed-Versions) -contains $NewVersion) { $failures += 'a simulated update installed something' } + } + 'simulate-error' { + Install-Old + $env:NUCLEUS_UPDATER_SIMULATE = 'checksum-error' + $env:NUCLEUS_UPDATER_SIMULATE_DURATION = '1' + Start-Installed + if (-not (Wait-Log @("started version=$old ", 'download failed: .*ChecksumException') 60)) { $failures += 'the simulated checksum failure did not surface' } + if ((Read-Log) -match 'installAndRestart') { $failures += 'a failed download went on to install' } + } + 'simulate-updated' { + Install-Old + $env:NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM = '0.9.0' + Start-Installed + if (-not (Wait-Log @("started version=$old ", "updated from 0\.9\.0 to $old") 60)) { $failures += 'the simulated post-update launch was not reported' } + } + 'run-simulate' { + Stop-App + Remove-Item $logFile -ErrorAction SilentlyContinue + $gradle = Start-Gradle @(':examples:hot-update-demo:run', "-PhotUpdateDemoVersion=$OldVersion", + '-Pnucleus.updater.simulate=download-error', '-Pnucleus.updater.simulate.duration=1') $name + if (-not (Wait-Log @("started version=$old ", 'supported=true', 'download failed: .*NetworkException') 300)) { + $failures += 'the simulated download failure did not surface in ./gradlew run' + } + Stop-App + $gradle | Stop-Process -Force -ErrorAction SilentlyContinue + } + 'run-feed' { + Install-Old + $before = Installed-Versions + Remove-Item $logFile -ErrorAction SilentlyContinue + $gradle = Start-Gradle @(':examples:hot-update-demo:run', "-PhotUpdateDemoVersion=$OldVersion", + "-Pnucleus.updater.feedUrl=$((Resolve-Path $NewFeedDir).Path)") $name + if (-not (Wait-Log @("started version=$old ", 'supported=true', 'downloaded .*nsis\.exe', 'installAndRestart returned') 300)) { + $failures += 'the unpackaged run did not check and download from the redirected feed' + } + Start-Sleep -Seconds 2 + if (-not (Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match 'hotupdatedemo\.MainKt' })) { + $failures += 'the unpackaged run exited instead of skipping the install' + } + if ((Installed-Versions) -join ',' -ne ($before -join ',')) { $failures += 'the unpackaged run touched the installed app' } + Stop-App + $gradle | Stop-Process -Force -ErrorAction SilentlyContinue + } + default { throw "Unknown scenario $name" } + } + } catch { + $failures += "error: $_" + } + Copy-Item $logFile (Join-Path $ReportDir "$name.app.log") -ErrorAction SilentlyContinue + Stop-App + $results[$name] = if ($failures.Count -eq 0) { 'PASSED' } else { "FAILED: $($failures -join '; ')" } + Write-Host "$name -> $($results[$name])" +} + +Clear-Switches +Stop-FeedServer +Write-Host '' +$results.GetEnumerator() | ForEach-Object { Write-Host ("{0,-18} {1}" -f $_.Key, $_.Value) } +if (@($results.Values | Where-Object { $_ -ne 'PASSED' }).Count -gt 0) { exit 1 } +Write-Host 'ALL PASSED' diff --git a/scripts/windows-hot-update-e2e.ps1 b/scripts/windows-hot-update-e2e.ps1 new file mode 100644 index 000000000..17f0ec6cc --- /dev/null +++ b/scripts/windows-hot-update-e2e.ps1 @@ -0,0 +1,465 @@ +<# +.SYNOPSIS + End-to-end check of the Windows hot update: the app must never disappear from the screen while it + updates itself. + +.DESCRIPTION + Installs the old NSIS installer silently into -InstallDir, serves the new one from a loopback + update feed (jwebserver + a generated latest.yml), and launches the app with HOT_UPDATE_DEMO_FEED + pointing at it; examples/hot-update-demo then downloads the update and calls installAndRestart on + its own. + + While that runs, a sampler polls every few milliseconds: + - the visible, non-cloaked top-level windows of processes started from -InstallDir (title and pid); + - the pixel on screen at the centre of the app window, which is what the user actually sees. + A sample with no app window, or a screen pixel that is not one of the demo's background colours, + counts as a gap. The run fails if any gap is longer than -MaxGapMs after the first window appeared. + + -Mode classic sets -Dnucleus.updater.hotUpdate.disabled=true (through JAVA_TOOL_OPTIONS) to measure + the close-install-relaunch update the hot update replaces. + + -Scenario picks what happens around the install (hot mode): + update nothing: the app must never leave the screen, and the new version + must delete the retired version and launcher. + relaunch-during-install the launcher is started again mid-install, as a shortcut would: it must + exist and run (no "file not found"), and one window must be left. + close-during-install the window is closed mid-install: the app must not be relaunched, the + install must still complete, and the next start runs the new version + and deletes the retired one. + failing-installer the feed serves an installer that exits with code 3: the app must stay + on screen, on its version, with its launcher intact. + stale-target-dir versions\ already holds leftovers from an interrupted attempt. + two-instances two instances (single instance off), holding docA and docB, both start + the update at once: the install lock must serialize them, and each must + come back on the new version with its own document. + notify-other-instance as above, but only the docA instance checks the feed: the docB one must + learn about the update from pendingRestartVersion, downloading nothing, + and restart onto it with its document. + + Build the fixtures first: + ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.0.0 (copy the .exe aside) + ./gradlew :examples:hot-update-demo:packageNsis -PhotUpdateDemoVersion=1.1.0 + +.EXAMPLE + powershell -File scripts/windows-hot-update-e2e.ps1 -OldInstaller v1\hotupdatedemo-1.0.0-win-x64-nsis.exe ` + -NewInstaller v2\hotupdatedemo-1.1.0-win-x64-nsis.exe -NewVersion 1.1.0 -JdkHome $env:JAVA_HOME +#> +param( + [Parameter(Mandatory)] [string] $OldInstaller, + # One or more newer installers, applied in turn: the feed moves to the next one as soon as the + # previous one is on screen, which chains hot updates (each started by a handed-over instance). + [Parameter(Mandatory)] [string[]] $NewInstaller, + [Parameter(Mandatory)] [string[]] $NewVersion, + [Parameter(Mandatory)] [string] $JdkHome, + [string] $InstallDir = "$env:TEMP\nucleus-hot-update-e2e\install", + [ValidateSet('hot', 'classic')] [string] $Mode = 'hot', + [ValidateSet('update', 'relaunch-during-install', 'close-during-install', 'failing-installer', 'stale-target-dir', + 'two-instances', 'notify-other-instance')] + [string] $Scenario = 'update', + [int] $Port = 8765, + [int] $MaxGapMs = 0, + [int] $TimeoutSeconds = 180, + [string] $ReportDir = "$env:TEMP\nucleus-hot-update-e2e", + # Extra JVM options for the app (e.g. a JUL config to trace the handoff). + [string] $JavaToolOptions = '' +) + +$ErrorActionPreference = 'Stop' +$exeName = 'HotUpdateDemo.exe' + +Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text; + +public static class HotUpdateSampler { + [DllImport("user32.dll")] static extern bool SetProcessDPIAware(); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr p); + delegate bool EnumProc(IntPtr h, IntPtr p); + [DllImport("user32.dll")] static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll")] static extern bool GetWindowRect(IntPtr h, out RECT r); + [DllImport("user32.dll")] static extern IntPtr GetDC(IntPtr h); + [DllImport("gdi32.dll")] static extern uint GetPixel(IntPtr dc, int x, int y); + [DllImport("dwmapi.dll")] static extern int DwmGetWindowAttribute(IntPtr h, int attr, out int v, int size); + [DllImport("kernel32.dll")] static extern IntPtr OpenProcess(int access, bool inherit, uint pid); + [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] static extern bool QueryFullProcessImageName(IntPtr h, int flags, StringBuilder s, ref int n); + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; } + + static readonly Dictionary paths = new Dictionary(); + public static int LastX = -1, LastY = -1; + + public static string Pixel(int x, int y) { + SetProcessDPIAware(); + return GetPixel(GetDC(IntPtr.Zero), x, y).ToString("X6"); + } + + static string ImagePath(uint pid) { + string p; + if (paths.TryGetValue(pid, out p)) return p; + p = ""; + IntPtr h = OpenProcess(0x1000, false, pid); + if (h != IntPtr.Zero) { + var sb = new StringBuilder(1024); int n = sb.Capacity; + if (QueryFullProcessImageName(h, 0, sb, ref n)) p = sb.ToString(); + CloseHandle(h); + } + paths[pid] = p; + return p; + } + + // One line per sample: elapsedMs|pixelRGB|pid:title;pid:title... + public static List Run(string installDir, string titlePrefix, string stopFile, int timeoutMs) { + SetProcessDPIAware(); + var lines = new List(); + var sw = Stopwatch.StartNew(); + IntPtr screen = GetDC(IntPtr.Zero); + int cx = -1, cy = -1; + while (sw.ElapsedMilliseconds < timeoutMs && !System.IO.File.Exists(stopFile)) { + var found = new List(); + EnumWindows((h, _) => { + if (!IsWindowVisible(h)) return true; + int cloaked; DwmGetWindowAttribute(h, 14, out cloaked, 4); + if (cloaked != 0) return true; + var sb = new StringBuilder(256); GetWindowText(h, sb, 256); + string title = sb.ToString(); + if (!title.StartsWith(titlePrefix)) return true; + uint pid; GetWindowThreadProcessId(h, out pid); + if (!ImagePath(pid).StartsWith(installDir, StringComparison.OrdinalIgnoreCase)) return true; + RECT r; GetWindowRect(h, out r); + // Left margin of the content: the background, clear of the centred text and the title bar. + cx = r.L + 30; cy = (r.T + r.B) / 2; + found.Add(pid + ":" + title); + return true; + }, IntPtr.Zero); + string pixel = cx < 0 ? "-" : GetPixel(screen, cx, cy).ToString("X6"); + lines.Add(sw.ElapsedMilliseconds + "|" + pixel + "|" + string.Join(";", found)); + LastX = cx; LastY = cy; + System.Threading.Thread.Sleep(5); + } + return lines; + } +} +'@ + +function Get-Sha512Base64([string] $path) { + $sha = [System.Security.Cryptography.SHA512]::Create() + $stream = [System.IO.File]::OpenRead($path) + try { [Convert]::ToBase64String($sha.ComputeHash($stream)) } finally { $stream.Dispose() } +} + +function Stop-App { + Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallDir, 'OrdinalIgnoreCase') } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } +} + +New-Item -ItemType Directory -Force -Path $ReportDir | Out-Null +$feedDir = Join-Path $ReportDir 'feed' +Remove-Item $feedDir -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $feedDir | Out-Null + +# --- Update feed ----------------------------------------------------------------------------- +if ($NewInstaller.Count -ne $NewVersion.Count) { throw "-NewInstaller and -NewVersion must have the same length" } +for ($i = 0; $i -lt $NewInstaller.Count; $i++) { + $installer = $NewInstaller[$i] + $name = Split-Path $installer -Leaf + Copy-Item $installer (Join-Path $feedDir $name) + if (Test-Path "$installer.blockmap") { Copy-Item "$installer.blockmap" (Join-Path $feedDir "$name.blockmap") } + $sha = Get-Sha512Base64 $installer + $size = (Get-Item $installer).Length + @" +version: $($NewVersion[$i]) +files: + - url: $name + sha512: $sha + size: $size +path: $name +sha512: $sha +releaseDate: '$(Get-Date -Format o)' +"@ | Set-Content -Encoding ascii (Join-Path $feedDir "latest-$i.yml") +} +if ($Scenario -eq 'failing-installer') { + # A GUI-subsystem exe (no console flashes) that fails like a broken installer would. + $fake = Join-Path $feedDir "fake-$($NewVersion[0])-win-x64-nsis.exe" + Add-Type -OutputType WindowsApplication -OutputAssembly $fake -TypeDefinition @' +public static class FailingInstaller { + public static int Main() { System.Threading.Thread.Sleep(2000); return 3; } +} +'@ + $sha = Get-Sha512Base64 $fake + @" +version: $($NewVersion[0]) +files: + - url: $(Split-Path $fake -Leaf) + sha512: $sha + size: $((Get-Item $fake).Length) +"@ | Set-Content -Encoding ascii (Join-Path $feedDir 'latest-0.yml') +} +Copy-Item (Join-Path $feedDir 'latest-0.yml') (Join-Path $feedDir 'latest.yml') +$finalVersion = $NewVersion[-1] +$oldVersion = $null + +$server = Start-Process -FilePath (Join-Path $JdkHome 'bin\jwebserver.exe') ` + -ArgumentList '-b', '127.0.0.1', '-p', "$Port", '-d', $feedDir -PassThru -WindowStyle Hidden +Start-Sleep -Seconds 2 + +# --- Install the old version ----------------------------------------------------------------- +Stop-App +if (Test-Path $InstallDir) { + $uninstaller = Get-ChildItem $InstallDir -Filter 'Uninstall *.exe' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($uninstaller) { Start-Process $uninstaller.FullName -ArgumentList '/S' -Wait } + Remove-Item $InstallDir -Recurse -Force -ErrorAction SilentlyContinue +} +Start-Process $OldInstaller -ArgumentList '/S', "/D=$InstallDir" -Wait +if (-not (Test-Path (Join-Path $InstallDir $exeName))) { throw "Old version was not installed into $InstallDir" } +$oldVersion = @(Get-ChildItem (Join-Path $InstallDir 'versions') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name)[0] +$logFile = "$env:TEMP\hot-update-demo.log" +Remove-Item $logFile -ErrorAction SilentlyContinue +if ($Scenario -eq 'stale-target-dir') { + $stale = Join-Path $InstallDir "versions\$($NewVersion[0])\app" + New-Item -ItemType Directory -Force -Path $stale | Out-Null + Set-Content (Join-Path $stale 'leftover.jar') 'not a jar' +} + +# --- Run the app and sample the screen until the new version has taken over ------------------ +$stopFile = Join-Path $ReportDir 'stop' +Remove-Item $stopFile -ErrorAction SilentlyContinue +$env:HOT_UPDATE_DEMO_FEED = "http://127.0.0.1:$Port" +$env:HOT_UPDATE_DEMO_TOPMOST = '1' # launched from a background process, the window would open behind others +$toolOptions = if ($Mode -eq 'classic') { "-Dnucleus.updater.hotUpdate.disabled=true $JavaToolOptions" } else { $JavaToolOptions } +if ($toolOptions.Trim()) { $env:JAVA_TOOL_OPTIONS = $toolOptions.Trim() } else { Remove-Item Env:JAVA_TOOL_OPTIONS -ErrorAction SilentlyContinue } + +$multiInstance = $Scenario -in 'two-instances', 'notify-other-instance' +$expectedWindows = if ($multiInstance) { 2 } else { 1 } +$watcher = Start-Job -ScriptBlock { + param($stopFile, $feedDir, $versions, $timeout, $scenario, $logFile, $launcher, $reportDir, $expectedWindows) + $deadline = (Get-Date).AddSeconds($timeout) + $next = 0 + $seenAt = $null + $actedAt = $null + while ((Get-Date) -lt $deadline) { + $processes = @(Get-Process -Name 'HotUpdateDemo' -ErrorAction SilentlyContinue) + $titles = @($processes | ForEach-Object MainWindowTitle) + if ($next -lt $versions.Count -and ($titles -contains "Hot Update Demo $($versions[$next])")) { + $next++ + if ($next -lt $versions.Count) { + Copy-Item (Join-Path $feedDir "latest-$next.yml") (Join-Path $feedDir 'latest.yml') -Force + } + } + $onFinal = @($titles | Where-Object { $_ -eq "Hot Update Demo $($versions[-1])" }).Count + if (-not $seenAt -and $next -ge $versions.Count -and $onFinal -ge $expectedWindows) { $seenAt = Get-Date } + $installing = (Test-Path $logFile) -and (Select-String -Path $logFile -Pattern 'installAndRestart' -Quiet) + if ($installing -and -not $actedAt) { + $actedAt = Get-Date + Start-Sleep -Milliseconds 1500 # the installer is running by now + switch ($scenario) { + 'relaunch-during-install' { + try { Start-Process $launcher -ErrorAction Stop; 'ok' | Set-Content (Join-Path $reportDir 'relaunch.txt') } + catch { "error: $_" | Set-Content (Join-Path $reportDir 'relaunch.txt') } + } + 'close-during-install' { + $processes | Where-Object MainWindowTitle | ForEach-Object { $_.CloseMainWindow() | Out-Null } + (Get-Date).ToString('o') | Set-Content (Join-Path $reportDir 'closed.txt') + } + } + } + # Keep sampling a few seconds after the last switch to catch a late disappearance. + if ($seenAt -and ((Get-Date) - $seenAt).TotalSeconds -gt 6) { break } + # No switch expected: watch long enough for the install to finish and a relaunch to show up. + if ($actedAt -and $scenario -in 'close-during-install', 'failing-installer' -and ((Get-Date) - $actedAt).TotalSeconds -gt 25) { break } + Start-Sleep -Milliseconds 200 + } + New-Item -ItemType File -Path $stopFile -Force | Out-Null +} -ArgumentList $stopFile, $feedDir, $NewVersion, $TimeoutSeconds, $Scenario, $logFile, (Join-Path $InstallDir $exeName), $ReportDir, $expectedWindows +Remove-Item (Join-Path $ReportDir 'relaunch.txt'), (Join-Path $ReportDir 'closed.txt') -ErrorAction SilentlyContinue + +$launcherPath = Join-Path $InstallDir $exeName +if ($multiInstance) { + $env:HOT_UPDATE_DEMO_MULTI = '1' + Start-Process $launcherPath -ArgumentList 'docA' | Out-Null + if ($Scenario -eq 'notify-other-instance') { $env:HOT_UPDATE_DEMO_CHECK = '0' } + Start-Process $launcherPath -ArgumentList 'docB' | Out-Null + Remove-Item Env:HOT_UPDATE_DEMO_MULTI, Env:HOT_UPDATE_DEMO_CHECK -ErrorAction SilentlyContinue +} else { + Start-Process $launcherPath | Out-Null +} +$samples = [HotUpdateSampler]::Run($InstallDir, 'Hot Update Demo', $stopFile, $TimeoutSeconds * 1000) +Wait-Job $watcher | Out-Null +Remove-Item Env:HOT_UPDATE_DEMO_FEED, Env:HOT_UPDATE_DEMO_TOPMOST, Env:JAVA_TOOL_OPTIONS -ErrorAction SilentlyContinue +$samples | Set-Content (Join-Path $ReportDir "samples-$Mode-$Scenario.txt") +$cfgAfterRun = Get-Content (Join-Path $InstallDir "app\$([IO.Path]::GetFileNameWithoutExtension($exeName)).cfg") -Raw +$launcherExists = Test-Path (Join-Path $InstallDir $exeName) + +if ($Scenario -eq 'close-during-install') { + # The user starts the app again later: the new version must run and retire the old one. + $env:HOT_UPDATE_DEMO_FEED = "http://127.0.0.1:$Port" + Start-Process (Join-Path $InstallDir $exeName) | Out-Null + Remove-Item Env:HOT_UPDATE_DEMO_FEED -ErrorAction SilentlyContinue + $restartDeadline = (Get-Date).AddSeconds(40) + while ((Get-Date) -lt $restartDeadline -and -not (Get-Process -Name 'HotUpdateDemo' -ErrorAction SilentlyContinue | + Where-Object MainWindowTitle -eq "Hot Update Demo $finalVersion")) { Start-Sleep -Milliseconds 200 } +} + +# What the screen shows at the app's position once it is gone: the reference for a gap. +Start-Sleep -Seconds 3 # let the new version clean the retired one up +$versions = @(Get-ChildItem (Join-Path $InstallDir 'versions') -Directory -ErrorAction SilentlyContinue | ForEach-Object Name) +$retired = @(Get-ChildItem $InstallDir -Filter '*.nucleus-old' -ErrorAction SilentlyContinue | ForEach-Object Name) +$running = @(Get-Process -Name 'HotUpdateDemo' -ErrorAction SilentlyContinue | Where-Object MainWindowTitle | ForEach-Object MainWindowTitle) +$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($InstallDir, 'OrdinalIgnoreCase') } | + ForEach-Object { "$($_.ProcessId)<-$($_.ParentProcessId):$(Split-Path $_.ExecutablePath -Leaf)" }) +Stop-App +Start-Sleep -Milliseconds 800 +$background = [HotUpdateSampler]::Pixel([HotUpdateSampler]::LastX, [HotUpdateSampler]::LastY) + +# --- Analyse --------------------------------------------------------------------------------- +$appColors = @('C06515', '327D2E', '9A1B6A', '2828C6') # demo palette, as GetPixel's 0x00BBGGRR + +function Get-Distance([string] $a, [string] $b) { + $x = [Convert]::ToInt32($a, 16); $y = [Convert]::ToInt32($b, 16) + $d = 0 + foreach ($shift in 0, 8, 16) { $d += [math]::Abs((($x -shr $shift) -band 255) - (($y -shr $shift) -band 255)) } + $d +} + +# The window manager cross-fades a window it shows or hides, so the pixel passes through blends of +# the two versions' colours: a sample is a gap only when it is closer to the background than to the app. +function Test-AppVisible([string] $pixel) { + if ($pixel -eq '-') { return $false } + if ($appColors -contains $pixel) { return $true } + $toApp = ($appColors | ForEach-Object { Get-Distance $pixel $_ } | Measure-Object -Minimum).Minimum + $toApp -lt (Get-Distance $pixel $background) +} +$firstSeen = $null; $newSeen = $null; $lastT = 0 +$gaps = @(); $gapStart = $null; $screenSeen = $false; $overlapMs = 0; $pixelGaps = @(); $pixelGapStart = $null +foreach ($line in $samples) { + $parts = $line.Split('|', 3) + $t = [long]$parts[0]; $pixel = $parts[1]; $windows = $parts[2] + $titles = @($windows.Split(';', [StringSplitOptions]::RemoveEmptyEntries) | ForEach-Object { $_.Split(':', 2)[1] }) + if ($titles.Count -gt 0 -and -not $firstSeen) { $firstSeen = $t } + if (-not $newSeen -and ($titles -contains "Hot Update Demo $finalVersion")) { $newSeen = $t } + if ($firstSeen) { + if ($titles.Count -eq 0) { if (-not $gapStart) { $gapStart = $t } } + elseif ($gapStart) { $gaps += ($t - $gapStart); $gapStart = $null } + if (($titles | Select-Object -Unique).Count -gt 1) { $overlapMs += ($t - $lastT) } + # Counted from the first frame the app is really on screen: the window is reported + # visible while the window manager is still fading it in at startup. + $onScreen = Test-AppVisible $pixel + if ($onScreen) { $screenSeen = $true } + if ($screenSeen) { + if (-not $onScreen) { if (-not $pixelGapStart) { $pixelGapStart = $t } } + elseif ($pixelGapStart) { $pixelGaps += ($t - $pixelGapStart); $pixelGapStart = $null } + } + } + $lastT = $t +} +if ($gapStart) { $gaps += ($lastT - $gapStart) } +if ($pixelGapStart) { $pixelGaps += ($lastT - $pixelGapStart) } +$maxGap = ($gaps + 0 | Measure-Object -Maximum).Maximum +$maxPixelGap = ($pixelGaps + 0 | Measure-Object -Maximum).Maximum +$intervals = for ($i = 1; $i -lt $samples.Count; $i++) { [long]$samples[$i].Split('|')[0] - [long]$samples[$i - 1].Split('|')[0] } +$avgInterval = [math]::Round(($intervals | Measure-Object -Average).Average, 1) + +Write-Host "mode=$Mode scenario=$Scenario installDir=$InstallDir samples=$($samples.Count) avgIntervalMs=$avgInterval" +Write-Host "firstWindowMs=$firstSeen newVersionWindowMs=$newSeen" +Write-Host "windowGaps=$($gaps.Count) maxWindowGapMs=$maxGap" +Write-Host "screenGaps=$($pixelGaps.Count) maxScreenGapMs=$maxPixelGap" +Write-Host "overlapMs=$overlapMs backgroundPixel=$background" +Write-Host "versionsLeft=$($versions -join ',') retiredLaunchersLeft=$($retired -join ',')" +Write-Host "runningWindows=$($running -join ',')" +Write-Host "processes=$($processes -join ' ')" +Get-Content "$env:TEMP\hot-update-demo.log" -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " app: $_" } + +Stop-Process -Id $server.Id -Force -ErrorAction SilentlyContinue + +$failures = @() +$finalWindows = @($running | Where-Object { $_ -like 'Hot Update Demo*' }) +$appLog = @(Get-Content $logFile -Encoding UTF8 -ErrorAction SilentlyContinue) +$expectedCommand = "command=$(Join-Path $InstallDir $exeName)" +if (-not $launcherExists) { $failures += "the launcher $exeName was missing after the run" } +if ($Scenario -ne 'failing-installer' -and ($cfgAfterRun -notmatch [regex]::Escape("versions\$finalVersion\runtime"))) { + $failures += "the launcher cfg does not start $finalVersion" +} +# Every start, the handed-over ones included, runs from the stable launcher path (autostart, +# protocol handlers and shortcuts registered by the app keep pointing at something that exists). +$badCommand = @($appLog | Where-Object { $_ -match ' started ' -and $_ -notmatch [regex]::Escape($expectedCommand) }) +if ($badCommand.Count -gt 0) { $failures += "a start did not run from $expectedCommand : $($badCommand -join ' | ')" } + +$screenVerified = @($samples | Where-Object { $appColors -contains $_.Split('|')[1] }).Count -gt 0 +function Test-NoGap { + if ($maxGap -gt $MaxGapMs) { $script:failures += "the app had no window on screen for $maxGap ms" } + # The app window is topmost: if the screen never showed it once, the desktop is not being + # composed (display off, session locked) and the screen check says nothing either way. + if (-not $screenVerified) { + Write-Host "WARNING: the screen never showed the app (display off or session locked?); screen check skipped" + } elseif ($maxPixelGap -gt $MaxGapMs) { + $script:failures += "the app was not visible at its position for $maxPixelGap ms" + } +} +function Test-CleanedUp { + if ($versions.Count -ne 1) { $script:failures += "retired versions were not cleaned up: $($versions -join ',')" } + if ($retired.Count -ne 0) { $script:failures += "retired launchers were not cleaned up: $($retired -join ',')" } +} + +switch ($Scenario) { + { $_ -in 'update', 'relaunch-during-install', 'stale-target-dir' } { + if (-not $newSeen) { $failures += "the new version never showed a window" } + if ($finalWindows.Count -ne 1) { $failures += "expected one app window at the end, got: $($finalWindows -join ',')" } + Test-NoGap + if ($Mode -eq 'hot') { Test-CleanedUp } + if ($_ -eq 'relaunch-during-install') { + $relaunch = Get-Content (Join-Path $ReportDir 'relaunch.txt') -ErrorAction SilentlyContinue + Write-Host "relaunchDuringInstall=$relaunch" + if ($relaunch -ne 'ok') { $failures += "starting the launcher during the install failed: $relaunch" } + } + } + 'close-during-install' { + # Samples after the window closed must stay empty: the app was quit, it must not come back. + $closedAt = $null; $reappeared = $null; $shown = $false + foreach ($line in $samples) { + $parts = $line.Split('|', 3); $t = [long]$parts[0] + if ($parts[2]) { $shown = $true } + if ($shown -and -not $closedAt -and -not $parts[2]) { $closedAt = $t } + if ($closedAt -and $parts[2]) { $reappeared = "$t ms: $($parts[2])"; break } + } + Write-Host "closedAtMs=$closedAt reappeared=$reappeared" + if (-not $closedAt) { $failures += "the window was never closed" } + if ($reappeared) { $failures += "the app came back after the user closed it ($reappeared)" } + if ($finalWindows -notcontains "Hot Update Demo $finalVersion") { $failures += "the next start did not run $finalVersion" } + Test-CleanedUp + } + { $_ -in 'two-instances', 'notify-other-instance' } { + if (-not $newSeen) { $failures += "the new version never showed a window" } + $onFinal = @($finalWindows | Where-Object { $_ -eq "Hot Update Demo $finalVersion" }) + if ($onFinal.Count -ne 2) { $failures += "expected two $finalVersion windows at the end, got: $($finalWindows -join ',')" } + Test-NoGap + Test-CleanedUp + foreach ($doc in 'docA', 'docB') { + if (-not ($appLog | Where-Object { $_ -match "started version=$([regex]::Escape($finalVersion)) args=\[$doc\]" })) { + $failures += "no $finalVersion instance came back with $doc" + } + } + $installs = @($appLog | Where-Object { $_ -match 'installAndRestart' }).Count + Write-Host "installAndRestartCalls=$installs pendingRestart=$(@($appLog | Where-Object { $_ -match 'pendingRestart' }).Count)" + if ($_ -eq 'notify-other-instance') { + if (-not ($appLog | Where-Object { $_ -match "pendingRestart $([regex]::Escape($finalVersion))" })) { + $failures += "the docB instance never learned about the installed update" + } + if ($installs -ne 1) { $failures += "expected one install (docA), got $installs" } + } + } + 'failing-installer' { + if ($newSeen) { $failures += "a new version showed up although the installer failed" } + if ($finalWindows -notcontains "Hot Update Demo $oldVersion") { $failures += "the app did not stay on $oldVersion" } + if (@($samples | Where-Object { $_ -match "Hot Update Demo $oldVersion" } | ForEach-Object { $_.Split('|')[2].Split(':')[0] } | Select-Object -Unique).Count -ne 1) { + $failures += "the app process changed although the installer failed" + } + if ($versions -join ',' -ne $oldVersion) { $failures += "the versions directory changed: $($versions -join ',')" } + Test-NoGap + } +} +if ($failures.Count -gt 0) { Write-Host "FAILED: $($failures -join '; ')"; exit 1 } +Write-Host "PASSED" diff --git a/service-management-macos/src/main/native/macos/NucleusServiceManagementBridge.m b/service-management-macos/src/main/native/macos/NucleusServiceManagementBridge.m index c3e7b1955..174dfeb77 100644 --- a/service-management-macos/src/main/native/macos/NucleusServiceManagementBridge.m +++ b/service-management-macos/src/main/native/macos/NucleusServiceManagementBridge.m @@ -1,6 +1,7 @@ #import #import #include +#include "../../../../../native-common/nucleus_jni.h" // ============================================================================ // Globals @@ -50,9 +51,7 @@ static void releaseEnv(BOOL didAttach) { } static void clearException(JNIEnv *env) { - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); } static jstring toJString(JNIEnv *env, NSString *str) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 1468852ef..3e3082d02 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -43,9 +43,6 @@ include(":linux-hidpi") include(":spellcheck") include(":system-color") include(":decorated-window-core") -include(":decorated-window-awt") -include(":decorated-window-jbr") -include(":decorated-window-jni") include(":decorated-window-tao") include(":nucleus-application") include(":decorated-window-jewel") @@ -72,6 +69,7 @@ include(":system-info") include(":autolaunch") include(":scheduler") include(":scheduler-testing") +include(":updater-testing") include(":fs-watcher") // Demo / sample applications (consolidated under examples/) @@ -80,7 +78,6 @@ include(":examples:compose-demo") include(":examples:tao-demo") include(":examples:swing-tao-demo") include(":examples:zstd-demo") -include(":examples:jni-demo") include(":examples:shared") include(":examples:jewel-demo") include(":examples:cmp-demo") @@ -96,7 +93,14 @@ include(":examples:mediafoundation-demo") include(":examples:avfoundation-demo") include(":examples:tao-native-test") include(":examples:window-scaffold-demo") +include(":examples:satellite-demo") +include(":examples:tabs-demo") +include(":examples:jewel-tabs-demo") +include(":examples:tab-satellites-demo") +include(":examples:reader-dock-demo") include(":examples:rect-stress-demo") include(":examples:watermark-demo") include(":examples:widget-demo") +include(":examples:macos-appex-demo") +include(":examples:hot-update-demo") includeBuild("plugin-build") diff --git a/system-color/src/main/native/linux/nucleus_systemcolor_linux.c b/system-color/src/main/native/linux/nucleus_systemcolor_linux.c index 94ff45dff..e75af7254 100644 --- a/system-color/src/main/native/linux/nucleus_systemcolor_linux.c +++ b/system-color/src/main/native/linux/nucleus_systemcolor_linux.c @@ -8,6 +8,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include #include #include @@ -211,7 +212,7 @@ static void notify_accent_color_changed(double r, double g, double b) { } } - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); if (didAttach) (*g_jvm)->DetachCurrentThread(g_jvm); } @@ -238,7 +239,7 @@ static void notify_high_contrast_changed(int isHigh) { } } - if ((*env)->ExceptionCheck(env)) (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); if (didAttach) (*g_jvm)->DetachCurrentThread(g_jvm); } diff --git a/system-color/src/main/native/macos/NucleusSystemColorBridge.m b/system-color/src/main/native/macos/NucleusSystemColorBridge.m index dc1c808e6..f75af545e 100644 --- a/system-color/src/main/native/macos/NucleusSystemColorBridge.m +++ b/system-color/src/main/native/macos/NucleusSystemColorBridge.m @@ -1,5 +1,6 @@ #import #include +#include "../../../../../native-common/nucleus_jni.h" static JavaVM *g_jvm = NULL; static id g_colorObserver = nil; @@ -56,9 +57,7 @@ static void notifyAccentColorChanged(void) { } } - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); } @@ -91,9 +90,7 @@ static void notifyContrastChanged(void) { } } - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); } diff --git a/system-color/src/main/native/windows/nucleus_systemcolor_windows.c b/system-color/src/main/native/windows/nucleus_systemcolor_windows.c index 3d9ddf9cd..088d1dcea 100644 --- a/system-color/src/main/native/windows/nucleus_systemcolor_windows.c +++ b/system-color/src/main/native/windows/nucleus_systemcolor_windows.c @@ -9,6 +9,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include /* ------------------------------------------------------------------ */ @@ -121,9 +122,7 @@ static void notifyAccentColorChanged(int r, int g, int b) { } } - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); } @@ -153,9 +152,7 @@ static void notifyHighContrastChanged(BOOL isHigh) { } } - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); - } + nucleus_jni_clear_exception(env); if (didAttach) { (*g_jvm)->DetachCurrentThread(g_jvm); } diff --git a/taskbar-progress-tao/src/main/kotlin/dev/nucleusframework/taskbarprogress/tao/NucleusTaskbarProgress.kt b/taskbar-progress-tao/src/main/kotlin/dev/nucleusframework/taskbarprogress/tao/NucleusTaskbarProgress.kt index 83289f5ee..51f40d0a5 100644 --- a/taskbar-progress-tao/src/main/kotlin/dev/nucleusframework/taskbarprogress/tao/NucleusTaskbarProgress.kt +++ b/taskbar-progress-tao/src/main/kotlin/dev/nucleusframework/taskbarprogress/tao/NucleusTaskbarProgress.kt @@ -5,12 +5,11 @@ import dev.nucleusframework.taskbarprogress.TaskbarProgress import java.util.concurrent.Executors /** - * Backend-agnostic taskbar/dock façade taking a [NucleusWindow]. Dispatches - * to the AWT-typed [TaskbarProgress] when the window is AWT-backed (JBR / JNI - * decorated windows) or to [TaoTaskbarProgress] when it is Tao-backed. + * Taskbar/dock façade taking a [NucleusWindow] and dispatching to + * [TaoTaskbarProgress]. * * App code should prefer this entry point over the backend-specific objects: - * a project can swap backends without touching call sites. + * call sites stay portable if the window type ever changes. * * **Threading**: every call is offloaded to a dedicated daemon worker. The * underlying Windows API (`ITaskbarList3`) internally uses `SendMessage` and @@ -33,7 +32,6 @@ public object NucleusTaskbarProgress { ): Boolean = dispatch( window, - awt = { TaskbarProgress.setProgress(it, value) }, tao = { TaoTaskbarProgress.setProgress(it, value) }, ) @@ -43,7 +41,6 @@ public object NucleusTaskbarProgress { ): Boolean = dispatch( window, - awt = { TaskbarProgress.setState(it, state) }, tao = { TaoTaskbarProgress.setState(it, state) }, ) @@ -53,7 +50,6 @@ public object NucleusTaskbarProgress { ): Boolean = dispatch( window, - awt = { TaskbarProgress.showProgress(it, value) }, tao = { TaoTaskbarProgress.showProgress(it, value) }, ) @@ -63,14 +59,12 @@ public object NucleusTaskbarProgress { ): Boolean = dispatch( window, - awt = { TaskbarProgress.showError(it, value) }, tao = { TaoTaskbarProgress.showError(it, value) }, ) public fun showIndeterminate(window: NucleusWindow): Boolean = dispatch( window, - awt = { TaskbarProgress.showIndeterminate(it) }, tao = { TaoTaskbarProgress.showIndeterminate(it) }, ) @@ -80,14 +74,12 @@ public object NucleusTaskbarProgress { ): Boolean = dispatch( window, - awt = { TaskbarProgress.showPaused(it, value) }, tao = { TaoTaskbarProgress.showPaused(it, value) }, ) public fun hideProgress(window: NucleusWindow): Boolean = dispatch( window, - awt = { TaskbarProgress.hideProgress(it) }, tao = { TaoTaskbarProgress.hideProgress(it) }, ) @@ -97,30 +89,21 @@ public object NucleusTaskbarProgress { ): Boolean = dispatch( window, - awt = { TaskbarProgress.requestAttention(it, type) }, tao = { TaoTaskbarProgress.requestAttention(it, type) }, ) public fun stopAttention(window: NucleusWindow): Boolean = dispatch( window, - awt = { TaskbarProgress.stopAttention(it) }, tao = { TaoTaskbarProgress.stopAttention(it) }, ) private inline fun dispatch( window: NucleusWindow, - crossinline awt: (java.awt.Window) -> Boolean, crossinline tao: (dev.nucleusframework.window.tao.TaoWindow) -> Boolean, ): Boolean { - val awtWindow = window.unsafe.awtWindow - val taoWindow = window.unsafe.taoWindow - if (awtWindow == null && taoWindow == null) return false - worker.submit { - runCatching { - if (awtWindow != null) awt(awtWindow) else tao(taoWindow!!) - } - } + val taoWindow = window.unsafe.taoWindow ?: return false + worker.submit { runCatching { tao(taoWindow) } } return true } } diff --git a/taskbar-progress/src/main/native/windows/nucleus_taskbar_progress.c b/taskbar-progress/src/main/native/windows/nucleus_taskbar_progress.c index 8add8147c..3d5f0f6a6 100644 --- a/taskbar-progress/src/main/native/windows/nucleus_taskbar_progress.c +++ b/taskbar-progress/src/main/native/windows/nucleus_taskbar_progress.c @@ -13,6 +13,7 @@ */ #include +#include "../../../../../native-common/nucleus_jni.h" #include /* ---- /NODEFAULTLIB stubs ----------------------------------------- */ @@ -158,14 +159,14 @@ static HWND GetHwndFromAwtWindow(JNIEnv *env, jobject awtWindow) { /* AWTAccessor.getComponentAccessor() */ awtAccessorClass = (*env)->FindClass(env, "sun/awt/AWTAccessor"); if (!awtAccessorClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return NULL; } getCompAccessor = (*env)->GetStaticMethodID(env, awtAccessorClass, "getComponentAccessor", "()Lsun/awt/AWTAccessor$ComponentAccessor;"); if (!getCompAccessor || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, awtAccessorClass); return NULL; } @@ -173,14 +174,14 @@ static HWND GetHwndFromAwtWindow(JNIEnv *env, jobject awtWindow) { compAccessor = (*env)->CallStaticObjectMethod(env, awtAccessorClass, getCompAccessor); (*env)->DeleteLocalRef(env, awtAccessorClass); if (!compAccessor || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return NULL; } /* componentAccessor.getPeer(window) */ compAccessorClass = (*env)->FindClass(env, "sun/awt/AWTAccessor$ComponentAccessor"); if (!compAccessorClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, compAccessor); return NULL; } @@ -189,7 +190,7 @@ static HWND GetHwndFromAwtWindow(JNIEnv *env, jobject awtWindow) { "getPeer", "(Ljava/awt/Component;)Ljava/awt/peer/ComponentPeer;"); (*env)->DeleteLocalRef(env, compAccessorClass); if (!getPeer || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, compAccessor); return NULL; } @@ -197,14 +198,14 @@ static HWND GetHwndFromAwtWindow(JNIEnv *env, jobject awtWindow) { peer = (*env)->CallObjectMethod(env, compAccessor, getPeer, awtWindow); (*env)->DeleteLocalRef(env, compAccessor); if (!peer || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); return NULL; } /* peer.getHWnd() */ wCompPeerClass = (*env)->FindClass(env, "sun/awt/windows/WComponentPeer"); if (!wCompPeerClass || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, peer); return NULL; } @@ -212,15 +213,14 @@ static HWND GetHwndFromAwtWindow(JNIEnv *env, jobject awtWindow) { getHWnd = (*env)->GetMethodID(env, wCompPeerClass, "getHWnd", "()J"); (*env)->DeleteLocalRef(env, wCompPeerClass); if (!getHWnd || (*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + nucleus_jni_clear_exception(env); (*env)->DeleteLocalRef(env, peer); return NULL; } hwnd = (*env)->CallLongMethod(env, peer, getHWnd); (*env)->DeleteLocalRef(env, peer); - if ((*env)->ExceptionCheck(env)) { - (*env)->ExceptionClear(env); + if (nucleus_jni_clear_exception(env)) { return NULL; } diff --git a/updater-runtime/README.md b/updater-runtime/README.md index 6f92fdcf9..fa56f6ffe 100644 --- a/updater-runtime/README.md +++ b/updater-runtime/README.md @@ -57,6 +57,8 @@ NucleusUpdater { allowDowngrade = false // Allow installing older versions allowPrerelease = false // Auto-set to true if currentVersion contains "-" executableType = null // Force format (deb, rpm, dmg...), auto-detected if null + allowLaunchOverrides = false // Installed app honours NUCLEUS_UPDATER_FEED_URL / _SIMULATE (see below) + simulation = null // Play an UpdateSimulation instead of contacting the provider } ``` @@ -234,6 +236,112 @@ fun UpdateBanner() { } ``` +## Testing updates without publishing a release + +Three levels, from the cheapest to the most faithful. None of them needs a code change beyond +the opt-in of the third. + +| I want to… | Use | What runs for real | +|----------------------------------------------|--------------------------------------------------------------|---------------------------------------------| +| build and review the update UI | **simulation**: `./gradlew run -Pnucleus.updater.simulate=update` | nothing leaves the machine; install skipped | +| check + download against my next build | **feed redirect** from `./gradlew run` | manifest, selection, download, SHA-512 | +| update an installed copy to my next build | **feed redirect** of the installed app + `serveUpdateFeed` | everything, installer and restart included | + +### 1. Simulation — the update UI from `./gradlew run` + +```bash +./gradlew run -Pnucleus.updater.simulate=update # an update is available and downloads +./gradlew run -Pnucleus.updater.simulate=download-error # … or: up-to-date, check-error, checksum-error +./gradlew run -Pnucleus.updater.simulate=3.0.0 -Pnucleus.updater.simulate.duration=20 -Pnucleus.updater.simulate.size=250000000 +./gradlew run -Pnucleus.updater.simulate.justUpdatedFrom=1.2.0 # the "what's new" launch +``` + +Every `NucleusUpdater` of the app then plays the scripted update: `isUpdateSupported()` is `true`, +`checkForUpdates()` offers the next minor version (or `.version`), `downloadUpdate()` reports +progress over `.duration` seconds (`.differential=true` reports a delta), and +`installAndRestart()` logs what it would install and **returns** — the app keeps running. +Failures surface as the real exceptions (`NetworkException`, `ChecksumException`). + +In code, for a UI test or a debug menu: + +```kotlin +NucleusUpdater { + provider = GitHubProvider("myorg", "myapp") + simulation = UpdateSimulation(UpdateSimulation.Scenario.DOWNLOAD_ERROR, downloadDuration = 3.seconds) +} +``` + +`updater.simulation` is non-null while a simulation plays — handy to badge the UI. + +### 2. Feed redirect — electron-updater's `dev-app-update.yml`, without the file + +`nucleus.updater.feedUrl` (system property) or `NUCLEUS_UPDATER_FEED_URL` (environment variable) +replaces the configured provider with a **local directory** (`LocalFileProvider` — a path or a +`file:` URL), an `https` server, or plain `http` to a **loopback** host: + +```bash +./gradlew packageNsis # after bumping packageVersion (any auto-updatable format) +./gradlew run -Pnucleus.updater.feedUrl=build/compose/binaries/main/nsis +``` + +The packaging output of any auto-updatable format is a complete feed — the plugin writes the +`latest*.yml` manifest next to the artifact even when no `publish` provider is configured. An +unpackaged run (`run`, an IDE) checks and downloads for real; the install is skipped, since there +is no installed app to replace (this is also what `installAndRestart` does in any unpackaged run). + +### 3. Updating an installed app — the whole path + +An installed app honours the redirect (and a launch-time simulation) **only when it opts in**, +since whoever sets the variable would otherwise choose what it installs: + +```kotlin +NucleusUpdater { + provider = GitHubProvider("myorg", "myapp") + allowLaunchOverrides = BuildConfig.isInternal // or true, if the switch is part of how you test releases +} +``` + +Then, with the current version installed: + +```bash +./gradlew serveUpdateFeed # bumped packageVersion: packages it, serves http://127.0.0.1:8421 +NUCLEUS_UPDATER_FEED_URL=http://127.0.0.1:8421 "C:\Users\me\AppData\Local\Programs\MyApp\MyApp.exe" +``` + +`serveUpdateFeed` serves the merged manifests of every auto-updatable format of the current OS, +the artifacts, block maps and signatures, with byte ranges (differential downloads work as in +production). `-Pnucleus.updater.serve.throttle=2m` (bytes per second, `k`/`m` suffixes) and +`-Pnucleus.updater.serve.latency=500` slow it down, `-Pnucleus.updater.serve.port` moves it, +`-Pnucleus.updater.serve.timeout=` stops it on its own. Pointing the app at the directory +instead (`NUCLEUS_UPDATER_FEED_URL=build/compose/binaries/main/nsis`) needs no server, but always +downloads the whole artifact. + +`./gradlew runDistributable -Pnucleus.updater.…` forwards the same switches as environment +variables. Ignored switches (an installed app without the opt-in, a remote `http` URL) are logged +as warnings, as is every redirect and simulation that applies. + +### Automated tests: `updater-testing` + +`dev.nucleusframework:nucleus.updater-testing` ships `UpdateFeedServer`, the loopback release host +the Nucleus updater is tortured against: it publishes artifacts with a generated manifest, serves +ranges, records every request, and misbehaves on demand. + +```kotlin +UpdateFeedServer().use { feed -> + feed.publish("2.0.0", File("build/compose/binaries/main/nsis/myapp-2.0.0-win-x64-nsis.exe")) + feed.fault(FeedFault.Throttle(bytesPerSecond = 1_000_000)) // a slow link + feed.fault(FeedFault.Truncate(afterBytes = 4096), path = "*.exe", times = 1) // one dropped transfer + // FeedFault.Status(503), Delay(2.seconds), Corrupt(offset), IgnoreRange + + val updater = NucleusUpdater { + currentVersion = "1.0.0" + executableType = "nsis" + provider = GenericProvider(feed.baseUrl) + } + // drive checkForUpdates() / downloadUpdate() and assert on feed.requests +} +``` + ## How it works 1. **Check** — Detects current OS/arch, fetches the appropriate `latest-*.yml` from the provider, parses it, and compares versions diff --git a/updater-runtime/api/updater-runtime.api b/updater-runtime/api/updater-runtime.api index 1ec8e0776..b7aba492a 100644 --- a/updater-runtime/api/updater-runtime.api +++ b/updater-runtime/api/updater-runtime.api @@ -25,9 +25,15 @@ public final class dev/nucleusframework/updater/NucleusUpdater { public final fun consumeUpdateEvent ()Ldev/nucleusframework/updater/UpdateEvent; public final fun downloadUpdate (Ldev/nucleusframework/updater/UpdateInfo;)Lkotlinx/coroutines/flow/Flow; public final fun getCurrentVersion ()Ljava/lang/String; + public final fun getFeedOverride ()Ljava/lang/String; + public final fun getPendingRestartVersion ()Lkotlinx/coroutines/flow/StateFlow; + public final fun getSimulation ()Ldev/nucleusframework/updater/UpdateSimulation; public final fun installAndQuit (Ljava/io/File;)V public final fun installAndRestart (Ljava/io/File;)V + public final fun installAndRestart (Ljava/io/File;Ljava/util/List;)V public final fun isUpdateSupported ()Z + public final fun restartToInstalledVersion (Ljava/util/List;)Z + public static synthetic fun restartToInstalledVersion$default (Ldev/nucleusframework/updater/NucleusUpdater;Ljava/util/List;ILjava/lang/Object;)Z public final fun wasJustUpdated ()Z } @@ -130,12 +136,43 @@ public final class dev/nucleusframework/updater/UpdateResult$NotAvailable : dev/ public fun toString ()Ljava/lang/String; } +public final class dev/nucleusframework/updater/UpdateSimulation { + public static final field Companion Ldev/nucleusframework/updater/UpdateSimulation$Companion; + public fun ()V + public synthetic fun (Ldev/nucleusframework/updater/UpdateSimulation$Scenario;Ljava/lang/String;JJJZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ldev/nucleusframework/updater/UpdateSimulation$Scenario;Ljava/lang/String;JJJZLjava/lang/String;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getCheckDuration-UwyO8pc ()J + public final fun getDownloadDuration-UwyO8pc ()J + public final fun getDownloadSize ()J + public final fun getJustUpdatedFrom ()Ljava/lang/String; + public final fun getScenario ()Ldev/nucleusframework/updater/UpdateSimulation$Scenario; + public final fun getVersion ()Ljava/lang/String; + public final fun isDifferential ()Z + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/UpdateSimulation$Companion { + public final fun fromSettings ()Ldev/nucleusframework/updater/UpdateSimulation; +} + +public final class dev/nucleusframework/updater/UpdateSimulation$Scenario : java/lang/Enum { + public static final field CHECKSUM_ERROR Ldev/nucleusframework/updater/UpdateSimulation$Scenario; + public static final field CHECK_ERROR Ldev/nucleusframework/updater/UpdateSimulation$Scenario; + public static final field DOWNLOAD_ERROR Ldev/nucleusframework/updater/UpdateSimulation$Scenario; + public static final field UPDATE_AVAILABLE Ldev/nucleusframework/updater/UpdateSimulation$Scenario; + public static final field UP_TO_DATE Ldev/nucleusframework/updater/UpdateSimulation$Scenario; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Ldev/nucleusframework/updater/UpdateSimulation$Scenario; + public static fun values ()[Ldev/nucleusframework/updater/UpdateSimulation$Scenario; +} + public final class dev/nucleusframework/updater/UpdaterConfig { public static final field Companion Ldev/nucleusframework/updater/UpdaterConfig$Companion; public static final field DEV_VERSION Ljava/lang/String; public field provider Ldev/nucleusframework/updater/provider/UpdateProvider; public fun ()V public final fun getAllowDowngrade ()Z + public final fun getAllowLaunchOverrides ()Z public final fun getAllowPrerelease ()Z public final fun getCacheDir ()Ljava/io/File; public final fun getChannel ()Ljava/lang/String; @@ -144,7 +181,9 @@ public final class dev/nucleusframework/updater/UpdaterConfig { public final fun getExecutableType ()Ljava/lang/String; public final fun getHttpClient ()Ljava/net/http/HttpClient; public final fun getProvider ()Ldev/nucleusframework/updater/provider/UpdateProvider; + public final fun getSimulation ()Ldev/nucleusframework/updater/UpdateSimulation; public final fun setAllowDowngrade (Z)V + public final fun setAllowLaunchOverrides (Z)V public final fun setAllowPrerelease (Z)V public final fun setCacheDir (Ljava/io/File;)V public final fun setChannel (Ljava/lang/String;)V @@ -153,6 +192,7 @@ public final class dev/nucleusframework/updater/UpdaterConfig { public final fun setExecutableType (Ljava/lang/String;)V public final fun setHttpClient (Ljava/net/http/HttpClient;)V public final fun setProvider (Ldev/nucleusframework/updater/provider/UpdateProvider;)V + public final fun setSimulation (Ldev/nucleusframework/updater/UpdateSimulation;)V } public final class dev/nucleusframework/updater/UpdaterConfig$Companion { @@ -236,6 +276,16 @@ public final class dev/nucleusframework/updater/provider/GitHubProvider : dev/nu public fun resolveMetadataUrl (Ljava/lang/String;Ldev/nucleusframework/core/runtime/Platform;Ljava/net/http/HttpClient;)Ljava/lang/String; } +public final class dev/nucleusframework/updater/provider/LocalFileProvider : dev/nucleusframework/updater/provider/UpdateProvider { + public fun (Ljava/io/File;)V + public fun authHeaders ()Ljava/util/Map; + public fun getBlockMapUrl (Ljava/lang/String;)Ljava/lang/String; + public final fun getDirectory ()Ljava/io/File; + public fun getDownloadUrl (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String; + public fun getUpdateMetadataUrl (Ljava/lang/String;Ldev/nucleusframework/core/runtime/Platform;)Ljava/lang/String; + public fun resolveMetadataUrl (Ljava/lang/String;Ldev/nucleusframework/core/runtime/Platform;Ljava/net/http/HttpClient;)Ljava/lang/String; +} + public abstract interface class dev/nucleusframework/updater/provider/UpdateProvider { public fun authHeaders ()Ljava/util/Map; public fun getBlockMapUrl (Ljava/lang/String;)Ljava/lang/String; diff --git a/updater-runtime/build.gradle.kts b/updater-runtime/build.gradle.kts index 429f879f0..997076bee 100644 --- a/updater-runtime/build.gradle.kts +++ b/updater-runtime/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation(libs.coroutines.core) implementation(libs.kotlinx.serialization.json) testImplementation(libs.junit) + testImplementation(project(":updater-testing")) } java { diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt index 2a573ebcf..e0b8bcc19 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/NucleusUpdater.kt @@ -8,27 +8,35 @@ import dev.nucleusframework.updater.exception.NetworkException import dev.nucleusframework.updater.exception.NoMatchingFileException import dev.nucleusframework.updater.exception.UpdateException import dev.nucleusframework.updater.internal.ChecksumVerifier +import dev.nucleusframework.updater.internal.FeedFetcher +import dev.nucleusframework.updater.internal.FeedOverride import dev.nucleusframework.updater.internal.FileSelector +import dev.nucleusframework.updater.internal.InstalledVersionWatcher import dev.nucleusframework.updater.internal.PlatformInfo import dev.nucleusframework.updater.internal.PlatformInstaller +import dev.nucleusframework.updater.internal.SimulatedUpdate import dev.nucleusframework.updater.internal.UpdateMarker +import dev.nucleusframework.updater.internal.UpdaterSettings +import dev.nucleusframework.updater.internal.WindowsHotUpdate import dev.nucleusframework.updater.internal.YamlParser import dev.nucleusframework.updater.internal.delta.DeltaPlan import dev.nucleusframework.updater.internal.delta.DeltaResolver import dev.nucleusframework.updater.internal.delta.DifferentialDownloader import dev.nucleusframework.updater.internal.delta.UpdateCache +import dev.nucleusframework.updater.provider.UpdateProvider import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import java.io.File -import java.net.URI import java.net.http.HttpClient -import java.net.http.HttpRequest -import java.net.http.HttpResponse import java.nio.file.Files +import java.util.concurrent.atomic.AtomicBoolean import java.util.logging.Level import java.util.logging.Logger import kotlin.coroutines.cancellation.CancellationException @@ -44,8 +52,53 @@ public class NucleusUpdater( public val currentVersion: String get() = this.config.currentVersion + /** + * The update simulation this updater plays instead of contacting any feed + * ([UpdaterConfig.simulation], or the one requested at launch with `nucleus.updater.simulate`), + * `null` for real updates — handy to badge a test build's update UI. + */ + public val simulation: UpdateSimulation? = + this.config.simulation ?: UpdateSimulation.fromSettings()?.takeIf { launchSimulation -> + (isUnpackaged || this.config.allowLaunchOverrides).also { honoured -> + if (!honoured) { + logger.warning( + "Ignoring the launch-time update simulation ($launchSimulation): this installed app " + + "does not set UpdaterConfig.allowLaunchOverrides", + ) + } + } + } + + private val simulated: SimulatedUpdate? = + simulation?.let { SimulatedUpdate(it, this.config.currentVersion) }?.also { + logger.warning("Update simulation active, no feed will be contacted: $simulation") + } + + private val redirect: FeedOverride.Applied? = + if (simulated != null) { + null + } else { + FeedOverride.resolve( + raw = UpdaterSettings.get(UpdaterSettings.FEED_URL), + packaged = !isUnpackaged, + allowed = this.config.allowLaunchOverrides, + ) + } + + /** + * The feed this updater reads when it was redirected at launch (see + * [UpdaterConfig.allowLaunchOverrides]), or `null` when it reads the configured provider. + */ + public val feedOverride: String? get() = redirect?.raw + + /** The configured provider, unless the feed was redirected at launch. */ + private val provider: UpdateProvider = redirect?.provider ?: this.config.provider + private var pendingUpdateVersion: String? = null + /** Whether the next [consumeUpdateEvent] still reports [UpdateSimulation.justUpdatedFrom]. */ + private val simulatedEventPending = AtomicBoolean(simulation?.justUpdatedFrom != null) + private val httpClient: HttpClient = config.httpClient ?: HttpClient @@ -53,18 +106,33 @@ public class NucleusUpdater( .followRedirects(HttpClient.Redirect.NORMAL) .build() + private val fetcher = FeedFetcher(httpClient) { provider.authHeaders() } + /** Holds the last downloaded artifact, which the next differential download builds upon. */ private val cache: UpdateCache by lazy { config.cacheDir?.let(::UpdateCache) ?: UpdateCache.default() } + /** + * Whether this app can update itself: it runs from a self-updatable package (NSIS, MSI, DMG, + * macOS ZIP, AppImage, DEB, RPM, Developer ID PKG), updates are simulated ([simulation]), or it + * runs unpackaged with its feed redirected at launch — where checking and downloading work and + * installing is skipped. + */ public fun isUpdateSupported(): Boolean { + if (simulated != null) return true val type = resolveExecutableType() - return type in SELF_UPDATABLE_TYPES + if (type == ExecutableType.DEV) return redirect != null + if (type in SELF_UPDATABLE_TYPES) return true + // A PKG installs an ordinary .app in /Applications, exactly like a DMG, so a Developer ID + // PKG can update itself from the ZIP/DMG artifacts of the same release. Only the Mac App + // Store build cannot — and that one is sandboxed, which is what distinguishes the two. + return type == ExecutableType.PKG && !ExecutableRuntime.isSandboxed() } public suspend fun checkForUpdates(): UpdateResult { - if (config.isDevMode()) return UpdateResult.NotAvailable + simulated?.let { return it.check() } + if (config.isDevMode() && redirect == null) return UpdateResult.NotAvailable if (!isUpdateSupported()) return UpdateResult.NotAvailable return withContext(Dispatchers.IO) { try { @@ -81,7 +149,13 @@ public class NucleusUpdater( } } - public fun downloadUpdate(info: UpdateInfo): Flow = + /** + * Downloads [info]'s artifact — differentially when the previous one is cached and the host + * serves ranges — and verifies its SHA-512. The last progress report carries the staged file. + */ + public fun downloadUpdate(info: UpdateInfo): Flow = simulated?.download(info) ?: download(info) + + private fun download(info: UpdateInfo): Flow = flow { pendingUpdateVersion = info.version val targetFile = info.currentFile @@ -149,13 +223,14 @@ public class NucleusUpdater( targetFile: UpdateFile, tempFile: File, ): DownloadOutcome? { - if (!config.differentialDownload) return null + // Range requests are what make a download differential; a local feed has nothing to save. + if (!config.differentialDownload || FeedFetcher.isLocal(targetFile.url)) return null return try { - val resolver = DeltaResolver(httpClient, config.provider.authHeaders(), cache) + val resolver = DeltaResolver(httpClient, provider.authHeaders(), cache) val resolved = resolver.resolve( target = targetFile, - blockMapUrl = config.provider.getBlockMapUrl(targetFile.url), + blockMapUrl = provider.getBlockMapUrl(targetFile.url), destination = tempFile, ) ?: return null @@ -167,7 +242,7 @@ public class NucleusUpdater( emit(DownloadProgress(0, plannedBytes, 0.0, isDifferential = true)) val transferred = - DifferentialDownloader(httpClient, config.provider.authHeaders()) + DifferentialDownloader(httpClient, provider.authHeaders()) .download(resolved.download) { downloaded, total -> emit(DownloadProgress(downloaded, total, percentOf(downloaded, total), isDifferential = true)) } @@ -188,22 +263,10 @@ public class NucleusUpdater( targetFile: UpdateFile, tempFile: File, ): DownloadOutcome { - val requestBuilder = - HttpRequest - .newBuilder() - .uri(URI.create(targetFile.url)) - .GET() - applyAuthHeaders(requestBuilder) - val response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()) - - if (response.statusCode() != HTTP_OK) { - throw NetworkException("HTTP ${response.statusCode()} downloading ${targetFile.url}") - } - val totalBytes = targetFile.size var bytesDownloaded = 0L - response.body().use { inputStream -> + fetcher.open(targetFile.url).use { inputStream -> tempFile.outputStream().use { outputStream -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) var bytesRead: Int @@ -227,7 +290,7 @@ public class NucleusUpdater( // differential downloads are off. val blockMapGzip = if (config.differentialDownload && !DeltaResolver.embedsBlockMap(targetFile)) { - fetchBlockMap(config.provider.getBlockMapUrl(targetFile.url)) + fetchBlockMap(provider.getBlockMapUrl(targetFile.url)) } else { null } @@ -246,16 +309,8 @@ public class NucleusUpdater( /** Downloads a block map, or returns `null` when the release does not publish one. */ private fun fetchBlockMap(url: String): ByteArray? = - try { - val requestBuilder = HttpRequest.newBuilder().uri(URI.create(url)).GET() - applyAuthHeaders(requestBuilder) - val response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) - response.body()?.takeIf { response.statusCode() == HTTP_OK && it.isNotEmpty() } - } catch ( - @Suppress("TooGenericExceptionCaught") e: Exception, - ) { - logger.log(Level.FINE, "No block map at $url; the next update will be a full download", e) - null + fetcher.readBytesOrNull(url).also { + if (it == null) logger.log(Level.FINE, "No block map at $url; the next update will be a full download") } private fun cacheForNextUpdate( @@ -277,16 +332,7 @@ public class NucleusUpdater( dest: File, ) { try { - val requestBuilder = - HttpRequest - .newBuilder() - .uri(URI.create("$url.asc")) - .GET() - applyAuthHeaders(requestBuilder) - val response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofByteArray()) - if (response.statusCode() == HTTP_OK) { - dest.writeBytes(response.body()) - } + fetcher.readBytesOrNull("$url.asc")?.let(dest::writeBytes) } catch ( @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Exception, ) { @@ -296,13 +342,80 @@ public class NucleusUpdater( } } + /** + * Installs [installerFile] and restarts the application on the new version. + * + * On a per-user Windows NSIS install of a JVM app (the plugin lays every one out for it) this + * returns immediately: the new version is installed while the application keeps running, then + * launched, and this process exits once the new version's first window is on screen — the + * application never disappears while it updates. If that install fails, the application keeps + * running on its current version. + * Everywhere else the application exits right away, the installer runs, and the new version + * is relaunched. + */ public fun installAndRestart(installerFile: File) { + installAndRestart(installerFile, relaunchArguments = emptyList()) + } + + /** + * [installAndRestart] that starts the new version with [relaunchArguments] — for an app that + * runs one instance per document, the document this instance has open. + * + * The original command line is deliberately not replayed (Chromium does not either): it may + * hold one-shot arguments — the autostart marker, which would make the new version believe it + * was started at login, or a deep link that would fire a second time. Honoured on Windows; + * macOS and Linux relaunch without arguments. + */ + public fun installAndRestart( + installerFile: File, + relaunchArguments: List, + ) { + if (skipsInstall(installerFile, restart = true)) return writeUpdateMarker() val platform = PlatformInfo.currentPlatform() - PlatformInstaller.install(installerFile, platform, restart = true) + val hotInstall = WindowsHotUpdate.eligibleInstall(installerFile, platform, resolveExecutableType()) + if (hotInstall != null) { + WindowsHotUpdate.start(installerFile, hotInstall, relaunchArguments) + return + } + PlatformInstaller.install(installerFile, platform, restart = true, relaunchArguments = relaunchArguments) + } + + /** + * The version installed on disk when it is not the one running — another instance of an app + * without single instance installed an update — or `null`. Windows hot-update installs only; + * elsewhere it stays `null`. + * + * Like Chromium's upgrade detector, this is how the other instances learn about an update: + * locally, without downloading anything. Observe it to offer "Restart to update", then call + * [restartToInstalledVersion]. Nothing restarts on its own — the instance may hold unsaved + * work the user has not decided to give up. + */ + public val pendingRestartVersion: StateFlow by lazy { + val install = WindowsHotUpdate.currentInstall(PlatformInfo.currentPlatform(), resolveExecutableType()) + install?.let { InstalledVersionWatcher(it).apply { start() }.version } + ?: MutableStateFlow(null).asStateFlow() + } + + /** + * Hands over to the version another instance already installed ([pendingRestartVersion]), + * started with [relaunchArguments] (see [installAndRestart]): nothing is downloaded or + * installed, and this process exits once the new version is on screen. + * + * Returns `false`, doing nothing, when no other version is installed. + */ + public fun restartToInstalledVersion(relaunchArguments: List = emptyList()): Boolean { + val install = + WindowsHotUpdate.currentInstall(PlatformInfo.currentPlatform(), resolveExecutableType()) + ?: return false + val installed = WindowsHotUpdate.installedVersionDir(install) ?: return false + writeUpdateMarker(installed.name) + WindowsHotUpdate.startHandOff(install, relaunchArguments) + return true } public fun installAndQuit(installerFile: File) { + if (skipsInstall(installerFile, restart = false)) return writeUpdateMarker() val platform = PlatformInfo.currentPlatform() PlatformInstaller.install(installerFile, platform, restart = false) @@ -314,7 +427,10 @@ public class NucleusUpdater( * post-update launch (e.g. to show a "What's new" dialog or run migrations). */ public fun consumeUpdateEvent(): UpdateEvent? { - val event = peekUpdateEvent() ?: return null + if (simulatedEventPending.getAndSet(false)) return simulatedUpdateEvent() + if (!UpdateMarker.exists()) return null + val event = peekUpdateEvent() + // Consumed either way: a marker for another version is stale and must not linger. UpdateMarker.delete() return event } @@ -323,16 +439,50 @@ public class NucleusUpdater( * Returns `true` if the application was launched after an update. * Does **not** consume the event — call [consumeUpdateEvent] to clear it. */ - public fun wasJustUpdated(): Boolean = UpdateMarker.exists() + public fun wasJustUpdated(): Boolean = (simulatedEventPending.get() || peekUpdateEvent() != null) + + private fun simulatedUpdateEvent(): UpdateEvent? { + val previous = simulation?.justUpdatedFrom ?: return null + val level = Version.fromString(config.currentVersion).levelFrom(Version.fromString(previous)) + return UpdateEvent(previous, config.currentVersion, level) + } + /** + * A simulation installs nothing, and neither does an unpackaged run: it has no installed app to + * replace, so the installer would install a copy beside the IDE run and exit it. Both log what + * would have been installed and return, leaving the app running. + */ + private fun skipsInstall( + installerFile: File, + restart: Boolean, + ): Boolean { + val reason = + when { + simulated != null -> "updates are simulated" + isUnpackaged -> "the app runs unpackaged, with no installed copy to replace" + else -> return false + } + val action = if (restart) "installAndRestart" else "installAndQuit" + logger.warning("$action skipped because $reason: would install ${installerFile.absolutePath}") + return true + } + + /** + * The event recorded before the last install, if that install is the version now running. The + * marker is written *before* the installer runs, so an install that failed — or was never + * completed — leaves a marker naming a version this is not; reporting it would announce an + * update that did not happen. + */ private fun peekUpdateEvent(): UpdateEvent? { val (previousVersion, newVersion) = UpdateMarker.read() ?: return null - val level = Version.fromString(newVersion).levelFrom(Version.fromString(previousVersion)) + val installed = Version.fromString(newVersion) + if (installed.compareTo(Version.fromString(config.currentVersion)) != 0) return null + val level = installed.levelFrom(Version.fromString(previousVersion)) return UpdateEvent(previousVersion, newVersion, level) } - private fun writeUpdateMarker() { - val targetVersion = pendingUpdateVersion ?: return + private fun writeUpdateMarker(targetVersion: String? = pendingUpdateVersion) { + if (targetVersion == null) return try { UpdateMarker.write(config.currentVersion, targetVersion) } catch ( @@ -345,21 +495,8 @@ public class NucleusUpdater( private fun doCheckForUpdates(): UpdateResult { val platform = PlatformInfo.currentPlatform() val arch = PlatformInfo.currentArch() - val metadataUrl = config.provider.resolveMetadataUrl(config.channel, platform, httpClient) - - val requestBuilder = - HttpRequest - .newBuilder() - .uri(URI.create(metadataUrl)) - .GET() - applyAuthHeaders(requestBuilder) - val response = httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) - - if (response.statusCode() != HTTP_OK) { - return UpdateResult.Error(NetworkException("HTTP ${response.statusCode()} for $metadataUrl")) - } - - val metadata = YamlParser.parse(response.body()) + val metadataUrl = provider.resolveMetadataUrl(config.channel, platform, httpClient) + val metadata = YamlParser.parse(fetcher.readText(metadataUrl)) val currentVersion = Version.fromString(config.currentVersion) val remoteVersion = Version.fromString(metadata.version) @@ -375,15 +512,20 @@ public class NucleusUpdater( return UpdateResult.NotAvailable } + // Another instance already installed it: nothing to download, only a restart + // (pendingRestartVersion). + if (isInstalledOnDisk(remoteVersion)) return UpdateResult.NotAvailable + // On macOS, ignore the build-time system property so auto-detection // can prefer ZIP (silent install). Users can still force DMG via config.executableType. + // An unpackaged run has no format of its own: it takes what an install on this OS would. val format = - config.executableType - ?: if (platform == Platform.MacOS) { - null - } else { - System.getProperty("nucleus.executable.type") - } + when { + isUnpackaged -> null + config.executableType != null -> config.executableType + platform == Platform.MacOS -> null + else -> System.getProperty("nucleus.executable.type") + } val selectedFile = FileSelector.select( @@ -406,7 +548,7 @@ public class NucleusUpdater( files = metadata.files.map { file -> UpdateFile( - url = config.provider.getDownloadUrl(file.url, metadata.version), + url = provider.getDownloadUrl(file.url, metadata.version), sha512 = file.sha512, size = file.size, blockMapSize = file.blockMapSize, @@ -415,7 +557,7 @@ public class NucleusUpdater( }, currentFile = UpdateFile( - url = config.provider.getDownloadUrl(selectedFile.url, metadata.version), + url = provider.getDownloadUrl(selectedFile.url, metadata.version), sha512 = selectedFile.sha512, size = selectedFile.size, blockMapSize = selectedFile.blockMapSize, @@ -428,20 +570,22 @@ public class NucleusUpdater( return UpdateResult.Available(updateInfo, level) } + private fun isInstalledOnDisk(version: Version): Boolean { + val install = WindowsHotUpdate.currentInstall(PlatformInfo.currentPlatform(), resolveExecutableType()) + val installed = install?.let(WindowsHotUpdate::installedVersionDir) ?: return false + return Version.fromString(installed.name) >= version + } + private fun resolveExecutableType(): ExecutableType { val explicit = config.executableType if (explicit != null) return ExecutableRuntime.parseType(explicit) return ExecutableRuntime.type() } - private fun applyAuthHeaders(builder: HttpRequest.Builder) { - config.provider.authHeaders().forEach { (key, value) -> - builder.header(key, value) - } - } + /** Whether this process runs unpackaged (`./gradlew run`, an IDE), with no installed app to replace. */ + private val isUnpackaged: Boolean get() = resolveExecutableType() == ExecutableType.DEV public companion object { - private const val HTTP_OK = 200 private const val PERCENT_MAX = 100.0 private val logger: Logger = Logger.getLogger(NucleusUpdater::class.java.name) diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/UpdateSimulation.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/UpdateSimulation.kt new file mode 100644 index 000000000..87aa7793b --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/UpdateSimulation.kt @@ -0,0 +1,126 @@ +package dev.nucleusframework.updater + +import dev.nucleusframework.updater.internal.UpdaterSettings +import java.util.logging.Logger +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * A scripted update that [NucleusUpdater] plays instead of contacting any feed, to build and review + * an app's whole update UI — "update available", download progress, failures, "just updated" — from + * `./gradlew run`, with nothing published, packaged or installed. + * + * While a simulation is active every public entry point behaves as it would for a real update, + * except that nothing leaves the machine and nothing is installed: + * - [NucleusUpdater.isUpdateSupported] is `true`, even from an IDE run; + * - [NucleusUpdater.checkForUpdates] answers after [checkDuration] according to [scenario]; + * - [NucleusUpdater.downloadUpdate] reports [downloadSize] bytes of progress over + * [downloadDuration], then hands over a placeholder file; + * - [NucleusUpdater.installAndRestart] and [NucleusUpdater.installAndQuit] log what they would + * install and return, so the app keeps running; + * - [NucleusUpdater.consumeUpdateEvent] reports an update from [justUpdatedFrom] once, when set. + * + * Set it in code with [UpdaterConfig.simulation], or at launch without touching the code: + * `-Dnucleus.updater.simulate=update` (or the `NUCLEUS_UPDATER_SIMULATE` environment variable, + * which also reaches an installed app), refined by `nucleus.updater.simulate.version`, + * `.duration` (seconds), `.size` (bytes), `.differential` and `.justUpdatedFrom`. From Gradle, + * `./gradlew run -Pnucleus.updater.simulate=update` forwards them to the app. An unpackaged run + * always honours a launch-time simulation; an installed app only with + * [UpdaterConfig.allowLaunchOverrides], since it would otherwise silence the app's real updates. + */ +public class UpdateSimulation( + /** What [NucleusUpdater.checkForUpdates] and [NucleusUpdater.downloadUpdate] will do. */ + public val scenario: Scenario = Scenario.UPDATE_AVAILABLE, + /** The version offered; `null` offers the next minor version of the running one. */ + public val version: String? = null, + /** How long the simulated update check takes. */ + public val checkDuration: Duration = DEFAULT_CHECK_DURATION, + /** How long the simulated download takes, from first to last progress report. */ + public val downloadDuration: Duration = DEFAULT_DOWNLOAD_DURATION, + /** The size of the offered artifact, in bytes. */ + public val downloadSize: Long = DEFAULT_DOWNLOAD_SIZE, + /** Whether the download reports itself as differential, transferring a fraction of [downloadSize]. */ + public val isDifferential: Boolean = false, + /** When set, the next [NucleusUpdater.consumeUpdateEvent] reports an update from this version. */ + public val justUpdatedFrom: String? = null, +) { + init { + require(downloadSize > 0) { "downloadSize must be positive, got $downloadSize" } + require(!checkDuration.isNegative() && !downloadDuration.isNegative()) { "durations must not be negative" } + } + + /** The outcome a simulation plays. */ + public enum class Scenario( + internal val id: String, + ) { + /** An update is available and downloads successfully. */ + UPDATE_AVAILABLE("update"), + + /** The running version is the latest one. */ + UP_TO_DATE("up-to-date"), + + /** The update check fails, as it does offline. */ + CHECK_ERROR("check-error"), + + /** The download fails part-way, as a dropped connection does. */ + DOWNLOAD_ERROR("download-error"), + + /** The whole artifact downloads, then fails its SHA-512 verification. */ + CHECKSUM_ERROR("checksum-error"), + } + + override fun toString(): String = + "UpdateSimulation(scenario=$scenario, version=${version ?: "next minor"}, " + + "download=$downloadSize bytes in $downloadDuration, differential=$isDifferential, " + + "justUpdatedFrom=$justUpdatedFrom)" + + /** Launch-time configuration of a simulation. */ + public companion object { + private val DEFAULT_CHECK_DURATION = 800.milliseconds + private val DEFAULT_DOWNLOAD_DURATION = 6.seconds + private const val DEFAULT_DOWNLOAD_SIZE = 84L * 1024 * 1024 + + private val logger = Logger.getLogger(UpdateSimulation::class.java.name) + + /** + * The simulation requested at launch through `nucleus.updater.simulate*` (system properties + * or environment variables), or `null` when none is. `nucleus.updater.simulate` takes a + * [Scenario] id (`update`, `up-to-date`, `check-error`, `download-error`, + * `checksum-error`), `true` for `update`, or a version to offer; `justUpdatedFrom` alone + * simulates only the post-update launch. + */ + public fun fromSettings(): UpdateSimulation? = fromSettings(UpdaterSettings::get) + + internal fun fromSettings(setting: (String) -> String?): UpdateSimulation? { + val raw = setting(UpdaterSettings.SIMULATE) + val justUpdatedFrom = setting(UpdaterSettings.SIMULATE_JUST_UPDATED_FROM) + if (raw == null && justUpdatedFrom == null) return null + if (raw.equals("false", ignoreCase = true) || raw == "0") return null + + val byId = Scenario.entries.firstOrNull { it.id.equals(raw, ignoreCase = true) } + val isFlag = raw == null || raw.equals("true", ignoreCase = true) || raw == "1" + val looksLikeVersion = raw != null && raw.first().isDigit() + if (byId == null && !isFlag && !looksLikeVersion) { + logger.warning( + "Ignoring ${UpdaterSettings.SIMULATE}=$raw: expected true, a version, or one of " + + Scenario.entries.joinToString { it.id }, + ) + return null + } + return UpdateSimulation( + // justUpdatedFrom alone: only the post-update launch is simulated, and a check finds nothing. + scenario = byId ?: if (raw == null) Scenario.UP_TO_DATE else Scenario.UPDATE_AVAILABLE, + version = setting(UpdaterSettings.SIMULATE_VERSION) ?: raw.takeIf { looksLikeVersion }, + downloadDuration = + setting(UpdaterSettings.SIMULATE_DURATION)?.toDoubleOrNull()?.takeIf { it >= 0 }?.seconds + ?: DEFAULT_DOWNLOAD_DURATION, + downloadSize = + setting(UpdaterSettings.SIMULATE_SIZE)?.toLongOrNull()?.takeIf { it > 0 } + ?: DEFAULT_DOWNLOAD_SIZE, + isDifferential = setting(UpdaterSettings.SIMULATE_DIFFERENTIAL).toBoolean(), + justUpdatedFrom = justUpdatedFrom, + ) + } + } +} diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/UpdaterConfig.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/UpdaterConfig.kt index b1a7d801c..c02836b6e 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/UpdaterConfig.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/UpdaterConfig.kt @@ -47,6 +47,44 @@ public class UpdaterConfig { */ public var cacheDir: File? = null + /** + * Whether an **installed** app honours the launch-time test switches, set as system properties + * or, easier for an installed app, as environment variables: + * + * - the feed redirect `nucleus.updater.feedUrl` / `NUCLEUS_UPDATER_FEED_URL`, which replaces + * [provider] with a local directory + * ([dev.nucleusframework.updater.provider.LocalFileProvider]) or a test server + * ([dev.nucleusframework.updater.provider.GenericProvider]) — a local path or `file:` URL, + * `https`, or plain `http` to a loopback host; + * - the simulation `nucleus.updater.simulate*` / `NUCLEUS_UPDATER_SIMULATE*` (see + * [UpdateSimulation.fromSettings]). + * + * ``` + * NUCLEUS_UPDATER_FEED_URL=C:\work\app\build\compose\binaries\main\nsis MyApp.exe + * NUCLEUS_UPDATER_FEED_URL=http://127.0.0.1:8080 MyApp.exe + * ``` + * + * The redirect is how the next version is tested on a machine running the current one with + * nothing published: the installed app checks, downloads, verifies and installs it through the + * production path. + * + * An unpackaged run (`./gradlew run`, an IDE) always honours both — it has no production feed + * to protect, and like electron-updater's `dev-app-update.yml` this is what makes the check and + * the download testable there (installing is skipped: there is no installed app to replace). An + * installed app honours them only when this is `true`, since whoever sets the variable would + * otherwise choose what the app installs, or silence its real updates. Leave it `false` in + * release builds unless the switches are part of how you test them; ignored switches are logged. + */ + public var allowLaunchOverrides: Boolean = false + + /** + * Plays a scripted update instead of contacting [provider], to build and review the update UI + * without publishing anything — see [UpdateSimulation]. When `null` (the default), the + * simulation requested at launch with `nucleus.updater.simulate` applies, if any (see + * [allowLaunchOverrides]). + */ + public var simulation: UpdateSimulation? = null + /** * Validates the config and freezes it into an immutable snapshot, so a [NucleusUpdater] * never observes post-construction mutation and a missing [provider] fails at @@ -66,6 +104,8 @@ public class UpdaterConfig { httpClient = httpClient, differentialDownload = differentialDownload, cacheDir = cacheDir, + allowLaunchOverrides = allowLaunchOverrides, + simulation = simulation, ) } @@ -85,6 +125,8 @@ internal data class ResolvedUpdaterConfig( val httpClient: HttpClient?, val differentialDownload: Boolean, val cacheDir: File?, + val allowLaunchOverrides: Boolean = false, + val simulation: UpdateSimulation? = null, ) { fun resolvedAllowPrerelease(): Boolean = allowPrerelease || currentVersion.contains("-") diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/FeedFetcher.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/FeedFetcher.kt new file mode 100644 index 000000000..051b65f99 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/FeedFetcher.kt @@ -0,0 +1,50 @@ +package dev.nucleusframework.updater.internal + +import dev.nucleusframework.updater.exception.NetworkException +import java.io.File +import java.io.InputStream +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse + +/** + * Reads feed resources — manifests, artifacts, block maps, signatures — over HTTP(S) or, for a + * [dev.nucleusframework.updater.provider.LocalFileProvider], from `file:` URLs. + */ +internal class FeedFetcher( + private val httpClient: HttpClient, + private val authHeaders: () -> Map, +) { + /** Reads a whole text resource; anything but a success is a [NetworkException]. */ + fun readText(url: String): String = open(url).use { it.readBytes().toString(Charsets.UTF_8) } + + /** Reads a whole resource, or `null` when it is absent or unreadable — for optional companions. */ + fun readBytesOrNull(url: String): ByteArray? = + runCatching { open(url).use { it.readBytes() } } + .getOrNull() + ?.takeIf { it.isNotEmpty() } + + /** Opens a resource for streaming; anything but a success is a [NetworkException]. */ + fun open(url: String): InputStream { + if (isLocal(url)) { + val file = File(URI.create(url)) + if (!file.isFile) throw NetworkException("No such file in the update feed: $file") + return file.inputStream() + } + val builder = HttpRequest.newBuilder().uri(URI.create(url)).GET() + authHeaders().forEach { (key, value) -> builder.header(key, value) } + val response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()) + if (response.statusCode() != HTTP_OK) { + response.body().close() + throw NetworkException("HTTP ${response.statusCode()} for $url") + } + return response.body() + } + + companion object { + private const val HTTP_OK = 200 + + fun isLocal(url: String): Boolean = url.startsWith("file:", ignoreCase = true) + } +} diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/FeedOverride.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/FeedOverride.kt new file mode 100644 index 000000000..afb1e8bcb --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/FeedOverride.kt @@ -0,0 +1,73 @@ +package dev.nucleusframework.updater.internal + +import dev.nucleusframework.updater.provider.GenericProvider +import dev.nucleusframework.updater.provider.LocalFileProvider +import dev.nucleusframework.updater.provider.UpdateProvider +import java.io.File +import java.net.URI +import java.util.logging.Logger + +/** + * Resolves the launch-time feed redirect ([UpdaterSettings.FEED_URL]) into the provider that + * replaces the configured one, or `null` when there is none or it must not be honoured. + */ +internal object FeedOverride { + private val logger: Logger = Logger.getLogger(FeedOverride::class.java.name) + + /** A redirect that was honoured: [provider] replaces the configured one. */ + class Applied( + val raw: String, + val provider: UpdateProvider, + ) + + /** + * @param packaged whether the app runs from an installed package, where the redirect needs + * [allowed]; an unpackaged run always honours it. + */ + fun resolve( + raw: String?, + packaged: Boolean, + allowed: Boolean, + ): Applied? { + if (raw.isNullOrBlank()) return null + val source = "${UpdaterSettings.FEED_URL} / ${UpdaterSettings.environmentName(UpdaterSettings.FEED_URL)}" + if (packaged && !allowed) { + logger.warning( + "Ignoring the update feed redirect $source=$raw: this installed app does not set " + + "UpdaterConfig.allowLaunchOverrides", + ) + return null + } + val provider = + try { + providerFor(raw) + } catch (e: IllegalArgumentException) { + logger.warning("Ignoring the update feed redirect $source=$raw: ${e.message}") + return null + } + logger.warning( + "Update feed redirected by $source to $raw — updates no longer come from the configured provider", + ) + return Applied(raw, provider) + } + + fun providerFor(raw: String): UpdateProvider { + val scheme = + SCHEME + .find(raw) + ?.groupValues + ?.get(1) + ?.lowercase() + return when { + scheme == "http" || scheme == "https" -> GenericProvider(raw) + scheme == "file" -> LocalFileProvider(File(URI.create(raw))) + // A drive letter (`C:\…`) parses as a one-letter scheme. + scheme == null || scheme.length == 1 -> LocalFileProvider(File(raw)) + else -> throw IllegalArgumentException( + "unsupported scheme '$scheme' (use a path, file:, https: or loopback http:)", + ) + } + } + + private val SCHEME = Regex("^([A-Za-z][A-Za-z0-9+.-]*):") +} diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/InstalledVersionWatcher.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/InstalledVersionWatcher.kt new file mode 100644 index 000000000..5b3b0acf4 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/InstalledVersionWatcher.kt @@ -0,0 +1,83 @@ +package dev.nucleusframework.updater.internal + +import dev.nucleusframework.core.runtime.VersionedInstall +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.io.File +import java.io.IOException +import java.nio.channels.OverlappingFileLockException +import java.nio.file.FileSystems +import java.nio.file.StandardWatchEventKinds +import java.util.concurrent.TimeUnit +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Publishes the version the launcher now starts when another process installed one next to the + * running version — typically another instance of an app without single instance. + * + * This is Chromium's `InstalledVersionMonitor` + `InstalledVersionPoller` pair: a change + * notification (here on `app\`, where the installer rewrites the launcher's `.cfg`) backed by a + * slow poll in case a notification is missed. The `.cfg` is written before the version it points + * to has finished extracting, so it is read under the shared install lock, which waits for an + * install in progress to complete. + */ +internal class InstalledVersionWatcher( + private val install: VersionedInstall, +) { + private val state = MutableStateFlow(read()) + + val version: StateFlow = state.asStateFlow() + + fun start() { + Thread(::watch, "nucleus-installed-version-watcher").apply { + isDaemon = true + priority = Thread.MIN_PRIORITY + start() + } + } + + private fun watch() { + try { + FileSystems.getDefault().newWatchService().use { watcher -> + File(install.root, APP_DIR_NAME).toPath().register( + watcher, + StandardWatchEventKinds.ENTRY_CREATE, + StandardWatchEventKinds.ENTRY_MODIFY, + ) + while (true) { + val key = watcher.poll(POLL_INTERVAL_MINUTES, TimeUnit.MINUTES) + key?.pollEvents() + key?.reset() + state.value = readSettled() + } + } + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } catch (e: IOException) { + logger.log(Level.WARNING, "Cannot watch ${install.root} for installed updates", e) + } + } + + /** Reads once no install is in progress; this very process installing keeps the last value. */ + private fun readSettled(): String? = + try { + WindowsHotUpdate.withInstallLock(install, shared = true) { read() } + } catch (e: IOException) { + if (e.cause is OverlappingFileLockException) { + state.value + } else { + logger.log(Level.FINE, "Install lock unavailable; reading without it", e) + read() + } + } + + private fun read(): String? = WindowsHotUpdate.installedVersionDir(install)?.name + + private companion object { + const val APP_DIR_NAME = "app" + const val POLL_INTERVAL_MINUTES = 30L + val logger: Logger = Logger.getLogger(InstalledVersionWatcher::class.java.name) + } +} diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt index 9af71793a..9c00b2363 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/PlatformInstaller.kt @@ -1,6 +1,7 @@ package dev.nucleusframework.updater.internal import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.UpdateHandoff import java.io.File import java.nio.file.Files import java.util.logging.Logger @@ -55,12 +56,13 @@ internal object PlatformInstaller { file: File, platform: Platform, restart: Boolean = true, + relaunchArguments: List = emptyList(), ) { val extension = file.name.substringAfterLast('.').lowercase() when { platform == Platform.MacOS && extension == "zip" -> installMacZip(file, restart) - platform == Platform.Windows -> installWindows(file, extension, restart) + platform == Platform.Windows -> installWindows(file, extension, restart, relaunchArguments) platform == Platform.Linux && extension == "appimage" -> installLinuxAppImage(file, restart) platform == Platform.Linux && (extension == "deb" || extension == "rpm") -> installLinuxPackage(file, extension, restart) @@ -331,15 +333,17 @@ internal object PlatformInstaller { file: File, extension: String, restart: Boolean, + relaunchArguments: List, ) { val pid = ProcessHandle.current().pid() val launcher = currentExecutablePath() val script = File(createUpdateWorkDir(), "nucleus-update.ps1") - script.writeText( + writePowerShellScript( + script, buildWindowsUpdateScript( pid = pid, installerCommand = windowsInstallerCommand(file, extension), - relaunchCommand = windowsRelaunchCommand(restart, launcher), + relaunchCommand = windowsRelaunchCommand(restart, launcher, relaunchArguments), artifactPath = file.absolutePath, scriptPath = script.absolutePath, ), @@ -355,6 +359,8 @@ internal object PlatformInstaller { script.absolutePath, ).redirectOutput(ProcessBuilder.Redirect.DISCARD) .redirectError(ProcessBuilder.Redirect.DISCARD) + // A classic update closes the app first: never let the installer think otherwise. + .apply { environment().remove(UpdateHandoff.ENV_HOT_INSTALL) } .start() } } diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/SimulatedUpdate.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/SimulatedUpdate.kt new file mode 100644 index 000000000..07156afa5 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/SimulatedUpdate.kt @@ -0,0 +1,128 @@ +package dev.nucleusframework.updater.internal + +import dev.nucleusframework.updater.DownloadProgress +import dev.nucleusframework.updater.UpdateFile +import dev.nucleusframework.updater.UpdateInfo +import dev.nucleusframework.updater.UpdateResult +import dev.nucleusframework.updater.UpdateSimulation +import dev.nucleusframework.updater.UpdateSimulation.Scenario +import dev.nucleusframework.updater.Version +import dev.nucleusframework.updater.exception.ChecksumException +import dev.nucleusframework.updater.exception.NetworkException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.io.File +import java.nio.file.Files +import java.time.Instant +import java.util.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** Plays an [UpdateSimulation] for [NucleusUpdater][dev.nucleusframework.updater.NucleusUpdater]. */ +internal class SimulatedUpdate( + private val simulation: UpdateSimulation, + private val currentVersion: String, +) { + /** The offered version: explicit, else the next minor of the running version. */ + val offeredVersion: String = + simulation.version ?: Version.fromString(currentVersion).let { "${it.major}.${it.minor + 1}.0" } + + suspend fun check(): UpdateResult { + delay(simulation.checkDuration) + return when (simulation.scenario) { + Scenario.UP_TO_DATE -> UpdateResult.NotAvailable + Scenario.CHECK_ERROR -> + UpdateResult.Error(NetworkException("Simulated update check failure (${UpdaterSettings.SIMULATE})")) + Scenario.UPDATE_AVAILABLE, Scenario.DOWNLOAD_ERROR, Scenario.CHECKSUM_ERROR -> { + val offered = Version.fromString(offeredVersion) + UpdateResult.Available(info(), offered.levelFrom(Version.fromString(currentVersion))) + } + } + } + + fun info(): UpdateInfo { + val file = + UpdateFile( + url = "simulated:$ARTIFACT_PREFIX-$offeredVersion", + sha512 = Base64.getEncoder().encodeToString(ByteArray(SHA512_BYTES)), + size = simulation.downloadSize, + fileName = "$ARTIFACT_PREFIX-$offeredVersion$ARTIFACT_EXTENSION", + ) + return UpdateInfo( + version = offeredVersion, + releaseDate = Instant.now().toString(), + files = listOf(file), + currentFile = file, + ) + } + + fun download(info: UpdateInfo): Flow = + flow { + val total = + if (simulation.isDifferential) { + (info.currentFile.size * DIFFERENTIAL_FRACTION).toLong().coerceAtLeast(1) + } else { + info.currentFile.size + } + val failAt = if (simulation.scenario == Scenario.DOWNLOAD_ERROR) DOWNLOAD_FAILURE_FRACTION else null + val steps = (simulation.downloadDuration / TICK).toInt().coerceAtLeast(1) + val tick: Duration = simulation.downloadDuration / steps + + emit(DownloadProgress(0, total, 0.0, isDifferential = simulation.isDifferential)) + for (step in 1..steps) { + delay(tick) + val fraction = step.toDouble() / steps + if (failAt != null && fraction >= failAt) { + throw NetworkException("Simulated download failure (${UpdaterSettings.SIMULATE})") + } + val downloaded = (total * fraction).toLong() + if (step < steps) { + emit( + DownloadProgress( + downloaded, + total, + fraction * PERCENT_MAX, + isDifferential = simulation.isDifferential, + ), + ) + } + } + if (simulation.scenario == Scenario.CHECKSUM_ERROR) { + throw ChecksumException(info.currentFile.sha512, SIMULATED_MISMATCH) + } + emit( + DownloadProgress( + bytesDownloaded = total, + totalBytes = total, + percent = PERCENT_MAX, + file = placeholder(info), + isDifferential = simulation.isDifferential, + ), + ) + } + + /** + * A file standing for the artifact, so an app that shows or checks the downloaded file finds + * one. It is not an installer: [NucleusUpdater] never runs it. + */ + private fun placeholder(info: UpdateInfo): File { + val dir = Files.createTempDirectory("nucleus-update-simulated-").toFile() + dir.deleteOnExit() + return File(dir, info.currentFile.fileName).apply { + writeText("Simulated Nucleus update to ${info.version}. Not an installer.\n") + deleteOnExit() + } + } + + companion object { + private const val ARTIFACT_PREFIX = "simulated-update" + private const val ARTIFACT_EXTENSION = ".bin" + private const val SHA512_BYTES = 64 + private const val PERCENT_MAX = 100.0 + private const val DIFFERENTIAL_FRACTION = 0.08 + private const val DOWNLOAD_FAILURE_FRACTION = 0.6 + private const val SIMULATED_MISMATCH = "simulated-mismatch" + private val TICK = 100.milliseconds + } +} diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/UpdaterSettings.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/UpdaterSettings.kt new file mode 100644 index 000000000..eddc36092 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/UpdaterSettings.kt @@ -0,0 +1,37 @@ +package dev.nucleusframework.updater.internal + +/** + * The launch-time switches that let a developer test updates without publishing a release: a + * system property first, then the matching environment variable (`nucleus.updater.feedUrl` → + * `NUCLEUS_UPDATER_FEED_URL`), since an installed app is far easier to start with an environment + * variable than with a JVM option — and a native image has no JVM options at all. + */ +internal object UpdaterSettings { + /** Redirects the update feed to a local directory or a test server (see `NucleusUpdater`). */ + const val FEED_URL = "nucleus.updater.feedUrl" + + /** Plays an [dev.nucleusframework.updater.UpdateSimulation] instead of contacting any feed. */ + const val SIMULATE = "nucleus.updater.simulate" + const val SIMULATE_VERSION = "nucleus.updater.simulate.version" + const val SIMULATE_DURATION = "nucleus.updater.simulate.duration" + const val SIMULATE_SIZE = "nucleus.updater.simulate.size" + const val SIMULATE_DIFFERENTIAL = "nucleus.updater.simulate.differential" + const val SIMULATE_JUST_UPDATED_FROM = "nucleus.updater.simulate.justUpdatedFrom" + + fun get( + key: String, + property: (String) -> String? = System::getProperty, + environment: (String) -> String? = System::getenv, + ): String? = + property(key)?.trim()?.takeIf { it.isNotEmpty() } + ?: environment(environmentName(key))?.trim()?.takeIf { it.isNotEmpty() } + + /** `nucleus.updater.simulate.justUpdatedFrom` → `NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM`. */ + fun environmentName(key: String): String = + key + .replace(CAMEL_HUMP, "$1_$2") + .replace('.', '_') + .uppercase() + + private val CAMEL_HUMP = Regex("([a-z0-9])([A-Z])") +} diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsHotUpdate.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsHotUpdate.kt new file mode 100644 index 000000000..b13fe9e12 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsHotUpdate.kt @@ -0,0 +1,418 @@ +package dev.nucleusframework.updater.internal + +import dev.nucleusframework.core.runtime.ExecutableType +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.SingleInstanceManager +import dev.nucleusframework.core.runtime.UpdateHandoff +import dev.nucleusframework.core.runtime.VersionedInstall +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile +import java.nio.channels.OverlappingFileLockException +import java.nio.file.Files +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.logging.Level +import java.util.logging.Logger +import kotlin.system.exitProcess + +private val logger: Logger = Logger.getLogger(WindowsHotUpdate::class.java.name) + +/** + * Hot update of a Windows NSIS install: the new version is installed **while this one keeps + * running**, then launched, and this process only exits once the new version's first window is on + * screen — the application never disappears while it updates. + * + * It relies on the versioned layout the Gradle plugin builds for NSIS (`versions\\` + * holding the runtime and the app, see [VersionedInstall]): the installer writes the new version + * next to the running one and rewrites the launcher's `.cfg`, so nothing this process holds open is + * touched. The installer is told it runs as a hot update through + * [UpdateHandoff.ENV_HOT_INSTALL]; without that it would close the running application first. + * + * Installs are serialized across processes by a lock file in `versions\` — the role Chromium gives + * its single machine-wide updater: an instance that finds another one installing waits, then sees + * the new version already installed and only hands over to it. + * + * If the hot path cannot start, the classic close-install-relaunch update runs instead. If the + * installer itself fails, the application simply keeps running: the classic update would run the + * same installer, fail the same way, and close and reopen the app at every update check. + */ +@Suppress("TooManyFunctions") +internal object WindowsHotUpdate { + private const val INSTALL_TIMEOUT_MINUTES = 10L + private const val READY_TIMEOUT_MS = 30_000L + private const val READY_POLL_MS = 20L + private const val LOCK_ATTEMPTS = 50 + private const val LOCK_RETRY_MS = 100L + private const val UNINSTALLER_PREFIX = "Uninstall " + private const val INSTALL_LOCK_NAME = ".nucleus-install.lock" + + private val HOT_UPDATABLE_TYPES = setOf(ExecutableType.NSIS, ExecutableType.EXE, ExecutableType.NSIS_WEB) + + private val started = AtomicBoolean(false) + + /** Outcome of the locked part of a hot update. */ + private enum class InstallOutcome { INSTALLED, FAILED, CANNOT_START } + + /** The install to hot-update with [installer], or `null` when only a classic update applies. */ + fun eligibleInstall( + installer: File, + platform: Platform, + type: ExecutableType, + install: VersionedInstall? = UpdateHandoff.versionedInstall, + ): VersionedInstall? { + if (!installer.name.endsWith(".exe", ignoreCase = true)) return null + return currentInstall(platform, type, install) + } + + /** The versioned install this process can hot-update and hand over from, if any. */ + fun currentInstall( + platform: Platform, + type: ExecutableType, + install: VersionedInstall? = UpdateHandoff.versionedInstall, + ): VersionedInstall? { + if (System.getProperty(DISABLE_PROPERTY).toBoolean()) return null + if (platform != Platform.Windows || type !in HOT_UPDATABLE_TYPES) return null + return install?.takeIf { it.launcher.isFile && canWriteInstall(it) } + } + + /** + * A per-machine install (`Program Files`) is not writable by the running app: it could neither + * move its launcher aside nor delete the retired version, and the elevated installer does not + * reliably inherit [UpdateHandoff.ENV_HOT_INSTALL] through UAC — it would close the app anyway. + * Those installs take the classic update. Probed with a real file, since ACLs are what decide. + */ + internal fun canWriteInstall(install: VersionedInstall): Boolean = + try { + val probe = File.createTempFile(".nucleus-write-probe", null, install.versionsDir) + probe.delete() + true + } catch ( + @Suppress("SwallowedException") e: IOException, + ) { + logger.info("Install directory is not writable (${e.message}); using a classic update") + false + } + + /** + * Starts the hot update on a background thread and returns immediately: the application stays + * usable while the installer runs, and exits once the new version has taken over, launched + * with [relaunchArguments]. + */ + fun start( + installer: File, + install: VersionedInstall, + relaunchArguments: List, + ) { + if (!started.compareAndSet(false, true)) return + Thread({ run(installer, install, relaunchArguments) }, "nucleus-hot-update").start() + } + + /** + * Hands over to the version another instance already installed, without installing anything. + * Returns immediately; the process exits once the new version is on screen. + */ + fun startHandOff( + install: VersionedInstall, + relaunchArguments: List, + ) { + if (!started.compareAndSet(false, true)) return + Thread({ handOff(install, relaunchArguments) }, "nucleus-hot-update").start() + } + + private fun run( + installer: File, + install: VersionedInstall, + relaunchArguments: List, + ) { + val outcome = + try { + withInstallLock(install) { installLocked(installer, install) } + } catch (e: IOException) { + logger.log(Level.WARNING, "Could not take the install lock", e) + InstallOutcome.CANNOT_START + } + when (outcome) { + InstallOutcome.CANNOT_START -> { + logger.warning("Hot update could not start; falling back to a classic update") + started.set(false) + PlatformInstaller.install( + installer, + Platform.Windows, + restart = true, + relaunchArguments = relaunchArguments, + ) + } + InstallOutcome.FAILED -> { + logger.severe("Hot update failed; the application keeps running on its current version") + started.set(false) + } + InstallOutcome.INSTALLED -> { + installer.delete() + handOff(install, relaunchArguments) + } + } + } + + private fun installLocked( + installer: File, + install: VersionedInstall, + ): InstallOutcome { + // Another instance (an app without single instance) installed a newer version while this + // one waited for the lock, or earlier: installing again would overwrite files it may be + // running from. Just hand over. + installedVersionDir(install)?.let { installed -> + logger.info("${installed.name} is already installed; handing over to it") + return InstallOutcome.INSTALLED + } + val workDir = + try { + retireLaunchers(install.root) + createUpdateWorkDir() + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.WARNING, "Could not prepare the hot update", e) + return InstallOutcome.CANNOT_START + } + val installed = + try { + runInstaller(installer, install, workDir) + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.WARNING, "Hot update installer failed", e) + null + } + if (installed == null) return InstallOutcome.FAILED + logger.info("Hot update installed ${installed.name}; handing over to it") + return InstallOutcome.INSTALLED + } + + /** + * Runs [block] under the cross-process install lock: exclusive for an install, [shared] for a + * reader that must not see an install half done. Blocks while another process holds it. + * + * A lock the same JVM already holds through another channel is reported by Java as an + * [OverlappingFileLockException] rather than waited for, so that case is retried briefly (the + * installed-version watcher only reads under the lock for a moment). + */ + internal fun withInstallLock( + install: VersionedInstall, + shared: Boolean = false, + block: () -> T, + ): T { + RandomAccessFile(File(install.versionsDir, INSTALL_LOCK_NAME), "rw").use { file -> + var attempt = 0 + while (true) { + try { + file.channel.lock(0, Long.MAX_VALUE, shared).use { return block() } + } catch (e: OverlappingFileLockException) { + if (++attempt >= LOCK_ATTEMPTS) throw IOException("Install lock held by this process", e) + Thread.sleep(LOCK_RETRY_MS) + } + } + } + } + + /** + * Frees every launcher at the install root for the installer. A running executable cannot be + * overwritten but can be renamed, so each one is moved aside and copied back: the copy is not + * mapped by any process, so the installer can replace it, and the launcher path — shortcuts, + * the Run key, protocol handlers — keeps working throughout the install. + * + * Returns the retired originals, which the new version deletes once this process has exited. + */ + internal fun retireLaunchers(root: File): List = + root + .listFiles { file -> + file.isFile && + file.name.endsWith(".exe", ignoreCase = true) && + !file.name.startsWith(UNINSTALLER_PREFIX) + }.orEmpty() + .mapNotNull { launcher -> + val suffix = "${System.nanoTime()}${UpdateHandoff.RETIRED_LAUNCHER_SUFFIX}" + val retired = File(root, "${launcher.name}.$suffix") + if (!launcher.renameTo(retired)) return@mapNotNull null + Files.copy(retired.toPath(), launcher.toPath()) + // jpackage ships the launcher read-only, and the installer cannot overwrite that. + launcher.setWritable(true) + retired + } + + /** + * Runs the installer in hot mode and returns the version directory it installed, or `null` when + * it failed or did not install a new version next to the running one. + */ + private fun runInstaller( + installer: File, + install: VersionedInstall, + workDir: File, + ): File? { + val script = File(workDir, "nucleus-hot-update.ps1") + // Tells the script the app quit on its own: it must not be relaunched then. A process the + // installer kills runs no shutdown hook, which is exactly the case the relaunch is for. + val exitedMarker = File(workDir, "app-exited") + Runtime.getRuntime().addShutdownHook(Thread { runCatching { exitedMarker.createNewFile() } }) + writePowerShellScript( + script, + buildWindowsHotUpdateScript( + pid = ProcessHandle.current().pid(), + installerPath = installer.absolutePath, + launcher = install.launcher.absolutePath, + exitedMarker = exitedMarker.absolutePath, + ), + ) + val process = + ProcessBuilder( + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-WindowStyle", + "Hidden", + "-File", + script.absolutePath, + ).redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .apply { environment()[UpdateHandoff.ENV_HOT_INSTALL] = "1" } + .start() + if (!process.waitFor(INSTALL_TIMEOUT_MINUTES, TimeUnit.MINUTES)) { + logger.warning("Hot update installer did not finish within $INSTALL_TIMEOUT_MINUTES minutes") + return null + } + workDir.deleteRecursively() + val exitCode = process.exitValue() + if (exitCode != 0) { + logger.warning("Hot update installer exited with code $exitCode") + return null + } + return installedVersionDir(install) + } + + /** + * The version the launcher's `.cfg` now starts, when it is not the one this process runs — a + * newer version installed next to it, by this process or another. Read from the `.cfg` rather + * than derived from the update's version string, so the plugin's directory naming is the only + * source of truth. + */ + internal fun installedVersionDir(install: VersionedInstall): File? { + val cfg = File(install.root, "app/${install.launcher.nameWithoutExtension}.cfg") + if (!cfg.isFile || !install.launcher.isFile) return null + val runtimeLine = + cfg.readLines().firstOrNull { it.trim().startsWith(RUNTIME_KEY) } ?: return null + val runtimePath = runtimeLine.substringAfter('=').trim() + val prefix = "${ROOTDIR_MACRO}\\${UpdateHandoff.VERSIONS_DIR_NAME}\\" + if (!runtimePath.startsWith(prefix, ignoreCase = true)) return null + val versionName = runtimePath.removePrefix(prefix).substringBefore('\\') + val versionDir = File(install.versionsDir, versionName) + val isNew = !versionDir.name.equals(install.versionDir.name, ignoreCase = true) + return versionDir.takeIf { isNew && File(it, "runtime").isDirectory } + } + + /** + * Launches the new version with [relaunchArguments] and exits once it signals it is on screen. + * Should it quit before that, this process stays: an application that stays visible beats a gap. + */ + private fun handOff( + install: VersionedInstall, + relaunchArguments: List, + ) { + val workDir = createUpdateWorkDir() + val readyFile = File(workDir, "ready") + SingleInstanceManager.releaseForHandoff() + val successor = + try { + ProcessBuilder(listOf(install.launcher.absolutePath) + relaunchArguments) + .directory(install.root) + .apply { + environment().remove(UpdateHandoff.ENV_HOT_INSTALL) + environment()[UpdateHandoff.ENV_READY_FILE] = readyFile.absolutePath + environment()[UpdateHandoff.ENV_PREVIOUS_PID] = previousPids(install).joinToString(",") + }.redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + } catch ( + @Suppress("TooGenericExceptionCaught") e: Exception, + ) { + logger.log(Level.SEVERE, "Could not launch the updated application", e) + started.set(false) + return + } + + val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(READY_TIMEOUT_MS) + while (!readyFile.isFile) { + if (!successor.isAlive) { + logger.severe( + "The updated application exited (code ${successor.exitValue()}) before showing a " + + "window; keeping this instance running", + ) + workDir.deleteRecursively() + started.set(false) + return + } + if (System.nanoTime() > deadline) { + logger.warning( + "The updated application did not signal readiness within ${READY_TIMEOUT_MS}ms " + + "(no Nucleus window? call UpdateHandoff.signalReady()); exiting anyway", + ) + break + } + Thread.sleep(READY_POLL_MS) + } + workDir.deleteRecursively() + exitProcess(0) + } + + /** + * This process, plus the launcher it runs under: the jpackage launcher restarts itself as a + * child, and the parent — running the retired launcher executable — outlives the JVM briefly. + * The new version waits for both before deleting what they hold. + */ + private fun previousPids(install: VersionedInstall): List { + val current = ProcessHandle.current() + val launcherParent = + current.parent().filter { parent -> + parent + .info() + .command() + .map { File(it).absoluteFile.parentFile == install.root } + .orElse(false) + } + return listOf(current.pid()) + launcherParent.map { listOf(it.pid()) }.orElse(emptyList()) + } + + /** Set to `true` to always take the classic close-install-relaunch path. */ + internal const val DISABLE_PROPERTY = "nucleus.updater.hotUpdate.disabled" + + private const val RUNTIME_KEY = "app.runtime" + private const val ROOTDIR_MACRO = "\$ROOTDIR" +} + +/** + * PowerShell that runs the NSIS installer as a hot update and waits for it. The environment + * carries [UpdateHandoff.ENV_HOT_INSTALL], so a hot-update-aware installer leaves the application + * running; should the installer close it anyway (one built without hot update support), the + * script relaunches it once the installer is done, exactly like a classic update — but not when + * the user quit the app during the install ([exitedMarker] exists then). + * + * Exits with the installer's exit code. + */ +internal fun buildWindowsHotUpdateScript( + pid: Long, + installerPath: String, + launcher: String, + exitedMarker: String, +): String = + """ + |${'$'}installer = Start-Process '${psSingleQuote(installerPath)}' -ArgumentList '/S', '--updated' -Wait -PassThru + |${'$'}code = ${'$'}installer.ExitCode + |${'$'}closedByInstaller = -not (Get-Process -Id $pid -ErrorAction SilentlyContinue) -and + | -not (Test-Path -LiteralPath '${psSingleQuote(exitedMarker)}') + |if (${'$'}closedByInstaller) { + | # The installer closed the application: relaunch it as a classic update would + | Remove-Item Env:${UpdateHandoff.ENV_HOT_INSTALL} -ErrorAction SilentlyContinue + | Start-Process '${psSingleQuote(launcher)}' + |} + |exit ${'$'}code + """.trimMargin() diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt index f9d194abd..9cc78a5a7 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/internal/WindowsUpdateScript.kt @@ -13,6 +13,19 @@ import java.io.File */ internal fun psSingleQuote(value: String): String = value.replace("'", "''") +/** + * Writes a PowerShell script as UTF-8 **with a BOM**. Windows PowerShell 5.1 reads a BOM-less + * script in the ANSI code page, so any non-ASCII path — the installer under + * `C:\Users\Hélène\AppData\Local\Temp`, the app under `...\Programs` — would be mangled and not + * found: the update silently did nothing for every user with an accented account name. + */ +internal fun writePowerShellScript( + script: File, + content: String, +) { + script.writeText("\uFEFF$content", Charsets.UTF_8) +} + /** * PowerShell that waits for the current process, runs the downloaded installer, * optionally relaunches, then deletes the artifact and itself. @@ -54,9 +67,43 @@ internal fun windowsInstallerCommand( internal fun windowsRelaunchCommand( restart: Boolean, launcher: String?, -): String = - if (restart && launcher != null) { - "\n# Relaunch the application\nStart-Process '${psSingleQuote(launcher)}'" - } else { - "" + arguments: List = emptyList(), +): String { + if (!restart || launcher == null) return "" + val argumentList = + if (arguments.isEmpty()) "" else " -ArgumentList '${psSingleQuote(windowsCommandLine(arguments))}'" + return "\n# Relaunch the application\nStart-Process '${psSingleQuote(launcher)}'$argumentList" +} + +/** + * Joins [arguments] into one Windows command line that `CommandLineToArgvW` (and so the JVM's + * `main(args)`) splits back into the same list. `Start-Process -ArgumentList` passes an array + * joined with bare spaces, which would split an argument holding a space. + */ +internal fun windowsCommandLine(arguments: List): String = + arguments.joinToString(" ") { argument -> + if (argument.isNotEmpty() && argument.none { it == ' ' || it == '\t' || it == '"' }) { + argument + } else { + buildString { + append('"') + var backslashes = 0 + for (c in argument) { + when (c) { + '\\' -> backslashes++ + '"' -> { + // Backslashes before a quote are doubled, and the quote itself escaped. + append("\\".repeat(backslashes * 2 + 1)).append('"') + backslashes = 0 + } + else -> { + append("\\".repeat(backslashes)).append(c) + backslashes = 0 + } + } + } + // Trailing backslashes are doubled so they do not escape the closing quote. + append("\\".repeat(backslashes * 2)).append('"') + } + } } diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/provider/GenericProvider.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/provider/GenericProvider.kt index a6bc8a585..1dd5c6a9b 100644 --- a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/provider/GenericProvider.kt +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/provider/GenericProvider.kt @@ -56,5 +56,6 @@ private fun requireSecureBaseUrl(baseUrl: String) { } } +// URI.getHost() keeps the brackets of an IPv6 literal: `http://[::1]:8080` has host `[::1]`. private fun isLoopbackHost(host: String?): Boolean = - host == "localhost" || host == "127.0.0.1" || host == "::1" || host?.startsWith("127.") == true + host == "localhost" || host == "127.0.0.1" || host == "::1" || host == "[::1]" || host?.startsWith("127.") == true diff --git a/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/provider/LocalFileProvider.kt b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/provider/LocalFileProvider.kt new file mode 100644 index 000000000..20e0fc1d9 --- /dev/null +++ b/updater-runtime/src/main/kotlin/dev/nucleusframework/updater/provider/LocalFileProvider.kt @@ -0,0 +1,56 @@ +package dev.nucleusframework.updater.provider + +import dev.nucleusframework.core.runtime.Platform +import java.io.File + +/** + * Reads updates from a directory on this machine — typically the packaging output of the next + * version (`build/compose/binaries/main/nsis`), which already holds the artifact, its block map and + * the `[-mac|-linux].yml` manifest the Nucleus plugin writes next to it. + * + * Meant for testing an update end to end without publishing it anywhere, the way Squirrel and + * Velopack read a local release directory. Everything but the transport is the production path: + * the manifest is parsed, the artifact selected and its SHA-512 verified, the installer run. + * Differential downloads need HTTP range requests, so a local feed always downloads (copies) the + * whole artifact; serve the directory over loopback HTTP (`./gradlew serveUpdateFeed`) to exercise + * them too. + * + * An installed app can be pointed at a directory without changing its code: see + * [dev.nucleusframework.updater.UpdaterConfig.allowLaunchOverrides]. + */ +public class LocalFileProvider( + directory: File, +) : UpdateProvider { + /** The feed directory, made absolute. */ + public val directory: File = directory.absoluteFile.normalize() + + override fun getUpdateMetadataUrl( + channel: String, + platform: Platform, + ): String { + val suffix = + when (platform) { + Platform.MacOS -> "-mac" + Platform.Linux -> "-linux" + Platform.Windows, Platform.Unknown -> "" + } + return fileUrl("$channel$suffix.yml") + } + + override fun getDownloadUrl( + fileName: String, + version: String, + ): String = fileUrl(fileName) + + /** + * Resolves [fileName] inside [directory]: a manifest naming `../elsewhere` must not reach + * outside the feed. + */ + private fun fileUrl(fileName: String): String { + val file = File(directory, fileName).normalize() + require(file.toPath().startsWith(directory.toPath())) { + "Update file '$fileName' resolves outside the feed directory $directory" + } + return file.toURI().toString() + } +} diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/CheckForUpdatesLogicTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/CheckForUpdatesLogicTest.kt index 2fb5604ab..8686caf04 100644 --- a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/CheckForUpdatesLogicTest.kt +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/CheckForUpdatesLogicTest.kt @@ -43,11 +43,14 @@ class CheckForUpdatesLogicTest { @Test fun `unsupported executable type short-circuits to not available`() { publish(version = "2.0.0", fileName = "App-2.0.0.zip") + // A store container cannot replace its own payload. Note that "pkg" is no longer such a + // case: a Developer ID PKG installs an ordinary .app and updates like a DMG, and only the + // sandboxed Mac App Store build stays excluded. See PkgUpdateSupportTest. val updater = NucleusUpdater { currentVersion = "1.0.0" provider = LoopbackProvider(server.baseUrl) - executableType = "pkg" + executableType = "appx" } assertFalse(updater.isUpdateSupported()) assertEquals(UpdateResult.NotAvailable, runBlocking { updater.checkForUpdates() }) diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/LaunchOverridesTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/LaunchOverridesTest.kt new file mode 100644 index 000000000..ebb06be23 --- /dev/null +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/LaunchOverridesTest.kt @@ -0,0 +1,390 @@ +package dev.nucleusframework.updater + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.updater.UpdateSimulation.Scenario +import dev.nucleusframework.updater.exception.ChecksumException +import dev.nucleusframework.updater.exception.NetworkException +import dev.nucleusframework.updater.internal.FeedOverride +import dev.nucleusframework.updater.internal.UpdateMarker +import dev.nucleusframework.updater.internal.UpdaterSettings +import dev.nucleusframework.updater.provider.GenericProvider +import dev.nucleusframework.updater.provider.LocalFileProvider +import dev.nucleusframework.updater.provider.UpdateProvider +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.security.MessageDigest +import java.util.Base64 +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.TimeSource + +/** + * The switches that test updates without publishing one: the launch-time feed redirect + * (`nucleus.updater.feedUrl`), the [UpdateSimulation], and the install that an unpackaged run skips. + */ +class LaunchOverridesTest { + @get:Rule + val tmp = TemporaryFolder() + + private val touchedProperties = mutableSetOf() + + @After + fun clearProperties() { + touchedProperties.forEach(System::clearProperty) + } + + private fun property( + key: String, + value: String, + ) { + touchedProperties += key + System.setProperty(key, value) + } + + // ---- settings ------------------------------------------------------------------------------- + + @Test + fun `settings map to environment variable names`() { + assertEquals("NUCLEUS_UPDATER_FEED_URL", UpdaterSettings.environmentName(UpdaterSettings.FEED_URL)) + assertEquals("NUCLEUS_UPDATER_SIMULATE", UpdaterSettings.environmentName(UpdaterSettings.SIMULATE)) + assertEquals( + "NUCLEUS_UPDATER_SIMULATE_JUST_UPDATED_FROM", + UpdaterSettings.environmentName(UpdaterSettings.SIMULATE_JUST_UPDATED_FROM), + ) + } + + @Test + fun `a system property wins over the environment and blanks count as unset`() { + val env = mapOf("NUCLEUS_UPDATER_FEED_URL" to "from-env") + assertEquals("from-prop", UpdaterSettings.get(UpdaterSettings.FEED_URL, { "from-prop" }, env::get)) + assertEquals("from-env", UpdaterSettings.get(UpdaterSettings.FEED_URL, { " " }, env::get)) + assertNull(UpdaterSettings.get(UpdaterSettings.FEED_URL, { null }, { "" })) + } + + // ---- feed redirect -------------------------------------------------------------------------- + + @Test + fun `an unpackaged run honours the redirect, an installed app only when allowed`() { + assertNotNull(FeedOverride.resolve("http://127.0.0.1:8080", packaged = false, allowed = false)) + assertNull(FeedOverride.resolve("http://127.0.0.1:8080", packaged = true, allowed = false)) + assertNotNull(FeedOverride.resolve("http://127.0.0.1:8080", packaged = true, allowed = true)) + assertNull(FeedOverride.resolve(null, packaged = false, allowed = true)) + assertNull(FeedOverride.resolve(" ", packaged = false, allowed = true)) + } + + @Test + fun `the redirect accepts https, loopback http, file URLs and paths`() { + assertTrue(FeedOverride.providerFor("https://staging.example.com/feed") is GenericProvider) + assertTrue(FeedOverride.providerFor("http://localhost:9000") is GenericProvider) + assertTrue(FeedOverride.providerFor("http://[::1]:9000") is GenericProvider) + val dir = tmp.newFolder("feed dir") + assertEquals( + dir.absoluteFile, + (FeedOverride.providerFor(dir.toURI().toString()) as LocalFileProvider).directory, + ) + assertEquals(dir.absoluteFile, (FeedOverride.providerFor(dir.absolutePath) as LocalFileProvider).directory) + assertTrue(FeedOverride.providerFor("C:\\builds\\nsis") is LocalFileProvider) + assertTrue(FeedOverride.providerFor("relative/dir") is LocalFileProvider) + } + + @Test + fun `the redirect refuses plain http to a remote host and unknown schemes`() { + assertNull(FeedOverride.resolve("http://updates.example.com", packaged = false, allowed = true)) + assertNull(FeedOverride.resolve("ftp://127.0.0.1/feed", packaged = false, allowed = true)) + assertNull(FeedOverride.resolve("file://%%%", packaged = false, allowed = true)) + } + + @Test + fun `a local provider names manifests per OS and stays inside its directory`() { + val dir = tmp.newFolder("local") + val provider = LocalFileProvider(dir) + assertTrue(provider.getUpdateMetadataUrl("latest", Platform.Windows).endsWith("/latest.yml")) + assertTrue(provider.getUpdateMetadataUrl("beta", Platform.MacOS).endsWith("/beta-mac.yml")) + assertTrue(provider.getUpdateMetadataUrl("latest", Platform.Linux).endsWith("/latest-linux.yml")) + assertThrows(IllegalArgumentException::class.java) { provider.getDownloadUrl("../x.exe", "1.0.0") } + assertThrows(IllegalArgumentException::class.java) { provider.getDownloadUrl("sub/../../x.exe", "1.0.0") } + } + + @Test + fun `an unpackaged run redirected to a local feed checks and downloads, then skips the install`() { + val feed = localFeed("2.0.0") + property(UpdaterSettings.FEED_URL, feed.absolutePath) + val updater = updater(executableType = "dev") + + assertEquals(feed.absolutePath, updater.feedOverride) + assertTrue(updater.isUpdateSupported()) + val info = (runBlocking { updater.checkForUpdates() } as UpdateResult.Available).info + val file = runBlocking { updater.downloadUpdate(info).toList() }.last().file!! + assertEquals(ARTIFACT_BYTES.toList(), file.readBytes().toList()) + + val markerBefore = UpdateMarker.read() + // Would exit the test JVM if it did not skip. + updater.installAndRestart(file) + updater.installAndQuit(file) + assertEquals("a skipped install records no update", markerBefore, UpdateMarker.read()) + file.parentFile.deleteRecursively() + } + + @Test + fun `an unpackaged run in dev version is still redirected`() { + property(UpdaterSettings.FEED_URL, localFeed("2.0.0").absolutePath) + val updater = updater(executableType = "dev", currentVersion = UpdaterConfig.DEV_VERSION) + assertTrue(runBlocking { updater.checkForUpdates() } is UpdateResult.Available) + } + + @Test + fun `an unpackaged run without a redirect does not update`() { + val updater = updater(executableType = "dev") + assertFalse(updater.isUpdateSupported()) + assertEquals(UpdateResult.NotAvailable, runBlocking { updater.checkForUpdates() }) + } + + @Test + fun `an installed app ignores the redirect unless it allows launch overrides`() { + property(UpdaterSettings.FEED_URL, localFeed("2.0.0").absolutePath) + val locked = updater(executableType = PACKAGED_TYPE) + assertNull(locked.feedOverride) + assertTrue("the configured provider is used", runBlocking { locked.checkForUpdates() } is UpdateResult.Error) + + val open = updater(executableType = PACKAGED_TYPE, allowLaunchOverrides = true) + assertNotNull(open.feedOverride) + assertTrue(runBlocking { open.checkForUpdates() } is UpdateResult.Available) + } + + // ---- simulation ----------------------------------------------------------------------------- + + @Test + fun `simulation settings parse into a simulation`() { + fun parse(vararg settings: Pair) = UpdateSimulation.fromSettings(settings.toMap()::get) + + assertNull(parse()) + assertNull(parse(UpdaterSettings.SIMULATE to "false")) + assertNull(parse(UpdaterSettings.SIMULATE to "nonsense")) + assertEquals(Scenario.UPDATE_AVAILABLE, parse(UpdaterSettings.SIMULATE to "true")!!.scenario) + assertEquals(Scenario.UPDATE_AVAILABLE, parse(UpdaterSettings.SIMULATE to "update")!!.scenario) + assertEquals(Scenario.UP_TO_DATE, parse(UpdaterSettings.SIMULATE to "up-to-date")!!.scenario) + assertEquals(Scenario.CHECKSUM_ERROR, parse(UpdaterSettings.SIMULATE to "CHECKSUM-ERROR")!!.scenario) + parse(UpdaterSettings.SIMULATE to "3.2.1").let { + assertEquals(Scenario.UPDATE_AVAILABLE, it!!.scenario) + assertEquals("3.2.1", it.version) + } + parse( + UpdaterSettings.SIMULATE to "download-error", + UpdaterSettings.SIMULATE_VERSION to "9.0.0", + UpdaterSettings.SIMULATE_DURATION to "1.5", + UpdaterSettings.SIMULATE_SIZE to "1000", + UpdaterSettings.SIMULATE_DIFFERENTIAL to "true", + ).let { + assertEquals(Scenario.DOWNLOAD_ERROR, it!!.scenario) + assertEquals("9.0.0", it.version) + assertEquals(1500.milliseconds, it.downloadDuration) + assertEquals(1000L, it.downloadSize) + assertTrue(it.isDifferential) + } + parse(UpdaterSettings.SIMULATE_JUST_UPDATED_FROM to "0.9.0").let { + assertEquals("justUpdatedFrom alone finds no update", Scenario.UP_TO_DATE, it!!.scenario) + assertEquals("0.9.0", it.justUpdatedFrom) + } + } + + @Test + fun `a simulated update is offered, downloaded and not installed, even unpackaged`() { + val updater = simulated(UpdateSimulation(downloadDuration = 600.milliseconds, downloadSize = 10_000)) + assertTrue(updater.isUpdateSupported()) + val result = runBlocking { updater.checkForUpdates() } as UpdateResult.Available + assertEquals("the next minor version is offered", "1.5.0", result.info.version) + assertEquals(UpdateLevel.MINOR, result.level) + + val started = TimeSource.Monotonic.markNow() + val progress = runBlocking { updater.downloadUpdate(result.info).toList() } + val elapsed = started.elapsedNow() + assertTrue("the download takes its duration, took $elapsed", elapsed >= 550.milliseconds) + assertTrue("several progress reports, got ${progress.size}", progress.size >= 5) + assertEquals(progress.map { it.percent }.sorted(), progress.map { it.percent }) + assertEquals(10_000L, progress.last().bytesDownloaded) + val file = progress.last().file!! + assertTrue(file.isFile) + assertTrue("only the last report carries the file", progress.dropLast(1).none { it.file != null }) + + val markerBefore = UpdateMarker.read() + updater.installAndRestart(file) + assertEquals(markerBefore, UpdateMarker.read()) + } + + @Test + fun `simulated failures surface as the real errors`() { + val offline = simulated(UpdateSimulation(Scenario.CHECK_ERROR, checkDuration = Duration.ZERO)) + assertTrue(runBlocking { offline.checkForUpdates() } is UpdateResult.Error) + + val upToDate = simulated(UpdateSimulation(Scenario.UP_TO_DATE, checkDuration = Duration.ZERO)) + assertEquals(UpdateResult.NotAvailable, runBlocking { upToDate.checkForUpdates() }) + + val dropped = + simulated( + UpdateSimulation( + Scenario.DOWNLOAD_ERROR, + checkDuration = Duration.ZERO, + downloadDuration = 300.milliseconds, + ), + ) + val droppedInfo = (runBlocking { dropped.checkForUpdates() } as UpdateResult.Available).info + val seen = mutableListOf() + assertThrows(NetworkException::class.java) { + runBlocking { dropped.downloadUpdate(droppedInfo).collect(seen::add) } + } + assertTrue( + "it failed part-way", + seen.isNotEmpty() && seen.none { it.file != null } && seen.last().percent < 100.0, + ) + + val tampered = + simulated( + UpdateSimulation( + Scenario.CHECKSUM_ERROR, + checkDuration = Duration.ZERO, + downloadDuration = 100.milliseconds, + ), + ) + val tamperedInfo = (runBlocking { tampered.checkForUpdates() } as UpdateResult.Available).info + assertThrows(ChecksumException::class.java) { runBlocking { tampered.downloadUpdate(tamperedInfo).collect() } } + } + + @Test + fun `a simulated download can be cancelled`() { + val updater = + simulated( + UpdateSimulation(checkDuration = Duration.ZERO, downloadDuration = kotlin.time.Duration.parse("10s")), + ) + val info = (runBlocking { updater.checkForUpdates() } as UpdateResult.Available).info + val finished = runBlocking { withTimeoutOrNull(300.milliseconds) { updater.downloadUpdate(info).collect() } } + assertNull(finished) + } + + @Test + fun `a differential simulation transfers a fraction of the artifact`() { + val updater = + simulated( + UpdateSimulation( + checkDuration = Duration.ZERO, + downloadDuration = Duration.ZERO, + isDifferential = true, + ), + ) + val info = (runBlocking { updater.checkForUpdates() } as UpdateResult.Available).info + val last = runBlocking { updater.downloadUpdate(info).toList() }.last() + assertTrue(last.isDifferential) + assertTrue(last.totalBytes < info.currentFile.size / 5) + } + + @Test + fun `a simulated post-update launch is reported once`() { + val updater = simulated(UpdateSimulation(justUpdatedFrom = "1.3.2")) + assertTrue(updater.wasJustUpdated()) + assertTrue("peeking does not consume", updater.wasJustUpdated()) + assertEquals(UpdateEvent("1.3.2", "1.4.0", UpdateLevel.MINOR), updater.consumeUpdateEvent()) + assertFalse(updater.wasJustUpdated()) + } + + @Test + fun `a launch-time simulation needs the opt-in in an installed app`() { + property(UpdaterSettings.SIMULATE, "2.0.0") + assertNotNull("unpackaged: honoured", updater(executableType = "dev").simulation) + assertNull("installed: ignored", updater(executableType = PACKAGED_TYPE).simulation) + assertEquals("2.0.0", updater(executableType = PACKAGED_TYPE, allowLaunchOverrides = true).simulation?.version) + } + + @Test + fun `a simulation set in code wins over the launch settings and the redirect`() { + property(UpdaterSettings.SIMULATE, "up-to-date") + property(UpdaterSettings.FEED_URL, localFeed("2.0.0").absolutePath) + val updater = + NucleusUpdater { + currentVersion = "1.4.0" + executableType = "dev" + provider = Unreachable + simulation = UpdateSimulation(version = "7.0.0", checkDuration = Duration.ZERO) + } + assertNull("no redirect while simulating", updater.feedOverride) + assertEquals("7.0.0", (runBlocking { updater.checkForUpdates() } as UpdateResult.Available).info.version) + } + + // ---- helpers -------------------------------------------------------------------------------- + + private fun simulated(simulation: UpdateSimulation): NucleusUpdater = + NucleusUpdater { + currentVersion = "1.4.0" + executableType = "dev" + provider = Unreachable + this.simulation = simulation + } + + private fun updater( + executableType: String, + currentVersion: String = "1.0.0", + allowLaunchOverrides: Boolean = false, + ): NucleusUpdater = + NucleusUpdater { + this.currentVersion = currentVersion + this.executableType = executableType + this.allowLaunchOverrides = allowLaunchOverrides + provider = Unreachable + differentialDownload = false + cacheDir = tmp.root.resolve("cache") + } + + /** A directory laid out like a packaging output: artifact + manifest for this OS. */ + private fun localFeed(version: String): File { + val dir = tmp.newFolder("feed-$version-${System.nanoTime()}") + val name = + when (Platform.Current) { + Platform.Windows -> "MyApp-$version-win-x64-nsis.exe" + Platform.MacOS -> "MyApp-$version-mac-arm64.zip" + else -> "MyApp-$version-linux-x86_64.AppImage" + } + File(dir, name).writeBytes(ARTIFACT_BYTES) + val sha = Base64.getEncoder().encodeToString(MessageDigest.getInstance("SHA-512").digest(ARTIFACT_BYTES)) + val manifest = LocalFileProvider(dir).getUpdateMetadataUrl("latest", Platform.Current) + File(java.net.URI(manifest)).writeText( + "version: $version\nfiles:\n - url: $name\n sha512: $sha\n size: ${ARTIFACT_BYTES.size}\n" + + "path: $name\nsha512: $sha\nreleaseDate: '2026-09-25T00:00:00.000Z'\n", + ) + return dir + } + + /** The provider the app ships with; unreachable, so reaching it is visible as an error. */ + private object Unreachable : UpdateProvider { + override fun getUpdateMetadataUrl( + channel: String, + platform: Platform, + ): String = "http://127.0.0.1:1/$channel.yml" + + override fun getDownloadUrl( + fileName: String, + version: String, + ): String = "http://127.0.0.1:1/$fileName" + } + + private companion object { + val ARTIFACT_BYTES = ByteArray(200_000) { (it * 31 % 251).toByte() } + + val PACKAGED_TYPE = + when (Platform.Current) { + Platform.Windows -> "nsis" + Platform.MacOS -> "zip" + else -> "appimage" + } + } +} diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/PkgUpdateSupportTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/PkgUpdateSupportTest.kt new file mode 100644 index 000000000..4ef2df039 --- /dev/null +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/PkgUpdateSupportTest.kt @@ -0,0 +1,36 @@ +package dev.nucleusframework.updater + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A Developer ID PKG installs an ordinary `.app`, so it self-updates from the release's ZIP/DMG + * like any direct-distribution build. Only the sandboxed Mac App Store build must stay excluded. + */ +class PkgUpdateSupportTest { + private fun updater(type: String): NucleusUpdater = + NucleusUpdater { + currentVersion = "1.0.0" + provider = FakeUpdateProvider() + executableType = type + } + + @Test + fun `a pkg outside the app sandbox can update itself`() { + assertTrue(updater("pkg").isUpdateSupported()) + } + + @Test + fun `store containers stay excluded`() { + assertFalse(updater("appx").isUpdateSupported()) + assertFalse(updater("flatpak").isUpdateSupported()) + } + + @Test + fun `direct distribution formats keep updating`() { + assertTrue(updater("dmg").isUpdateSupported()) + assertTrue(updater("zip").isUpdateSupported()) + assertTrue(updater("nsis").isUpdateSupported()) + } +} diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt index fe5752580..6e9dda6ea 100644 --- a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/UpdateEventTest.kt @@ -15,14 +15,16 @@ class UpdateEventTest { @Before fun setup() { - updater = - NucleusUpdater { - currentVersion = "2.0.0" - provider = FakeUpdateProvider() - } + updater = updaterAt("2.0.0") UpdateMarker.delete() } + private fun updaterAt(version: String): NucleusUpdater = + NucleusUpdater { + currentVersion = version + provider = FakeUpdateProvider() + } + @After fun cleanup() { UpdateMarker.delete() @@ -74,7 +76,7 @@ class UpdateEventTest { fun `consumeUpdateEvent detects minor update level`() { UpdateMarker.write("1.0.0", "1.1.0") - val event = updater.consumeUpdateEvent() + val event = updaterAt("1.1.0").consumeUpdateEvent() assertNotNull(event) assertEquals(UpdateLevel.MINOR, event!!.updateLevel) } @@ -83,7 +85,7 @@ class UpdateEventTest { fun `consumeUpdateEvent detects patch update level`() { UpdateMarker.write("1.0.0", "1.0.1") - val event = updater.consumeUpdateEvent() + val event = updaterAt("1.0.1").consumeUpdateEvent() assertNotNull(event) assertEquals(UpdateLevel.PATCH, event!!.updateLevel) } @@ -92,8 +94,19 @@ class UpdateEventTest { fun `consumeUpdateEvent detects pre-release update level`() { UpdateMarker.write("1.0.0-beta.1", "1.0.0-beta.2") - val event = updater.consumeUpdateEvent() + val event = updaterAt("1.0.0-beta.2").consumeUpdateEvent() assertNotNull(event) assertEquals(UpdateLevel.PRE_RELEASE, event!!.updateLevel) } + + @Test + fun `a marker left by an install that did not complete is dropped, not reported`() { + // installAndRestart wrote it for 2.1.0, but the installer failed: still running 2.0.0. + UpdateMarker.write("2.0.0", "2.1.0") + + assertFalse(updater.wasJustUpdated()) + assertNull(updater.consumeUpdateEvent()) + // Consumed: the stale marker does not resurface once 2.1.0 is finally installed. + assertNull(updaterAt("2.1.0").consumeUpdateEvent()) + } } diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateMultiInstanceTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateMultiInstanceTest.kt new file mode 100644 index 000000000..f832fd0f7 --- /dev/null +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateMultiInstanceTest.kt @@ -0,0 +1,98 @@ +package dev.nucleusframework.updater + +import dev.nucleusframework.core.runtime.VersionedInstall +import dev.nucleusframework.updater.internal.InstalledVersionWatcher +import dev.nucleusframework.updater.internal.WindowsHotUpdate +import dev.nucleusframework.updater.internal.windowsCommandLine +import dev.nucleusframework.updater.internal.windowsRelaunchCommand +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.concurrent.thread + +class WindowsHotUpdateMultiInstanceTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun install(): VersionedInstall { + val root = tmp.newFolder("App") + val current = File(root, "versions/1.0.0").apply { File(this, "runtime").mkdirs() } + val launcher = File(root, "App.exe").apply { writeText("launcher") } + pointCfgAt(root, "1.0.0") + return VersionedInstall(root, current, launcher) + } + + private fun pointCfgAt( + root: File, + version: String, + ) { + File(root, "app").mkdirs() + File(root, "app/App.cfg").writeText("[Application]\r\napp.runtime=\$ROOTDIR\\versions\\$version\\runtime\r\n") + } + + @Test + fun `command line splits back into the same arguments`() { + assertEquals("plain", windowsCommandLine(listOf("plain"))) + assertEquals("\"C:\\My Docs\\a.txt\"", windowsCommandLine(listOf("C:\\My Docs\\a.txt"))) + assertEquals("\"say \\\"hi\\\"\"", windowsCommandLine(listOf("say \"hi\""))) + // Trailing backslashes must not escape the closing quote. + assertEquals("\"C:\\My Dir\\\\\"", windowsCommandLine(listOf("C:\\My Dir\\"))) + assertEquals("\"\" two", windowsCommandLine(listOf("", "two"))) + } + + @Test + fun `classic relaunch passes the arguments as one quoted command line`() { + val command = windowsRelaunchCommand(true, "C:\\App\\App.exe", listOf("C:\\it's here\\doc.txt")) + + assertTrue(command.contains("Start-Process 'C:\\App\\App.exe' -ArgumentList '\"C:\\it''s here\\doc.txt\"'")) + assertEquals( + "\n# Relaunch the application\nStart-Process 'C:\\App\\App.exe'", + windowsRelaunchCommand(true, "C:\\App\\App.exe"), + ) + } + + @Test + fun `a reader waits for an install in progress`() { + val install = install() + val installing = CountDownLatch(1) + val order = mutableListOf() + val installer = + thread { + WindowsHotUpdate.withInstallLock(install) { + installing.countDown() + Thread.sleep(400) + synchronized(order) { order += "install done" } + } + } + installing.await(5, TimeUnit.SECONDS) + + WindowsHotUpdate.withInstallLock(install, shared = true) { synchronized(order) { order += "read" } } + installer.join() + + assertEquals(listOf("install done", "read"), order) + } + + @Test + fun `watcher publishes a version another process installed`() { + val install = install() + val watcher = InstalledVersionWatcher(install).apply { start() } + assertNull(watcher.version.value) + + // What the other instance's installer leaves behind: the new version, then the cfg. + File(install.versionsDir, "1.1.0/runtime").mkdirs() + Thread.sleep(200) // let the watch service register before the change + pointCfgAt(install.root, "1.1.0") + + val seen = runBlocking { withTimeout(10_000) { watcher.version.first { it != null } } } + assertEquals("1.1.0", seen) + } +} diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateTest.kt new file mode 100644 index 000000000..cc0ca78ac --- /dev/null +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/WindowsHotUpdateTest.kt @@ -0,0 +1,145 @@ +package dev.nucleusframework.updater + +import dev.nucleusframework.core.runtime.ExecutableType +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.core.runtime.VersionedInstall +import dev.nucleusframework.updater.internal.WindowsHotUpdate +import dev.nucleusframework.updater.internal.buildWindowsHotUpdateScript +import dev.nucleusframework.updater.internal.writePowerShellScript +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class WindowsHotUpdateTest { + @get:Rule + val tmp = TemporaryFolder() + + private fun install(): VersionedInstall { + val root = tmp.newFolder("App") + val current = File(root, "versions/1.0.0").apply { File(this, "runtime").mkdirs() } + val launcher = File(root, "App.exe").apply { writeText("launcher") } + return VersionedInstall(root, current, launcher) + } + + private fun writeCfg( + install: VersionedInstall, + version: String, + ) { + File(install.root, "app").mkdirs() + File(install.root, "app/App.cfg").writeText( + "[Application]\r\napp.runtime=\$ROOTDIR\\versions\\$version\\runtime\r\n" + + "app.classpath=\$ROOTDIR\\versions\\$version\\app\\app.jar\r\n", + ) + } + + @Test + fun `installed version is read back from the rewritten cfg`() { + val install = install() + File(install.versionsDir, "1.1.0/runtime").mkdirs() + writeCfg(install, "1.1.0") + + val installed = WindowsHotUpdate.installedVersionDir(install) + + assertEquals(File(install.versionsDir, "1.1.0"), installed) + } + + @Test + fun `cfg still pointing at the running version means nothing was installed`() { + val install = install() + writeCfg(install, "1.0.0") + + assertNull(WindowsHotUpdate.installedVersionDir(install)) + } + + @Test + fun `cfg pointing at a missing runtime means nothing was installed`() { + val install = install() + writeCfg(install, "1.1.0") + + assertNull(WindowsHotUpdate.installedVersionDir(install)) + } + + @Test + fun `launchers are retired and copied back writable, the uninstaller is left alone`() { + val install = install() + install.launcher.setWritable(false) // jpackage ships it read-only + val helper = File(install.root, "Helper.exe").apply { writeText("helper") } + val uninstaller = File(install.root, "Uninstall App.exe").apply { writeText("uninstaller") } + + val retired = WindowsHotUpdate.retireLaunchers(install.root) + + assertEquals(2, retired.size) + assertTrue(retired.all { it.isFile && it.name.endsWith(".nucleus-old") }) + assertEquals(setOf("launcher", "helper"), retired.map { it.readText() }.toSet()) + // The launcher paths keep working during the install, and the installer can replace them. + assertEquals("launcher", install.launcher.readText()) + assertTrue(install.launcher.canWrite()) + assertEquals("helper", helper.readText()) + assertEquals(listOf(uninstaller.name), install.root.list()!!.filter { it.startsWith("Uninstall") }) + } + + @Test + fun `only Windows NSIS installs of the versioned layout are eligible`() { + val install = install() + val exe = File(tmp.root, "app-1.1.0-nsis.exe") + + assertNotNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, install)) + assertNotNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.EXE, install)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.MSI, install)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, null)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Linux, ExecutableType.NSIS, install)) + assertNull( + WindowsHotUpdate.eligibleInstall(File(tmp.root, "app.msi"), Platform.Windows, ExecutableType.NSIS, install), + ) + } + + @Test + fun `an install whose versions directory cannot be written is not eligible`() { + val install = install() + val exe = File(tmp.root, "app-1.1.0-nsis.exe") + // A plain file where the versions directory should be: creating the probe fails. + val readOnly = VersionedInstall(install.root, File(tmp.newFile("versions-file"), "1.0.0"), install.launcher) + + assertNotNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, install)) + assertNull(WindowsHotUpdate.eligibleInstall(exe, Platform.Windows, ExecutableType.NSIS, readOnly)) + } + + @Test + fun `PowerShell scripts are written with a BOM so non-ASCII paths survive`() { + val script = File(tmp.root, "update.ps1") + + writePowerShellScript(script, "Start-Process 'C:\\Users\\Hélène\\App.exe'") + + val bytes = script.readBytes() + assertEquals(listOf(0xEF, 0xBB, 0xBF), bytes.take(3).map { it.toInt() and 0xFF }) + assertTrue(String(bytes, Charsets.UTF_8).contains("Hélène")) + } + + @Test + fun `hot update script runs the installer silently and relaunches only if the app was closed`() { + val script = + buildWindowsHotUpdateScript( + pid = 4242, + installerPath = "C:\\Temp\\it's\\setup.exe", + launcher = "C:\\Apps\\App\\App.exe", + exitedMarker = "C:\\Temp\\work\\app-exited", + ) + + assertTrue( + script.contains( + "Start-Process 'C:\\Temp\\it''s\\setup.exe' -ArgumentList '/S', '--updated' -Wait -PassThru", + ), + ) + assertTrue(script.contains("-not (Get-Process -Id 4242 -ErrorAction SilentlyContinue)")) + // A user who quit during the install is not relaunched. + assertTrue(script.contains("-not (Test-Path -LiteralPath 'C:\\Temp\\work\\app-exited')")) + assertTrue(script.contains("Remove-Item Env:NUCLEUS_HOT_UPDATE")) + assertTrue(script.contains("Start-Process 'C:\\Apps\\App\\App.exe'")) + assertTrue(script.trimEnd().endsWith("exit \$code")) + } +} diff --git a/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialTortureTest.kt b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialTortureTest.kt new file mode 100644 index 000000000..4982a0559 --- /dev/null +++ b/updater-runtime/src/test/kotlin/dev/nucleusframework/updater/delta/DifferentialTortureTest.kt @@ -0,0 +1,152 @@ +package dev.nucleusframework.updater.delta + +import dev.nucleusframework.updater.DownloadProgress +import dev.nucleusframework.updater.NucleusUpdater +import dev.nucleusframework.updater.UpdateResult +import dev.nucleusframework.updater.provider.GenericProvider +import dev.nucleusframework.updater.testing.FeedFault +import dev.nucleusframework.updater.testing.UpdateFeedServer +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.time.Duration.Companion.seconds + +/** + * The differential path under a misbehaving host ([UpdateFeedServer] faults), with the block maps a + * real electron-builder produced. Whatever goes wrong with the ranged requests, the update must + * still end byte-identical — by falling back to a full download — and a healthy host must still + * yield a real delta. + */ +class DifferentialTortureTest { + @get:Rule + val tmp = TemporaryFolder() + + private lateinit var feed: UpdateFeedServer + private lateinit var cacheDir: File + private val downloaded = mutableListOf() + + @Before + fun setUp() { + DeltaFixtures.verify() + feed = UpdateFeedServer(directory = tmp.newFolder("feed")) + cacheDir = tmp.newFolder("cache") + // A first update through the updater caches 1.0.0 and its block map: the base of the delta. + publish("1.0.0", DeltaFixtures.v1(), "v1") + val first = download("0.9.0") + assertFalse(first.last().isDifferential) + publish("2.0.0", DeltaFixtures.v2(), "v2") + feed.clearRequests() + } + + @After + fun tearDown() { + feed.close() + downloaded.forEach { it.parentFile?.deleteRecursively() } + } + + @Test + fun `a healthy host yields a real delta`() { + val progress = download("1.0.0") + assertTrue(progress.last().isDifferential) + assertEquals(DeltaFixtures.EXPECTED_DELTA_BYTES, progress.last().bytesDownloaded) + assertArtifactIsV2(progress) + assertTrue("ranged requests were made", feed.requests.any { it.range != null && it.status == 206 }) + } + + @Test + fun `a host that ignores Range falls back to a full download`() { + feed.fault(FeedFault.IgnoreRange, path = ARTIFACT) + val progress = download("1.0.0") + assertFalse(progress.last().isDifferential) + assertArtifactIsV2(progress) + } + + @Test + fun `a ranged response cut part-way falls back to a full download`() { + feed.fault(FeedFault.Truncate(afterBytes = 100), path = ARTIFACT, times = 1) + val progress = download("1.0.0") + assertFalse(progress.last().isDifferential) + assertArtifactIsV2(progress) + } + + @Test + fun `a corrupted ranged response is caught and falls back to a full download`() { + // Corrupt the first bytes of whatever the first ranged request covers. + feed.fault(FeedFault.Corrupt(offset = 200_000), path = ARTIFACT, times = 1) + val progress = download("1.0.0") + assertFalse(progress.last().isDifferential) + assertArtifactIsV2(progress) + } + + @Test + fun `a missing block map falls back to a full download`() { + feed.fault(FeedFault.Status(404), path = "$ARTIFACT.blockmap") + val progress = download("1.0.0") + assertFalse(progress.last().isDifferential) + assertArtifactIsV2(progress) + } + + @Test + fun `a failing range request falls back to a full download`() { + feed.fault(FeedFault.Status(500), path = ARTIFACT, times = 1) + val progress = download("1.0.0") + assertFalse(progress.last().isDifferential) + assertArtifactIsV2(progress) + } + + @Test + fun `a slow host still yields a delta with monotonic progress`() { + feed.fault(FeedFault.Throttle(bytesPerSecond = 40_000), path = ARTIFACT) + val progress = download("1.0.0") + assertTrue(progress.last().isDifferential) + val percents = progress.map { it.percent } + assertEquals(percents.sorted(), percents) + assertArtifactIsV2(progress) + } + + private fun publish( + version: String, + bytes: ByteArray, + blockMapFixture: String, + ) { + val staging = tmp.newFolder() + val artifact = File(staging, "MyApp-$version.zip").apply { writeBytes(bytes) } + File(staging, "MyApp-$version.zip.blockmap").writeBytes(DeltaFixtures.blockMapGzip(blockMapFixture)) + feed.publish(version, artifact) + } + + private fun download(currentVersion: String): List { + val updater = + NucleusUpdater { + this.currentVersion = currentVersion + executableType = "zip" + provider = GenericProvider(feed.baseUrl) + cacheDir = this@DifferentialTortureTest.cacheDir + } + return runBlocking { + withTimeout(60.seconds) { + val result = updater.checkForUpdates() + assertTrue("an update must be offered, got $result", result is UpdateResult.Available) + updater.downloadUpdate((result as UpdateResult.Available).info).toList() + } + }.also { events -> events.last().file?.let(downloaded::add) } + } + + private fun assertArtifactIsV2(progress: List) { + assertArrayEquals(DeltaFixtures.v2(), progress.last().file!!.readBytes()) + } + + private companion object { + const val ARTIFACT = "MyApp-2.0.0.zip" + } +} diff --git a/updater-testing/api/updater-testing.api b/updater-testing/api/updater-testing.api new file mode 100644 index 000000000..c4a5230d7 --- /dev/null +++ b/updater-testing/api/updater-testing.api @@ -0,0 +1,75 @@ +public abstract class dev/nucleusframework/updater/testing/FeedFault { +} + +public final class dev/nucleusframework/updater/testing/FeedFault$Corrupt : dev/nucleusframework/updater/testing/FeedFault { + public fun ()V + public fun (J)V + public synthetic fun (JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getOffset ()J + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/testing/FeedFault$Delay : dev/nucleusframework/updater/testing/FeedFault { + public synthetic fun (JLkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDuration-UwyO8pc ()J + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/testing/FeedFault$IgnoreRange : dev/nucleusframework/updater/testing/FeedFault { + public static final field INSTANCE Ldev/nucleusframework/updater/testing/FeedFault$IgnoreRange; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/testing/FeedFault$Status : dev/nucleusframework/updater/testing/FeedFault { + public fun (I)V + public final fun getCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/testing/FeedFault$Throttle : dev/nucleusframework/updater/testing/FeedFault { + public fun (J)V + public final fun getBytesPerSecond ()J + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/testing/FeedFault$Truncate : dev/nucleusframework/updater/testing/FeedFault { + public fun (J)V + public final fun getAfterBytes ()J + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/testing/FeedRequest { + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IJ)V + public final fun getBytesSent ()J + public final fun getMethod ()Ljava/lang/String; + public final fun getPath ()Ljava/lang/String; + public final fun getRange ()Ljava/lang/String; + public final fun getStatus ()I + public fun toString ()Ljava/lang/String; +} + +public final class dev/nucleusframework/updater/testing/UpdateFeedServer : java/lang/AutoCloseable { + public static final field Companion Ldev/nucleusframework/updater/testing/UpdateFeedServer$Companion; + public fun ()V + public fun (Ljava/io/File;I)V + public synthetic fun (Ljava/io/File;IILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun clearFaults ()V + public final fun clearRequests ()V + public fun close ()V + public final fun fault (Ldev/nucleusframework/updater/testing/FeedFault;Ljava/lang/String;I)V + public static synthetic fun fault$default (Ldev/nucleusframework/updater/testing/UpdateFeedServer;Ldev/nucleusframework/updater/testing/FeedFault;Ljava/lang/String;IILjava/lang/Object;)V + public final fun getBaseUrl ()Ljava/lang/String; + public final fun getDirectory ()Ljava/io/File; + public final fun getRequests ()Ljava/util/List; + public final fun publish (Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ldev/nucleusframework/core/runtime/Platform;Ljava/time/Instant;)Ljava/io/File; + public final fun publish (Ljava/lang/String;[Ljava/io/File;)Ljava/io/File; + public static synthetic fun publish$default (Ldev/nucleusframework/updater/testing/UpdateFeedServer;Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ldev/nucleusframework/core/runtime/Platform;Ljava/time/Instant;ILjava/lang/Object;)Ljava/io/File; +} + +public final class dev/nucleusframework/updater/testing/UpdateFeedServer$Companion { + public final fun manifestName (Ljava/lang/String;Ldev/nucleusframework/core/runtime/Platform;)Ljava/lang/String; + public static synthetic fun manifestName$default (Ldev/nucleusframework/updater/testing/UpdateFeedServer$Companion;Ljava/lang/String;Ldev/nucleusframework/core/runtime/Platform;ILjava/lang/Object;)Ljava/lang/String; +} + diff --git a/decorated-window-awt/build.gradle.kts b/updater-testing/build.gradle.kts similarity index 71% rename from decorated-window-awt/build.gradle.kts rename to updater-testing/build.gradle.kts index 4b2c55265..cf2fb6399 100644 --- a/decorated-window-awt/build.gradle.kts +++ b/updater-testing/build.gradle.kts @@ -2,8 +2,6 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { kotlin("jvm") - alias(libs.plugins.kotlinComposePlugin) - alias(libs.plugins.jetbrainsCompose) alias(libs.plugins.vanniktechMavenPublish) } @@ -15,11 +13,9 @@ val publishVersion = ?: "1.0.0" dependencies { - api(project(":decorated-window-core")) - implementation(project(":core-runtime")) - api(libs.compose.desktop.common) - testImplementation(kotlin("test")) - testImplementation(compose.desktop.currentOs) + api(project(":updater-runtime")) + testImplementation(libs.coroutines.core) + testImplementation(libs.junit) } java { @@ -34,12 +30,12 @@ kotlin { } mavenPublishing { - coordinates("dev.nucleusframework", "nucleus.decorated-window-awt", publishVersion) + coordinates("dev.nucleusframework", "nucleus.updater-testing", publishVersion) pom { - name.set("Nucleus Decorated Window AWT") + name.set("Nucleus Updater Testing") description.set( - "AWT/Compose Desktop integration of Nucleus Decorated Window (consumed by JBR and JNI backends)", + "Loopback update feed server with fault injection, for testing Nucleus auto-updates without publishing a release", ) url.set("https://github.com/NucleusFramework/Nucleus") diff --git a/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/FeedFault.kt b/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/FeedFault.kt new file mode 100644 index 000000000..f147f8e61 --- /dev/null +++ b/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/FeedFault.kt @@ -0,0 +1,60 @@ +package dev.nucleusframework.updater.testing + +import kotlin.time.Duration + +/** + * A misbehaviour [UpdateFeedServer] injects into the responses it serves, to see how an app's + * update flow copes with the failures real release hosts, proxies and networks produce. + * + * Several faults may apply to one request; [Status] wins over everything else, and the others + * combine (a [Delay] then a [Throttle]d, [Truncate]d body, for instance). + */ +public sealed class FeedFault { + /** Answers with HTTP [code] and an empty body instead of serving the file. */ + public class Status( + public val code: Int, + ) : FeedFault() { + override fun toString(): String = "Status($code)" + } + + /** Waits [duration] before answering: a slow host, or one that times out. */ + public class Delay( + public val duration: Duration, + ) : FeedFault() { + override fun toString(): String = "Delay($duration)" + } + + /** Sends the body at no more than [bytesPerSecond]: a slow link, to watch download progress. */ + public class Throttle( + public val bytesPerSecond: Long, + ) : FeedFault() { + init { + require(bytesPerSecond > 0) { "bytesPerSecond must be positive, got $bytesPerSecond" } + } + + override fun toString(): String = "Throttle($bytesPerSecond B/s)" + } + + /** + * Announces the whole body, sends only its first [afterBytes] bytes and drops the connection: + * a transfer cut part-way. + */ + public class Truncate( + public val afterBytes: Long, + ) : FeedFault() { + override fun toString(): String = "Truncate(after $afterBytes bytes)" + } + + /** + * Flips the byte at [offset] of the body (relative to the start of the file, whatever range is + * requested): a corrupted transfer or a tampered artifact, which the SHA-512 check must catch. + */ + public class Corrupt( + public val offset: Long = 0, + ) : FeedFault() { + override fun toString(): String = "Corrupt(at $offset)" + } + + /** Ignores `Range` and serves the whole file with HTTP 200: a host without range support. */ + public data object IgnoreRange : FeedFault() +} diff --git a/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/FeedRequest.kt b/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/FeedRequest.kt new file mode 100644 index 000000000..3bb783f92 --- /dev/null +++ b/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/FeedRequest.kt @@ -0,0 +1,17 @@ +package dev.nucleusframework.updater.testing + +/** A request [UpdateFeedServer] answered, as recorded in [UpdateFeedServer.requests]. */ +public class FeedRequest( + /** The HTTP method, `GET` or `HEAD`. */ + public val method: String, + /** The requested file name, relative to the feed root (`latest.yml`, `MyApp-2.0.0.exe`). */ + public val path: String, + /** The `Range` header, if the client sent one. */ + public val range: String?, + /** The status the server answered with. */ + public val status: Int, + /** The body bytes actually sent. */ + public val bytesSent: Long, +) { + override fun toString(): String = "$method /$path${range?.let { " [$it]" }.orEmpty()} → $status ($bytesSent bytes)" +} diff --git a/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/UpdateFeedServer.kt b/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/UpdateFeedServer.kt new file mode 100644 index 000000000..8a63267be --- /dev/null +++ b/updater-testing/src/main/kotlin/dev/nucleusframework/updater/testing/UpdateFeedServer.kt @@ -0,0 +1,366 @@ +package dev.nucleusframework.updater.testing + +import com.sun.net.httpserver.HttpExchange +import com.sun.net.httpserver.HttpServer +import dev.nucleusframework.core.runtime.Platform +import java.io.File +import java.io.IOException +import java.io.OutputStream +import java.io.RandomAccessFile +import java.net.InetAddress +import java.net.InetSocketAddress +import java.security.MessageDigest +import java.time.Instant +import java.util.Base64 +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +/** + * A loopback HTTP server that serves an update feed the way a release host does — manifests, + * artifacts, block maps and detached signatures, with byte ranges for differential downloads — + * and can misbehave on demand ([fault]), to test an app's update flow end to end without + * publishing a release. + * + * ```kotlin + * UpdateFeedServer().use { feed -> + * feed.publish("2.0.0", File("build/compose/binaries/main/nsis/MyApp-2.0.0.exe")) + * feed.fault(FeedFault.Throttle(bytesPerSecond = 2_000_000)) + * + * val updater = NucleusUpdater { + * currentVersion = "1.0.0" + * executableType = "nsis" + * provider = GenericProvider(feed.baseUrl) + * } + * // checkForUpdates(), downloadUpdate(), … + * } + * ``` + * + * An installed app is pointed at a running server with `NUCLEUS_UPDATER_FEED_URL=` (see + * `UpdaterConfig.allowLaunchOverrides`). The server only listens on the loopback interface. + * + * @param directory the feed root: files are served from it by name, and [publish] writes into it. + * Defaults to a fresh temporary directory, deleted by [close]. + * @param port the port to listen on; `0` picks a free one. + */ +public class UpdateFeedServer( + directory: File? = null, + port: Int = 0, +) : AutoCloseable { + private val ownsDirectory = directory == null + + /** The feed root. */ + public val directory: File = + ( + directory ?: kotlin.io.path + .createTempDirectory("nucleus-update-feed-") + .toFile() + ).absoluteFile.normalize() + + private val executor: ExecutorService = + Executors.newCachedThreadPool { runnable -> + Thread(runnable, "nucleus-update-feed-${THREAD_IDS.incrementAndGet()}").apply { isDaemon = true } + } + private val server: HttpServer = + HttpServer.create(InetSocketAddress(InetAddress.getLoopbackAddress(), port), 0).apply { + executor = this@UpdateFeedServer.executor + createContext("/") { exchange -> serve(exchange) } + start() + } + + private val faults = CopyOnWriteArrayList() + private val recorded = CopyOnWriteArrayList() + + /** The feed URL to hand to `GenericProvider` or `NUCLEUS_UPDATER_FEED_URL`. */ + public val baseUrl: String = "http://127.0.0.1:${server.address.port}" + + /** Every request answered so far, oldest first. */ + public val requests: List get() = recorded.toList() + + init { + this.directory.mkdirs() + } + + /** + * Publishes [artifacts] as release [version]: copies them into [directory] with their `.blockmap` + * and `.asc` companions when present next to them, and writes the `[-mac|-linux].yml` + * manifest listing them with their SHA-512 and size, as electron-builder does. Publishing again + * replaces the manifest, so a test can move the feed on to a newer version. + * + * @return the manifest file written. + */ + public fun publish( + version: String, + artifacts: List, + channel: String = "latest", + platform: Platform = Platform.Current, + releaseDate: Instant = Instant.now(), + ): File { + require(artifacts.isNotEmpty()) { "Publish at least one artifact" } + val entries = + artifacts.map { artifact -> + require(artifact.isFile) { "No such artifact: $artifact" } + val published = copyIntoFeed(artifact) + for (companion in COMPANION_EXTENSIONS) { + File(artifact.path + companion).takeIf { it.isFile }?.let(::copyIntoFeed) + } + ManifestEntry(published.name, sha512Base64(published), published.length()) + } + val manifest = File(directory, manifestName(channel, platform)) + manifest.writeText(manifestYaml(version, entries, releaseDate)) + return manifest + } + + /** [publish] for a vararg list of artifacts on the `latest` channel of this OS. */ + public fun publish( + version: String, + vararg artifacts: File, + ): File = publish(version, artifacts.toList()) + + /** + * Injects [fault] into the responses for files whose name matches [path] (a glob: `*` matches + * any run of characters, so `*.exe` or `latest*.yml`), for the next [times] matching requests. + */ + public fun fault( + fault: FeedFault, + path: String = "*", + times: Int = Int.MAX_VALUE, + ) { + require(times > 0) { "times must be positive, got $times" } + faults += ActiveFault(fault, globToRegex(path), AtomicInteger(times)) + } + + /** Removes every injected fault. */ + public fun clearFaults() { + faults.clear() + } + + /** Forgets the [requests] recorded so far. */ + public fun clearRequests() { + recorded.clear() + } + + override fun close() { + server.stop(0) + executor.shutdownNow() + executor.awaitTermination(STOP_TIMEOUT_SECONDS, TimeUnit.SECONDS) + if (ownsDirectory) directory.deleteRecursively() + } + + private fun copyIntoFeed(file: File): File { + val target = File(directory, file.name) + if (file.absoluteFile.normalize() != target) file.copyTo(target, overwrite = true) + return target + } + + private fun serve(exchange: HttpExchange) { + val method = exchange.requestMethod.uppercase() + val path = exchange.requestURI.path.trimStart('/') + val range = exchange.requestHeaders.getFirst("Range") + var status = HTTP_NOT_FOUND + val sent = longArrayOf(0) + try { + val applicable = takeFaults(path) + applicable.firstNotNullOfOrNull { it as? FeedFault.Status }?.let { fault -> + status = fault.code + exchange.sendResponseHeaders(fault.code, -1) + return + } + applicable.filterIsInstance().forEach { Thread.sleep(it.duration.inWholeMilliseconds) } + + val file = File(directory, path).normalize() + if (method !in SUPPORTED_METHODS) { + status = HTTP_BAD_METHOD + exchange.sendResponseHeaders(status, -1) + return + } + if (!file.toPath().startsWith(directory.toPath()) || !file.isFile) { + exchange.sendResponseHeaders(status, -1) + return + } + + val length = file.length() + val requested = range?.takeUnless { FeedFault.IgnoreRange in applicable }?.let { parseRange(it, length) } + if (requested == UNSATISFIABLE) { + status = HTTP_RANGE_NOT_SATISFIABLE + exchange.responseHeaders.add("Content-Range", "bytes */$length") + exchange.sendResponseHeaders(status, -1) + return + } + val (start, endInclusive) = requested ?: (0L to length - 1) + val count = endInclusive - start + 1 + status = if (requested != null) HTTP_PARTIAL_CONTENT else HTTP_OK + exchange.responseHeaders.add("Accept-Ranges", "bytes") + if (requested != null) exchange.responseHeaders.add("Content-Range", "bytes $start-$endInclusive/$length") + + if (method == "HEAD") { + exchange.responseHeaders.add("Content-Length", count.toString()) + exchange.sendResponseHeaders(status, -1) + return + } + exchange.sendResponseHeaders(status, if (count == 0L) -1 else count) + if (count > 0) streamBody(file, start, count, applicable, exchange.responseBody, sent) + } catch (_: IOException) { + // The client went away, or a Truncate fault dropped the connection on purpose. + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + recorded += FeedRequest(method, path, range, status, sent[0]) + runCatching { exchange.close() } + } + } + + /** Sends [count] bytes of [file] from [start], applying the body faults and counting into [sentBytes]. */ + @Suppress("LongParameterList") + private fun streamBody( + file: File, + start: Long, + count: Long, + applicable: List, + out: OutputStream, + sentBytes: LongArray, + ) { + val limit = applicable.filterIsInstance().minOfOrNull { it.afterBytes } ?: Long.MAX_VALUE + val rate = applicable.filterIsInstance().minOfOrNull { it.bytesPerSecond } + val corruptAt = applicable.filterIsInstance().map { it.offset }.toSet() + val chunkSize = + rate?.let { (it / THROTTLE_TICKS_PER_SECOND).coerceIn(1, BUFFER_SIZE.toLong()).toInt() } ?: BUFFER_SIZE + val buffer = ByteArray(chunkSize) + val began = System.nanoTime() + var sent = 0L + RandomAccessFile(file, "r").use { input -> + input.seek(start) + while (sent < count) { + if (sent >= limit) { + // Put what was "sent" on the wire first: dropping it with the connection would look + // like a stale pooled connection, which HTTP clients silently retry. + out.flush() + throw IOException("Truncated by FeedFault.Truncate after $sent bytes") + } + val toRead = minOf(chunkSize.toLong(), count - sent, limit - sent).toInt() + val read = input.read(buffer, 0, toRead) + if (read < 0) break + corruptAt + .map { it - (start + sent) } + .filter { it in 0 until read } + .forEach { index -> buffer[index.toInt()] = (buffer[index.toInt()].toInt() xor ALL_BITS).toByte() } + out.write(buffer, 0, read) + sent += read + sentBytes[0] = sent + if (rate != null) { + val dueNanos = sent * NANOS_PER_SECOND / rate + val aheadMillis = (dueNanos - (System.nanoTime() - began)) / NANOS_PER_MILLI + if (aheadMillis > 0) Thread.sleep(aheadMillis) + } + } + out.flush() + } + if (sent < count) throw IOException("Truncated by FeedFault.Truncate after $sent bytes") + } + + private fun takeFaults(path: String): List = + faults + .filter { active -> + active.pattern.matches(path) && active.remaining.getAndUpdate { if (it > 0) it - 1 else 0 } > 0 + }.map { it.fault } + + private class ActiveFault( + val fault: FeedFault, + val pattern: Regex, + val remaining: AtomicInteger, + ) + + private class ManifestEntry( + val url: String, + val sha512: String, + val size: Long, + ) + + /** Feed naming shared with the updater. */ + public companion object { + private const val HTTP_OK = 200 + private const val HTTP_PARTIAL_CONTENT = 206 + private const val HTTP_NOT_FOUND = 404 + private const val HTTP_BAD_METHOD = 405 + private const val HTTP_RANGE_NOT_SATISFIABLE = 416 + private const val BUFFER_SIZE = 64 * 1024 + private const val ALL_BITS = 0xFF + private const val THROTTLE_TICKS_PER_SECOND = 20 + private const val NANOS_PER_SECOND = 1_000_000_000L + private const val NANOS_PER_MILLI = 1_000_000L + private const val STOP_TIMEOUT_SECONDS = 5L + private val SUPPORTED_METHODS = setOf("GET", "HEAD") + private val COMPANION_EXTENSIONS = listOf(".blockmap", ".asc") + private val UNSATISFIABLE = -1L to -1L + private val THREAD_IDS = AtomicInteger() + + /** The manifest a client of [platform] reads for [channel]: `latest.yml`, `beta-mac.yml`, … */ + public fun manifestName( + channel: String, + platform: Platform = Platform.Current, + ): String = + when (platform) { + Platform.MacOS -> "$channel-mac.yml" + Platform.Linux -> "$channel-linux.yml" + Platform.Windows, Platform.Unknown -> "$channel.yml" + } + + private fun manifestYaml( + version: String, + entries: List, + releaseDate: Instant, + ): String = + buildString { + appendLine("version: $version") + appendLine("files:") + for (entry in entries) { + appendLine(" - url: ${entry.url}") + appendLine(" sha512: ${entry.sha512}") + appendLine(" size: ${entry.size}") + } + appendLine("path: ${entries.first().url}") + appendLine("sha512: ${entries.first().sha512}") + appendLine("releaseDate: '$releaseDate'") + } + + private fun sha512Base64(file: File): String { + val digest = MessageDigest.getInstance("SHA-512") + file.inputStream().use { input -> + val buffer = ByteArray(BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read < 0) break + digest.update(buffer, 0, read) + } + } + return Base64.getEncoder().encodeToString(digest.digest()) + } + + /** Parses `bytes=a-b`, `bytes=a-` and `bytes=-n`; `null` when it is not a single byte range. */ + private fun parseRange( + header: String, + length: Long, + ): Pair? { + val spec = header.trim() + if (!spec.startsWith("bytes=") || ',' in spec) return null + val (first, last) = spec.removePrefix("bytes=").split('-', limit = 2).takeIf { it.size == 2 } ?: return null + val range = + when { + first.isBlank() -> { + val suffix = last.trim().toLongOrNull() ?: return null + (length - suffix).coerceAtLeast(0) to length - 1 + } + else -> { + val start = first.trim().toLongOrNull() ?: return null + val end = last.trim().takeIf { it.isNotEmpty() }?.toLongOrNull() ?: (length - 1) + start to minOf(end, length - 1) + } + } + return if (range.first > range.second || range.first >= length) UNSATISFIABLE else range + } + + private fun globToRegex(glob: String): Regex = Regex(glob.split('*').joinToString(".*") { Regex.escape(it) }) + } +} diff --git a/updater-testing/src/test/kotlin/dev/nucleusframework/updater/testing/UpdaterTortureTest.kt b/updater-testing/src/test/kotlin/dev/nucleusframework/updater/testing/UpdaterTortureTest.kt new file mode 100644 index 000000000..3a656276f --- /dev/null +++ b/updater-testing/src/test/kotlin/dev/nucleusframework/updater/testing/UpdaterTortureTest.kt @@ -0,0 +1,366 @@ +package dev.nucleusframework.updater.testing + +import dev.nucleusframework.core.runtime.Platform +import dev.nucleusframework.updater.DownloadProgress +import dev.nucleusframework.updater.NucleusUpdater +import dev.nucleusframework.updater.UpdateInfo +import dev.nucleusframework.updater.UpdateResult +import dev.nucleusframework.updater.exception.ChecksumException +import dev.nucleusframework.updater.exception.NetworkException +import dev.nucleusframework.updater.exception.UpdateException +import dev.nucleusframework.updater.provider.GenericProvider +import dev.nucleusframework.updater.provider.LocalFileProvider +import dev.nucleusframework.updater.provider.UpdateProvider +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.random.Random +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Drives the real [NucleusUpdater] against an [UpdateFeedServer] that misbehaves the ways release + * hosts, proxies and networks do. Every case checks the two invariants an update must keep whatever + * happens: a download that completes is byte-identical to the published artifact, and a download + * that fails leaves nothing behind — no staged file an app could install. + */ +class UpdaterTortureTest { + private lateinit var feed: UpdateFeedServer + private lateinit var work: File + private val artifactName = artifactNameForThisOs("2.0.0") + private lateinit var artifact: File + private lateinit var stagingBefore: Set + + @Before + fun setUp() { + stagingBefore = stagingDirs() + feed = UpdateFeedServer() + work = Files.createTempDirectory("nucleus-torture-").toFile() + artifact = File(work, artifactName).apply { writeBytes(Random(42).nextBytes(ARTIFACT_SIZE)) } + feed.publish("2.0.0", listOf(artifact), platform = Platform.Current) + } + + @After + fun tearDown() { + feed.close() + work.deleteRecursively() + } + + private fun updater( + provider: UpdateProvider = GenericProvider(feed.baseUrl), + currentVersion: String = "1.0.0", + ): NucleusUpdater = + NucleusUpdater { + this.currentVersion = currentVersion + // An installed build: the self-updatable format of each OS, no dev-mode short-circuit. + executableType = packagedTypeForThisOs() + this.provider = provider + differentialDownload = false + cacheDir = File(work, "cache") + } + + private fun available(updater: NucleusUpdater): UpdateInfo { + val result = runBlocking { updater.checkForUpdates() } + assertTrue("expected an update, got $result", result is UpdateResult.Available) + return (result as UpdateResult.Available).info + } + + private fun assertDownloadsIntact( + updater: NucleusUpdater, + info: UpdateInfo = available(updater), + ): List { + val progress = runBlocking { withTimeout(60.seconds) { updater.downloadUpdate(info).toList() } } + val file = progress.last().file + assertNotNull("the last progress report carries the file", file) + assertArrayEquals("the downloaded artifact is byte-identical", artifact.readBytes(), file!!.readBytes()) + file.parentFile.deleteRecursively() + return progress + } + + private fun assertDownloadFails( + updater: NucleusUpdater, + info: UpdateInfo, + expected: Class, + ) { + val staged = mutableListOf() + try { + runBlocking { + withTimeout(60.seconds) { + updater.downloadUpdate(info).onEach { p -> p.file?.let(staged::add) }.collect() + } + } + staged.forEach { it.parentFile.deleteRecursively() } + fail("the download must fail") + } catch (e: UpdateException) { + assertTrue("expected ${expected.simpleName}, got $e", expected.isInstance(e)) + } + assertTrue("a failed download hands over no file", staged.isEmpty()) + assertNoStagingLeft() + } + + /** Download staging dirs (`nucleus-update-*` in the temp dir) this test created and left behind. */ + private fun stagingDirs(): Set = + File(System.getProperty("java.io.tmpdir")) + .listFiles { f -> + f.isDirectory && + f.name.startsWith("nucleus-update-") && + !f.name.startsWith("nucleus-update-feed-") + }.orEmpty() + .toSet() + + private fun assertNoStagingLeft() { + // Other test JVMs stage downloads in the same temp dir: only this test's artifact counts. + val leftovers = + (stagingDirs() - stagingBefore).filter { dir -> dir.list().orEmpty().any { it.startsWith("TortureApp-") } } + assertTrue("staging left behind: ${leftovers.map { "$it ${it.list()?.toList()}" }}", leftovers.isEmpty()) + } + + @Test + fun `a healthy feed updates byte for byte with monotonic progress`() { + val progress = assertDownloadsIntact(updater()) + val percents = progress.map { it.percent } + assertEquals(percents.sorted(), percents) + assertEquals(100.0, percents.last(), 0.0) + } + + @Test + fun `the running version or a newer one is not offered`() { + runBlocking { + assertEquals(UpdateResult.NotAvailable, updater(currentVersion = "2.0.0").checkForUpdates()) + assertEquals(UpdateResult.NotAvailable, updater(currentVersion = "3.1.0").checkForUpdates()) + } + } + + @Test + fun `a server error on the manifest is an error result, not an exception`() { + feed.fault(FeedFault.Status(503), path = "*.yml") + val result = runBlocking { updater().checkForUpdates() } + assertTrue("got $result", result is UpdateResult.Error) + } + + @Test + fun `a missing manifest is an error result`() { + File(feed.directory, UpdateFeedServer.manifestName("latest")).delete() + val result = runBlocking { updater().checkForUpdates() } + assertTrue("got $result", result is UpdateResult.Error) + } + + @Test + fun `a garbage manifest is an error result`() { + File(feed.directory, UpdateFeedServer.manifestName("latest")).writeBytes(Random(7).nextBytes(4096)) + val result = runBlocking { updater().checkForUpdates() } + assertTrue("got $result", result is UpdateResult.Error || result is UpdateResult.NotAvailable) + } + + @Test + fun `an artifact that went missing after the check fails cleanly`() { + val updater = updater() + val info = available(updater) + feed.fault(FeedFault.Status(404), path = artifactName) + assertDownloadFails(updater, info, NetworkException::class.java) + } + + @Test + fun `a connection cut part-way fails cleanly`() { + val updater = updater() + val info = available(updater) + feed.fault(FeedFault.Truncate(afterBytes = ARTIFACT_SIZE / 3L), path = artifactName) + assertDownloadFails(updater, info, UpdateException::class.java) + } + + @Test + fun `a corrupted byte is caught by the SHA-512 check`() { + val updater = updater() + val info = available(updater) + feed.fault(FeedFault.Corrupt(offset = ARTIFACT_SIZE / 2L), path = artifactName) + assertDownloadFails(updater, info, ChecksumException::class.java) + } + + @Test + fun `an artifact replaced between check and download fails the checksum`() { + val updater = updater() + val info = available(updater) + File(feed.directory, artifactName).writeBytes(Random(99).nextBytes(ARTIFACT_SIZE)) + assertDownloadFails(updater, info, ChecksumException::class.java) + } + + @Test + fun `a transient failure does not poison the next attempt`() { + val updater = updater() + val info = available(updater) + feed.fault(FeedFault.Truncate(afterBytes = 1000), path = artifactName, times = 1) + assertDownloadFails(updater, info, UpdateException::class.java) + assertDownloadsIntact(updater, info) + } + + @Test + fun `a throttled link reports many progress steps and still completes`() { + feed.fault(FeedFault.Throttle(bytesPerSecond = ARTIFACT_SIZE * 2L), path = artifactName) + val progress = assertDownloadsIntact(updater()) + assertTrue("a slow link reports progress along the way, got ${progress.size}", progress.size > 5) + } + + @Test + fun `cancelling a slow download leaves nothing staged`() { + val updater = updater() + val info = available(updater) + feed.fault(FeedFault.Throttle(bytesPerSecond = 64 * 1024L), path = artifactName) + val seen = mutableListOf() + val finished = + runBlocking { + withTimeoutOrNull(700.milliseconds) { updater.downloadUpdate(info).collect { seen += it } } + } + assertEquals("the download was cancelled mid-way", null, finished) + assertTrue("it had started", seen.isNotEmpty()) + assertTrue("no file handed over", seen.none { it.file != null }) + // The staging directory is removed as the cancellation unwinds. + Thread.sleep(300) + assertNoStagingLeft() + } + + @Test + fun `a slow host is waited for`() { + feed.fault(FeedFault.Delay(1.seconds)) + assertDownloadsIntact(updater()) + } + + @Test + fun `parallel checks and downloads each get an intact private copy`() { + feed.fault(FeedFault.Throttle(bytesPerSecond = ARTIFACT_SIZE * 4L), path = artifactName) + val updaters = List(PARALLEL) { updater() } + val files = + runBlocking { + updaters + .map { u -> + async(kotlinx.coroutines.Dispatchers.IO) { + val info = (u.checkForUpdates() as UpdateResult.Available).info + u.downloadUpdate(info).last().file!! + } + }.awaitAll() + } + assertEquals("every download is staged privately", PARALLEL, files.map { it.absolutePath }.toSet().size) + files.forEach { assertArrayEquals(artifact.readBytes(), it.readBytes()) } + files.forEach { it.parentFile.deleteRecursively() } + } + + @Test + fun `a newer release published while running is picked up by the next check`() { + val updater = updater(currentVersion = "2.0.0") + runBlocking { assertEquals(UpdateResult.NotAvailable, updater.checkForUpdates()) } + val next = File(work, artifactNameForThisOs("2.1.0")).apply { writeBytes(Random(3).nextBytes(1024)) } + feed.publish("2.1.0", next) + val result = runBlocking { updater.checkForUpdates() } + assertEquals("2.1.0", (result as UpdateResult.Available).info.version) + } + + @Test + fun `a local directory feed updates through the same path`() { + val dir = File(work, "feed dir with spaces ünïcødé").apply { mkdirs() } + feed.directory.listFiles()!!.forEach { it.copyTo(File(dir, it.name)) } + assertDownloadsIntact(updater(provider = LocalFileProvider(dir))) + } + + @Test + fun `a local feed with a missing artifact fails cleanly`() { + val dir = File(work, "local").apply { mkdirs() } + feed.directory.listFiles()!!.forEach { it.copyTo(File(dir, it.name)) } + val updater = updater(provider = LocalFileProvider(dir)) + val info = available(updater) + File(dir, artifactName).delete() + assertDownloadFails(updater, info, NetworkException::class.java) + } + + @Test + fun `a local manifest pointing outside its directory is refused`() { + val dir = File(work, "evil").apply { mkdirs() } + File(dir, UpdateFeedServer.manifestName("latest")).writeText( + "version: 9.0.0\nfiles:\n - url: ../../outside.exe\n sha512: AAAA\n size: 1\n", + ) + val result = runBlocking { updater(provider = LocalFileProvider(dir)).checkForUpdates() } + assertTrue("got $result", result is UpdateResult.Error) + } + + @Test + fun `the server honours single byte ranges`() { + val client = + java.net.http.HttpClient + .newHttpClient() + val response = + client.send( + java.net.http.HttpRequest + .newBuilder(java.net.URI("${feed.baseUrl}/$artifactName")) + .header("Range", "bytes=10-19") + .build(), + java.net.http.HttpResponse.BodyHandlers + .ofByteArray(), + ) + assertEquals(206, response.statusCode()) + assertArrayEquals(artifact.readBytes().copyOfRange(10, 20), response.body()) + feed.fault(FeedFault.IgnoreRange) + val ignored = + client.send( + java.net.http.HttpRequest + .newBuilder(java.net.URI("${feed.baseUrl}/$artifactName")) + .header("Range", "bytes=10-19") + .build(), + java.net.http.HttpResponse.BodyHandlers + .ofByteArray(), + ) + assertEquals(200, ignored.statusCode()) + assertEquals(ARTIFACT_SIZE, ignored.body().size) + } + + @Test + fun `the server refuses to serve outside its directory`() { + File(work, "secret.txt").writeText("secret") + val client = + java.net.http.HttpClient + .newHttpClient() + val response = + client.send( + java.net.http.HttpRequest + .newBuilder(java.net.URI("${feed.baseUrl}/..%2Fsecret.txt")) + .build(), + java.net.http.HttpResponse.BodyHandlers + .ofString(), + ) + assertEquals(404, response.statusCode()) + assertFalse(response.body().contains("secret")) + } + + private companion object { + const val ARTIFACT_SIZE = 3 * 1024 * 1024 + 17 + const val PARALLEL = 6 + + fun artifactNameForThisOs(version: String): String = + when (Platform.Current) { + Platform.Windows -> "TortureApp-$version-win-x64-nsis.exe" + Platform.MacOS -> "TortureApp-$version-mac-arm64.zip" + else -> "TortureApp-$version-linux-x86_64.AppImage" + } + + fun packagedTypeForThisOs(): String = + when (Platform.Current) { + Platform.Windows -> "nsis" + Platform.MacOS -> "zip" + else -> "appimage" + } + } +}