diff --git a/.github/scripts/check-single-executable.sh b/.github/scripts/check-single-executable.sh index ae5b13ce9..30b5d31e0 100755 --- a/.github/scripts/check-single-executable.sh +++ b/.github/scripts/check-single-executable.sh @@ -21,6 +21,13 @@ # # Needs a display. On Linux run it under `xvfb-run -a`; a macOS runner has one. # +# On Windows (Git Bash, with the Visual Studio C++ tools installed) the tarball is the static +# kit build-windows.sh writes, packaged by package-static-renderer.sh. The build script has no +# download route for it yet, so the kit is unpacked into the scratch directory and handed to +# the build as DXC_WINDOWS_NATIVE_LIB, which links exactly what a consumer's build would. +# dumpbin (or llvm-readobj, or objdump) must then show Windows' own DLLs only; a hosted +# runner has no graphics card, so the window draws with Windows' software adapter. +# # What it prints is the evidence: the executable's size, before and after stripping, and # every library it names. set -euo pipefail @@ -44,9 +51,11 @@ fail() { } os="$(uname -s)" +exe="" case "$os" in Darwin|Linux) ;; - *) fail "this checks macOS and Linux executables; $os is not one" ;; + MINGW*|MSYS*|CYGWIN*) os=Windows exe=.exe ;; + *) fail "this checks macOS, Linux and Windows executables; $os is not one" ;; esac if [[ "$os" == Linux && -z "${DISPLAY:-}" ]]; then fail "DISPLAY is not set" "Run it under xvfb-run -a." @@ -69,13 +78,15 @@ esac # Listed into a variable first: grep -q stops reading early, and under pipefail the tar it # cut off would fail the check. contents="$(tar -tzf "$artifact")" -grep -qE '^(\./)?lib/libcompose_rust_renderer\.a$' <<< "$contents" || - fail "$(basename "$artifact") does not carry lib/libcompose_rust_renderer.a" \ +archive_pattern='^(\./)?lib/libcompose_rust_renderer\.a$' +[[ "$os" != Windows ]] || archive_pattern='^(\./)?libcompose_rust_renderer\.a$' +grep -qE "$archive_pattern" <<< "$contents" || + fail "$(basename "$artifact") does not carry the static renderer archive" \ "Only the static renderer makes an application one executable." # A workspace build in this checkout would be found before the cache, and then this would # test that instead of the artifact. -for built in renderer/build/macos renderer/build/linux \ +for built in renderer/build/macos renderer/build/linux renderer/build/windows \ renderer/build/linux-arm64 renderer/build/native-image/dist/lib; do [[ ! -e "$repo_root/$built" ]] || fail "$built exists in this checkout" \ "The build script would link that instead of the artifact. Run this in a clean checkout." @@ -93,6 +104,7 @@ cp "$artifact" "$artifact.sha256" "$scratch/cache/downloads/" # Nothing may point the build anywhere else. unset COMPOSE_RUST_RENDERER_DIR DIOXUS_COMPOSE_RENDERER_DIR DXC_MACOS_NATIVE_LIB DXC_LINUX_NATIVE_LIB +unset DXC_WINDOWS_NATIVE_LIB DXC_WINDOWS_CRT RUSTFLAGS CARGO_ENCODED_RUSTFLAGS CARGO_BUILD_RUSTFLAGS # The Kotlin/Native renderer cannot close its own window, so the self-check ends the # process once the frames are in. unset COMPOSE_RUST_AUTOEXIT_MS @@ -106,9 +118,40 @@ export COMPOSE_RUST_TEXT_SELF_CHECK=1 # No ICU data file anywhere an application could be pointed at one. The executable must find # words and line breaks with what is linked into it. A runner that ships one (a browser # carries icudtl.dat) has to have it removed before this runs. -icu_files="$(find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true)" +if [[ "$os" == Windows ]]; then + # Git Bash sees the drives as /c, /d. The system and program directories are where a + # browser or a runtime keeps one; the checkout and the scratch directory are not searched, + # because the kit's own skia/icudtl.dat is not in the tarball and must not be. + icu_roots=() + for root in "/c/Program Files" "/c/Program Files (x86)" "/c/ProgramData" "/c/Users" \ + "/c/Windows" "/c/hostedtoolcache" "/c/msys64" "/d/a/_tool" "/d/a/_temp"; do + [[ ! -d "$root" ]] || icu_roots+=("$root") + done + icu_files="$(find "${icu_roots[@]}" -name 'icudtl.dat' 2>/dev/null || true)" +else + icu_files="$(find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true)" +fi [[ -z "$icu_files" ]] || fail "an ICU data file is on this machine, so the text check would not prove anything" \ - $icu_files + "$icu_files" + +if [[ "$os" == Windows ]]; then + # The tarball root is the kit root, as build-windows.sh writes it and build.rs reads it. + kit="$scratch/kit" + mkdir -p "$kit" + tar -xzf "$artifact" -C "$kit" + [[ -f "$kit/libcompose_rust_renderer.a" ]] || fail "the unpacked kit has no libcompose_rust_renderer.a at its root" + for piece in gcc/libstdc++.a native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib \ + skiko/skiko-bridges.lib skiko/skia.lib; do + [[ -f "$kit/$piece" ]] || fail "the kit has no $piece" \ + "package-static-renderer.sh packs the whole kit build-windows.sh writes for windows-x64." + done + find "$kit" -name 'icudtl.dat' | grep . && fail "the kit carries an icudtl.dat" \ + "The ICU data is to be inside the renderer's object, not beside it." + DXC_WINDOWS_NATIVE_LIB="$(cygpath -w "$kit")" + export DXC_WINDOWS_NATIVE_LIB + # A hosted runner has no card: Windows' software adapter draws instead. + export DXC_D3D12_WARP="${DXC_D3D12_WARP:-1}" +fi # What every system the application may name lives under. Anything else is a file that # would have to travel with it. @@ -119,9 +162,59 @@ system_library() { esac } +# Visual Studio's tools, on PATH inside a developer prompt and found through vswhere otherwise. +# Prints nothing when there is none. +vs_tool() { + local name="$1" found vswhere + found="$(command -v "$name" || true)" + if [[ -z "$found" ]]; then + vswhere="/c/Program Files (x86)/Microsoft Visual Studio/Installer/vswhere.exe" + [[ -f "$vswhere" ]] || return 0 + found="$("$vswhere" -latest -products '*' -find "VC/Tools/MSVC/**/bin/Hostx64/x64/$name" | tr -d '\r' | head -1)" + [[ -z "$found" ]] || found="$(cygpath -u "$found")" + fi + echo "$found" +} + +# The DLLs a Windows executable asks the loader for, listed by dumpbin. The rules are those of +# scripts/check-windows-consumer.sh: Windows' own DLLs only. +check_windows_libraries() { + local binary="$1" dumpbin dlls dll lower system not_ours=() + dumpbin="$(vs_tool dumpbin.exe)" + [[ -n "$dumpbin" ]] || fail "no dumpbin to list the executable's DLLs with" \ + "Install the Visual Studio C++ build tools." + echo "-- dumpbin -dependents $(basename "$binary")" + "$dumpbin" -nologo -dependents "$(cygpath -w "$binary")" | tr -d '\r' | + sed -n '/Image has the following dependencies/,/Summary/p' + dlls="$("$dumpbin" -nologo -dependents "$(cygpath -w "$binary")" | tr -d '\r' | + sed -n 's/^ *\([A-Za-z0-9_.-]*\.[dD][lL][lL]\)$/\1/p' | sort -fu)" + [[ -n "$dlls" ]] || fail "dumpbin named no DLL at all, which is not a Windows executable that opens a window" + system="$(cygpath -u "${SystemRoot:-C:\\Windows}")/System32" + for dll in $dlls; do + lower="$(echo "$dll" | tr '[:upper:]' '[:lower:]')" + case "$lower" in + vcruntime*|msvcp*|concrt*|libgcc*|libstdc++*|libwinpthread*|*mingw*|\ + libcompose_rust_renderer*|*jvm*|*java*|awt*|skiko*|icu*) + fail "$(basename "$binary") needs $dll, which is not part of Windows" \ + "The runtime and the renderer are to be linked into the executable." ;; + api-ms-win-*|ext-ms-win-*) ;; + *) [[ -f "$system/$dll" || -f "$system/$lower" ]] || not_ours+=("$dll") ;; + esac + done + [[ "${#not_ours[@]}" -eq 0 ]] || fail "$(basename "$binary") needs DLLs Windows does not ship: ${not_ours[*]}" + # A statically linked JNI library announces itself with JNI_OnLoad_. + ! grep -aq 'JNI_OnLoad' "$binary" || + fail "the executable carries a JNI library, so a Java runtime is linked into it" + echo "-- $(wc -w <<< "$dlls" | tr -d ' ') DLLs, every one part of Windows: $(echo $dlls)" +} + # Prints what the executable names and fails on anything that is not the system's. check_libraries() { local binary="$1" failures=0 library resolved + if [[ "$os" == Windows ]]; then + check_windows_libraries "$binary" + return + fi if [[ "$os" == Darwin ]]; then echo "-- otool -L $(basename "$binary")" otool -L "$binary" | tail -n +2 @@ -185,7 +278,7 @@ check_libraries() { } size_of() { - if [[ "$os" == Darwin ]]; then stat -f %z "$1"; else stat -c %s "$1"; fi + if [[ "$os" == Darwin ]]; then stat -f %z "$1"; else wc -c < "$1" | tr -d ' '; fi } megabytes() { @@ -207,7 +300,7 @@ build() { grep -q "unpacked the renderer for v$crate_version" "$log" || grep -q "$scratch/cache/renderer" "$log" || echo "note the build log does not say it unpacked the artifact (a cached build replays nothing)" >&2 - echo "$repo_root/target/single-executable/$name/release/consumer" + echo "$repo_root/target/single-executable/$name/release/consumer$exe" } run_alone() { @@ -215,13 +308,13 @@ run_alone() { local directory="$scratch/run/$name" rm -rf "$directory" mkdir -p "$directory" - cp "$binary" "$directory/consumer" + cp "$binary" "$directory/consumer$exe" echo "-- ls -la $directory" ls -la "$directory" - [[ "$(ls -A "$directory")" == "consumer" ]] || fail "something other than the executable is in $directory" + [[ "$(ls -A "$directory")" == "consumer$exe" ]] || fail "something other than the executable is in $directory" echo "== running it alone in $directory" local log="$scratch/run-$name.log" status=0 - ( cd "$directory" && ./consumer --self-check ) > "$log" 2>&1 || status=$? + ( cd "$directory" && "./consumer$exe" --self-check ) > "$log" 2>&1 || status=$? cat "$log" grep -q "compose-rust text self-check: ok" "$log" || fail "the renderer did not lay Korean text out correctly with no ICU data file" \ @@ -229,6 +322,12 @@ run_alone() { [[ "$status" -eq 0 ]] || fail "the executable did not draw on its own (exit $status)" # And the renderer said so itself: its window opened, its scene was made on the main # dispatcher this platform has, and a frame reached the window. + if [[ "$os" == Windows ]]; then + # The Windows renderer's start lines are not the ones the other two print (it has no + # main dispatcher of the platform's), so what proves it drew is the exit status of the + # self-check, which the executable returns only once frames went through. + return 0 + fi local dispatcher="$smoke_main_dispatcher_kn_linux" [[ "$os" != Darwin ]] || dispatcher="$smoke_main_dispatcher_kn_macos" check_window_start "$(cat "$log")" "$status" "$dispatcher" || @@ -237,6 +336,12 @@ run_alone() { report() { local binary="$1" stripped="$scratch/consumer.stripped" + if [[ "$os" == Windows ]]; then + # A release MSVC executable keeps its symbols in a .pdb beside it, so it is as small + # as it gets. + echo "-- size: $(megabytes "$(size_of "$binary")")" + return + fi cp "$binary" "$stripped" if [[ "$os" == Darwin ]]; then strip -x "$stripped"; else strip "$stripped"; fi echo "-- size: $(megabytes "$(size_of "$binary")"), stripped $(megabytes "$(size_of "$stripped")")" @@ -245,7 +350,7 @@ report() { binary="$(build default "")" [[ -x "$binary" ]] || fail "the build produced no $binary" -ls "$scratch/cache/renderer"/v"$crate_version"/* >/dev/null 2>&1 || +[[ "$os" == Windows ]] || ls "$scratch/cache/renderer"/v"$crate_version"/* >/dev/null 2>&1 || fail "the build did not unpack the artifact into $scratch/cache/renderer" \ "It found a renderer somewhere else, so this did not test the artifact." check_libraries "$binary" diff --git a/.github/scripts/delete-pr-caches.sh b/.github/scripts/delete-pr-caches.sh new file mode 100755 index 000000000..1201aeb6c --- /dev/null +++ b/.github/scripts/delete-pr-caches.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Usage: delete-pr-caches.sh +# +# Deletes the Actions caches that runs of one pull request saved (ref refs/pull//merge). +# A pull request's caches can only be read by that pull request, so once it is merged or +# closed they are dead weight against the repository's 10 GB limit, and they push out the +# caches that other runs could use. +# +# It deletes nothing but that one ref, and it never fails the run: a cache that is already +# gone or a listing that errors is reported as a warning. +set -uo pipefail + +number="${1:-}" +if [[ ! "$number" =~ ^[0-9]+$ ]]; then + echo "::warning title=No cache cleanup::'$number' is not a pull request number" + exit 0 +fi +ref="refs/pull/$number/merge" + +if ! ids="$(gh cache list --ref "$ref" --limit 1000 --json id --jq '.[].id' 2>&1)"; then + echo "::warning title=Could not list caches::$ids" + exit 0 +fi +[[ -n "$ids" ]] || { echo "no caches under $ref"; exit 0; } + +count="$(wc -l <<< "$ids" | tr -d ' ')" +[[ "$count" -lt 1000 ]] || echo "::warning title=More caches may remain::listed $count caches, the most the listing returns; run again to delete the rest" + +for id in $ids; do + [[ "$id" =~ ^[0-9]+$ ]] || continue + if gh cache delete "$id" >/dev/null 2>&1; then echo "deleted cache $id of $ref" + else echo "::warning title=Cache not deleted::cache $id of $ref"; fi +done +exit 0 diff --git a/.github/scripts/find-released-renderer.sh b/.github/scripts/find-released-renderer.sh new file mode 100755 index 000000000..1e2090209 --- /dev/null +++ b/.github/scripts/find-released-renderer.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Usage: find-released-renderer.sh +# +# Prints "released=true" or "released=false" for GITHUB_OUTPUT. true means the release +# v carries the renderer tarball and its checksum for every desktop target. +# +# "The release has no such asset" and "gh failed" are different answers. A missing release +# or missing asset is false, which sends the build to building the renderer itself. Any +# other gh failure (authentication, rate limit, network) is reported as a warning and also +# false, but it is said, because otherwise a broken token turns into a silent rebuild that +# takes hours. +set -uo pipefail + +version="${1:?usage: $0 }" +repo="${GITHUB_REPOSITORY:-}" + +err="$(mktemp)" +trap 'rm -f "$err"' EXIT +if names="$(gh release view "v$version" ${repo:+-R "$repo"} --json assets -q '.assets[].name' 2>"$err")"; then + : +else + if grep -qiE 'release not found|not found' "$err"; then + echo "no release v$version yet" >&2 + else + echo "::warning title=Could not read the release::gh failed: $(tr '\n' ' ' < "$err")" + fi + echo "released=false" + exit 0 +fi + +released=true +for target in macos-aarch64 linux-x64 linux-arm64 windows-x64; do + asset="compose-rust-renderer-v$version-$target.tar.gz" + for n in "$asset" "$asset.sha256"; do + grep -qxF "$n" <<<"$names" || { echo "missing $n" >&2; released=false; } + done +done +echo "released=$released" diff --git a/.github/scripts/heavy-checks.py b/.github/scripts/heavy-checks.py index 98686f1ff..c5417e954 100644 --- a/.github/scripts/heavy-checks.py +++ b/.github/scripts/heavy-checks.py @@ -53,10 +53,12 @@ KNOWN_HEAVY = [ "test-compose-from-fork.yml", "test-kn-desktop-renderer.yml", + "packager-linux.yml", "test-native-renderer.yml", "test-samples.yml", "test-windows-resize-metrics.yml", "test-windows-resize-probe.yml", + "package-windows-host.yml", ] REPO_ROOT = Path(__file__).resolve().parents[2] diff --git a/.github/scripts/native-needs-macos.sh b/.github/scripts/native-needs-macos.sh new file mode 100755 index 000000000..245c52bf4 --- /dev/null +++ b/.github/scripts/native-needs-macos.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Usage: git diff --no-renames --name-only | native-needs-macos.sh +# +# Reads the changed file names on stdin and prints "macos=true" or "macos=false" for +# GITHUB_OUTPUT. test-native-renderer.yml builds the macOS static renderer, checks the macOS +# single executable and builds the iOS renderer on macOS runners; false means every changed +# file is one of the other platforms' own, so those jobs are left out. It fails open: an empty +# list, an unreadable list or any file not named below prints true. +# +# The other platforms' own files (they feed no macOS job of this workflow): +# renderer/windows/, renderer/staticlib-windows/, renderer/linux/, renderer/staticlib-linux/ +# renderer/desktop/scripts/*.ps1, build-windows.sh, build-linux.sh, +# renderer/desktop/scripts/windows-build-contract.test.sh, windows-metadata/ +# scripts/check-windows-consumer.sh, .github/scripts/check-linux-input-access.sh, +# .github/scripts/atspi-walk.py, .github/workflows/windows-static.yml +# +# Everything else counts as macOS-relevant: the shared renderer sources, the build scripts, +# package-static-renderer.sh and check-single-executable.sh (the macOS legs run them), +# compose-rust/build*, parity scripts and the workflows themselves. +set -uo pipefail + +files="$(cat 2>/dev/null)" || { echo "macos=true"; exit 0; } +[[ -n "$files" ]] || { echo "macos=true"; exit 0; } + +while IFS= read -r file; do + [[ -n "$file" ]] || continue + case "$file" in + renderer/windows/*|renderer/staticlib-windows/*|renderer/linux/*|renderer/staticlib-linux/*) ;; + renderer/desktop/scripts/windows-metadata/*|renderer/desktop/scripts/*.ps1) ;; + renderer/desktop/scripts/build-windows.sh|renderer/desktop/scripts/build-linux.sh) ;; + renderer/desktop/scripts/windows-build-contract.test.sh) ;; + scripts/check-windows-consumer.sh|.github/scripts/check-linux-input-access.sh) ;; + .github/scripts/atspi-walk.py|.github/workflows/windows-static.yml) ;; + *) echo "macos=true"; exit 0 ;; + esac +done <<< "$files" +echo "macos=false" diff --git a/.github/scripts/needs-macos.sh b/.github/scripts/needs-macos.sh new file mode 100755 index 000000000..ebd61d14e --- /dev/null +++ b/.github/scripts/needs-macos.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Usage: git diff --name-only | needs-macos.sh +# +# Reads the changed file names on stdin and prints "macos=true" or "macos=false" for +# GITHUB_OUTPUT. false means every changed file is one that no macOS job can be affected +# by, so test.yml leaves its macOS jobs out. It fails open: an empty list, an unreadable +# list or any file not named below prints true, so a doubt always runs the macOS jobs. +# +# Never affecting macOS, by path: +# docs/ documentation and the planning documents +# *.md at the root, LICENSE, .gitignore +# text that nothing builds +# .github/workflows/{pages,publish-crates,release-sync,windows-static,test-windows-c, +# test-windows-resize-metrics,test-windows-resize-probe}.yml +# workflows no shell test reads. The others stay on the macOS +# side: install-nik.test.sh, kn-start-report.test.sh and +# others read them, and install-nik only means anything on +# macOS. +# renderer/windows/, renderer/staticlib-windows/, renderer/desktop/scripts/*.ps1, +# renderer/desktop/scripts/build-windows.sh, +# renderer/desktop/scripts/windows-build-contract.test.sh, +# renderer/desktop/scripts/windows-metadata/ +# the Windows renderer's own sources and build +# scripts/check-windows-consumer.sh +# the Windows consumer check +# +# Everything else counts as macOS-relevant, including .github/scripts/ (the shell suite +# runs on macOS and tests them), samples/, compose-rust/ and the rest of renderer/. +set -uo pipefail + +files="$(cat 2>/dev/null)" || { echo "macos=true"; exit 0; } +[[ -n "$files" ]] || { echo "macos=true"; exit 0; } + +while IFS= read -r file; do + [[ -n "$file" ]] || continue + case "$file" in + docs/*) ;; + .github/workflows/pages.yml|.github/workflows/publish-crates.yml|.github/workflows/release-sync.yml) ;; + .github/workflows/windows-static.yml|.github/workflows/test-windows-c.yml) ;; + .github/workflows/test-windows-resize-metrics.yml|.github/workflows/test-windows-resize-probe.yml) ;; + */*) case "$file" in + renderer/windows/*|renderer/staticlib-windows/*|renderer/desktop/scripts/windows-metadata/*) ;; + renderer/desktop/scripts/*.ps1|renderer/desktop/scripts/build-windows.sh) ;; + renderer/desktop/scripts/windows-build-contract.test.sh) ;; + scripts/check-windows-consumer.sh) ;; + *) echo "macos=true"; exit 0 ;; + esac ;; + *.md|LICENSE|.gitignore) ;; + *) echo "macos=true"; exit 0 ;; + esac +done <<< "$files" +echo "macos=false" diff --git a/.github/scripts/package-static-renderer.sh b/.github/scripts/package-static-renderer.sh index 8f19c41dc..5c779b970 100755 --- a/.github/scripts/package-static-renderer.sh +++ b/.github/scripts/package-static-renderer.sh @@ -14,9 +14,19 @@ # schema-hash.txt the schema the renderer was generated from # renderer.version the crate version it is for # +# windows-x64 is the kit root as build-windows.sh writes renderer/build/windows, which the +# Host's build script reads (compose-rust/build.rs), so nothing is under lib/ or include/: +# +# libcompose_rust_renderer.a, libcompose_rust_renderer_api.h, schema-hash.txt, +# renderer.version, and +# gcc/*.a the GCC runtime the renderer object carries +# native/*.lib the Win32 window, the MinGW bridge, the embedded +# ICU loader, and the bridge for a static UCRT +# skiko/*.lib skiko's C++ half and Skia, MSVC +# # what build-macos.sh or build-linux.sh wrote # (renderer/build/macos, build/linux, ...) -# macos-aarch64, linux-x64 or linux-arm64 +# macos-aarch64, linux-x64, linux-arm64 or windows-x64 # the crate version, without a leading v # # The name, the checksum file and the version file are the ones build/renderer_dir.rs reads, @@ -41,8 +51,8 @@ version="${3#v}" out="$4" case "$target" in - macos-aarch64|linux-x64|linux-arm64) ;; - *) fail "no static renderer is packaged for '$target'" "known: macos-aarch64, linux-x64, linux-arm64" ;; + macos-aarch64|linux-x64|linux-arm64|windows-x64) ;; + *) fail "no static renderer is packaged for '$target'" "known: macos-aarch64, linux-x64, linux-arm64, windows-x64" ;; esac [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([-+].*)?$ ]] || fail "'$3' is not a crate version" @@ -54,16 +64,48 @@ for file in "$archive" "$header" "$hash"; do "Build the renderer first: renderer/desktop/scripts/build-macos.sh or build-linux.sh." done +# The pieces of the Windows kit beyond the archive, header and schema hash. Listed by +# name, so a kit that lost one fails here and not in an application's link. +windows_kit=( + gcc/libstdc++.a gcc/libgcc.a gcc/libgcc_eh.a gcc/libwinpthread.a + native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib + skiko/skiko-bridges.lib skiko/skia.lib +) +if [[ "$target" == windows-x64 ]]; then + for file in "${windows_kit[@]}"; do + [[ -f "$build_dir/$file" ]] || fail "no $file in $build_dir" \ + "Build the renderer first: renderer/desktop/scripts/build-windows.sh writes the whole kit." + done +fi + name="compose-rust-renderer-v$version-$target" mkdir -p "$out" out="$(cd "$out" && pwd)" staging="$out/$name.staging" rm -rf "$staging" -mkdir -p "$staging/lib" "$staging/include" -cp "$archive" "$staging/lib/" -cp "$header" "$staging/include/" +if [[ "$target" == windows-x64 ]]; then + mkdir -p "$staging" + cp "$archive" "$header" "$staging/" +else + mkdir -p "$staging/lib" "$staging/include" + cp "$archive" "$staging/lib/" + cp "$header" "$staging/include/" +fi cp "$hash" "$staging/schema-hash.txt" echo "$version" > "$staging/renderer.version" +if [[ "$target" == windows-x64 ]]; then + for file in "${windows_kit[@]}"; do + mkdir -p "$staging/$(dirname "$file")" + cp "$build_dir/$file" "$staging/$file" + done + # Any further library the kit holds in those directories (skia ships several .lib files). + for dir in gcc native skiko; do + for file in "$build_dir/$dir"/*.a "$build_dir/$dir"/*.lib; do + [[ -f "$file" && ! -e "$staging/$dir/$(basename "$file")" ]] || continue + cp "$file" "$staging/$dir/" + done + done +fi # COPYFILE_DISABLE keeps macOS tar from adding ._ resource fork entries to the archive. COPYFILE_DISABLE=1 tar -czf "$out/$name.tar.gz" -C "$staging" . diff --git a/.github/workflows/cache-cleanup.yml b/.github/workflows/cache-cleanup.yml new file mode 100644 index 000000000..4cd1944d1 --- /dev/null +++ b/.github/workflows/cache-cleanup.yml @@ -0,0 +1,39 @@ +# Deletes a pull request's Actions caches when it is merged or closed. +# +# A cache saved by a pull request run belongs to that pull request's ref and nothing else can +# read it, but it still counts against the repository's 10 GB limit. Every pull request here +# saved about 8 GB of Kotlin and Gradle caches, so two of them fill the limit and GitHub +# evicts the oldest entries first, which is how the large Windows caches never survived. +# +# Only the closed pull request's own ref is touched. The number comes from the event payload +# through an environment variable, never into the script text. +name: Cache cleanup + +on: + pull_request: + types: [closed] + +concurrency: + group: cache-cleanup-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: {} + +jobs: + delete: + name: Delete the closed pull request's caches + runs-on: ubuntu-latest + permissions: + actions: write + contents: read + steps: + - uses: actions/checkout@v4 + with: + # The base branch's copy of the script, not the pull request's. + ref: ${{ github.event.pull_request.base.sha }} + - name: Delete the caches saved under refs/pull/N/merge + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: ./.github/scripts/delete-pr-caches.sh "$PR_NUMBER" diff --git a/.github/workflows/fork-desktop-modules.yml b/.github/workflows/fork-desktop-modules.yml index a63af54ad..f9775ce61 100644 --- a/.github/workflows/fork-desktop-modules.yml +++ b/.github/workflows/fork-desktop-modules.yml @@ -45,7 +45,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 with: - cache-read-only: false + cache-read-only: ${{ github.event_name == 'pull_request' }} gradle-home-cache-excludes: | caches/build-cache-1 diff --git a/.github/workflows/package-windows-host.yml b/.github/workflows/package-windows-host.yml new file mode 100644 index 000000000..e1758068d --- /dev/null +++ b/.github/workflows/package-windows-host.yml @@ -0,0 +1,137 @@ +# Windows package, host sample -- builds the notepad sample from this checkout against the +# Kotlin/Native renderer built from this same tree, packages it, signs it with a throwaway +# certificate, and runs the Windows App Certification Kit, which must pass the DPI awareness +# check outright. That proves what the crate puts into every executable (its application +# manifest) reaches the package the kit inspects. +# +# Heavy: the renderer is built here (windows-static.yml, two to three hours, cached), so it +# runs on request like the other heavy workflows. package-windows.yml keeps the fast checks. + +name: Windows package host + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + paths: + - 'scripts/packager-windows/**' + - 'compose-rust/build.rs' + - 'compose-rust/build/**' + - '.github/workflows/package-windows-host.yml' + workflow_dispatch: + inputs: + project: + description: 'Application directory holding Dioxus.toml and Cargo.toml' + default: 'samples/notepad' + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: package-windows-host-${{ github.event.pull_request.number || github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-heavy-ci' && format('-other-label-{0}', github.run_id) || '' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + PROJECT: ${{ inputs.project || 'samples/notepad' }} + +jobs: + requested: + name: Heavy checks requested + if: >- + github.event_name != 'pull_request' || + (github.event.action == 'labeled' && github.event.label.name == 'run-heavy-ci') || + (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-heavy-ci')) + runs-on: ubuntu-latest + steps: + - run: echo "Heavy checks requested by ${{ github.event_name }} ${{ github.event.action }}" + + windows-static: + name: renderer + needs: requested + uses: ./.github/workflows/windows-static.yml + + host: + name: Build a sample here, package and certify it + needs: [requested, windows-static] + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Take the renderer built from this tree + uses: actions/download-artifact@v4 + with: + name: windows-static-renderer-kit + path: ${{ runner.temp }}/renderer-kit + + - name: Build the packager and the notepad sample + # The Kotlin/Native static renderer from windows-static.yml, linked into the + # executable, so the sample is one file and its schema is this tree's. + env: + DXC_WINDOWS_NATIVE_LIB: ${{ runner.temp }}/renderer-kit + run: | + cargo build --locked --release -p compose-rust-msix --bin compose-rust-msix + if ($LASTEXITCODE -ne 0) { throw "the packager did not build" } + cargo build --locked --release -p sample-notepad --bin sample-notepad + if ($LASTEXITCODE -ne 0) { throw "the sample did not build" } + + - name: Show the manifest embedded in the sample + run: | + $ErrorActionPreference = 'Stop' + . ./scripts/packager-windows/scripts/common.ps1 + $mt = Find-SdkTool 'mt.exe' + $exe = 'target\release\sample-notepad.exe' + & $mt -nologo "-inputresource:$exe;#1" "-out:$env:RUNNER_TEMP\embedded.manifest" + if ($LASTEXITCODE -ne 0) { throw "sample-notepad.exe has no embedded manifest" } + $text = Get-Content -Raw "$env:RUNNER_TEMP\embedded.manifest" + Write-Host $text + if ($text -notmatch 'PerMonitorV2') { throw "the embedded manifest does not declare PerMonitorV2" } + + - name: Lay the sample out beside the renderer and package it + id: pack + run: | + $ErrorActionPreference = 'Stop' + $payload = Join-Path $env:RUNNER_TEMP 'host-payload' + New-Item -ItemType Directory -Force -Path (Join-Path $payload 'bin') | Out-Null + Copy-Item -Force 'target\release\sample-notepad.exe' (Join-Path $payload 'bin\sample-notepad.exe') + $out = Join-Path $env:RUNNER_TEMP 'msix' + $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' + & 'target\release\compose-rust-msix.exe' --project $env:PROJECT --out $out --summary $summary ` + --test-identity --payload-dir $payload --exe 'bin\sample-notepad.exe' + if ($LASTEXITCODE -ne 0) { throw "packaging failed" } + Get-Content $summary | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + + - name: Create and trust a test certificate + id: cert + run: > + ./scripts/packager-windows/scripts/new-test-certificate.ps1 + -Publisher '${{ steps.pack.outputs.publisher }}' + -OutDir "$env:RUNNER_TEMP\cert" -Trust + + - name: Sign + run: > + ./scripts/packager-windows/scripts/sign.ps1 + -Path '${{ steps.pack.outputs.package }}','${{ steps.pack.outputs.bundle }}' + -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' + + - name: Windows App Certification Kit (DPI awareness must pass) + run: > + ./scripts/packager-windows/scripts/run-wack.ps1 + -Package '${{ steps.pack.outputs.bundle }}' + -Report "$env:RUNNER_TEMP\wack\host-bundle-report.xml" + -RequirePass DPIAwarenessValidation + + - name: Upload the report + if: always() + uses: actions/upload-artifact@v4 + with: + name: wack-reports-host + path: ${{ runner.temp }}/wack/ + if-no-files-found: warn diff --git a/.github/workflows/package-windows.yml b/.github/workflows/package-windows.yml new file mode 100644 index 000000000..a62c00edb --- /dev/null +++ b/.github/workflows/package-windows.yml @@ -0,0 +1,165 @@ +# Windows package -- builds an MSIX and an .msixbundle from one executable, signs them +# with a throwaway certificate, and runs the Windows App Certification Kit against them. +# +# The kit is what Partner Center runs on a submission, so a package that passes here is +# not refused there for the same reasons. The Store signs what it takes in itself; the +# test certificate exists only so that this machine will install the package. +# +# What is packaged here: +# +# stand-in a tiny windowed program built here, described by the notepad sample's +# metadata. It proves the pipeline in minutes and owes nothing to the +# renderer. +# +# A sample built against a renderer from this tree is package-windows-host.yml, which is +# heavy and runs on request. + +name: Windows package + +on: + push: + paths: + - 'scripts/packager-windows/**' + - 'compose-rust/build.rs' + - 'compose-rust/build/**' + - 'compose-rust/tests/windows_manifest.rs' + - '.github/workflows/package-windows.yml' + pull_request: + paths: + - 'scripts/packager-windows/**' + - 'compose-rust/build.rs' + - 'compose-rust/build/**' + - 'compose-rust/tests/windows_manifest.rs' + - '.github/workflows/package-windows.yml' + workflow_dispatch: + inputs: + project: + description: 'Application directory holding Dioxus.toml and Cargo.toml' + default: 'samples/notepad' + type: string + +permissions: + contents: read + actions: read + +concurrency: + group: package-windows-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + PROJECT: ${{ inputs.project || 'samples/notepad' }} + +jobs: + test: + name: Packager tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: Format + run: cargo fmt -p compose-rust-msix -- --check + + - name: Clippy + run: cargo clippy --locked -p compose-rust-msix --all-targets -- -D warnings + + - name: Tests + run: cargo test --locked -p compose-rust-msix + + - name: Application manifest resource + # The resource the crate's build script writes into every Windows program. The + # renderer is not needed to check its bytes, so the crate is built without it. + if: runner.os == 'Linux' + run: cargo test --locked -p compose-rust --no-default-features --test windows_manifest + + package: + name: Package, sign and certify (stand-in) + needs: test + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + + - name: Install the Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup default stable + + - name: Build the packager and the stand-in program + run: cargo build --locked --release -p compose-rust-msix --bin compose-rust-msix --example fixture-app + + - name: Package + id: pack + run: | + $ErrorActionPreference = 'Stop' + $tool = 'target\release\compose-rust-msix.exe' + $out = Join-Path $env:RUNNER_TEMP 'msix' + $summary = Join-Path $env:RUNNER_TEMP 'msix-summary.txt' + & $tool --project $env:PROJECT --out $out --summary $summary --test-identity ` + --exe 'target\release\examples\fixture-app.exe' + if ($LASTEXITCODE -ne 0) { throw "packaging failed" } + Get-Content $summary | Add-Content -Path $env:GITHUB_OUTPUT -Encoding utf8 + Get-Content (Join-Path $out 'layout\AppxManifest.xml') + + - name: Create and trust a test certificate + id: cert + run: > + ./scripts/packager-windows/scripts/new-test-certificate.ps1 + -Publisher '${{ steps.pack.outputs.publisher }}' + -OutDir "$env:RUNNER_TEMP\cert" -Trust + + - name: Sign + run: > + ./scripts/packager-windows/scripts/sign.ps1 + -Path '${{ steps.pack.outputs.package }}','${{ steps.pack.outputs.bundle }}' + -Pfx '${{ steps.cert.outputs.pfx }}' -Password '${{ steps.cert.outputs.password }}' + + - name: Install and uninstall the bundle + run: | + $ErrorActionPreference = 'Stop' + Add-AppxPackage -Path '${{ steps.pack.outputs.bundle }}' + $installed = Get-AppxPackage -Name '${{ steps.pack.outputs.identity }}' + if (-not $installed) { throw "the bundle did not install" } + Write-Host "installed $($installed.PackageFullName) at $($installed.InstallLocation)" + if ($installed.Version -ne '${{ steps.pack.outputs.version }}') { throw "installed $($installed.Version), packaged ${{ steps.pack.outputs.version }}" } + $installed | Remove-AppxPackage + + - name: Windows App Certification Kit (package) + run: > + ./scripts/packager-windows/scripts/run-wack.ps1 + -Package '${{ steps.pack.outputs.package }}' + -Report "$env:RUNNER_TEMP\wack\package-report.xml" + + - name: Windows App Certification Kit (bundle) + run: > + ./scripts/packager-windows/scripts/run-wack.ps1 + -Package '${{ steps.pack.outputs.bundle }}' + -Report "$env:RUNNER_TEMP\wack\bundle-report.xml" + + - name: Upload the reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: wack-reports-stand-in + path: ${{ runner.temp }}/wack/ + if-no-files-found: warn + + - name: Upload the packages + if: always() + uses: actions/upload-artifact@v4 + with: + name: msix-packages-stand-in + path: | + ${{ runner.temp }}/msix/*.msix + ${{ runner.temp }}/msix/*.msixbundle + ${{ runner.temp }}/cert/test-signing.cer + if-no-files-found: warn diff --git a/.github/workflows/packager-linux.yml b/.github/workflows/packager-linux.yml new file mode 100644 index 000000000..98be05557 --- /dev/null +++ b/.github/workflows/packager-linux.yml @@ -0,0 +1,473 @@ +# Linux packaging: an AppImage and a Flatpak built the way Flathub builds one. Direct +# distribution is packaged and updated with Velopack, so nothing here carries update +# information. +# +# - rust: the packager's tests, lint, and the lock file. +# - appimage: builds a small program, wraps it as a signed AppImage, checks the contents +# and the metadata the desktop tools read. Then the calculator (built in this +# run with the static renderer linked in) is wrapped and started under a +# virtual display, on both architectures. +# - flatpak: builds the same program from source with flatpak-builder, offline, with +# every crate vendored, installs it, then builds 1.0.1 into the same repository +# and updates the installation with `flatpak update`. Then a calculator, built in +# this run (calculator job) as one executable with the Linux static renderer +# linked in, for x86_64 and aarch64, is +# packaged as a prebuilt archive and started in the sandbox under a virtual +# display, and the Flathub submission for dev.darkpyonix.Ember is generated. +# Every manifest goes through Flathub's linter. The builder and the linter are +# Flathub's own, from the org.flatpak.Builder Flatpak, because the one in +# Ubuntu's archive does not know the options Flathub requires. +# +# Heavy: the static renderer is built first (static-renderer.yml, the same definition the +# release uses), which takes hours, and the Flatpak SDKs are large. So it runs on request +# only: the run-heavy-ci label, or by hand. heavy-checks.yml holds the merge until it has +# passed on the pull request's head. + +name: Linux packaging + +on: + pull_request: + # Only when asked: the run-heavy-ci label going on, or a push or reopen while it is on. + types: [synchronize, reopened, labeled] + paths: + - scripts/packager-linux/** + - .github/workflows/packager-linux.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # A run started by some other label going on gets a group of its own, so it cannot + # cancel a heavy run. + group: packager-linux-${{ github.event.pull_request.number || github.ref }}${{ github.event.action == 'labeled' && github.event.label.name != 'run-heavy-ci' && format('-other-label-{0}', github.run_id) || '' }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + CARGO_PROFILE_DEV_DEBUG: "0" + FLATPAK_RUNTIME: "25.08" + # A screenshot for the test listings, pinned to a commit the way a store listing must be + # (Ember's large icon; the images are stand-ins, the pinning and mirroring are real). + SCREENSHOT_URL: https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/faa430a4b75ec45928623130ea2f874b628a7224/src-tauri/icons/128x128@2x.png + # Flathub's builder and linter, run from the org.flatpak.Builder Flatpak. + # Flathub's builder, from the org.flatpak.Builder Flatpak; the install step adds the user + # installation to its sandbox so it sees the runtimes installed there. + FLATPAK_BUILDER: flatpak run --command=flatpak-builder org.flatpak.Builder + FLATPAK_BUILDER_LINT: flatpak run --command=flatpak-builder-lint org.flatpak.Builder + +jobs: + requested: + name: Heavy checks requested + if: >- + github.event_name != 'pull_request' || + (github.event.action == 'labeled' && github.event.label.name == 'run-heavy-ci') || + (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, 'run-heavy-ci')) + runs-on: ubuntu-latest + steps: + - run: echo "Heavy checks requested by ${{ github.event_name }} ${{ github.event.action }}" + + # The Linux renderer, from the reusable workflow the release uses. The calculator below + # links it, so it is one executable. The called workflow has no concurrency group of + # its own (it only runs inside a caller), so this workflow's group covers it. + static-renderer: + name: renderer + needs: requested + strategy: + fail-fast: false + matrix: + target: [linux-x64, linux-arm64] + uses: ./.github/workflows/static-renderer.yml + with: + target: ${{ matrix.target }} + + calculator: + name: Calculator (${{ matrix.arch }}) + needs: [requested, static-renderer] + runs-on: ${{ matrix.runner }} + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x86_64 + target: linux-x64 + - runner: ubuntu-24.04-arm + arch: aarch64 + target: linux-arm64 + steps: + - uses: actions/checkout@v4 + + - name: Install the libraries a Linux application links + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends build-essential pkg-config \ + libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev + rustup toolchain install stable --profile minimal && rustup default stable + + - name: Download the static renderer + uses: actions/download-artifact@v4 + with: + pattern: compose-rust-renderer-v*-${{ matrix.target }} + path: renderer-artifact + merge-multiple: true + + - name: Build the calculator against it, as one executable + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/renderer" work/calculator/calculator + tar -xzf renderer-artifact/*.tar.gz -C "$RUNNER_TEMP/renderer" + COMPOSE_RUST_RENDERER_DIR="$RUNNER_TEMP/renderer" \ + cargo build --release -p sample-calculator --features compose-rust/native-renderer + cp target/release/sample-calculator work/calculator/calculator/ + exe=work/calculator/calculator/sample-calculator + readelf -d "$exe" | grep -E '\((NEEDED|RUNPATH|RPATH)\)' || true + # The renderer is linked in: no library of its own is needed beside it. + ! readelf -d "$exe" | grep -qi renderer + tar -czf "work/calculator-${{ matrix.target }}.tar.gz" -C work/calculator calculator + + - uses: actions/upload-artifact@v4 + with: + name: calculator-${{ matrix.target }} + path: work/calculator-${{ matrix.target }}.tar.gz + + rust: + needs: requested + name: Packager tests + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + run: | + rustup toolchain install stable --profile minimal --component rustfmt,clippy + rustup default stable + + - name: The lock file already records every dependency + # A Flathub build runs `cargo build --locked` with no network, so a lock file that + # needs updating is a build that cannot happen there. + run: | + cargo metadata --format-version 1 >/dev/null + if ! git diff --exit-code Cargo.lock; then + mkdir -p lock && cp Cargo.lock lock/ + echo "::error::Cargo.lock is out of date; the updated file is the 'cargo-lock' artifact" + echo "LOCK_STALE=1" >> "$GITHUB_ENV" + fi + + - uses: actions/upload-artifact@v4 + if: env.LOCK_STALE == '1' + with: + name: cargo-lock + path: lock/Cargo.lock + + - name: Formatting + run: cargo fmt --all -- --check + + - name: Clippy + run: | + cargo clippy -p packager-linux -p packager-linux-demo --all-targets -- -D warnings + + - name: Tests + run: | + cargo test -p packager-linux + + - name: Repository rules + run: | + bash scripts/tests/no-em-dash.test.sh + bash scripts/tests/no-spec-citations.test.sh + + - name: Fail on a stale lock file + if: env.LOCK_STALE == '1' + run: exit 1 + + appimage: + name: AppImage (${{ matrix.arch }}) + needs: [requested, calculator] + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + arch: x86_64 + target: linux-x64 + - runner: ubuntu-24.04-arm + arch: aarch64 + target: linux-arm64 + env: + ARCH: ${{ matrix.arch }} + steps: + - uses: actions/checkout@v4 + + - name: Install tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + desktop-file-utils appstream gnupg fuse3 libfuse2t64 patchelf \ + xvfb xauth imagemagick libgl1 libegl1 libgl1-mesa-dri fontconfig \ + libxtst6 libxi6 libxrender1 libxext6 libxrandr2 fonts-dejavu-core + rustup toolchain install stable --profile minimal && rustup default stable + ls -l /dev/fuse || echo "no /dev/fuse" + + - name: Build the packager and the program + run: | + set -euo pipefail + cargo build --release -p packager-linux + mkdir -p work/1.0.0 + for v in 1.0.0; do + COMPOSE_RUST_APP_VERSION=$v cargo build --release -p packager-linux-demo + cp target/release/update-demo "work/$v/update-demo" + "work/$v/update-demo" --version + done + echo "PACKAGER_LINUX=$PWD/target/release/packager-linux" >> "$GITHUB_ENV" + + - name: Make a signing key + run: | + set -euo pipefail + export GNUPGHOME="$PWD/work/gnupg" && mkdir -p -m 700 "$GNUPGHOME" + echo "GNUPGHOME=$GNUPGHOME" >> "$GITHUB_ENV" + gpg --batch --passphrase '' --quick-gen-key "Release " ed25519 sign never + echo "RELEASE_KEY=$(gpg --list-keys --with-colons release@example.invalid | awk -F: '/^fpr/ {print $10; exit}')" >> "$GITHUB_ENV" + + - name: Package the program + run: | + set -euo pipefail + scripts/packager-linux/appimage.sh \ + --dioxus-toml scripts/packager-linux/demo/Dioxus.toml \ + --payload "work/1.0.0/update-demo" --version 1.0.0 \ + --sign-key "$RELEASE_KEY" --out-dir "work/dist" + ls -l work/dist + + - name: The AppImage carries its payload and valid metadata + run: | + set -euo pipefail + app=work/dist/UpdateDemo-1.0.0-$ARCH.AppImage + # No update information: Velopack owns updates. + ! "$app" --appimage-updateinformation 2>/dev/null | grep -q zsync + "$app" --appimage-signature | head -3 + "$app" --appimage-extract >/dev/null + test -x squashfs-root/usr/lib/update-demo/update-demo + desktop-file-validate squashfs-root/dev.darkpyonix.compose.UpdateDemo.desktop + appstreamcli validate --no-net --explain \ + squashfs-root/usr/share/metainfo/dev.darkpyonix.compose.UpdateDemo.appdata.xml + rm -rf squashfs-root + cp "$app" work/UpdateDemo.AppImage + work/UpdateDemo.AppImage --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" + + - name: Download the calculator + uses: actions/download-artifact@v4 + with: + name: calculator-${{ matrix.target }} + path: work + + - name: Wrap the calculator as an AppImage and start it + # Built in this run from this tree as one executable with the static renderer + # linked in (the calculator job), so the same step runs on both architectures. + run: | + set -euo pipefail + mkdir -p work/calc + tar -xzf "work/calculator-${{ matrix.target }}.tar.gz" -C work/calc + # Unsigned: these are test builds, and the signing key is not created yet. + scripts/packager-linux/appimage.sh \ + --dioxus-toml samples/calculator/Dioxus.toml \ + --overlay scripts/packager-linux/samples.toml \ + --exec sample-calculator --icon samples/calculator/assets/icon.png \ + --icon samples/calculator/assets/icon-256.png \ + --payload work/calc/calculator --version 0.0.0 \ + --out-dir work/dist + app=work/dist/Calculator-0.0.0-$ARCH.AppImage + xvfb-run -a -s "-screen 0 1280x800x24" bash -c " + '$app' > work/calculator.log 2>&1 & + pid=\$! + sleep 25 + import -window root work/calculator-appimage.png + if kill -0 \$pid 2>/dev/null; then echo 'still running after 25 s'; kill \$pid; else echo 'exited early'; wait \$pid; echo \"status \$?\"; fi + " | tee work/calculator-run.txt + cat work/calculator.log + grep -q 'still running' work/calculator-run.txt + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: appimage-${{ matrix.arch }} + path: | + work/dist/*.AppImage + work/*.log + work/*.png + + flatpak: + name: Flatpak builds and updates + needs: [requested, calculator] + runs-on: ubuntu-24.04 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + + - name: Install flatpak and Flathub's builder + run: | + set -euo pipefail + # Ubuntu 24.04 stops unprivileged user namespaces through AppArmor, and + # bubblewrap, which builds and runs every sandbox, needs them. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + sudo apt-get update + # No flatpak-builder from apt: it is too old for the options Flathub requires. + # Flathub's own builder is installed below as the org.flatpak.Builder Flatpak. + sudo apt-get install -y --no-install-recommends flatpak elfutils dbus-daemon patchelf xvfb xauth imagemagick \ + build-essential pkg-config libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev + flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak --user remote-ls flathub --runtime --columns=ref | grep -E '^runtime/org\.freedesktop\.Platform/x86_64/[0-9.]+$' || true + flatpak --user install --noninteractive flathub org.flatpak.Builder \ + "org.freedesktop.Platform//$FLATPAK_RUNTIME" "org.freedesktop.Sdk//$FLATPAK_RUNTIME" \ + "org.freedesktop.Sdk.Extension.rust-stable//$FLATPAK_RUNTIME" + # Inside the builder's sandbox the user installation is not where the builder looks, so + # it asked flathub for the SDK that is already installed and was refused. + echo "FLATPAK_BUILDER=flatpak run --filesystem=$HOME/.local/share/flatpak --env=FLATPAK_USER_DIR=$HOME/.local/share/flatpak --command=flatpak-builder org.flatpak.Builder" >> "$GITHUB_ENV" + echo "== what the SDK has for rewriting an executable's library names" + flatpak run --user --command=sh "org.freedesktop.Sdk//$FLATPAK_RUNTIME" -c 'command -v patchelf readelf || true' + rustup toolchain install stable --profile minimal && rustup default stable + cargo build --release -p packager-linux + echo "PACKAGER_LINUX=$PWD/target/release/packager-linux" >> "$GITHUB_ENV" + + - name: Build 1.0.0 from source, offline, and install it + run: | + set -euo pipefail + mkdir -p work/flatpak-src + # What a release publishes: the tagged source as an archive. + git archive --format=tar.gz --prefix=compose-rust/ -o work/flatpak-src/source.tar.gz HEAD + # flatpak talks to its system helper over the session bus, which a runner has + # none of until one is started. + dbus-run-session -- scripts/packager-linux/flatpak.sh \ + --dioxus-toml scripts/packager-linux/demo/Dioxus.toml --version 1.0.0 \ + --source-path source.tar.gz --cargo-lock Cargo.lock \ + --package packager-linux-demo --bin update-demo \ + --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ + --out work/flatpak-src --build work/repo --lint + id=dev.darkpyonix.compose.UpdateDemo + flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.0" + # One executable: it runs, and no renderer library is among what it loads. + flatpak run --devel --command=sh "$id" -c 'ls /app/lib/update-demo; readelf -d /app/lib/update-demo/update-demo | grep -E "RUNPATH|RPATH|NEEDED" || true' + flatpak run --command=sh "$id" -c ' + set -e + test -x /app/lib/update-demo/update-demo + ! readelf -d /app/lib/update-demo/update-demo | grep -qi "renderer" + ! ls /app/lib/update-demo | grep -qi "renderer"' + cat work/flatpak-src/$id.json + + - name: Build 1.0.1 into the same repository, and flatpak update installs it + run: | + set -euo pipefail + id=dev.darkpyonix.compose.UpdateDemo + # The installed copy came from the builder; install it again from the repository + # as a remote, the way a store delivers it, so that `flatpak update` has + # somewhere to look. + flatpak --user remote-add --no-gpg-verify --if-not-exists local-repo "$PWD/work/repo" + flatpak --user uninstall --noninteractive "$id" + flatpak --user install --noninteractive local-repo "$id" + flatpak run "$id" --version | grep -qx "update-demo 1.0.0" + # flatpak talks to its system helper over the session bus, which a runner has + # none of until one is started. + dbus-run-session -- scripts/packager-linux/flatpak.sh \ + --dioxus-toml scripts/packager-linux/demo/Dioxus.toml --version 1.0.1 \ + --source-path source.tar.gz --cargo-lock Cargo.lock \ + --package packager-linux-demo --bin update-demo \ + --screenshot "$SCREENSHOT_URL" --screenshot-caption "The demo" \ + --out work/flatpak-src + (cd work/flatpak-src && dbus-run-session -- $FLATPAK_BUILDER \ + --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media --user --force-clean \ + --install-deps-from=flathub --state-dir="$PWD/.flatpak-builder" --repo="$PWD/../repo" build "$id.json") + flatpak --user update --noninteractive "$id" + flatpak run "$id" --version | tee /dev/stderr | grep -qx "update-demo 1.0.1" + flatpak --user info "$id" + echo "### Flatpak 1.0.0 updated to 1.0.1 with flatpak update" >> "$GITHUB_STEP_SUMMARY" + + - name: Download the calculators + uses: actions/download-artifact@v4 + with: + pattern: calculator-linux-* + path: work/calc + merge-multiple: true + + - name: Package the calculator as a prebuilt archive and start it in the sandbox + run: | + set -euo pipefail + mkdir -p work/flatpak-calculator + cp work/calc/calculator-linux-x64.tar.gz work/flatpak-calculator/ + # flatpak talks to its system helper over the session bus, which a runner has + # none of until one is started. + dbus-run-session -- scripts/packager-linux/flatpak.sh \ + --dioxus-toml samples/calculator/Dioxus.toml \ + --overlay scripts/packager-linux/samples.toml \ + --exec sample-calculator --icon samples/calculator/assets/icon.png \ + --icon samples/calculator/assets/icon-256.png --version 0.0.0 \ + --prebuilt-path calculator-linux-x64.tar.gz \ + --screenshot "$SCREENSHOT_URL" --screenshot-caption "The calculator" \ + --out work/flatpak-calculator --build work/repo-calculator --lint + id=dev.darkpyonix.composerust.samples.calculator + cat "work/flatpak-calculator/$id.json" + xvfb-run -a -s "-screen 0 1280x800x24" bash -c " + flatpak run $id > work/calculator-flatpak.log 2>&1 & + pid=\$! + sleep 30 + import -window root work/calculator-flatpak.png + if kill -0 \$pid 2>/dev/null; then echo 'still running after 30 s'; kill \$pid; else echo 'exited early'; wait \$pid; echo \"status \$?\"; fi + " | tee work/calculator-flatpak-run.txt + cat work/calculator-flatpak.log + grep -q 'still running' work/calculator-flatpak-run.txt + + - name: Prepare the Flathub submission for dev.darkpyonix.Ember + # What flathub/dev.darkpyonix.Ember will hold, generated and linted. Ember has no + # release yet, so two inputs are stand-ins and the listing is not submitted: + # the archives are the calculators built above (both architectures, as file:// sources), and the screenshot is + # Ember's large icon, pinned. For a real release, + # scripts/packager-linux/flathub/ember.sh takes the tag and fills in Ember's own + # archives, digests and the screenshot pinned to that tag. + env: + EMBER_COMMIT: faa430a4b75ec45928623130ea2f874b628a7224 + run: | + set -euo pipefail + raw="https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/$EMBER_COMMIT" + mkdir -p work/ember-icons + curl -fsSL -o work/ember-icons/128.png "$raw/src-tauri/icons/128x128.png" + curl -fsSL -o work/ember-icons/256.png "$raw/src-tauri/icons/128x128@2x.png" + mkdir -p work/flathub-ember + x64="$PWD/work/calc/calculator-linux-x64.tar.gz" + arm64="$PWD/work/calc/calculator-linux-arm64.tar.gz" + dbus-run-session -- scripts/packager-linux/flatpak.sh \ + --dioxus-toml scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml \ + --app-id dev.darkpyonix.Ember --version 0.0.0 \ + --icon work/ember-icons/128.png --icon work/ember-icons/256.png \ + --screenshot "$raw/src-tauri/icons/128x128@2x.png" --screenshot-caption "Ember" \ + --prebuilt-url "file://$x64" --prebuilt-sha256 "$(sha256sum "$x64" | cut -d' ' -f1)" --prebuilt-arch x86_64 \ + --prebuilt-url "file://$arm64" --prebuilt-sha256 "$(sha256sum "$arm64" | cut -d' ' -f1)" --prebuilt-arch aarch64 \ + --out work/flathub-ember --lint + rm -rf work/flathub-ember/.flatpak-builder work/flathub-ember/build + find work/flathub-ember -type f | sort + cat work/flathub-ember/dev.darkpyonix.Ember.metainfo.xml + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: flathub-submission-ember + path: work/flathub-ember/ + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: flatpak + path: | + work/flatpak-src/*.json + work/flatpak-src/*.xml + work/flatpak-src/*.desktop + work/flatpak-calculator/*.json + work/flatpak-calculator/*.xml + work/flatpak-calculator/*.desktop + work/flatpak-src/lint-*.json + work/flatpak-calculator/lint-*.json + work/*.log + work/*.png diff --git a/.github/workflows/packager-macos.yml b/.github/workflows/packager-macos.yml new file mode 100644 index 000000000..3c87a5abb --- /dev/null +++ b/.github/workflows/packager-macos.yml @@ -0,0 +1,48 @@ +# macOS packaging: the packager's tests. +# +# Direct distribution is packaged and updated with Velopack, so nothing here builds an +# update feed. + +name: macOS packaging + +on: + push: + branches: [develop, main, 'feature/packager-*'] + paths: + - 'scripts/packager-macos/**' + - 'scripts/bundle-renderer.sh' + - '.github/workflows/packager-macos.yml' + pull_request: + paths: + - 'scripts/packager-macos/**' + - 'scripts/bundle-renderer.sh' + - '.github/workflows/packager-macos.yml' + workflow_dispatch: + +concurrency: + group: packager-macos-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + packager: + name: Packager tests (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [macos-latest, ubuntu-latest] + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Run the packager's tests + run: python -m unittest discover -s scripts/packager-macos/tests -v diff --git a/.github/workflows/static-renderer.yml b/.github/workflows/static-renderer.yml index 4e4b96855..3830d9589 100644 --- a/.github/workflows/static-renderer.yml +++ b/.github/workflows/static-renderer.yml @@ -141,7 +141,8 @@ jobs: key: compose-${{ steps.target.outputs.compose_key }}-${{ hashFiles('renderer/scripts/build-compose.sh', 'renderer/scripts/compose-fork.changes') }} - name: Cache the Kotlin toolchain - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ${{ steps.target.outputs.toolchain }} @@ -168,7 +169,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target ${{ steps.target.outputs.compose }} - name: Keep the patched Compose for the next run - if: steps.compose-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.compose-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: ~/.m2/repository @@ -285,3 +286,12 @@ jobs: ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz.sha256 retention-days: 7 + + - name: Save the Kotlin toolchain (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ steps.target.outputs.toolchain }} + ~/.konan + key: ${{ steps.cache-1.outputs.cache-primary-key }} diff --git a/.github/workflows/test-compose-from-fork.yml b/.github/workflows/test-compose-from-fork.yml index a237a3fcb..af644b7e0 100644 --- a/.github/workflows/test-compose-from-fork.yml +++ b/.github/workflows/test-compose-from-fork.yml @@ -161,12 +161,13 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 with: - cache-read-only: false + cache-read-only: ${{ github.event_name == 'pull_request' }} gradle-home-cache-excludes: | caches/build-cache-1 - name: Cache Kotlin/Native for the Compose build - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: ~/.konan key: konan-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('renderer/scripts/build-compose.sh') }} @@ -174,7 +175,8 @@ jobs: konan-${{ runner.os }}-${{ runner.arch }}- - name: Cache the Kotlin toolchain the renderer builds with - uses: actions/cache@v4 + id: cache-2 + uses: actions/cache/restore@v4 with: path: ${{ matrix.toolchain_cache }} key: kotlin-native-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('renderer/kotlin', 'renderer/libs.versions.toml', 'renderer/**/module.yaml') }} @@ -323,3 +325,17 @@ jobs: # comparison with the ext.2 baseline: DarkPyonix/compose-rust#361. run: | echo "the fork and the patched build published different contents, as the moved pin is expected to; see the comparison step" >&2 + + - name: Save Kotlin/Native for the Compose build (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.konan + key: ${{ steps.cache-1.outputs.cache-primary-key }} + + - name: Save the Kotlin toolchain the renderer builds with (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ${{ matrix.toolchain_cache }} + key: ${{ steps.cache-2.outputs.cache-primary-key }} diff --git a/.github/workflows/test-graalvm-renderer.yml b/.github/workflows/test-graalvm-renderer.yml index f1540c8f3..bf73a4d5e 100644 --- a/.github/workflows/test-graalvm-renderer.yml +++ b/.github/workflows/test-graalvm-renderer.yml @@ -82,7 +82,8 @@ jobs: - uses: actions/checkout@v4 - name: Cache Liberica NIK - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: ~/.cache/compose-rust/nik key: nik-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('scripts/install-nik.sh') }} @@ -91,7 +92,8 @@ jobs: run: ./scripts/install-nik.sh --github-env - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-2 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -224,6 +226,22 @@ jobs: path: renderer/build/native-image/dist/ retention-days: 7 + - name: Save Liberica NIK (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: ~/.cache/compose-rust/nik + key: ${{ steps.cache-1.outputs.cache-primary-key }} + + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-2.outputs.cache-primary-key }} + compose-desktop: name: Build the Compose fork's desktop modules # The Linux image carries no Java toolkit, which takes changes Compose's desktop modules @@ -258,7 +276,7 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v4 with: - cache-read-only: false + cache-read-only: ${{ github.event_name == 'pull_request' }} gradle-home-cache-excludes: | caches/build-cache-1 @@ -323,7 +341,8 @@ jobs: libgl1-mesa-dev fonts-noto-cjk xvfb xauth dbus-x11 - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-3 + uses: actions/cache/restore@v4 with: path: | ~/.cache/JetBrains/Kotlin @@ -415,6 +434,15 @@ jobs: path: renderer/build/native-image-linux/dist/ retention-days: 7 + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-3.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-3.outputs.cache-primary-key }} + linux-arm64: needs: compose-desktop name: Build and smoke test the GraalVM renderer (Linux, arm64) @@ -439,7 +467,8 @@ jobs: libgl1-mesa-dev fonts-noto-cjk xvfb xauth dbus-x11 - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-4 + uses: actions/cache/restore@v4 with: path: | ~/.cache/JetBrains/Kotlin @@ -479,3 +508,12 @@ jobs: name: graalvm-renderer-linux-arm64 path: renderer/build/native-image-linux/dist/ retention-days: 7 + + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-4.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-4.outputs.cache-primary-key }} diff --git a/.github/workflows/test-kn-desktop-renderer.yml b/.github/workflows/test-kn-desktop-renderer.yml index 13f67df5b..139b8a956 100644 --- a/.github/workflows/test-kn-desktop-renderer.yml +++ b/.github/workflows/test-kn-desktop-renderer.yml @@ -37,6 +37,11 @@ on: - renderer/desktop/src/** - renderer/desktop/module.yaml - renderer/desktop/scripts/** + # The Windows build's own files feed no job here (macOS and Linux modules only). + - '!renderer/desktop/scripts/build-windows.sh' + - '!renderer/desktop/scripts/*.ps1' + - '!renderer/desktop/scripts/windows-build-contract.test.sh' + - '!renderer/desktop/scripts/windows-metadata/**' - renderer/ios/src/shared/** - renderer/scripts/** - renderer/kotlin @@ -135,7 +140,8 @@ jobs: key: compose-${{ matrix.compose_key }}-${{ hashFiles('renderer/scripts/build-compose.sh', 'renderer/scripts/compose-fork.changes') }} - name: Cache the Kotlin toolchain - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ${{ matrix.toolchain }} @@ -177,7 +183,7 @@ jobs: run: ./renderer/scripts/require-fork-artifacts.sh ${{ matrix.module }} - name: Keep the patched Compose for the next run - if: steps.compose-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.compose-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: ~/.m2/repository @@ -293,3 +299,12 @@ jobs: # The same check as the macOS job's, with the main dispatcher Linux has. if: matrix.module == 'linux' run: xvfb-run -a -s '-screen 0 1280x1024x24' ./renderer/desktop/scripts/smoke-test-kn.sh compose-rust/tests/fixtures/consumer/target/release/consumer linux + + - name: Save the Kotlin toolchain (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ matrix.toolchain }} + ~/.konan + key: ${{ steps.cache-1.outputs.cache-primary-key }} diff --git a/.github/workflows/test-native-renderer.yml b/.github/workflows/test-native-renderer.yml index e07d6c1bb..2cf9f7721 100644 --- a/.github/workflows/test-native-renderer.yml +++ b/.github/workflows/test-native-renderer.yml @@ -63,6 +63,7 @@ on: - scripts/parity/** - .github/workflows/test-native-renderer.yml - .github/workflows/static-renderer.yml + - .github/workflows/windows-static.yml - .github/workflows/fork-desktop-modules.yml # Pushes to main as well: the release branch is rebuilt from develop, so its head is a # different commit from any that was checked as a pull request. The release workflow @@ -115,6 +116,8 @@ jobs: runs-on: ubuntu-latest outputs: renderer: ${{ steps.check.outputs.renderer }} + macos: ${{ steps.macos.outputs.macos }} + executables: ${{ steps.macos.outputs.executables }} steps: - uses: actions/checkout@v4 with: @@ -146,6 +149,35 @@ jobs: echo "renderer=false" >> "$GITHUB_OUTPUT" fi + - id: macos + # The macOS runners are the scarce ones, so a pull request that only touches another + # platform's own files leaves the macOS jobs out (native-needs-macos.sh). Anything + # but an explicit macos=false keeps them: tags, main, the nightly run, manual runs, + # an error, an empty list or any other path. A job-level condition cannot read a + # matrix, so the single-executable matrix is built here. + if: always() + run: | + macos=true + if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then + decision="$(git -c core.quotepath=off diff --no-renames --name-only HEAD^1 HEAD | + ./.github/scripts/native-needs-macos.sh)" || decision="macos=true" + [[ "$decision" == "macos=false" ]] && macos=false + fi + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + # Windows is left out of a tag's run for now: the tag's windows-x64 artifact is still the + # GraalVM one (the `windows` job names it the same way), and windows-static does not + # upload its own on a tag until the release takes the static kit instead. + all='{"include":[{"target":"macos-aarch64","runs-on":"macos-latest"},{"target":"linux-x64","runs-on":"ubuntu-24.04"},{"target":"linux-arm64","runs-on":"ubuntu-24.04-arm"}]}' + else + all='{"include":[{"target":"macos-aarch64","runs-on":"macos-latest"},{"target":"linux-x64","runs-on":"ubuntu-24.04"},{"target":"linux-arm64","runs-on":"ubuntu-24.04-arm"},{"target":"windows-x64","runs-on":"windows-latest"}]}' + fi + if [[ "$macos" == "false" ]]; then + all="$(jq -c '.include |= map(select(.["runs-on"] != "macos-latest"))' <<< "$all")" + fi + echo "macos=$macos" >> "$GITHUB_OUTPUT" + echo "executables=$all" >> "$GITHUB_OUTPUT" + cat "$GITHUB_OUTPUT" + # Runs on every push to develop and main, and nightly. Nobody here owns a Windows # machine, so this job *is* the Windows verification: if it goes red, Windows is # broken. It proves build, startup, render, clean close and exit 0. It cannot prove @@ -280,7 +312,7 @@ jobs: # x86-64 there, because Kotlin/Native has no arm64 Linux host. macos-static: needs: changed - if: needs.changed.outputs.renderer == 'true' + if: needs.changed.outputs.renderer == 'true' && needs.changed.outputs.macos != 'false' uses: ./.github/workflows/static-renderer.yml with: target: macos-aarch64 @@ -309,21 +341,14 @@ jobs: single-executable: # Each platform's check needs only its own artifact, so one platform failing to build # does not hide whether the others are one executable. - needs: [changed, macos-static, linux-static, linux-arm64-static] + needs: [changed, macos-static, linux-static, linux-arm64-static, windows-static] if: ${{ !cancelled() && needs.changed.outputs.renderer == 'true' }} name: One executable on ${{ matrix.target }} runs-on: ${{ matrix.runs-on }} timeout-minutes: 60 strategy: fail-fast: false - matrix: - include: - - target: macos-aarch64 - runs-on: macos-latest - - target: linux-x64 - runs-on: ubuntu-24.04 - - target: linux-arm64 - runs-on: ubuntu-24.04-arm + matrix: ${{ fromJSON(needs.changed.outputs.executables) }} env: # Mesa draws with the processor; a hosted runner has no card. LIBGL_ALWAYS_SOFTWARE: '1' @@ -332,6 +357,7 @@ jobs: - uses: actions/checkout@v4 - name: Install the Rust toolchain + shell: bash run: rustup toolchain install stable --profile minimal && rustup default stable - name: Install the libraries an application links on Linux, and a virtual display @@ -359,8 +385,16 @@ jobs: run: | # find reports the directories it may not read and exits non-zero for them, # which is not a failure to find anything; what is left is checked afterwards. - { find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true; } | - while IFS= read -r file; do echo "removing $file"; sudo rm -f "$file"; done + if [[ "$RUNNER_OS" == Windows ]]; then + # Git Bash's view of the drives; no sudo, the runner is an administrator. + { find "/c/Program Files" "/c/Program Files (x86)" "/c/ProgramData" "/c/Users" \ + "/c/Windows" "/c/hostedtoolcache" "/c/msys64" "/d/a/_tool" "/d/a/_temp" \ + -name 'icudtl.dat' 2>/dev/null || true; } | + while IFS= read -r file; do echo "removing $file"; rm -f "$file"; done + else + { find /usr /opt /Library /Applications /home /Users -name 'icudtl.dat' 2>/dev/null || true; } | + while IFS= read -r file; do echo "removing $file"; sudo rm -f "$file"; done + fi - name: Build it, check what it needs, and run it alone shell: bash @@ -390,7 +424,7 @@ jobs: # it evidence for PR-2: one unmodified host, two renderers, one ABI. ios: needs: changed - if: needs.changed.outputs.renderer == 'true' + if: needs.changed.outputs.renderer == 'true' && needs.changed.outputs.macos != 'false' name: Build and smoke test the renderer (iOS, simulator) # macos-26, not macos-latest. Compose Multiplatform's iOS artifacts carry # precompiled Objective-C objects built against the iOS 26 SDK, and one of them @@ -403,7 +437,8 @@ jobs: - uses: actions/checkout@v4 - name: Cache the Kotlin toolchain, konan and Maven dependencies - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -485,6 +520,16 @@ jobs: ${{ runner.temp }}/ios-navigation.png retention-days: 7 + - name: Save the Kotlin toolchain, konan and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.konan + ~/.m2/repository + key: ${{ steps.cache-1.outputs.cache-primary-key }} + # Everything below runs only for a version tag, and only after every platform above has # built and smoke tested. A crates.io version can be yanked but never replaced, and the # crate's build script points at the release artifact for its own version, so publishing diff --git a/.github/workflows/test-samples.yml b/.github/workflows/test-samples.yml index e33c99ca6..9b48a1e48 100644 --- a/.github/workflows/test-samples.yml +++ b/.github/workflows/test-samples.yml @@ -8,6 +8,10 @@ # the ones a sample build reads beyond the crate itself; a change to compose-rust alone is # tested by the workspace's own tests, which include every sample's. # +# A release also puts the same files in the `samples-latest` release, a fixed place that +# always holds the newest samples. Both releases are in this repository and are written +# with the workflow's own token. +# # A sample that does not build on a platform is skipped and the release goes ahead with # the rest. Eleven samples across three platforms is thirty-three builds, and holding all # of them back because one of them broke on one platform helps nobody. A platform where @@ -19,8 +23,31 @@ # release, and a library release is not held up by a sample that does not build. # # Each sample links the renderer for its platform. On macOS and Linux that is the static -# archive, built by static-renderer.yml, so each sample is one executable; on Windows the -# renderer library still ships beside it. +# archive of the release for the checked-out crate version +# (compose-rust-renderer-v-.tar.gz, checked against its .sha256), so a +# sample build does not rebuild it; when the release has no such asset yet (a version still +# in a pull request), the resolve job says so and the renderer is built here by +# static-renderer.yml. On Windows the renderer is the Kotlin/Native static library built by +# windows-static.yml, so each sample is one executable on every desktop platform. +# +# What a release carries, per sample and target, named `-.`: +# - every desktop target: `.tar.gz` of the executable; +# - macos-aarch64: a `.app` signed ad hoc (packager-macos, verified with +# `codesign --verify --deep --strict`) inside a `.dmg`; +# - linux-x64, linux-arm64: an `.AppImage` (packager-linux); +# - windows-x64: a `.zip` and an unsigned `.msix` (packager-windows, sideload channel). +# Android ships an APK, iOS a simulator bundle, the web one archive of pages. Velopack +# installers and updates are not part of this release; that is still open under FR-22.6. +# +# Tag and release naming: +# - The tag is `sample-v` (compose-rust/Cargo.toml) and goes on a merge +# commit of `main` only. The release job refuses a tag whose commit is not on `main`, +# or whose version is not the crate's. +# - The release for the tag is never `latest` (that belongs to the library's `v*` +# releases). `samples-latest` is a pre-release that moves to the newest tag and holds +# the same files. +# - A dispatch with no tag, or with `desktop_only`, builds and uploads workflow artifacts +# and creates no release (a dry run). `desktop_only` skips Android, iOS and the web. name: Samples test @@ -45,6 +72,11 @@ on: required: false type: string default: '' + desktop_only: + description: 'Build the desktop targets only (skip Android, iOS and the web). A tag is ignored for release purposes: nothing is published' + required: false + type: boolean + default: false concurrency: # One run per pull request: a new commit cancels the older run. A tag push or a dispatch @@ -68,11 +100,63 @@ jobs: steps: - run: echo "Heavy checks requested by ${{ github.event_name }} ${{ github.event.action }}" - # The renderers macOS and Linux ship, from the same reusable workflow the release uses, - # so that every sample there is one executable, built the way an application is. + # Does the release for this commit's crate version carry the renderer assets? Decides + # whether the build downloads them or builds the renderer itself. + # + # The renderer used is the one released for the crate version, unless the release carries + # another schema than this head (compared by schema-hash.txt): then the renderer is built + # from this head and the run says so in a notice. + # The Windows static branches below take effect once the release carries a static + # windows-x64 tarball; until then the Windows build still uses the DLL kit. + resolve: + name: Find the released renderer + needs: requested + runs-on: ubuntu-latest + outputs: + released: ${{ steps.find.outputs.released }} + version: ${{ steps.find.outputs.version }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Read the crate version and look for the release asset + id: find + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' compose-rust/Cargo.toml | head -1)" + echo "version=$version" >> "$GITHUB_OUTPUT" + # Needs all four desktop assets and their checksums; a half-uploaded release + # counts as not released. A gh failure is warned about, not hidden. + result="$(.github/scripts/find-released-renderer.sh "$version")" + echo "$result" + released="$(sed -n 's/^released=//p' <<<"$result")" + # A release built from another schema can never agree with this head: the crate's + # build script refuses a renderer of a different schema. Say so, and build the + # renderer from this head instead, so the check stays meaningful on a head that + # changed the schema without moving the version yet. + if [[ "$released" == "true" ]]; then + work="$(mktemp -d)" + asset="compose-rust-renderer-v$version-linux-x64.tar.gz" + gh release download "v$version" -R "$GITHUB_REPOSITORY" --pattern "$asset" --dir "$work" + released_hash="$(tar -xzOf "$work/$asset" --wildcards '*schema-hash.txt' | tr -d '[:space:]')" + head_hash="$(tr -d '[:space:]' < compose-rust/schema-hash.txt)" + if [[ "$released_hash" != "$head_hash" ]]; then + echo "::notice title=Schema changed since v$version::the release carries schema $released_hash and this head $head_hash, so the samples are checked against the renderer built from this head" + released=false + fi + fi + echo "released=$released" >> "$GITHUB_OUTPUT" + echo "crate $version, released renderer: $released" + + # The renderers macOS and Linux ship, from the same reusable workflow the release uses. + # Only when the release does not carry them yet. static-renderer: name: renderer - needs: requested + needs: resolve + if: needs.resolve.outputs.released != 'true' strategy: fail-fast: false matrix: @@ -82,20 +166,24 @@ jobs: target: ${{ matrix.target }} ref: ${{ inputs.tag || github.ref }} - fork-desktop: - # The Windows renderer's desktop module asks for the fork's Compose and skiko-awt by version. + # The Windows renderer: Kotlin/Native, linked into the executable, so a Windows sample + # is one file like the others. This replaces the GraalVM DLL the samples used to ship + # beside the program. Its long first half is cached on exactly its inputs. + windows-static: + name: renderer (windows-x64) needs: requested - name: Build the Compose fork's desktop modules - uses: ./.github/workflows/fork-desktop-modules.yml + uses: ./.github/workflows/windows-static.yml build: name: Build the sample (${{ matrix.target }}) - needs: [requested, static-renderer, fork-desktop] - # Windows builds its own renderer and does not need the static ones, and one platform's - # renderer failing does not stop the others' samples. - if: ${{ !cancelled() && needs.requested.result == 'success' }} + needs: [requested, resolve, static-renderer, windows-static] + # Skipped renderer jobs (a released renderer) are fine; one platform's renderer + # failing does not stop the others' samples. + if: ${{ !cancelled() && needs.requested.result == 'success' && needs.resolve.result == 'success' }} runs-on: ${{ matrix.runner }} - timeout-minutes: 120 + # The Windows leg compiles eleven samples one after another and used up the two hours + # with the renderer already built. + timeout-minutes: 240 strategy: fail-fast: false matrix: @@ -112,12 +200,6 @@ jobs: - runner: windows-2022 target: windows-x64 static: false - renderer: powershell .\renderer\desktop\scripts\build-native-windows.ps1 - # `dist`, not `dist/lib`. Windows puts the DLL in `dist\bin` and the JDK's - # font configuration in `dist\lib`, because AWT reads that from java.home and - # the renderer sets java.home to `dist`. The search order looks in a given - # directory and then in its `bin` and `lib`, so naming the parent finds both. - dist: renderer/build/native-image/dist steps: - uses: actions/checkout@v4 with: @@ -148,14 +230,6 @@ jobs: fi echo "link.exe now resolves to: $(command -v link || echo 'nothing on PATH')" - - name: Install upstream GraalVM 25 - if: runner.os == 'Windows' - uses: graalvm/setup-graalvm@v1 - with: - java-version: '25' - distribution: 'graalvm' - github-token: ${{ secrets.GITHUB_TOKEN }} - - name: Install the libraries a Linux application links # Development packages, because linking names libX11.so and friends. The # executable needs only their runtime halves, which a desktop already has. @@ -163,59 +237,61 @@ jobs: run: | sudo apt-get update sudo apt-get install -y build-essential pkg-config \ - libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev - - - name: Cache the Kotlin toolchain and Maven dependencies - if: runner.os == 'Windows' - uses: actions/cache@v4 - with: - path: ~/.m2/repository - key: kotlin-${{ matrix.target }}-${{ hashFiles('renderer/**/module.yaml') }} - restore-keys: kotlin-${{ matrix.target }}- - - - name: Take the Compose fork's desktop modules - if: runner.os == 'Windows' - uses: actions/download-artifact@v4 - with: - name: compose-fork-desktop - path: ${{ runner.temp }}/compose-fork-desktop - - - name: Put them where the renderer's build looks - if: runner.os == 'Windows' - shell: bash - run: | - mkdir -p ~/.m2/repository/org/jetbrains - cp -R "$RUNNER_TEMP/compose-fork-desktop/." ~/.m2/repository/org/ - - - name: Build the Windows renderer - if: runner.os == 'Windows' - run: ${{ matrix.renderer }} + libx11-dev libxext-dev libgl1-mesa-dev libegl-dev libfontconfig1-dev libfreetype-dev \ + desktop-file-utils appstream gnupg - name: Download the static renderer - if: matrix.static + if: matrix.static && needs.resolve.outputs.released != 'true' uses: actions/download-artifact@v4 with: pattern: compose-rust-renderer-v*-${{ matrix.target }} path: renderer-artifact merge-multiple: true + - name: Download the released renderer + if: needs.resolve.outputs.released == 'true' && runner.os != 'Windows' + shell: bash + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ needs.resolve.outputs.version }} + run: | + set -euo pipefail + mkdir -p renderer-artifact + asset="compose-rust-renderer-v$VERSION-${{ matrix.target }}.tar.gz" + gh release download "v$VERSION" -R "$GITHUB_REPOSITORY" \ + --pattern "$asset" --pattern "$asset.sha256" --dir renderer-artifact + cd renderer-artifact + # The .sha256 holds " "; check it against the file just fetched. + if command -v sha256sum >/dev/null; then sha256sum -c "$asset.sha256" + else shasum -a 256 -c "$asset.sha256"; fi + - name: Unpack it where the build will be pointed - if: matrix.static + if: runner.os != 'Windows' && (matrix.static || needs.resolve.outputs.released == 'true') shell: bash run: | set -euo pipefail - mkdir -p "$RUNNER_TEMP/renderer" - tar -xzf renderer-artifact/*.tar.gz -C "$RUNNER_TEMP/renderer" - ls -la "$RUNNER_TEMP/renderer/lib" - echo "RENDERER=$RUNNER_TEMP/renderer" >> "$GITHUB_ENV" + # Forward slashes on Windows too: GNU tar reads "D:" as a remote host, and an + # absolute path in RENDERER has to survive the build script's working directory. + dir="$RUNNER_TEMP/renderer" + if command -v cygpath >/dev/null; then dir="$(cygpath -m "$dir")"; fi + mkdir -p "$dir" + tar -xzf renderer-artifact/*.tar.gz -C "$dir" + ls -la "$dir" + echo "RENDERER=$dir" >> "$GITHUB_ENV" + echo "DIST=$dir" >> "$GITHUB_ENV" + + - name: Take the Windows renderer kit + if: runner.os == 'Windows' + uses: actions/download-artifact@v4 + with: + name: windows-static-renderer-kit + path: ${{ runner.temp }}/renderer-kit - name: Point the build at the Windows renderer if: runner.os == 'Windows' shell: bash - # Absolute. A build script runs with its crate directory as the working - # directory, not the workspace root, so a relative path here points at - # compose-rust/renderer/... and finds nothing. - run: echo "RENDERER=${{ github.workspace }}/${{ matrix.dist }}" >> "$GITHUB_ENV" + # Absolute: a build script runs with its crate directory as the working directory. + run: echo "DXC_WINDOWS_NATIVE_LIB=$(cygpath -m "$RUNNER_TEMP/renderer-kit")" >> "$GITHUB_ENV" - name: Build every sample against it # A sample that does not build is skipped rather than allowed to end the run. The @@ -227,7 +303,7 @@ jobs: shell: bash run: | set -uo pipefail - export COMPOSE_RUST_RENDERER_DIR="$RENDERER" + [[ -z "${RENDERER:-}" ]] || export COMPOSE_RUST_RENDERER_DIR="$RENDERER" : > built.txt : > skipped.txt for manifest in samples/*/Cargo.toml; do @@ -249,6 +325,8 @@ jobs: done echo "built:"; cat built.txt echo "skipped:"; cat skipped.txt + mkdir -p artifacts + [[ ! -s skipped.txt ]] || sed "s/\$/ ${{ matrix.target }}/" skipped.txt > "artifacts/skipped-${{ matrix.target }}.txt" # Nothing at all is a different case. That is the renderer or the toolchain # being wrong rather than one sample being wrong, and shipping an empty platform # quietly would hide it. @@ -258,9 +336,8 @@ jobs: fi - name: Package each sample - # On macOS and Linux a sample is one executable with the renderer inside it, and - # that file is the whole archive. On Windows it still runs beside the renderer - # library it was linked against, so the two are shipped together. + # On every desktop platform a sample is one executable with the renderer inside it, + # and that file is the whole archive. shell: bash run: | set -euo pipefail @@ -270,13 +347,7 @@ jobs: stage="staging/$app" mkdir -p "$stage" if [[ "${{ runner.os }}" == "Windows" ]]; then - cp -R "${{ matrix.dist }}/." "$stage/" - # The executable goes in beside the DLL rather than above it. Windows - # searches the directory the executable is in, and the renderer's DLL lives - # in `bin` because AWT reads its font configuration from the `lib` next to - # it: the renderer sets java.home to their parent, so the two have to stay - # where they are and the program comes to them. - cp "target/release/sample-$app.exe" "$stage/bin/" + cp "target/release/sample-$app.exe" "$stage/" else cp "target/release/sample-$app" "$stage/" # Nothing may travel with it, so nothing it names may be anything but the @@ -289,9 +360,112 @@ jobs: fi tar -czf "artifacts/$app-${{ matrix.target }}.tar.gz" -C staging "$app" done < built.txt + + - name: Make each macOS sample an ad hoc signed application in a disk image + # A sample that fails here keeps its archive and loses only the .app and .dmg. + # Ad hoc signing needs no Apple account; Gatekeeper asks once on a downloaded copy. + if: runner.os == 'macOS' + shell: bash + run: | + set -uo pipefail + command -v uv >/dev/null || curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" + version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' compose-rust/Cargo.toml | head -n 1)" + while read -r app; do + [[ -n "$app" ]] || continue + out="apps/$app" + mkdir -p "$out" + if uv run scripts/packager-macos/package-macos app \ + --manifest-dir "samples/$app" --executable "target/release/sample-$app" \ + --version "$version" --out "$out" \ + && bundle="$(find "$out" -maxdepth 1 -name '*.app' | head -n 1)" \ + && [[ -n "$bundle" ]] \ + && codesign --verify --deep --strict --verbose=2 "$bundle" \ + && uv run scripts/packager-macos/package-macos dmg --app "$bundle" \ + --out "artifacts/$app-${{ matrix.target }}.dmg"; then + ditto -c -k --keepParent "$bundle" "artifacts/$app-${{ matrix.target }}.app.zip" + else + echo "::warning title=No application bundle::sample-$app was not packaged as a signed .app and .dmg for ${{ matrix.target }}" + fi + done < built.txt + + - name: Zip and package each Windows sample + # A .zip of the executable, and an unsigned .msix. The MSIX is on the sideload + # channel with no identity from Partner Center: installing it needs developer mode, + # or signing it again with a certificate the machine trusts. + if: runner.os == 'Windows' + shell: bash + run: | + set -uo pipefail + cargo build --release -p compose-rust-msix --bin compose-rust-msix + while read -r app; do + [[ -n "$app" ]] || continue + powershell -NoProfile -Command "Compress-Archive -Force -Path 'staging/$app/sample-$app.exe' -DestinationPath 'artifacts/$app-${{ matrix.target }}.zip'" \ + || echo "::warning title=No zip::sample-$app was not zipped for ${{ matrix.target }}" + out="msix/$app"; mkdir -p "$out" + if target/release/compose-rust-msix.exe --project "samples/$app" \ + --exe "target/release/sample-$app.exe" --channel sideload \ + --out "$out" --summary "$out/summary.txt"; then + pkg="$(sed -n 's/^package=//p' "$out/summary.txt" | tr -d '\r' | head -n 1)" + if [[ -f "$pkg" ]]; then cp "$pkg" "artifacts/$app-${{ matrix.target }}.msix" + else echo "::warning title=No MSIX::the packager reported no package for sample-$app"; fi + else + echo "::warning title=No MSIX::sample-$app was not packaged as an MSIX for ${{ matrix.target }}" + fi + done < built.txt + + - name: Wrap each Linux sample as an AppImage + # The same staged directory, as one file. A sample that fails here keeps its + # archive and loses only the AppImage. + # + # Signed when the repository has a signing key. + if: runner.os == 'Linux' + shell: bash + env: + SIGNING_KEY: ${{ secrets.APPIMAGE_SIGNING_KEY }} + TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -uo pipefail + # The crate's version, so a dry run on a branch is named like the release. + version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' compose-rust/Cargo.toml | head -n 1)" + sign=() + if [[ -n "$SIGNING_KEY" ]]; then + export GNUPGHOME="$PWD/.scratch/gnupg" && mkdir -p -m 700 "$GNUPGHOME" + gpg --batch --import <<<"$SIGNING_KEY" + sign=(--sign-key "$(gpg --list-secret-keys --with-colons | awk -F: '/^fpr/ {print $10; exit}')") + else + echo "::warning title=Unsigned AppImages::APPIMAGE_SIGNING_KEY is not set, so the AppImages are not signed" + fi + cargo build --release -p packager-linux + export PACKAGER_LINUX="$PWD/target/release/packager-linux" + while read -r app; do + [[ -n "$app" ]] || continue + # scripts/make-sample-icons.py draws a 256 pixel icon beside the 64 pixel + # one the window wears; a sample that brings its own may have only the one. + icons=(--icon "samples/$app/assets/icon.png") + [[ -f "samples/$app/assets/icon-256.png" ]] && icons+=(--icon "samples/$app/assets/icon-256.png") + if ! scripts/packager-linux/appimage.sh \ + --dioxus-toml "samples/$app/Dioxus.toml" \ + --overlay scripts/packager-linux/samples.toml \ + --exec "sample-$app" "${icons[@]}" \ + --payload "staging/$app" --version "$version" \ + ${sign[@]+"${sign[@]}"} \ + --out-dir artifacts; then + echo "::warning title=No AppImage::sample-$app was not wrapped as an AppImage for ${{ matrix.target }}" + fi + done < built.txt + + - name: Record checksums + shell: bash + run: | + set -euo pipefail cd artifacts - if command -v sha256sum >/dev/null; then sha256sum *.tar.gz > "SHA256SUMS-${{ matrix.target }}" - else shasum -a 256 *.tar.gz > "SHA256SUMS-${{ matrix.target }}"; fi + # find, not a glob list: no platform makes every file type, and a pattern that + # matches nothing must not end the step under pipefail. + files="$(find . -maxdepth 1 -type f \( -name '*.tar.gz' -o -name '*.AppImage' -o -name '*.zip' -o -name '*.msix' -o -name '*.dmg' \) | sed 's#^\./##' | sort -u)" + if command -v sha256sum >/dev/null; then sum=(sha256sum); else sum=(shasum -a 256); fi + # shellcheck disable=SC2086 + "${sum[@]}" $files > "SHA256SUMS-${{ matrix.target }}" cat "SHA256SUMS-${{ matrix.target }}" - uses: actions/upload-artifact@v4 @@ -299,9 +473,9 @@ jobs: name: samples-${{ matrix.target }} path: artifacts/ - android: needs: requested + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} name: Build the sample (Android) runs-on: ubuntu-24.04 timeout-minutes: 120 @@ -331,9 +505,10 @@ jobs: echo "ANDROID_NDK_HOME=$sdk/ndk/27.1.12297006" >> "$GITHUB_ENV" - name: Cache Gradle and Maven dependencies + id: cache-2 # Gradle, not the Kotlin toolchain: an application's Kotlin is compiled by its own # Gradle build here rather than by ours. - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | ~/.gradle/caches @@ -361,8 +536,19 @@ jobs: name: samples-android path: artifacts/ + - name: Save Gradle and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + ~/.m2/repository + key: ${{ steps.cache-2.outputs.cache-primary-key }} + ios: needs: requested + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} name: Build the sample (iOS, simulator) runs-on: macos-26 timeout-minutes: 120 @@ -377,7 +563,8 @@ jobs: rustup target add aarch64-apple-ios-sim - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-3 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -416,8 +603,18 @@ jobs: name: samples-ios-simulator path: artifacts/ + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-3.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-3.outputs.cache-primary-key }} + web: needs: requested + if: ${{ !(github.event_name == 'workflow_dispatch' && inputs.desktop_only) }} name: Build the sample (web) runs-on: ubuntu-24.04 timeout-minutes: 120 @@ -437,7 +634,8 @@ jobs: java-version: '21' - name: Cache the Kotlin toolchain and Maven dependencies - uses: actions/cache@v4 + id: cache-4 + uses: actions/cache/restore@v4 with: path: | ~/.cache/JetBrains/Kotlin @@ -460,12 +658,27 @@ jobs: name: samples-web path: artifacts/ + - name: Save the Kotlin toolchain and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-4.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cache/JetBrains/Kotlin + ~/.m2/repository + key: ${{ steps.cache-4.outputs.cache-primary-key }} + release: name: Attach the samples to the GitHub release needs: [build, android, ios, web] - # A tag, or a dispatch that names one. A pull request run and a dispatch without a tag - # build everything and stop there. - if: github.event_name == 'push' || inputs.tag != '' + # A tag, or a dispatch that names one, and not a desktop-only dispatch. A pull request + # run and a dispatch without a tag build everything and stop there (a dry run). The + # mobile and web jobs may be skipped; the desktop build must have run. + if: >- + ${{ !cancelled() && needs.build.result == 'success' && + (needs.android.result == 'success' || needs.android.result == 'skipped') && + (needs.ios.result == 'success' || needs.ios.result == 'skipped') && + (needs.web.result == 'success' || needs.web.result == 'skipped') && + (github.event_name == 'push' || (inputs.tag != '' && !inputs.desktop_only)) }} runs-on: ubuntu-latest permissions: contents: write @@ -473,6 +686,23 @@ jobs: - uses: actions/checkout@v4 with: ref: ${{ inputs.tag || github.ref }} + fetch-depth: 0 + + - name: Check the tag names a main merge commit and the crate version + env: + TAG: ${{ inputs.tag || github.ref_name }} + run: | + set -euo pipefail + version="$(sed -n 's/^version *= *"\(.*\)"/\1/p' compose-rust/Cargo.toml | head -n 1)" + if [[ "$TAG" != "sample-v$version" ]]; then + echo "::error title=Wrong tag::$TAG is not sample-v$version, the version in compose-rust/Cargo.toml" + exit 1 + fi + git fetch origin main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error title=Tag not on main::$TAG points at a commit that is not on main. Tag the merge commit of the release pull request into main." + exit 1 + fi - uses: actions/download-artifact@v4 with: @@ -488,15 +718,15 @@ jobs: - name: Say which sample and platform pairs are in this release id: notes + env: + TAG: ${{ inputs.tag || github.ref_name }} run: | set -euo pipefail { - echo "Sample applications, one archive per sample per platform." + echo "Sample applications, per sample and platform. Files are named -.." echo - echo "A macOS or Linux archive contains one executable, with the renderer inside" - echo "it; it runs from anywhere. A Windows archive contains the program and the" - echo "renderer library it was linked against; run it from the extracted" - echo "directory. Android ships an" + echo "Every desktop archive contains one executable, with the renderer inside" + echo "it; it runs from anywhere. Android ships an" echo "APK per sample. iOS ships a simulator bundle per sample, because a" echo "bundle for a device has to be signed by a certificate Apple issued" echo "against a profile naming that device. The web archive is every sample" @@ -504,23 +734,46 @@ jobs: echo echo 'Verify with `shasum -a 256 -c SHA256SUMS-`.' echo + echo "Linux also ships each sample as an AppImage. Make it executable and run it." + echo + echo "macOS also ships each sample as a .app inside a .dmg (and the .app as a" + echo ".app.zip). The app is signed ad hoc, not by an Apple developer identity, so" + echo "Gatekeeper blocks the first launch of a downloaded copy: right-click the app" + echo "and choose Open, or run xattr -dr com.apple.quarantine .app." + echo + echo "Windows also ships a .zip and an unsigned .msix. Installing the MSIX needs" + echo "Windows developer mode, or signing it again with a certificate the machine" + echo "trusts." + echo + echo "No Velopack installers or auto-update yet; that is a remaining part of" + echo "FR-22.6." + echo + echo "Built from $GITHUB_REPOSITORY at $(git rev-parse --short HEAD) ($TAG)." + echo echo "Built in this release:" echo - for archive in artifacts/*.tar.gz artifacts/*.apk; do + for archive in artifacts/*.tar.gz artifacts/*.AppImage artifacts/*.dmg artifacts/*.zip artifacts/*.msix artifacts/*.apk; do [ -e "$archive" ] || continue echo "- $(basename "$archive")" done echo echo "A sample that does not build on a platform is skipped rather than" - echo "holding up the rest, so a pair missing from the list above failed to" - echo "compile there. The workflow log says which." + echo "holding up the rest. Skipped sample and platform pairs (empty means none" + echo "on the desktop; the workflow log says why):" + echo + for f in artifacts/skipped-*.txt; do + [ -s "$f" ] || continue + sed 's/^/- /' "$f" + done } > notes.md cat notes.md - - name: Create or update the release + - name: Create or update the release for this tag # Published rather than drafted. A draft is invisible to everyone but the people # with write access to this repository, so a release nobody can download is the - # same as no release at all. + # same as no release at all. Never marked latest: that belongs to the library's + # v* releases, and a sample release that took it would make the newest library + # release look older than it is. env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag || github.ref_name }} @@ -528,10 +781,51 @@ jobs: set -euo pipefail if gh release view "$TAG" >/dev/null 2>&1; then gh release upload "$TAG" artifacts/* --clobber - gh release edit "$TAG" --draft=false --notes-file notes.md + gh release edit "$TAG" --draft=false --latest=false --notes-file notes.md else - gh release create "$TAG" \ + gh release create "$TAG" --latest=false \ --title "Sample apps $TAG" \ --notes-file notes.md \ artifacts/* fi + + - name: Replace the files of the samples-latest release + # The release tagged samples-latest always holds the newest samples at one fixed + # address. It is a pre-release and never latest, so it does not compete with the + # library's v* releases. It is updated in place: the new files are uploaded over + # the old ones first, and only then are the files the new set lacks removed, so a + # download in the middle still finds a file. Rebuilding an older tag leaves it + # alone, so samples-latest never goes backwards. + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag || github.ref_name }} + FEED: samples-latest + run: | + set -euo pipefail + newest="$(git ls-remote --tags --refs origin 'refs/tags/sample-v*' \ + | sed 's#.*refs/tags/##' | sort -V | tail -n 1)" + if [[ -n "$newest" && "$newest" != "$TAG" ]]; then + echo "::notice title=samples-latest unchanged::$TAG is older than $newest, which is what samples-latest carries" + exit 0 + fi + sha="$(git rev-parse HEAD)" + title="Sample apps, newest ($TAG)" + if gh release view "$FEED" >/dev/null 2>&1; then + # The tag follows the build, so the release names the commit its files came + # from. + gh api -X PATCH "repos/$GITHUB_REPOSITORY/git/refs/tags/$FEED" \ + -f sha="$sha" -F force=true >/dev/null \ + || echo "::warning title=samples-latest tag not moved::the files are $TAG's, but the tag still points at the previous build" + gh release edit "$FEED" --draft=false --prerelease --latest=false \ + --title "$title" --notes-file notes.md + else + gh release create "$FEED" --target "$sha" --prerelease --latest=false \ + --title "$title" --notes-file notes.md + fi + gh release upload "$FEED" artifacts/* --clobber + keep="$(cd artifacts && ls -1)" + gh release view "$FEED" --json assets --jq '.assets[].name' | while read -r asset; do + if ! grep -qxF "$asset" <<<"$keep"; then + gh release delete-asset "$FEED" "$asset" --yes + fi + done diff --git a/.github/workflows/test-windows-resize-metrics.yml b/.github/workflows/test-windows-resize-metrics.yml index a6e618aa1..24c9a0071 100644 --- a/.github/workflows/test-windows-resize-metrics.yml +++ b/.github/workflows/test-windows-resize-metrics.yml @@ -113,7 +113,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target mingwX64 - name: Keep them for the next run - if: steps.kit-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.kit-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | @@ -252,7 +252,7 @@ jobs: run: ./renderer/desktop/scripts/build-windows.sh - name: Keep the Kotlin toolchain for the next run - if: always() && steps.toolchain-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (always() && steps.toolchain-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | @@ -533,7 +533,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target macosArm64 - name: Keep it for the next run - if: steps.kit-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.kit-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: ~/.m2/repository diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fba0b31d1..bf8c12f85 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,12 @@ on: # and running again on the push tested the identical tree and doubled the actions page. pull_request: workflow_dispatch: + # Nightly on the default branch, so the Kotlin, Gradle and cargo caches exist on main, which + # every pull request may read. Pull request runs only restore caches; this run saves them. + # 18:00 UTC is 03:00 in Korea, when the team's own runs are fewest; if it meets a heavy run + # it waits for a macOS runner, it does not fail. + schedule: + - cron: '0 18 * * *' concurrency: # One run per pull request: a new commit cancels the older run. Pushes to develop, @@ -42,7 +48,65 @@ env: CARGO_PROFILE_TEST_DEBUG: "0" jobs: + changed: + # Decides whether the macOS jobs run. Hosted macOS runners are the scarce ones (five at a + # time), so a change that no macOS job can be affected by, such as documents or the + # Windows renderer's own files, leaves them out. The rule is .github/scripts/needs-macos.sh + # and it fails open: any doubt, an error here, or an event other than a pull request + # (a manual run) runs all of them. A job left out is reported as skipped, which a required + # check accepts, and the Linux and Windows jobs always run. + name: Decide whether macOS jobs run + runs-on: ubuntu-latest + outputs: + macos: ${{ steps.decide.outputs.macos }} + os: ${{ steps.matrix.outputs.os }} + kotlin: ${{ steps.matrix.outputs.kotlin }} + steps: + - uses: actions/checkout@v4 + with: + # The merge commit and its first parent, the base, so the diff is the pull request. + fetch-depth: 2 + - id: decide + continue-on-error: true + run: | + if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then + echo "macos=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # --no-renames lists both ends of a rename, so a file moved out of renderer/ into + # docs/ still names renderer/. core.quotepath keeps non-ASCII names readable; a + # name git still has to quote is not on the skip list and so runs macOS. + decision="$(git -c core.quotepath=off diff --no-renames --name-only HEAD^1 HEAD | + ./.github/scripts/needs-macos.sh)" || decision="macos=true" + [[ "$decision" == "macos=true" || "$decision" == "macos=false" ]] || decision="macos=true" + echo "$decision" >> "$GITHUB_OUTPUT" + echo "$decision" + - id: matrix + if: always() + # A job-level condition cannot read the matrix, so the macOS legs are left out of + # the matrices themselves. Anything but an explicit macos=false keeps them. + env: + MACOS: ${{ steps.decide.outputs.macos }} + run: | + kotlin='{"include":[ + {"platform":"jvm","runs-on":"macos-latest","test":true}, + {"platform":"iosSimulatorArm64","runs-on":"macos-latest","test":true}, + {"platform":"iosArm64","runs-on":"macos-latest","test":false}, + {"platform":"wasmJs","runs-on":"ubuntu-latest","test":false}, + {"platform":"android","runs-on":"ubuntu-latest","test":true}]}' + if [[ "$MACOS" == "false" ]]; then + os='["ubuntu-latest"]' + kotlin="$(jq -c '.include |= map(select(.["runs-on"] != "macos-latest"))' <<< "$kotlin")" + else + os='["macos-latest","ubuntu-latest"]' + kotlin="$(jq -c . <<< "$kotlin")" + fi + echo "os=$os" >> "$GITHUB_OUTPUT" + echo "kotlin=$kotlin" >> "$GITHUB_OUTPUT" + cat "$GITHUB_OUTPUT" + rust: + needs: changed name: Check Rust formatting, lints and tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -50,7 +114,7 @@ jobs: matrix: # macos-latest is Apple silicon, which is the platform SPEC PR-8 # describes and the one the benchmark baseline was measured on. - os: [macos-latest, ubuntu-latest] + os: ${{ fromJSON(needs.changed.outputs.os) }} steps: - uses: actions/checkout@v4 @@ -62,7 +126,8 @@ jobs: cargo --version - name: Cache cargo registry and build artifacts - uses: actions/cache@v4 + id: cache-1 + uses: actions/cache/restore@v4 with: path: | ~/.cargo/registry/index @@ -106,6 +171,17 @@ jobs: DXC_SKIP_KOTLIN: '1' run: ./scripts/check.sh + - name: Save cargo registry and build artifacts (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-1.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo/registry/index + ~/.cargo/registry/cache + ~/.cargo/git/db + target + key: ${{ steps.cache-1.outputs.cache-primary-key }} + generated: # The generated files, regenerated from this tree's schema and compared with what is # checked in. The Rust gate's staleness tests say that a file is stale; this says what @@ -165,7 +241,7 @@ jobs: uses: ./.github/workflows/fork-desktop-modules.yml kotlin: - needs: fork-desktop + needs: [fork-desktop, changed] # One job per platform, not one job for all of them. # # `./kotlin build` with no argument compiles every module for every platform it @@ -177,51 +253,34 @@ jobs: # The split also keeps each cache honest. The jvm and android jobs never download # konan at all, and a Kotlin/Native toolchain change no longer invalidates the cache # of the job that finishes first. + # + # The matrix is built by the changed job, which leaves the macOS legs out when no macOS job + # can be affected. What each leg is, and why: + # jvm the desktop module and every UI test; macOS because it is the primary + # platform and the one the Compose desktop tests are written against. + # iosSimulatorArm64 Kotlin/Native; compiles the iOS renderer, which shares its + # interpreter sources with the desktop one through symlinks, and runs + # the protocol codec tests on it. + # iosArm64 the device slice, linked and never run: running it needs a signed + # app on real hardware. It proves the arm64 slice still compiles. + # wasmJs compiled, not run: a browser test loads skiko's wasm module, and on + # this runner it arrives truncated, which aborts before any test + # starts. WebBoundaryTest would need that fixed and + # web/scripts/build-host.sh run first. + # android built and tested on Linux. name: Build and test the renderer (${{ matrix.platform }}) runs-on: ${{ matrix.runs-on }} strategy: fail-fast: false - matrix: - include: - # The renderer's desktop module and every UI test live here. macOS because it is - # the primary platform and the one the Compose desktop tests are written against. - - platform: jvm - runs-on: macos-latest - test: true - # Kotlin/Native. Compiles the iOS renderer, which shares its interpreter sources - # with the desktop one through symlinks, and runs the protocol codec tests on it. - - platform: iosSimulatorArm64 - runs-on: macos-latest - test: true - # The device slice is linked, never run: running it needs a signed app on real - # hardware. Building it is what proves the arm64 slice still compiles. - - platform: iosArm64 - runs-on: macos-latest - test: false - # Compiled, not run, and now for one reason rather than two. A browser test - # loads skiko's wasm module, and on this runner it arrives truncated, which - # aborts before any test starts (`section (code 10, "Code") extends past end of - # the module`). It does not reproduce locally, where the same command passes, so - # this is the toolchain or the runner rather than the code under test. - # - # What is now lost by not running them is real. `web/test/WebBoundaryTest.kt` - # drives the boundary against a Host built from the same Rust source the desktop - # renderer runs, and measures what a call costs. Turning it on takes two things: - # the truncated skiko fixed, and `web/scripts/build-host.sh` run first, because - # without the Host beside the page those tests report what is missing and pass. - - platform: wasmJs - runs-on: ubuntu-latest - test: false - - platform: android - runs-on: ubuntu-latest - test: true + matrix: ${{ fromJSON(needs.changed.outputs.kotlin) }} steps: - uses: actions/checkout@v4 - name: Cache the Kotlin toolchain, konan and Maven dependencies + id: cache-2 # Keyed on the platform as well as the inputs, so each job carries only what it # actually downloads instead of every job restoring every toolchain. - uses: actions/cache@v4 + uses: actions/cache/restore@v4 with: path: | ~/Library/Caches/JetBrains/Kotlin @@ -254,7 +313,19 @@ jobs: working-directory: renderer run: ./kotlin test -p ${{ matrix.platform }} + - name: Save the Kotlin toolchain, konan and Maven dependencies (not from a pull request) + if: github.event_name != 'pull_request' && steps.cache-2.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.cache/JetBrains/Kotlin + ~/.konan + ~/.m2/repository + key: ${{ steps.cache-2.outputs.cache-primary-key }} + shell: + needs: changed name: Run the shell script tests (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: @@ -263,7 +334,7 @@ jobs: # Both, because some of these scripts are platform specific and skip elsewhere. # install-nik.sh only means anything on macOS, so running the suite on Linux alone # would silently cover none of it. - os: [ubuntu-latest, macos-latest] + os: ${{ fromJSON(needs.changed.outputs.os) }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/windows-static.yml b/.github/workflows/windows-static.yml index 2327a4697..ae47b7465 100644 --- a/.github/workflows/windows-static.yml +++ b/.github/workflows/windows-static.yml @@ -26,8 +26,10 @@ on: # that calls it. workflow_dispatch: + # A called workflow takes its caller's name as github.workflow, so each caller gets its own + # group: the native renderer test and the Windows package host no longer cancel each other. concurrency: - group: windows-static-${{ github.event.pull_request.number || github.ref }} + group: windows-static-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: @@ -103,7 +105,7 @@ jobs: run: ./renderer/scripts/build-compose.sh --target mingwX64 - name: Keep them for the next run - if: steps.kit-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (steps.kit-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | @@ -246,7 +248,7 @@ jobs: run: ./renderer/desktop/scripts/build-windows.sh - name: Keep the Kotlin toolchain for the next run - if: always() && steps.toolchain-cache.outputs.cache-hit != 'true' + if: github.event_name != 'pull_request' && (always() && steps.toolchain-cache.outputs.cache-hit != 'true') uses: actions/cache/save@v4 with: path: | @@ -274,6 +276,13 @@ jobs: ls -la renderer/build/windows renderer/build/windows/*/ du -sh renderer/build/windows + - name: Hand the renderer kit to workflows that call this one + uses: actions/upload-artifact@v4 + with: + name: windows-static-renderer-kit + path: renderer/build/windows + retention-days: 3 + - name: Kotlin exceptions and startup in an MSVC executable, with and without the rewrite if: always() && steps.renderer.outcome == 'success' run: ./scripts/check-windows-mingw-link.sh renderer/build/windows "$(cygpath -u "$RUNNER_TEMP")/mingw-link-probe" @@ -290,3 +299,25 @@ jobs: path: ${{ runner.temp }}/consumer-windows/consumer.exe if-no-files-found: ignore retention-days: 7 + + - name: Package the kit the way the release carries it + id: package + # The same tarball the other platforms ship: the static kit under lib/ and include/, + # with the rest of what an MSVC link needs beside it, and its checksum. A tag keeps + # the GraalVM tarball of the same name until the release takes this one instead. + if: steps.renderer.outcome == 'success' && !startsWith(github.ref, 'refs/tags/v') + run: | + set -euo pipefail + version="$(sed -n 's/^version = "\(.*\)"$/\1/p' compose-rust/Cargo.toml | head -1)" + ./.github/scripts/package-static-renderer.sh renderer/build/windows windows-x64 "$version" "$RUNNER_TEMP/artifact" + echo "name=compose-rust-renderer-v$version-windows-x64" >> "$GITHUB_OUTPUT" + + - name: Keep the artifact + if: steps.package.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.package.outputs.name }} + path: | + ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz + ${{ runner.temp }}/artifact/${{ steps.package.outputs.name }}.tar.gz.sha256 + retention-days: 7 diff --git a/.gitignore b/.gitignore index 5b8d24a08..5b29219c7 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ target # macOS .DS_Store +# Python bytecode from running tools/packager and its tests +__pycache__/ + # Agent worktrees .claude/ .agent-salvage/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 430009a4d..5548001a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,78 @@ # Changelog +## v0.0.7 + +### Native file open and save dialogs + +The Host can ask for a platform file dialog with `FileDialog` (open a file, files, a folder or folders, or save with a suggested name and folder, filters, a title and an initial folder) and receives one result per request: the chosen absolute paths, a cancel, or "unavailable". On macOS the dialog is an `NSOpenPanel` or `NSSavePanel` sheet on the owning window. Windows, Linux, iOS, Android and the web answer "unavailable" until their dialogs are wired in ([#483](https://github.com/DarkPyonix/compose-rust/issues/483)). + +### The composable attribute is spelled `#[Composable]` + +The attribute that turns a function into a composable now has Compose's spelling, +`#[Composable]`, and is exported as `compose_rust::Composable`. Rust attribute names are +snake_case by convention only, and the widgets already use Compose's PascalCase names. The +lowercase `#[composable]` is gone, with no alias: the crate is before 1.0, so one release +moves every use. Rename each `#[composable]` in your code to `#[Composable]`. + +### Host-driven focus and IME composition notifications + +The Host can move input focus to a node, or clear it, with `Batch::request_focus(node_id)` (a new `RequestFocus` command, tag 25), and a `TextField` reports when an IME composition starts and ends with `.on_composition_change(impl FnMut(bool))` ([#410](https://github.com/DarkPyonix/compose-rust/issues/410), [#423](https://github.com/DarkPyonix/compose-rust/pull/423)). + +### `Button` text alignment, label size and a content slot + +`Button::text_align` and `Button::font_size` use the existing `TextAlign` and `FontSize` attributes, and a `Button` can take children that are drawn as a vertically centred row, so a button can be a list row with a left aligned label ([#425](https://github.com/DarkPyonix/compose-rust/issues/425), [#453](https://github.com/DarkPyonix/compose-rust/pull/453)). No schema change. + +### A weighted `Row` or `Column` child keeps room for its longest word + +A child with a weight on the main axis gets the larger of its share and its minimum content width, so a label in a narrow weighted cell is no longer broken into one glyph per line. Lazy lists and other lazy layouts keep the stock weight ([#427](https://github.com/DarkPyonix/compose-rust/issues/427), [#448](https://github.com/DarkPyonix/compose-rust/pull/448)). + +### Colours and size for `Switch`, `Slider` and `Checkbox` + +`Switch` has `track_color`, `thumb_color` and `size(width, height)`, `Slider` has `track_color`, `active_track_color` and `thumb_color`, and `Checkbox` has `container_color`, `check_color` and `size(side)` ([#426](https://github.com/DarkPyonix/compose-rust/issues/426), [#428](https://github.com/DarkPyonix/compose-rust/issues/428)). A value that is not set is not sent, and the design system draws that slot. On the wire these are the new `TrackColor` (121), `ThumbColor` (122), `ActiveTrackColor` (123), `ContainerColor` (124), `CheckColor` (125) and `ControlSize` (126) properties. The schema hash changes, so a renderer and a crate from before this change no longer agree. + +### A `WindowDrag(false)` child keeps its press out of a drag region on macOS + +Inside a region that is a window drag area, a node marked `WindowDrag(false)` (a button, a text field) now keeps its press: the press is not turned into a window drag ([#482](https://github.com/DarkPyonix/compose-rust/issues/482), [#458](https://github.com/DarkPyonix/compose-rust/pull/458)). + +### Change-marker bars in the code editor gutter + +A `Bar` gutter decoration whose colour is `DiffAdded`, `DiffModified` or `DiffRemoved` is drawn as a 4dp change-marker bar to the right of the line-number column, 12dp from the code. A line gets one record, so there is no wire change. When a line carries several, `DiffRemoved` wins over `DiffModified`, which wins over `DiffAdded`. Other `Dot`, `Bar` and `Glyph` decorations and an editor without bars are laid out as before. The editor's state description tells assistive technology which lines changed ([#477](https://github.com/DarkPyonix/compose-rust/pull/477)). + +### Accessibility modifier and `accessible_name` + +`Modifier` has `label`, `role`, `checked`, `selected` and `semantics(role, label, state)`, so any node can be given a role, a name and a checked or selected state for assistive technology ([#429](https://github.com/DarkPyonix/compose-rust/issues/429)). `Button`, `Switch`, `Checkbox`, `RadioButton` and `Slider` have `accessible_name`, which replaces a visible label and is read instead of an icon-only button's icon name. On the wire this is a new `Semantics` modifier (tag 34) and a new `AccessibleName` property (119). The schema hash changes, so a renderer and a crate from before this change no longer agree. `Modifier::label` keeps each distinct name for the life of the program, at most 4096 of them; a name past that limit is left off, and debug builds print a warning once. + +### Node accessibility: roles, states and a click action + +The accessibility modifier grows, add-only ([#473](https://github.com/DarkPyonix/compose-rust/issues/473), [#489](https://github.com/DarkPyonix/compose-rust/pull/489)). There are 11 more roles (`TabList`, `Tree`, `TreeItem`, `Menu`, `MenuItem`, `TextBox`, `Heading`, `Navigation`, `List`, `ListItem`, `Group`), the states `expanded`, `disabled`, `focused` and `hidden` (a hidden node leaves the accessibility tree), and a `click` action that calls the node's own click handler, so an assistive tool or an automated audit can press a control without touching the screen. A disabled node, or one whose `enabled` is false, cannot be pressed this way. Roles that Compose has no name for (everything except `Heading` and the ones it already had) are carried as a short role word in the node's state description for now. + +Known limits: the `focus` action has a wire bit but is not exposed by the renderer yet (the node's focus requester is created after the accessibility modifier and only when a focus request exists), expanded and collapsed are a description and not an action, and the platform's own roles (macOS tab group, outline, menu) need a change in the Compose fork and are not part of this release. [#473](https://github.com/DarkPyonix/compose-rust/issues/473) stays open for them. + +### Window size reports for every whole-dp change, and a first report for tall phone-sized windows + +A window that sets `report_size` also receives `WindowSizeChanged` whenever its width or height, each rounded to the nearest whole dp, changes inside a size class, so a host that lays out from the exact size (an HTML and CSS host, for example) can reflow. Use `Window(on_close).report_size(true)` for a `Window` node and `Window::new().with_report_size(true)` for the implicit single window; a window that does not opt in behaves exactly as before. Each whole-dp change while the user drags an edge costs one call into the Host, so only a host that needs the exact size should turn it on ([#467](https://github.com/DarkPyonix/compose-rust/issues/467), [#470](https://github.com/DarkPyonix/compose-rust/pull/470), [#479](https://github.com/DarkPyonix/compose-rust/issues/479), [#489](https://github.com/DarkPyonix/compose-rust/pull/489)). + +Separately, a window that is narrow but tall (390 by 844 dp: Compact wide, Medium high) never sent its first size, so `current_window_size()` kept 0 by 0. The renderer now sends it. This needs no opt-in. + +### macOS safe area measured again after the window is shown + +On macOS the window buttons' safe area (`WindowSafeArea`) could stay `{0, 0, 0}` for a window that never resizes, because the caption was measured once before the title bar was laid out. The Compose fork's macOS window now measures it again after it is shown, a few times at short intervals, and the window modules are pinned at that fork commit ([#468](https://github.com/DarkPyonix/compose-rust/issues/468), [#470](https://github.com/DarkPyonix/compose-rust/pull/470), fork [#182](https://github.com/thisisthepy/compose-multiplatform-core-extended/pull/182)). Known limit: this has not been confirmed at a 390 by 844 window yet. An application should keep a fallback for a zero safe area until it has been. + +### Windows links the Kotlin/Native static renderer by default + +Windows targets now resolve and link the Kotlin/Native static renderer kit (`renderer/build/windows` in a checkout or `compose-rust-renderer-v-windows-x64.tar.gz` from a release) as `RendererKind::Static` by default, producing a single executable with no GraalVM DLLs beside it ([#332](https://github.com/DarkPyonix/compose-rust/issues/332), [#333](https://github.com/DarkPyonix/compose-rust/pull/333), [#465](https://github.com/DarkPyonix/compose-rust/pull/465)). `DXC_WINDOWS_NATIVE_LIB` and `COMPOSE_RUST_RENDERER_DIR` still override the path. + +### Schema hash and wire changes + +The schema hash changes in this release, so a renderer and a crate from before it no longer agree; upgrade both together. All changes are add-only: + +- `Modifier` tag 34 `Semantics { role, state }`; the property `AccessibleName` (119) carries the label. The `Semantics` role enum grows to 19 values and its state word to 13 bits (checked, selected, expanded, disabled, focused, hidden, and the click and focus action bits). +- Properties `TrackColor` (121), `ThumbColor` (122), `ActiveTrackColor` (123), `ContainerColor` (124), `CheckColor` (125) and `ControlSize` (126) for `Switch`, `Slider` and `Checkbox`. +- Property `ReportSize` (127) on a `Window` node. +- `SetWindow` flags word, bit 1: `report_size` for the implicit window. The record length is unchanged and a window that does not set it writes the same bytes as before. +- Command `RequestFocus` (tag 25) and the `OnCompositionChange` property with its event. +- `WINDOW_REVISION` moves to the fork commit that holds the macOS caption re-measure. + ## v0.0.6 ### Focus ring only for keyboard focus @@ -10,6 +83,14 @@ A pressable container draws its focus ring only for keyboard focus, at the node' A `Row` with a weight in a `Column` (or a `Column` with a weight in a `Row`) is no longer clamped to its content size on the cross axis, so the content no longer collects at the start when the window grows ([#439](https://github.com/DarkPyonix/compose-rust/issues/439), [#440](https://github.com/DarkPyonix/compose-rust/pull/440)). +### Colours and size for `Switch`, `Slider` and `Checkbox` + +`Switch` has `track_color`, `thumb_color` and `size(width, height)`, `Slider` has `track_color`, `active_track_color` and `thumb_color`, and `Checkbox` has `container_color`, `check_color` and `size(side)` ([#426](https://github.com/DarkPyonix/compose-rust/issues/426), [#428](https://github.com/DarkPyonix/compose-rust/issues/428)). A value that is not set is not sent, and the design system draws that slot. On the wire these are the new `TrackColor` (121), `ThumbColor` (122), `ActiveTrackColor` (123), `ContainerColor` (124), `CheckColor` (125) and `ControlSize` (126) properties. The schema hash changes, so a renderer and a crate from before this change no longer agree. + +### Accessibility modifier and `accessible_name` + +`Modifier` has `label`, `role`, `checked`, `selected` and `semantics(role, label, state)`, so any node can be given a role, a name and a checked or selected state for assistive technology ([#429](https://github.com/DarkPyonix/compose-rust/issues/429)). `Button`, `Switch`, `Checkbox`, `RadioButton` and `Slider` have `accessible_name`, which replaces a visible label and is read instead of an icon-only button's icon name. On the wire this is a new `Semantics` modifier (tag 34) and a new `AccessibleName` property (119). The schema hash changes, so a renderer and a crate from before this change no longer agree. `Modifier::label` keeps each distinct name for the life of the program, at most 4096 of them; a name past that limit is left off, and debug builds print a warning once. + ### Modifier `match_parent_size` `Modifier` has a new `MatchParentSize` (wire tag 31) for a child of a `Box`: it fills exactly the parent's bounds without enlarging the parent, so an overlay such as a press tint needs no size or radius restated ([#431](https://github.com/DarkPyonix/compose-rust/issues/431)). The schema hash changes, so a renderer and a crate from before this change no longer agree. @@ -22,6 +103,10 @@ A `Row` with a weight in a `Column` (or a `Column` with a weight in a `Row`) is Every pressable surface (`on_click` containers, `Clickable` nodes and the toolkit `Button`) scales to 0.975 and shows a faint tint while pressed, inside the renderer with no Host round trip, and `Modifier::PressFeedback { scale, tint_alpha }` (wire tag 33) changes or turns it off ([#305](https://github.com/DarkPyonix/compose-rust/issues/305)). The schema hash changes. +### Disabled `Button` label keeps its contrast + +A disabled `Button` keeps its label at 3:1 or better against its faded fill on the page, a card and a raised card, and an active `Button` is unchanged ([#432](https://github.com/DarkPyonix/compose-rust/issues/432), [#447](https://github.com/DarkPyonix/compose-rust/pull/447)). + ### Schema hash and known test issue The schema hash changes in this release: a `Modifier` gains `MatchParentSize` (tag 31), `WindowDrag` (tag 32) and `PressFeedback` (tag 33), and the `WindowSizeChanged` event record grows from 32 to 44 bytes. A renderer and a crate from different versions no longer agree, so upgrade both together. Three `fr33_1_*` paste and menu tests fail on a Mac with a non-English locale. They fail the same way on `develop` without these changes, so this is not a regression ([#436](https://github.com/DarkPyonix/compose-rust/issues/436)). diff --git a/Cargo.lock b/Cargo.lock index 569dfe821..c012899dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bytes" version = "1.12.1" @@ -32,7 +44,7 @@ dependencies = [ [[package]] name = "compose-rust" -version = "0.0.6" +version = "0.0.7" dependencies = [ "compose-rust-macros", "jni", @@ -40,13 +52,86 @@ dependencies = [ [[package]] name = "compose-rust-macros" -version = "0.0.6" +version = "0.0.7" dependencies = [ + "compose-rust", "proc-macro2", "quote", - "syn", + "syn 2.0.119", + "trybuild", +] + +[[package]] +name = "compose-rust-msix" +version = "0.1.0" +dependencies = [ + "png", + "toml 0.8.23", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -88,7 +173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -103,6 +188,54 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "packager-linux" +version = "0.0.0" +dependencies = [ + "serde", + "toml 1.1.8+spec-1.1.0", +] + +[[package]] +name = "packager-linux-demo" +version = "0.0.0" +dependencies = [ + "compose-rust", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -225,6 +358,73 @@ dependencies = [ "sample-frames", ] +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "syn" version = "2.0.119" @@ -236,6 +436,32 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-tuple" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "876fef147edbcbddc8ac5cbbba92c7b86519e314e86638596c09673b2ed01e7f" + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -253,7 +479,102 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "trybuild" +version = "1.0.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62db9c92d704393fbf2132041720cc80b689f2d3f28521015c2ac866223c11b8" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-tuple", + "termcolor", + "toml 1.1.8+spec-1.1.0", ] [[package]] @@ -361,3 +682,30 @@ name = "windows_x86_64_msvc" version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 47c320686..9e238fa35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ members = [ "samples/statistics", "samples/store", "samples/todo", + "scripts/packager-linux", + "scripts/packager-linux/demo", + "scripts/packager-windows", ] resolver = "2" diff --git a/README.md b/README.md index ab5f8b5e4..a1f0a8d16 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ compose-rust is four things put together: What the 0.0.2 crate contains: -- **The authoring API.** `#[composable]`, `remember`, the states and effects, the `Recomposer`, +- **The authoring API.** `#[Composable]`, `remember`, the states and effects, the `Recomposer`, `launch` and one function per widget ([#23](https://github.com/DarkPyonix/compose-rust/issues/23), [#64](https://github.com/DarkPyonix/compose-rust/issues/64)). It is new in 0.0.2, with animation: `Modifier.graphics_layer`, `Modifier.alpha`, and @@ -76,7 +76,7 @@ What the 0.0.2 crate contains: use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn counter() { let count = remember(|| mutable_state_of(0)); Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| { @@ -93,7 +93,7 @@ fn main() { A widget is a statement: `Text("Hello")` describes a widget, its methods set what Compose takes as named arguments, and a container's children are the closure given to `content`. -`#[composable]` inserts the groups, so you never write them by hand, and they close correctly +`#[Composable]` inserts the groups, so you never write them by hand, and they close correctly through branches, early `return`, `?` and unwinding. Names follow Compose. The widget vocabulary is the one in the schema; nothing is defined twice. The [guide](https://darkpyonix.dev/compose-rust/en/writing-ui.html) covers the rest. @@ -215,7 +215,7 @@ default, and `org.thisisthepy.compose.designsystem` for the contract both layers ### Using the crate ```bash -cargo add compose-rust@0.0.6 +cargo add compose-rust@0.0.7 ``` `cargo build` works out which renderer the target needs, downloads the release artifact for the @@ -241,6 +241,38 @@ the Windows Kotlin/Native renderer is already one executable. build. A binary built that way says what is missing and exits non-zero rather than opening no window. +### Shipping a macOS application + +`scripts/packager-macos/package-macos` turns the executable `cargo build --release` made into an +application bundle, using the name, identifier and version your `dx` bundle configuration and`Cargo.toml` already state. It needs Python 3.11 and Xcode's command line tools, and no Apple +developer account. + +```sh +scripts/packager-macos/package-macos app --manifest-dir . \ + --executable target/release/my-app --out dist +scripts/packager-macos/package-macos dmg --app "dist/My App.app" --out dist/MyApp-1.0.0.dmg +``` + +The bundle carries no update settings: direct distribution is packaged and updated with +Velopack. + +Every bundle is signed ad hoc. Apple silicon needs a signature to run anything, and an ad hoc +one needs no account, but it does not name a developer Apple has verified, so **macOS asks +before it opens a downloaded copy for the first time**: + +- **macOS 15 (Sequoia) and newer:** open the app and click Done when macOS says it could not + verify it. Then in System Settings, Privacy & Security, scroll to Security and click + **Open Anyway** next to the app (the button stays for about an hour), confirm, and enter + your password. +- **macOS 14 and older:** Control-click the app, choose **Open**, then **Open** again. +- **Any version:** `xattr -dr com.apple.quarantine "/Applications/My App.app"` removes the + download mark, and macOS opens it without asking. + +Move the app into Applications before the first launch: run from the disk image or Downloads, +macOS starts a hidden temporary copy. The disk image carries these steps as `If macOS will not open the +app.txt`. + + ### Using the authoring API from `develop` 0.0.2 carries the authoring API and a renderer built from the same schema, so a project that diff --git a/compose-rust/Cargo.toml b/compose-rust/Cargo.toml index 1fd534929..233d75983 100644 --- a/compose-rust/Cargo.toml +++ b/compose-rust/Cargo.toml @@ -3,7 +3,7 @@ name = "compose-rust" # 0.0.x on purpose: the boundary works and so do the desktop and iOS renderers, but the # widget vocabulary is still being filled in and nothing here is stable yet. A number that # says so is more honest than one that implies a first usable release. -version = "0.0.6" +version = "0.0.7" edition.workspace = true license.workspace = true rust-version.workspace = true @@ -43,11 +43,18 @@ crate-type = ["cdylib"] # `default-features = false` for a headless or documentation build, and know that the # resulting binary says so on standard error and exits non-zero rather than opening no # window and returning 0. -default = ["native-renderer"] +default = ["native-renderer", "windows-manifest"] # Drives the boundary with no renderer at all, for tests and for embedding the Host in # something that draws for itself. Suppresses the renderer download. mock-renderer = [] native-renderer = [] +# Embeds an application manifest in every Windows executable built on this crate, +# declaring per-monitor v2 DPI awareness (the awareness the renderer asks for at startup) +# so that Windows, and the Store's certification checks, see it before any code runs. An +# application that embeds a manifest of its own leaves this out, because two manifest +# resources fail the link: `default-features = false, features = ["native-renderer"]`. +# Does nothing on other platforms. +windows-manifest = [] # Nothing from Dioxus, on purpose. This crate is the boundary, the protocol and the records, # and its dependency graph has no Dioxus crate in it. The Dioxus authoring layer lives in @@ -55,7 +62,7 @@ native-renderer = [] # crate and not the other way round. `scripts/tests/core-has-no-dioxus.test.sh` fails the # build if that changes. [dependencies] -compose-rust-macros = { path = "macros", version = "0.0.6" } +compose-rust-macros = { path = "macros", version = "0.0.7" } # The Android shims are generated against jni-rs. Nothing here is linked into a desktop, # iOS or web build, and the alternative bindings generators were rejected for their diff --git a/compose-rust/android-kotlin/shared/Accessibility.kt b/compose-rust/android-kotlin/shared/Accessibility.kt new file mode 100644 index 000000000..d9b4abdca --- /dev/null +++ b/compose-rust/android-kotlin/shared/Accessibility.kt @@ -0,0 +1,119 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.focused +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.ui.node.Node + +/** The two bits of one tri-state field in the `Semantics` state word. */ +private const val FIELD_MASK = 0b11 +private const val CHECKED_SHIFT = 0 +private const val SELECTED_SHIFT = 2 +private const val EXPANDED_SHIFT = 4 +private const val DISABLED_SHIFT = 6 +private const val FOCUSED_SHIFT = 8 +private const val HIDDEN_BIT = 1 shl 10 +private const val CLICK_ACTION_BIT = 1 shl 11 + +/** A field of the state word: unset, off or on. */ +private fun Int.field(shift: Int): Boolean? = when ((this shr shift) and FIELD_MASK) { + 1 -> false + 2 -> true + else -> null +} + +private fun SemanticsRole.toRole(): Role? = when (this) { + SemanticsRole.None -> null + SemanticsRole.Button -> Role.Button + SemanticsRole.Checkbox -> Role.Checkbox + SemanticsRole.Switch -> Role.Switch + SemanticsRole.RadioButton -> Role.RadioButton + SemanticsRole.Tab -> Role.Tab + SemanticsRole.Image -> Role.Image + SemanticsRole.DropdownList -> Role.DropdownList + else -> null +} + +/** The word a screen reader gets for a role Compose's `Role` cannot express. */ +private fun SemanticsRole.word(): String? = when (this) { + SemanticsRole.TabList -> "tablist" + SemanticsRole.Tree -> "tree" + SemanticsRole.TreeItem -> "treeitem" + SemanticsRole.Menu -> "menu" + SemanticsRole.MenuItem -> "menuitem" + SemanticsRole.TextBox -> "textbox" + SemanticsRole.Navigation -> "navigation" + SemanticsRole.List -> "list" + SemanticsRole.ListItem -> "listitem" + SemanticsRole.Group -> "group" + else -> null +} + +/** + * What the Host said this node is to assistive technology: the accessible name, and the + * role and state of a `Semantics` modifier. + * + * It is applied here, outside the widget, because every widget receives this modifier as its + * own, so the one place covers a container, a control and a node the Host only labelled. A + * node that said nothing gets the same modifier back, so nothing changes for it. + */ +internal fun Modifier.accessibility( + node: Node, + dispatcher: EventDispatcher? = null, + clickHandlerId: Long? = null, +): Modifier { + val name = node.text(PropertyKind.AccessibleName).takeIf { it.isNotEmpty() } + val declared = node.modifiers.lastOrNull { it is ProtocolModifier.Semantics } as? ProtocolModifier.Semantics + if (name == null && declared == null) return this + val state = declared?.state ?: 0 + val declaredRole = declared?.role + val role = declaredRole?.toRole() + val checked = state.field(CHECKED_SHIFT) + val selected = state.field(SELECTED_SHIFT) + val expanded = state.field(EXPANDED_SHIFT) + val isDisabled = state.field(DISABLED_SHIFT) + val focused = state.field(FOCUSED_SHIFT) + val hidden = state and HIDDEN_BIT != 0 + val isHeading = declaredRole == SemanticsRole.Heading + val description = listOfNotNull( + declaredRole?.word(), + expanded?.let { if (it) "expanded" else "collapsed" }, + ).joinToString(", ").takeIf { it.isNotEmpty() } + // A disabled node is not pressed, whichever way it was disabled: by this modifier or by + // the node's own enabled property that the ordinary click path honours. + val pressable = isDisabled != true && node.flag(PropertyKind.Enabled, default = true) + val handler = clickHandlerId.takeIf { pressable && state and CLICK_ACTION_BIT != 0 } + return semantics { + if (name != null) contentDescription = name + if (role != null) this.role = role + if (isHeading) heading() + if (description != null) stateDescription = description + if (checked != null) toggleableState = ToggleableState(checked) + if (selected != null) this.selected = selected + if (isDisabled == true) disabled() + if (focused != null) this.focused = focused + if (hidden) hideFromAccessibility() + if (handler != null && dispatcher != null) { + onClick(label = null) { + dispatcher.dispatch(HostEvent.Clicked(node.id, handler)) + true + } + } + } +} diff --git a/compose-rust/android-kotlin/shared/CodeEditorModel.kt b/compose-rust/android-kotlin/shared/CodeEditorModel.kt index 3ee193413..c5c6824de 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorModel.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorModel.kt @@ -63,6 +63,23 @@ class LiveGutterMark( val glyph: Int, ) +/** + * What a change-marker bar says about its line. Declared in the order the bars win a line: + * a removal outranks a modification, which outranks an addition. + */ +enum class ChangeMark { Removed, Modified, Added } + +/** The change a mark stands for, or null when it is an ordinary mark beside the line number. */ +fun LiveGutterMark.changeMark(): ChangeMark? { + if (kind != GutterDecorationKind.Bar) return null + return when (color) { + ColorRole.DiffAdded -> ChangeMark.Added + ColorRole.DiffModified -> ChangeMark.Modified + ColorRole.DiffRemoved -> ChangeMark.Removed + else -> null + } +} + /** A range the Host asked to have brought into view. A new request is a new object. */ class RevealRequest(val start: CodePosition, val end: CodePosition, val align: RevealAlign) @@ -186,6 +203,12 @@ class CodeEditorModel( private val foldList = ArrayList() private val gutterList = ArrayList() + /** Changes whenever the gutter marks are replaced, moved or dropped, for the flags below. */ + private var gutterStamp = 0 + private var flagsStamp = -1 + private var changeBarsPresent = false + private var ordinaryMarksPresent = false + /** Changes whenever a fold is added, moved, removed or flipped, for the cache below. */ private var foldStamp = 0 private var hiddenStamp = -1 @@ -246,6 +269,7 @@ class CodeEditorModel( redoStack.clear() foldList.clear() gutterList.clear() + gutterStamp += 1 foldStamp += 1 selection = CodeSelection.at(CodePosition.Zero) contentRevision += 1 @@ -567,6 +591,7 @@ class CodeEditorModel( /** Replaces every mark beside the line numbers with the Host's new list. */ fun setGutterDecorations(records: List) { gutterList.clear() + gutterStamp += 1 for (record in records) { if (record.kind == GutterDecorationKind.Glyph && !isScalarValue(record.glyph)) { output += EditorOutput.Error("a gutter glyph is ${record.glyph}, which is not a Unicode scalar value") @@ -717,6 +742,82 @@ class CodeEditorModel( /** The marks beside a line, in the order the Host sent them. */ fun gutterMarksAt(line: Int): List = gutterList.filter { it.line == line } + /** The marks of a line that sit in the mark column: every one that is not a change bar. */ + fun ordinaryMarksAt(line: Int): List = + gutterList.filter { it.line == line && it.changeMark() == null } + + /** + * The index in [gutterMarks] of the first mark at or after [from] that sits on [line] in + * the mark column, or -1. Drawing walks the marks with this instead of building a list + * per line, so a frame allocates nothing for them. + */ + fun nextOrdinaryMarkIndex(line: Int, from: Int): Int { + if (!ordinaryMarksPresent()) return -1 + for (i in from until gutterList.size) { + val mark = gutterList[i] + if (mark.line == line && mark.changeMark() == null) return i + } + return -1 + } + + private fun refreshMarkFlags() { + if (flagsStamp == gutterStamp) return + var bars = false + var ordinary = false + for (i in 0 until gutterList.size) { + if (gutterList[i].changeMark() != null) bars = true else ordinary = true + } + changeBarsPresent = bars + ordinaryMarksPresent = ordinary + flagsStamp = gutterStamp + } + + private fun ordinaryMarksPresent(): Boolean { + refreshMarkFlags() + return ordinaryMarksPresent + } + + /** Whether any mark is a change bar, which is what makes the gutter reserve room for them. */ + val hasChangeBars: Boolean + get() { + refreshMarkFlags() + return changeBarsPresent + } + + /** Whether any mark belongs in the mark column. */ + val hasOrdinaryMarks: Boolean get() = ordinaryMarksPresent() + + /** + * The one change bar a line shows. Several can sit on a line; the others stay in the + * model and keep following edits, but only the strongest is drawn. + */ + fun changeBarAt(line: Int): LiveGutterMark? { + if (!hasChangeBars) return null + var best: LiveGutterMark? = null + var bestRank = Int.MAX_VALUE + for (i in 0 until gutterList.size) { + val mark = gutterList[i] + if (mark.line != line) continue + val rank = mark.changeMark()?.ordinal ?: continue + if (rank < bestRank) { + best = mark + bestRank = rank + } + } + return best + } + + /** The lines that show a change bar, by the change they show, in line order. */ + fun changeLines(): Map> { + val shown = HashMap() + for (mark in gutterList) { + val change = mark.changeMark() ?: continue + val old = shown[mark.line] + if (old == null || change.ordinal < old.ordinal) shown[mark.line] = change + } + return shown.entries.groupBy({ it.value }, { it.key }).mapValues { it.value.sorted() } + } + /** * A selection the Host asked for, written against [baseVersion]. Both ends are moved * along with what the reader typed since. An end that is not a place in the document @@ -941,6 +1042,7 @@ class CodeEditorModel( moved = true } if (gutterList.isNotEmpty()) { + gutterStamp += 1 val iterator = gutterList.iterator() while (iterator.hasNext()) { val mark = iterator.next() diff --git a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt index 6d87e935d..37d69bd9e 100644 --- a/compose-rust/android-kotlin/shared/CodeEditorSurface.kt +++ b/compose-rust/android-kotlin/shared/CodeEditorSurface.kt @@ -64,6 +64,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -168,8 +170,58 @@ class CodeEditorLook( val gutterMark: (ColorRole?) -> Color = { lens }, /** The ink of the marker that folds or unfolds a stretch. */ val foldMarker: Color = lineNumber, + /** The width of a change-marker bar, which does not scale with the row. */ + val changeBarWidth: Dp = 4.dp, + /** The space between a change-marker bar and what comes after it. */ + val changeBarGap: Dp = 12.dp, + /** The word a screen reader hears for a changed line. */ + val changeMarkWord: (ChangeMark) -> String = { localizedChangeMarkWord(it) }, ) +/** The word for a changed line in [language], in the same set of languages as the sidebar's. */ +internal fun localizedChangeMarkWord(change: ChangeMark, language: String = androidx.compose.ui.text.intl.Locale.current.language): String = + when (language) { + "ko" -> when (change) { + ChangeMark.Added -> "추가됨" + ChangeMark.Modified -> "수정됨" + ChangeMark.Removed -> "삭제됨" + } + else -> when (change) { + ChangeMark.Added -> "added" + ChangeMark.Modified -> "modified" + ChangeMark.Removed -> "removed" + } + } + +/** The room the gutter keeps for change bars and the gap behind them: none when no line has one. */ +internal fun Density.changeColumnPx(model: CodeEditorModel, look: CodeEditorLook): Int = + if (model.hasChangeBars) (look.changeBarWidth + look.changeBarGap).roundToPx() else 0 + +/** + * What a screen reader hears about the changed lines, or null when there are none. The + * editor is one node, so the lines are listed under the word for their change. + */ +internal fun changeDescription(model: CodeEditorModel, look: CodeEditorLook): String? { + val lines = model.changeLines() + if (lines.isEmpty()) return null + return ChangeMark.entries.filter { it in lines }.joinToString("; ") { change -> + val numbers = lines.getValue(change) + val shown = numbers.take(CHANGE_LINES_SPOKEN).joinToString(", ") { (it + 1).toString() } + val rest = if (numbers.size > CHANGE_LINES_SPOKEN) ", +${numbers.size - CHANGE_LINES_SPOKEN}" else "" + "${look.changeMarkWord(change)}: $shown$rest" + } +} + +private const val CHANGE_LINES_SPOKEN = 50 + +/** Gives the editor's node the description of its changed lines, when it has any. */ +@Composable +internal fun Modifier.changeSemantics(model: CodeEditorModel, look: CodeEditorLook): Modifier { + val revision = model.overlayRevision + val text = remember(model, revision, look) { changeDescription(model, look) } ?: return this + return semantics { stateDescription = text } +} + /** What a surface tells the editor that holds it. All of them are called on the UI thread. */ class CodeEditorCallbacks( /** The model has something queued for the Host. */ @@ -194,16 +246,17 @@ fun CodeEditorSurface( callbacks: CodeEditorCallbacks, modifier: Modifier = Modifier, path: CodeEditorPath = defaultCodeEditorPath, + focusRequest: Long = 0L, ) { when (path) { - CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false) - CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true) + CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) + CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true, focusRequest = focusRequest) CodeEditorPath.Drawn -> { val drawn = drawnCodeEditorSurface if (drawn != null) { drawn(model, look, callbacks, modifier) } else { - FieldSurface(model, look, callbacks, modifier, whole = false) + FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) } } } @@ -784,6 +837,7 @@ private fun FieldSurface( callbacks: CodeEditorCallbacks, modifier: Modifier, whole: Boolean, + focusRequest: Long = 0L, ) { val density = LocalDensity.current val rowPx = remember(look.textStyle, density) { rowHeightPx(look.textStyle, density) } @@ -806,6 +860,10 @@ private fun FieldSurface( // reader itself reads the field's layout state, so what draws from it still follows. val layoutHolder = remember { arrayOfNulls<() -> TextLayoutResult?>(1) } val focus = remember { FocusRequester() } + val overlayFocusStack = dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack.current + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focus.requestFocus() + } // Read here so the row map and the gutter follow every change of text and overlay. val contentRevision = model.contentRevision @@ -813,13 +871,13 @@ private fun FieldSurface( val rows = remember(contentRevision, overlayRevision) { RowMap.of(model) } val lineCount = rows.lineCount val digits = lineCount.toString().length - val hasMarks = model.gutterMarks.isNotEmpty() + val hasMarks = model.hasOrdinaryMarks val hasFolds = model.folds.isNotEmpty() val markColumnPx = if (hasMarks) rowPx else 0 val foldColumnPx = if (hasFolds) rowPx else 0 val gutterWidthPx = with(density) { digits * digitWidth + (look.gutterPadding * 2).roundToPx() + look.gutterDividerWidth.roundToPx() - } + markColumnPx + foldColumnPx + } + markColumnPx + foldColumnPx + density.changeColumnPx(model, look) // Refilling the window recomposes this, so the field moves to where the new lines are. window.revision @@ -865,6 +923,7 @@ private fun FieldSurface( Box( modifier .boundedHeight(DEFAULT_EDITOR_HEIGHT) + .changeSemantics(model, look) .background(look.container) .clipToBounds() .onSizeChanged { size -> @@ -951,6 +1010,7 @@ private fun FieldSurface( } } .onFocusChanged { focusState -> + overlayFocusStack.noteFocused(focus, focusState.hasFocus) model.focusChanged(focusState.hasFocus) callbacks.onOutput() } @@ -1095,7 +1155,7 @@ internal fun pressGutter( if (foldColumn > 0 && point.x >= gutterWidth - foldColumn) { model.toggleFold(line) } else if (markColumn > 0 && point.x < markColumn) { - model.gutterMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) + model.ordinaryMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) } } @@ -1326,15 +1386,16 @@ private fun DrawScope.drawGutter( val last = min(rows.lineCount - 1, rows.lineAtRow((scroll + viewportHeight) / rowPx + 1)) val padding = look.gutterPadding.toPx() val divider = look.gutterDividerWidth.toPx() + val changeColumn = changeColumnPx(window.model, look) for (line in first..last) { if (rows.isHidden(line)) continue val label = (line + 1).toString() val color = if (line == caretLine) look.currentLineNumber else look.lineNumber val measured = measurer.measure(label, style.copy(color = color)) - val x = width - divider - foldColumn - padding - measured.size.width + val x = width - divider - foldColumn - changeColumn - padding - measured.size.width val top = rows.textRow(line) * rowPx.toFloat() drawText(measured, topLeft = Offset(x, top)) - drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, measurer, style, look) + drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, changeColumn, measurer, style, look) } } @@ -1347,13 +1408,24 @@ internal fun DrawScope.drawGutterAdornments( markColumn: Int, foldLeft: Float, foldColumn: Int, + changeColumn: Int, measurer: TextMeasurer, style: TextStyle, look: CodeEditorLook, ) { val cell = rowPx.toFloat() + // The change bar sits right of the numbers, the full row high, so that the bars of + // neighbouring lines meet. + val bar = model.changeBarAt(line) + if (bar != null) { + val left = foldLeft - changeColumn + drawRect(look.gutterMark(bar.color), Offset(left, top), Size(look.changeBarWidth.toPx(), cell)) + } if (markColumn > 0) { - for (mark in model.gutterMarksAt(line)) { + var index = model.nextOrdinaryMarkIndex(line, 0) + while (index >= 0) { + val mark = model.gutterMarks[index] + index = model.nextOrdinaryMarkIndex(line, index + 1) val color = look.gutterMark(mark.color) when (mark.kind) { GutterDecorationKind.Dot -> drawCircle(color, cell * 0.2f, Offset(markColumn / 2f, top + cell / 2f)) diff --git a/compose-rust/android-kotlin/shared/ComposeRustHost.kt b/compose-rust/android-kotlin/shared/ComposeRustHost.kt index 33496f8af..681dcea32 100644 --- a/compose-rust/android-kotlin/shared/ComposeRustHost.kt +++ b/compose-rust/android-kotlin/shared/ComposeRustHost.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.snapshots.Snapshot @@ -30,6 +31,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.runtime.mutableStateOf @@ -39,6 +41,7 @@ import dev.darkpyonix.composerust.foundation.systemChrome import androidx.compose.ui.graphics.Color import dev.darkpyonix.composerust.protocol.ColorRole import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.FrameRequests @@ -76,7 +79,17 @@ import androidx.compose.runtime.getValue import androidx.compose.foundation.isSystemInDarkTheme import dev.darkpyonix.composerust.design.HostPlatform -internal class WindowDragExclusion { var downKey: Pair? = null } +/** + * The press a `WindowDrag(false)` node saw, as pointer id and uptime. Two fields rather than + * a pair, so marking a press allocates nothing. + */ +internal class WindowDragExclusion { + var downId: Long = -1L + var downUptime: Long = -1L + fun mark(id: Long, uptime: Long) { downId = id; downUptime = uptime } + fun clear() { downId = -1L; downUptime = -1L } + fun isMarked(id: Long, uptime: Long): Boolean = downId == id && downUptime == uptime +} internal val LocalWindowDragExclusion = staticCompositionLocalOf { null } /** @@ -132,6 +145,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // After the first batch, so the first thing the Host hears is a press that started // the application, if one did, and then what the platform says about permission. table.notifications.attach(this) + table.fileDialogs.attach(this) } /** Dispatches synchronously and returns the Host's consumption result. */ @@ -165,6 +179,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // answer from the platform asks for a frame for exactly this, because the frame is // where the UI thread, and so the Host, can be reached from any other thread. table.notifications.drain() + table.fileDialogs.drain() applyTransaction { apply -> connection.renderFrame(frameTimeNanos, apply) } } @@ -178,6 +193,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher fun shutdown() { table.notifications.shutdown() + table.fileDialogs.shutdown() connection.shutdown() } @@ -379,6 +395,11 @@ private fun ComposeRustTree( // however many windows there are. val windowNodeId = LocalWindowNodeId.current val pageRoots = if (windowNodeId == 0) host.roots else host.table.node(windowNodeId)?.children?.toList().orEmpty() + val focusManager = LocalFocusManager.current + val clearFocusRevision = host.table.focusClearRevision + LaunchedEffect(clearFocusRevision) { + if (clearFocusRevision > 0L) focusManager.clearFocus() + } LaunchedEffect(host, frames, windowNodeId, servesFrames) { if (windowNodeId != 0 || !servesFrames) return@LaunchedEffect // Counted from the Host's start, not from the moment this loop began listening. The @@ -396,6 +417,8 @@ private fun ComposeRustTree( // A notification centre answers on a thread of its own, and the way from any thread to // this one is a frame request. The centre asks through the same source this loop serves. SideEffect { host.table.notifications.wake = frames::request } + // A file dialog's answer arrives the same way, from whichever thread the platform uses. + SideEffect { host.table.fileDialogs.wake = frames::request } // Whether the window is the one in use decides whether a notification that asked to be // shown only when it is not is shown at all, and coming back to the window is when a // permission changed in the system's settings is looked for. @@ -433,11 +456,17 @@ private fun ComposeRustTree( // and the design system is resolved against it, because a system is allowed to // answer differently in a desktop window than on a phone. var sizeClass by remember(host) { mutableStateOf(WindowSizeClass.Compact) } + // The height class is state too, so that a window which only crosses a height boundary + // (a phone-width window that is tall) recomposes and reaches the reporter below. Read + // only through the effect, the size is not observed, and a window whose width class did + // not change would never be reported. + var heightClass by remember(host) { mutableStateOf(WindowHeightClass.Compact) } var measuredSize by remember(host, windowNodeId) { mutableStateOf(null) } val measured = Modifier.onSizeChanged { size -> with(density) { val widthDp = size.width.toDp().value sizeClass = windowSizeClassOf(widthDp) + heightClass = windowHeightClassOf(size.height.toDp().value) measuredSize = size } } @@ -455,6 +484,7 @@ private fun ComposeRustTree( chainOf = { asset -> (host.table.assets.asset(asset) as? Asset.Font)?.chain }, brushOf = { asset -> (host.table.assets.asset(asset) as? Asset.Brush)?.brush }, windowBackdrop = asked && platformBacksWindowWithMaterial(), + overlay = host.table.overlayTokens, ) // The animations the Host handed over are played here, on this side's frame clock, and // the Host is not called while they play unless one asked for events. The loop waits @@ -549,9 +579,25 @@ private fun ComposeRustTree( captionStyle.side == CaptionSide.Start WindowSafeArea(caption.height.value, if (atStart) width.value else 0f, if (atStart) 0f else width.value) } + // Read through a derived state so a recomposition does not repeat the map lookup (which + // boxes the node id) and the read only runs again when the property itself changes. + // The implicit root window (id 0) opts in through its SetWindow settings and any other + // window through its node property; the table answers for both. + val reportEveryDp = remember(host, windowNodeId) { + derivedStateOf { host.table.reportSizeFor(windowNodeId) } + }.value + // A window that asked for every whole-dp change reads the size here, in the composition, + // so a new size recomposes this and reaches the effect. Any other window keeps reading it + // only inside the effect, as before, so a resize inside a size class costs it no + // recomposition. + val observedSize = if (reportEveryDp) measuredSize else null + // Subscribes this scope to the height class: reading the class here is what makes a + // height-only class change recompose, and so run the effect below with the new size. + @Suppress("UNUSED_VARIABLE") + val observedHeightClass = heightClass SideEffect { - measuredSize?.let { size -> - with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea) } + (observedSize ?: measuredSize)?.let { size -> + with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } } } // How much room the buttons take and at which end. The platform's own are at the @@ -575,12 +621,13 @@ private fun ComposeRustTree( // wherever focus is. Heard on the way back up from the focused node, so a text field // that wants the key keeps it. val sidebarShortcuts = remember(host) { dev.darkpyonix.composerust.foundation.SidebarShortcuts() } + val overlayFocusStack = remember(host, windowNodeId) { dev.darkpyonix.composerust.foundation.OverlayFocusStack() } val dragWindow = LocalWindowDrag.current val dragExclusion = remember { WindowDragExclusion() } val dragStrip = if (caption.fullSize && caption.height > 0.dp) Modifier.pointerInput(caption.height, dragWindow) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed && dragExclusion.downKey != (down.id.value to down.uptimeMillis) && down.position.y < caption.height.toPx()) { + if (!down.isConsumed && !dragExclusion.isMarked(down.id.value, down.uptimeMillis) && down.position.y < caption.height.toPx()) { while (true) { val change = awaitPointerEvent(PointerEventPass.Final).changes.firstOrNull { it.id == down.id } ?: break if (change.isConsumed || !change.pressed) break @@ -595,6 +642,7 @@ private fun ComposeRustTree( } else Modifier CompositionLocalProvider( dev.darkpyonix.composerust.foundation.LocalSidebarShortcuts provides sidebarShortcuts, + dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack provides overlayFocusStack, LocalWindowDragExclusion provides dragExclusion, LocalDesignTheme provides theme, LocalReduceTransparency provides reduceTransparency, @@ -615,7 +663,9 @@ private fun ComposeRustTree( modifier .then(measured) .then(dragStrip) - .onKeyEvent { event -> sidebarShortcuts.handle(event, platform) } + .onKeyEvent { event -> + overlayFocusStack.handleEscape(event) || sidebarShortcuts.handle(event, platform) + } .background(host.table.windowFill(pageRoots, theme)), ) { Box(Modifier.padding(top = pageTop, bottom = pageBottom)) { diff --git a/compose-rust/android-kotlin/shared/ControlLook.kt b/compose-rust/android-kotlin/shared/ControlLook.kt new file mode 100644 index 000000000..302318c7f --- /dev/null +++ b/compose-rust/android-kotlin/shared/ControlLook.kt @@ -0,0 +1,142 @@ +package dev.darkpyonix.composerust.design + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.paintProp +import dev.darkpyonix.composerust.ui.resolvedShape + +/** + * The colours and size the application asked a Switch, Slider or Checkbox to draw with. + * + * Every slot is null until the node sent it, and a slot that is null keeps what the design + * system draws. A look with nothing in it changes nothing, which is how a control that + * sends none of these properties stays exactly as it was. + */ +class ControlLook( + /** The track of a Switch, or the part of a Slider's track that is not filled. */ + val track: Color? = null, + /** The travelling thumb of a Switch or a Slider. */ + val thumb: Color? = null, + /** The filled part of a Slider's track. */ + val activeTrack: Color? = null, + /** The box of a checked Checkbox. */ + val container: Color? = null, + /** The check mark. */ + val check: Color? = null, + /** A Switch's width, or a Checkbox's side. */ + val width: Dp? = null, + /** A Switch's height. */ + val height: Dp? = null, + /** The corner shape the node asked for, or null where the design system's applies. */ + val shape: Shape? = null, +) { + /** Whether a Switch or a Checkbox has to be drawn from this look rather than by default. */ + fun appliesTo(role: ToggleRole): Boolean = when (role) { + ToggleRole.Switch -> track != null || thumb != null || width != null || height != null + ToggleRole.Checkbox -> container != null || check != null || width != null + ToggleRole.RadioButton -> false + } + + companion object { + val None = ControlLook() + } +} + +/** + * The look of [node], rebuilt only when something it reads has changed. + * + * A recomposition that changes none of the six properties, the corner modifier or the theme + * hands back the look it made last time, and a node that sends none of them gets the shared + * [ControlLook.None], so the steady state allocates nothing. A property that is removed + * makes its slot null, which differs from the value it held, so the look returns to the + * defaults. + */ +@Composable +internal fun rememberControlLook(node: Node, theme: ResolvedTheme): ControlLook { + val cache = remember { ControlLookCache() } + return cache.read(node, theme) +} + +private class ControlLookCache { + private var primed = false + private var theme: ResolvedTheme? = null + private var track: PropertyValue? = null + private var thumb: PropertyValue? = null + private var activeTrack: PropertyValue? = null + private var container: PropertyValue? = null + private var check: PropertyValue? = null + private var size: PropertyValue? = null + private var corner: dev.darkpyonix.composerust.protocol.Modifier? = null + private var named = false + private var look: ControlLook = ControlLook.None + + fun read(node: Node, current: ResolvedTheme): ControlLook { + val props = node.props + val newTrack = props[PropertyKind.TrackColor] + val newThumb = props[PropertyKind.ThumbColor] + val newActive = props[PropertyKind.ActiveTrackColor] + val newContainer = props[PropertyKind.ContainerColor] + val newCheck = props[PropertyKind.CheckColor] + val newSize = props[PropertyKind.ControlSize] + var newNamed = false + var newCorner: dev.darkpyonix.composerust.protocol.Modifier? = null + val modifiers = node.modifiers + for (index in modifiers.indices) { + val value = modifiers[index] + val isNamed = value is dev.darkpyonix.composerust.protocol.Modifier.Shape || + value is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + if (isNamed) newNamed = true + if (isNamed || value is dev.darkpyonix.composerust.protocol.Modifier.CornerEach) newCorner = value + } + if (primed && theme === current && track == newTrack && thumb == newThumb && + activeTrack == newActive && container == newContainer && check == newCheck && + size == newSize && named == newNamed && corner == newCorner + ) { + return look + } + primed = true + theme = current + track = newTrack; thumb = newThumb; activeTrack = newActive + container = newContainer; check = newCheck; size = newSize + named = newNamed; corner = newCorner + val empty = newTrack == null && newThumb == null && newActive == null && + newContainer == null && newCheck == null && newSize == null && !newNamed + look = if (empty) ControlLook.None else node.controlLook(current) + return look + } +} + +/** Reads the look a node asked for. Colours go through the theme like every other paint. */ +internal fun Node.controlLook(theme: ResolvedTheme): ControlLook { + fun colour(kind: PropertyKind): Color? = paintProp(kind)?.let { theme.color(it) } + val size = bytes(PropertyKind.ControlSize)?.takeIf { it.size >= 8 } + val width = size?.let { littleEndianFloat(it, 0) }?.takeIf { it > 0f }?.dp + val height = size?.let { littleEndianFloat(it, 4) }?.takeIf { it > 0f }?.dp + val named = modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.Shape || + it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + } + return ControlLook( + track = colour(PropertyKind.TrackColor), + thumb = colour(PropertyKind.ThumbColor), + activeTrack = colour(PropertyKind.ActiveTrackColor), + container = colour(PropertyKind.ContainerColor), + check = colour(PropertyKind.CheckColor), + width = width, + height = height, + shape = if (named) modifiers.resolvedShape(theme) else null, + ) +} + +private fun littleEndianFloat(bytes: ByteArray, at: Int): Float { + var bits = 0 + for (index in 3 downTo 0) bits = (bits shl 8) or (bytes[at + index].toInt() and 0xff) + return Float.fromBits(bits) +} diff --git a/compose-rust/android-kotlin/shared/ControlWidgets.kt b/compose-rust/android-kotlin/shared/ControlWidgets.kt index b0d554ea9..896c05ea3 100644 --- a/compose-rust/android-kotlin/shared/ControlWidgets.kt +++ b/compose-rust/android-kotlin/shared/ControlWidgets.kt @@ -82,6 +82,7 @@ interface ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -97,6 +98,9 @@ interface ControlWidgets { * [named] colours the part that has been filled in and the thumb with it, where the * application said what those should look like. It is null in the ordinary case and * the design system answers instead, which is what an adaptive application wants. + * + * [look] holds the colours the application named for the two parts of the track and + * the thumb, each of which wins over [named] and over the design system where it is set. */ @Composable fun Slider( @@ -108,6 +112,7 @@ interface ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -152,6 +157,7 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).toggle(role) val motion = theme.rules.motion @@ -163,7 +169,11 @@ internal object DrawnControlWidgets : ControlWidgets { // animates without a frame of it crossing the boundary. val progress by animateFloatAsState(if (checked) 1f else 0f, spec) val container by animateColorAsState( - targetValue = if (checked) style.containerChecked else style.container, + targetValue = when { + role == ToggleRole.Switch && look.track != null -> look.track + checked -> look.container ?: style.containerChecked + else -> style.container + }, animationSpec = tween(spec.durationMillis, easing = motion.easing), ) val alpha = if (enabled) 1f else style.disabledAlpha @@ -208,14 +218,19 @@ internal object DrawnControlWidgets : ControlWidgets { } when (role) { - ToggleRole.Checkbox -> Box( - clickable - .size(style.size) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { drawTick(style.mark.copy(alpha = alpha), progress) }, - ) + ToggleRole.Checkbox -> { + val shape = look.shape ?: style.shape + // A box the application filled has no outline of its own to show. + val outline = if (checked && look.container != null) 0.dp else style.borderWidth + Box( + clickable + .size(look.width ?: style.size) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { drawTick((look.check ?: style.mark).copy(alpha = alpha), progress) }, + ) + } ToggleRole.RadioButton -> Box( clickable @@ -231,27 +246,41 @@ internal object DrawnControlWidgets : ControlWidgets { }, ) - ToggleRole.Switch -> Box( - clickable - .size(width = style.trackWidth, height = style.trackHeight) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { - val diameter = style.thumbSize.toPx() - val margin = (size.height - diameter) / 2f - val travel = size.width - diameter - margin * 2f - val thumb = if (checked) style.mark else style.markUnchecked - drawCircle( - color = thumb.copy(alpha = alpha), - radius = diameter / 2f, - center = Offset( - x = margin + diameter / 2f + travel * progress, - y = size.height / 2f, - ), + ToggleRole.Switch -> { + val shape = look.shape ?: style.shape + val outline = if (look.track != null) 0.dp else style.borderWidth + Box( + clickable + .size( + width = look.width ?: style.trackWidth, + height = look.height ?: style.trackHeight, ) - }, - ) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { + // A track of the application's own height has a thumb that + // scales with it, the same 4 to 5 proportion as a 16 dp thumb on + // a 20 dp track. + val diameter = if (look.height != null) { + size.height * SWITCH_THUMB_RATIO + } else { + style.thumbSize.toPx() + } + val margin = (size.height - diameter) / 2f + val travel = size.width - diameter - margin * 2f + val thumb = look.thumb ?: if (checked) style.mark else style.markUnchecked + drawCircle( + color = thumb.copy(alpha = alpha), + radius = diameter / 2f, + center = Offset( + x = margin + diameter / 2f + travel * progress, + y = size.height / 2f, + ), + ) + }, + ) + } } } @@ -265,10 +294,12 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).slider - val active = named ?: style.activeTrack - val knob = named ?: style.thumb + val active = look.activeTrack ?: named ?: style.activeTrack + val knob = look.thumb ?: named ?: style.thumb + val rest = look.track ?: style.track val min = range.start val max = range.endInclusive val span = (max - min).takeIf { it > 0f } @@ -312,7 +343,7 @@ internal object DrawnControlWidgets : ControlWidgets { val usable = size.width - thumb val radius = CornerRadius(trackHeight / 2f, trackHeight / 2f) drawRoundRect( - color = style.track, + color = rest, topLeft = Offset(left, trackTop), size = Size(usable, trackHeight), cornerRadius = radius, @@ -502,6 +533,9 @@ private fun valueAt(x: Float, width: Float, thumb: Float, min: Float, max: Float return min + (max - min) * ((x - thumb / 2f) / usable).coerceIn(0f, 1f) } +/** A switch thumb's diameter as a share of the track's height, when the height is the application's. */ +private const val SWITCH_THUMB_RATIO = 0.8f + /** Narrow enough to sit in a row, wide enough that the track is worth dragging. */ private val SLIDER_MIN_WIDTH = 120.dp diff --git a/compose-rust/android-kotlin/shared/DesignSystem.kt b/compose-rust/android-kotlin/shared/DesignSystem.kt index 682b9586f..f603e80fb 100644 --- a/compose-rust/android-kotlin/shared/DesignSystem.kt +++ b/compose-rust/android-kotlin/shared/DesignSystem.kt @@ -22,6 +22,7 @@ import dev.darkpyonix.composerust.protocol.DesignTokens import androidx.compose.animation.core.FiniteAnimationSpec import dev.darkpyonix.composerust.protocol.MaterialRole import dev.darkpyonix.composerust.protocol.MotionRole +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.protocol.SpaceRole @@ -180,6 +181,11 @@ class ResolvedTheme( * given is enough to find the rest of the stack: see [chainFor]. */ val fontChains: List = emptyList(), + + /** + * Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + */ + val overlay: OverlayTokens? = null, ) { /** * The stack [family] is the first family of, or null where it is not one. @@ -1638,6 +1644,8 @@ fun resolveTheme( * family lacks in the families after it. */ chainOf: (Int) -> FontChain? = { null }, + /** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + overlay: OverlayTokens? = null, ): ResolvedTheme { val system = when { theme == null -> adaptiveSystem(platform, DesignSystem.Material3) @@ -1684,6 +1692,7 @@ fun resolveTheme( platform, application, chains, + overlay, ) } diff --git a/compose-rust/android-kotlin/shared/FileDialogHost.kt b/compose-rust/android-kotlin/shared/FileDialogHost.kt new file mode 100644 index 000000000..4e1466bf8 --- /dev/null +++ b/compose-rust/android-kotlin/shared/FileDialogHost.kt @@ -0,0 +1,430 @@ +package dev.darkpyonix.composerust.ui.platform + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.runtime.EventDispatcher +import kotlinx.coroutines.channels.Channel + +/** The limits a request and its answer are held to. */ +object FileDialogLimits { + /** Filters kept from one request; the rest are dropped. */ + const val MAX_FILTERS = 16 + + /** Extensions kept from one filter; the rest are dropped. */ + const val MAX_EXTENSIONS = 32 + + /** Paths one answer may carry. More is not delivered as a shorter list: it is unavailable. */ + const val MAX_PATHS = 10_000 + + /** + * Bytes of paths one answer may carry, separators included. + * + * Half of what the largest event buffer the desktop connection will grow to, so the + * record around the paths always fits. A platform with a smaller buffer sets a smaller + * limit on its [FileDialogCenter]. + */ + const val MAX_PAYLOAD_BYTES = 32 * 1024 * 1024 +} + +/** One filter of a dialog: what the user sees, and the extensions it lets through. */ +data class FileDialogFilter( + val label: String, + /** Lower case, without a leading dot or `*.`. Empty means every file. */ + val extensions: List, +) + +/** + * One dialog, as a platform is asked to show it, after the request was checked and + * normalised. + * + * [windowNode] is the `Window` node the dialog belongs to, or 0 for the implicit root + * window. [name] is a bare file name, and only a save dialog has one. [filters] is empty for + * the folder kinds. [defaultFilter] is a position inside [filters], or 0. + */ +data class FileDialogSpec( + val request: Int, + val windowNode: Int, + val kind: FileDialogKind, + val title: String, + val directory: String, + val name: String, + val filters: List, + val defaultFilter: Int, + /** + * For each entry of [filters], its position in the list the Host sent. A filter dropped + * on the way makes this differ from the identity; the result's `filter` is mapped back + * through it. Empty means the identity. + */ + val sentPositions: List = emptyList(), +) + +/** + * One path a dialog returned. A platform that hands over text passes [Text]; one whose paths + * are bytes (a Linux file system does not promise UTF-8) passes [Bytes]. One that is not UTF-8 + * makes the whole choice unavailable. + */ +sealed interface FileDialogPath { + class Text(val value: String) : FileDialogPath + class Bytes(val value: ByteArray) : FileDialogPath +} + +/** How a platform's dialog ended. */ +sealed interface FileDialogOutcome { + /** + * The user chose. [filter] is the position of the filter that was selected, or null when + * the dialog had none. + */ + class Chosen(val paths: List, val filter: Int? = null) : FileDialogOutcome { + companion object { + fun ofText(paths: List, filter: Int? = null): Chosen = + Chosen(paths.map { FileDialogPath.Text(it) }, filter) + } + } + + /** The user closed the dialog. */ + data object Cancelled : FileDialogOutcome + + /** The dialog could not be shown. */ + data object Unavailable : FileDialogOutcome +} + +/** + * One platform's file dialogs. + * + * [show] is called on the UI thread and must return without waiting for the user. The + * outcome goes to [onResult], from whichever thread the platform answers on, once. Calling + * it again is harmless: only the first counts. + */ +interface FileDialogPlatform { + /** + * Shows [request] on [window], the handle for the window it belongs to. The handle is + * whatever [FileDialogCenter.windowResolver] gave for the request's window node, and is + * null for the implicit root window or when nothing resolves one. + */ + fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) + + /** + * The window a dialog was shown on is going away. The platform closes that window's + * dialog if it has one open. The request ends with a cancel whether or not it does. + */ + fun cancelWindow(window: Any?) {} +} + +/** + * A platform with no file dialog yet, or a run that cannot show one. Every request is + * answered at once with [FileDialogOutcome.Unavailable]; that is not a protocol error. + */ +object UnsupportedFileDialogs : FileDialogPlatform { + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) = + onResult(FileDialogOutcome.Unavailable) +} + +/** + * The file dialogs this process uses. + * + * Installed by each platform's entry point before the Host starts, the way the notification + * centre is. A run nobody installed anything for answers every request as unavailable. + */ +object FileDialogs { + var platform: FileDialogPlatform = UnsupportedFileDialogs +} + +/** + * What the interpreter does with file dialog requests, and the one route the answers take + * back to the Host. + * + * It owns the rules that do not depend on a platform: a request number waits for one answer + * only, requests on one window are shown one after the other in the order they came, a + * window that goes away ends its dialog with a cancel, and an answer is delivered on the UI + * thread inside a frame, whole or not at all. The platform underneath only shows and reports. + */ +class FileDialogCenter( + private val platform: FileDialogPlatform = FileDialogs.platform, + private val maxPayloadBytes: Int = FileDialogLimits.MAX_PAYLOAD_BYTES, +) { + /** What `request` made of the request, for the table to report. */ + enum class Accepted { Accepted, Duplicate, UnknownWindow } + + private class Entry(val spec: FileDialogSpec) { + /** The platform was asked, or the request is already answered and only waits to be delivered. */ + var started = false + + /** An answer is on its way to being delivered, so nothing else starts on this window yet. */ + var finishing = false + var handle: Any? = null + } + + private class Completion(val request: Int, val outcome: FileDialogOutcome) + + /** + * Answers from the platform, from any thread. A channel because it is the one queue every + * target can share across threads without a lock of its own, and unlimited because what + * goes in is a handful of answers. + */ + private val inbox = Channel(Channel.UNLIMITED) + + /** Requests that have not been delivered an answer yet, in the order they came. */ + private val pending = LinkedHashMap() + + private var dispatcher: EventDispatcher? = null + private var draining = false + + /** How an answer asks for the frame that delivers it. */ + var wake: () -> Unit = {} + + /** + * Gives the platform's handle for a `Window` node, read when the dialog is shown. Node 0 + * has none. The window manager of a multi-window application installs this. + */ + var windowResolver: (Int) -> Any? = { null } + + /** How many requests are waiting for an answer. */ + internal val pendingCount: Int get() = pending.size + + /** + * Takes the request, normalised, unless its number is already waiting for an answer. + * + * [windowKnown] says whether the window it names is the implicit root or an existing + * `Window` node. A request on an unknown window is still answered, as unavailable. + */ + fun request(mutation: Mutation.ShowFileDialog, windowKnown: Boolean): Accepted { + if (pending.containsKey(mutation.request)) return Accepted.Duplicate + val entry = Entry(normalize(mutation)) + pending[mutation.request] = entry + if (!windowKnown) { + entry.started = true + entry.finishing = true + inbox.trySend(Completion(mutation.request, FileDialogOutcome.Unavailable)) + wake() + return Accepted.UnknownWindow + } + wake() + return Accepted.Accepted + } + + /** + * The `Window` node [windowNode] was removed. Its dialog is closed and every request on it + * ends with one cancel. + */ + fun windowRemoved(windowNode: Int) { + if (windowNode == 0) return + var any = false + for (entry in pending.values.toList()) { + if (entry.spec.windowNode != windowNode || entry.finishing) continue + any = true + entry.finishing = true + if (entry.started) platform.cancelWindow(entry.handle) + inbox.trySend(Completion(entry.spec.request, FileDialogOutcome.Cancelled)) + } + if (any) wake() + } + + /** Connects the center to the Host, once its first batch has been applied. */ + fun attach(host: EventDispatcher) { + dispatcher = host + drain() + } + + /** The process is ending. */ + fun shutdown() { + dispatcher = null + pending.clear() + while (inbox.tryReceive().getOrNull() != null) Unit + } + + /** + * Starts what may start and hands every answer to the Host, on the UI thread. + * + * Called where the Host can be entered: after its start, and at the top of every frame + * an answer asked for. A request is started here and not where it arrives, because the + * batch that carried it may also have created the window it belongs to, and the platform + * window exists only once the batch is through. + */ + fun drain() { + val host = dispatcher ?: return + if (draining) return + draining = true + try { + var progress = true + while (progress) { + progress = false + while (true) { + val done = inbox.tryReceive().getOrNull() ?: break + // Only the first answer to a request counts; a second finds nothing waiting. + val entry = pending.remove(done.request) ?: continue + progress = true + host.dispatch(resultEvent(entry.spec, done.outcome)) + } + for (entry in pending.values.toList()) { + if (entry.started || entry.finishing) continue + if (pending.values.any { it !== entry && it.spec.windowNode == entry.spec.windowNode && (it.started || it.finishing) }) continue + start(entry) + progress = true + } + } + } finally { + draining = false + } + } + + private fun start(entry: Entry) { + entry.started = true + val request = entry.spec.request + val handle = windowResolver(entry.spec.windowNode) + entry.handle = handle + try { + platform.show(handle, entry.spec) { outcome -> + inbox.trySend(Completion(request, outcome)) + wake() + } + } catch (failure: Exception) { + // A platform that throws has no dialog to show, which is the same answer. + inbox.trySend(Completion(request, FileDialogOutcome.Unavailable)) + } + } + + private fun resultEvent(spec: FileDialogSpec, outcome: FileDialogOutcome): HostEvent { + val none = -1 + return when (val checked = judge(outcome)) { + is Checked.Paths -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = FileDialogStatus.Chosen, + // The platform answers with a position in the cleaned list; the Host is told the + // position in the list it sent. + filter = checked.filter + ?.takeIf { chosen -> chosen in spec.filters.indices } + ?.let { chosen -> spec.sentPositions.getOrElse(chosen) { chosen } } + ?: none, + paths = checked.joined, + ) + is Checked.Ended -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = checked.status, + filter = none, + paths = "", + ) + } + } + + private sealed interface Checked { + class Paths(val joined: String, val filter: Int?) : Checked + class Ended(val status: FileDialogStatus) : Checked + } + + /** + * What the Host is told about an outcome. + * + * A path that is not well formed text makes the whole choice unavailable: the other paths + * are not sent either, because the application could not tell that the list is shorter + * than what the user picked, and a cancel would be false. A choice with too many paths, or too many bytes of them, is + * unavailable too, never a shorter list: the application would not know it was short. + */ + private fun judge(outcome: FileDialogOutcome): Checked = when (outcome) { + FileDialogOutcome.Cancelled -> Checked.Ended(FileDialogStatus.Cancelled) + FileDialogOutcome.Unavailable -> Checked.Ended(FileDialogStatus.Unavailable) + is FileDialogOutcome.Chosen -> when { + outcome.paths.isEmpty() -> Checked.Ended(FileDialogStatus.Cancelled) + outcome.paths.size > FileDialogLimits.MAX_PATHS -> Checked.Ended(FileDialogStatus.Unavailable) + else -> { + val checked = outcome.paths.map { wellFormedPath(it) } + val texts = checked.filterNotNull() + when { + texts.size != checked.size -> Checked.Ended(FileDialogStatus.Unavailable) + texts.sumOf { it.encodeToByteArray().size.toLong() + 1L } > maxPayloadBytes.toLong() -> + Checked.Ended(FileDialogStatus.Unavailable) + else -> Checked.Paths(texts.joinToString("\u0000"), outcome.filter) + } + } + } + } + + companion object { + /** + * Checks a request and puts it in the form a platform can rely on: filters and + * extensions within their limits, extensions lower case without a leading dot or + * `*.`, a default filter that exists, a bare file name, and nothing in the fields a + * kind does not use. + */ + fun normalize(mutation: Mutation.ShowFileDialog): FileDialogSpec { + val folders = mutation.kind == FileDialogKind.OpenFolder || mutation.kind == FileDialogKind.OpenFolders + val parsed = if (folders) emptyList() else parseFilters(mutation.filters) + val filters = parsed.map { it.second } + val name = if (mutation.kind == FileDialogKind.Save) { + mutation.name.substringAfterLast('/').substringAfterLast('\\') + } else { + "" + } + return FileDialogSpec( + request = mutation.request, + windowNode = mutation.window, + kind = mutation.kind, + title = mutation.title, + directory = mutation.directory, + name = name, + filters = filters, + // The default is a position in the list the Host sent, so it follows its filter + // when an earlier one was dropped. + defaultFilter = parsed.indexOfFirst { it.first == mutation.defaultFilter }.takeIf { it >= 0 } ?: 0, + sentPositions = parsed.map { it.first }, + ) + } + + private fun parseFilters(encoded: String): List> { + if (encoded.isEmpty()) return emptyList() + // Label, extensions, label, extensions. A label with nothing after it lets every + // file through, like one with no extensions. + val parts = encoded.split('\u0000') + val filters = ArrayList>() + var index = 0 + var position = 0 + while (index < parts.size && position < FileDialogLimits.MAX_FILTERS) { + val named = (parts.getOrNull(index + 1) ?: "").split(',').filter { it.isNotBlank() } + val extensions = named + .map { it.trim().removePrefix("*.").removePrefix(".").lowercase() } + .filter { it.isNotEmpty() } + .take(FileDialogLimits.MAX_EXTENSIONS) + // A filter that names extensions and keeps none of them (a lone ".") would + // otherwise mean every file, the opposite of what it asked for. + if (named.isEmpty() || extensions.isNotEmpty()) { + filters += position to FileDialogFilter(parts[index], extensions) + } + position++ + index += 2 + } + return filters + } + + /** The path as text, or null for one that is empty, holds a NUL, or is not valid Unicode text. */ + private fun wellFormedPath(path: FileDialogPath): String? { + val text = when (path) { + is FileDialogPath.Text -> path.value + is FileDialogPath.Bytes -> try { + path.value.decodeToString(throwOnInvalidSequence = true) + } catch (failure: Exception) { + return null + } + } + if (text.isEmpty()) return null + var index = 0 + while (index < text.length) { + val unit = text[index] + when { + unit == '\u0000' -> return null + unit.isHighSurrogate() -> { + if (index + 1 >= text.length || !text[index + 1].isLowSurrogate()) return null + index++ + } + unit.isLowSurrogate() -> return null + } + index++ + } + return text + } + } +} diff --git a/compose-rust/android-kotlin/shared/HostButton.kt b/compose-rust/android-kotlin/shared/HostButton.kt index 63b5ea196..430e97285 100644 --- a/compose-rust/android-kotlin/shared/HostButton.kt +++ b/compose-rust/android-kotlin/shared/HostButton.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -21,10 +23,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected @@ -42,10 +49,14 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.compositeOver import dev.darkpyonix.composerust.design.ensureContrast import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.ui.node.Children +import dev.darkpyonix.composerust.ui.node.LocalInheritedTextColor import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.pressFeedback import dev.darkpyonix.composerust.ui.groupAlpha import dev.darkpyonix.composerust.ui.intProp @@ -66,6 +77,7 @@ import dev.darkpyonix.composerust.ui.variant internal fun HostButton( node: Node, modifier: Modifier, + table: NodeTable, dispatcher: EventDispatcher, theme: ResolvedTheme, ) { @@ -118,22 +130,50 @@ internal fun HostButton( // label instead of staying opaque behind it. The label is the exception: it is drawn // again outside the fade, in a colour that keeps its contrast with the faded fill. val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(node.id) { FocusRequester() } + val ownShape = node.modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + val shape = if (ownShape) node.modifiers.resolvedShape(theme) else style.shape + val hasExplicitSize = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Width || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + val setsOwnHeight = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + // Read before the decoration, because how much room the button needs depends on it. + val wordless = node.text(PropertyKind.Text).isEmpty() && + node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null + val labelPadH = if (wordless && hasExplicitSize) 0.dp else if (wordless) style.verticalPadding else style.horizontalPadding + val labelPadV = if (wordless && hasExplicitSize) 0.dp else style.verticalPadding + val clickable = if (handlerId == null) { available.then(modifier) } else { // The Host result is not used here: a click that reached a Button is never offered // to anything below it. Indication is null because the design system owns the // feedback, and a ripple is only one system's answer. - available.then(modifier).pressFeedback( - interactions, enabled, theme, style.shape, - node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, - node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, - ).clickable( - interactionSource = interactions, - indication = null, - enabled = enabled, - role = Role.Button, - ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } + available.then(modifier) + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } + .pressFeedback( + interactions, enabled, theme, shape, + node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, + node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, + ).clickable( + interactionSource = interactions, + indication = null, + enabled = enabled, + role = Role.Button, + ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } } // A Host that named its own Background has already had it applied to this node's @@ -145,30 +185,29 @@ internal fun HostButton( // pill, and asking for a variant instead hands the question to whichever design // system is active, which answers with its own accent. val ownFill = node.setsOwnBackground() - // Read before the decoration, because how much room the button needs depends on it. - val wordless = node.text(PropertyKind.Text).isEmpty() && - node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null val decorated = theme.rules - .elevation(clickable, elevation, style.shape, theme) - .clip(style.shape) - .then(if (ownFill) Modifier else Modifier.background(container, style.shape)) + .elevation(clickable, elevation, shape, theme) + .clip(shape) + .then(if (ownFill) Modifier else Modifier.background(container, shape)) .then( if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, borderColor, style.shape) + Modifier.border(style.borderWidth, borderColor, shape) } else { Modifier }, ) .topHighlight(style.topHighlight) - .defaultMinSize(minHeight = style.minHeight) + .let { + if (setsOwnHeight || (wordless && hasExplicitSize)) it else it.defaultMinSize(minHeight = style.minHeight) + } // A button with a glyph and no word gets the same room on both axes. The // horizontal padding is there to keep a word off the button's ends, and a button // with no word has nothing to keep off them: using it anyway makes every icon // control half as wide again as it needs to be, which two of them in a floating // group and one at the head of a composer both showed. .labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ) @@ -202,13 +241,28 @@ internal fun HostButton( // the glyph means, so it is what the name is made of, and assistive technology never // meets an unnamed control. val named = decorated - .let { if (icon != null && text.isEmpty()) it.semantics { contentDescription = icon.name } else it } + .let { + // The Host's own name for the button is read instead of the icon's meaning. + if (icon != null && text.isEmpty() && node.text(PropertyKind.AccessibleName).isEmpty()) { + it.semantics { contentDescription = icon.name } + } else { + it + } + } .let { if (node.property(PropertyKind.Checked) != null) it.semantics { selected = checked } else it } // The row gap between the glyph and the word is the design system's smallest step, so a // dense language sets them closer than a roomy one does. @Composable fun ButtonLabel() { - if (icon == null) { + if (node.children.isNotEmpty()) { + // Children replace the label. They are laid out as a Row centred vertically, so + // a weighted Spacer pushes whatever follows it to the button's inner edge. + CompositionLocalProvider(LocalInheritedTextColor provides ColorProducer { tint }) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Children(node, table, dispatcher) + } + } + } else if (icon == null) { ThemedText(text = text, style = label, maxLines = lines, softWrap = lines > 1, overflow = ellipsis) } else { Row( @@ -222,7 +276,10 @@ internal fun HostButton( } val alignment = if (alignStart) Alignment.CenterStart else Alignment.Center - if (enabled) { + // A button with children is faded as one group, children included: its content is the + // Host's to colour, so there is no label tint to keep readable, and composing the + // children twice would draw and expose each of them twice. + if (enabled || node.children.isNotEmpty()) { Box(named, contentAlignment = alignment) { ButtonLabel() } } else { Box { @@ -233,8 +290,8 @@ internal fun HostButton( } Box( Modifier.matchParentSize().labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ), contentAlignment = alignment, diff --git a/compose-rust/android-kotlin/shared/HostCodeEditor.kt b/compose-rust/android-kotlin/shared/HostCodeEditor.kt index 5f5399d21..38beff3dc 100644 --- a/compose-rust/android-kotlin/shared/HostCodeEditor.kt +++ b/compose-rust/android-kotlin/shared/HostCodeEditor.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import dev.darkpyonix.composerust.design.HostPlatform import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.foundation.code.CodeEditorCallbacks @@ -73,7 +74,8 @@ internal fun HostCodeEditor( }, ) } - CodeEditorSurface(model, look, callbacks, modifier) + val focusRequest = node.focusRequestRevision + CodeEditorSurface(model, look, callbacks, modifier, focusRequest = focusRequest) } /** What the design system says an editor looks like here, resolved to plain values. */ @@ -107,6 +109,9 @@ internal fun codeEditorLook(node: Node, theme: ResolvedTheme, tabWidth: Int?): C // A mark takes the role the Host named, and otherwise the lens's quiet ink. gutterMark = { role -> if (role == null) style.lens else theme.color(role) }, foldMarker = style.lineNumber, + // The same for every design system: the width and gap of a change-marker bar. + changeBarWidth = 4.dp, + changeBarGap = 12.dp, ) } diff --git a/compose-rust/android-kotlin/shared/HostContainer.kt b/compose-rust/android-kotlin/shared/HostContainer.kt index 9914be33c..bafe57099 100644 --- a/compose-rust/android-kotlin/shared/HostContainer.kt +++ b/compose-rust/android-kotlin/shared/HostContainer.kt @@ -7,6 +7,7 @@ import dev.darkpyonix.composerust.protocol.TypeRole import dev.darkpyonix.composerust.ui.textStyle import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,12 +46,20 @@ import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.weightOf import dev.darkpyonix.composerust.design.liftsOffThePage +/** True when the Host set a shape modifier of its own, which replaces the default shape. */ +internal fun Node.setsOwnShape(): Boolean = + modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + /** * The corner this container is drawn with: the one the Host asked for if it asked, and the * design system's corner for this kind of container otherwise. */ internal fun Node.containerShape(style: ContainerStyle, theme: ResolvedTheme): Shape = - if (modifiers.any { it is ProtocolModifier.Shape || it is ProtocolModifier.ShapeRole || it is ProtocolModifier.CornerEach }) { + if (setsOwnShape()) { modifiers.resolvedShape(theme) } else { style.shape @@ -98,12 +107,34 @@ internal fun Modifier.containerDecoration( theme: ResolvedTheme, /** True for a menu, a dialog or a tooltip: a sheet over the page rather than in it. */ overlay: Boolean = false, + role: ContainerRole? = null, ): Modifier { - val shape = node.containerShape(style, theme) - val raised = if (node.setsOwnElevation()) { + val shape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + else -> null + } ?: style.shape + } else { + style.shape + } + val effectiveElevation = if (node.setsOwnElevation()) { + 0.dp + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + else -> null + } ?: style.elevation + } else { + style.elevation + } + val raised = if (node.setsOwnElevation() || effectiveElevation.value <= 0f) { this } else { - theme.rules.elevation(this, style.elevation, shape, theme) + theme.rules.elevation(this, effectiveElevation, shape, theme) } val material = style.material // A Host that named its own Background has already had it applied, so painting the @@ -139,6 +170,17 @@ internal fun Modifier.containerDecoration( .then( if (node.setsOwnPadding()) { Modifier + } else if (overlay) { + val customPadding = when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + else -> null + } + if (customPadding != null) { + Modifier.padding(customPadding) + } else { + Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) + } } else { Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) }, diff --git a/compose-rust/android-kotlin/shared/HostDropdown.kt b/compose-rust/android-kotlin/shared/HostDropdown.kt index 52b3f5267..dc1fcdd9f 100644 --- a/compose-rust/android-kotlin/shared/HostDropdown.kt +++ b/compose-rust/android-kotlin/shared/HostDropdown.kt @@ -196,6 +196,29 @@ private fun OpeningList( ) } if (open) { + val popupShape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else { + theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { androidx.compose.foundation.shape.RoundedCornerShape(it.dp) } ?: style.shape + } + val popupElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val popupRaised = if (node.setsOwnElevation() || popupElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, popupElevation, popupShape, theme) + } + val explicitPopupPad = theme.overlay?.popupPadding + val popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +228,11 @@ private fun OpeningList( Column( Modifier .testTag(dropdownPopupTestTag(node.id)) - .clip(style.shape) - .background(style.container, style.shape) - .border(style.borderWidth, style.borderColor, style.shape) - .padding(vertical = theme.space(SpaceRole.Xs)), + .then(popupRaised) + .clip(popupShape) + .background(style.container, popupShape) + .border(style.borderWidth, style.borderColor, popupShape) + .then(popupPadding), ) { options.forEachIndexed { index, childId -> key(childId) { diff --git a/compose-rust/android-kotlin/shared/HostOverlay.kt b/compose-rust/android-kotlin/shared/HostOverlay.kt index d32738685..834c02465 100644 --- a/compose-rust/android-kotlin/shared/HostOverlay.kt +++ b/compose-rust/android-kotlin/shared/HostOverlay.kt @@ -1,6 +1,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -8,8 +9,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf 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 @@ -17,6 +21,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -37,10 +50,106 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider +/** + * The open modal overlays of a window, in the order they were opened, and the control that + * held focus before each one opened. + * + * Escape asks to close only the topmost open overlay, and only after whatever holds focus + * inside it has declined the key. When the overlay closes, focus returns to the control that + * had it before the overlay opened. + */ +class OverlayFocusStack { + private class Entry(val nodeId: Int, var onEscape: () -> Unit, val opener: FocusRequester?) + + private val entries = mutableListOf() + private var currentFocused: FocusRequester? = null + + internal fun noteFocused(requester: FocusRequester, focused: Boolean) { + if (focused) { + currentFocused = requester + } else if (currentFocused === requester) { + currentFocused = null + } + } + + internal fun push(nodeId: Int, onEscape: () -> Unit): FocusRequester? { + entries.removeAll { it.nodeId == nodeId } + val opener = currentFocused + entries += Entry(nodeId, onEscape, opener) + return opener + } + + internal fun update(nodeId: Int, onEscape: () -> Unit) { + entries.firstOrNull { it.nodeId == nodeId }?.onEscape = onEscape + } + + internal fun pop(nodeId: Int) { + val index = entries.indexOfFirst { it.nodeId == nodeId } + if (index < 0) return + val removed = entries.removeAt(index) + val target = if (entries.isEmpty()) removed.opener else null + if (target != null) { + try { + target.requestFocus() + } catch (_: IllegalStateException) { + // The opener left the tree while the overlay was open. + } + } + } + + internal fun handleEscape(event: KeyEvent, fromNodeId: Int? = null): Boolean { + if (event.type != KeyEventType.KeyDown || event.key != Key.Escape) return false + val top = entries.lastOrNull() ?: return false + if (fromNodeId != null && top.nodeId != fromNodeId) return false + top.onEscape() + return true + } +} + +val LocalOverlayFocusStack = staticCompositionLocalOf { OverlayFocusStack() } + +/** + * Keeps [node]'s presence in the window's overlay stack and moves focus into [surfaceRequester] + * when the overlay opens, returning focus to the opener when it leaves. + */ +@Composable +internal fun ModalOverlayFocus( + nodeId: Int, + surfaceRequester: FocusRequester, + onEscape: () -> Unit, +) { + val stack = LocalOverlayFocusStack.current + SideEffect { stack.update(nodeId, onEscape) } + DisposableEffect(stack, nodeId) { + stack.push(nodeId, onEscape) + onDispose { stack.pop(nodeId) } + } + LaunchedEffect(nodeId) { + try { + surfaceRequester.requestFocus() + } catch (_: IllegalStateException) { + } + } +} + +/** Modifier that places an overlay's surface in the focus tree and catches an unconsumed Escape. */ +@Composable +internal fun Modifier.modalOverlaySurface( + nodeId: Int, + surfaceRequester: FocusRequester, +): Modifier { + val stack = LocalOverlayFocusStack.current + return this + .onKeyEvent { event -> stack.handleEscape(event, fromNodeId = nodeId) } + .focusRequester(surfaceRequester) + .focusable() +} + /** Whether an overlay is showing, and the way to change it without asking the Host. */ internal class OverlayOpen(val value: Boolean, val set: (Boolean) -> Unit) @@ -72,6 +181,9 @@ internal fun dismiss(node: Node, dispatcher: EventDispatcher) { * The scrim, the placement and the corner come from the design system, so the same records * are a Material dialog, a HIG alert or a Fluent content dialog. */ +/** Test tag of a dialog's surface inside its popup. */ +fun dialogTestTag(nodeId: Int): String = "${nodeTestTag(nodeId)}-dialog" + @Composable internal fun HostDialog( node: Node, @@ -83,19 +195,29 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = { open.set(false) dismiss(node, dispatcher) }, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim).testTag(dialogTestTag(node.id)), contentAlignment = Alignment.Center, ) { - Column(modifier.containerDecoration(node, style, theme, overlay = true)) { + Column( + modifier + .modalOverlaySurface(node.id, surfaceRequester) + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), + ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } @@ -146,7 +268,7 @@ internal fun HostMenu( Column( Modifier .testTag(menuPopupTestTag(node.id)) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Menu), ) { entries.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } diff --git a/compose-rust/android-kotlin/shared/HostRichText.kt b/compose-rust/android-kotlin/shared/HostRichText.kt index 3393b22e4..4bd48f00e 100644 --- a/compose-rust/android-kotlin/shared/HostRichText.kt +++ b/compose-rust/android-kotlin/shared/HostRichText.kt @@ -6,6 +6,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.LinkAnnotation @@ -14,11 +20,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.MultiParagraphIntrinsics import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.SpanRecords import dev.darkpyonix.composerust.protocol.TypeRole +import dev.darkpyonix.composerust.protocol.WordBreak +import dev.darkpyonix.composerust.protocol.OverflowWrap import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.ui.node.AssetCache import dev.darkpyonix.composerust.ui.node.Node @@ -126,12 +135,13 @@ internal fun HostRichText( ) { val input = node.textInput() val density = textDensity(input, LocalDensity.current) + val fontResolver = LocalFontFamilyResolver.current val resolved = resolveText( input, theme, assets, density, - LocalFontFamilyResolver.current, + fontResolver, link = { run -> // A link is a press on a range, and a press is the event the boundary already // has. No new event tag for a thing that is already a click. @@ -143,6 +153,10 @@ internal fun HostRichText( ) }, ) + val wordFloor = if (input.softWrap && (input.wordBreak == null || input.wordBreak == WordBreak.Normal) && input.overflowWrap != OverflowWrap.Anywhere) { + val words = resolveText(input, theme, assets, density, fontResolver, intrinsicWordBreak = WordBreak.KeepAll) + kotlin.math.ceil(MultiParagraphIntrinsics(words.text, words.style, words.placeholders, density, fontResolver).minIntrinsicWidth).toInt() + } else 0 val runs = node.runsDecodeProblem() ?: resolved.runsProblem val fonts = node.fontListProblem() ?: resolved.problems.firstOrNull() val problem = when { @@ -156,7 +170,7 @@ internal fun HostRichText( CompositionLocalProvider(LocalDensity provides density) { BasicText( text = resolved.text, - modifier = modifier, + modifier = modifier.then(WordFloor(wordFloor)), style = resolved.style, overflow = resolved.overflow, softWrap = resolved.softWrap, @@ -167,6 +181,16 @@ internal fun HostRichText( } } +private class WordFloor(private val width: Int) : LayoutModifier { + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = + measurable.measure(constraints).let { placeable -> + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int = + maxOf(width, measurable.minIntrinsicWidth(height)) +} + /** * Says a set of runs is wrong, and goes on drawing the string without them. * diff --git a/compose-rust/android-kotlin/shared/HostSheet.kt b/compose-rust/android-kotlin/shared/HostSheet.kt index 2bdd6d721..74cd7dce3 100644 --- a/compose-rust/android-kotlin/shared/HostSheet.kt +++ b/compose-rust/android-kotlin/shared/HostSheet.kt @@ -24,6 +24,7 @@ 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.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -33,6 +34,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.design.SheetEdge +import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.runtime.LocalWindowSizeClass @@ -40,6 +42,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import kotlin.math.roundToInt /** How far a sheet has to be pulled towards its edge before letting go closes it. */ @@ -80,17 +83,79 @@ internal fun HostSheet( open.set(false) dismiss(node, dispatcher) } + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } + + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + + val shape = if (node.setsOwnShape()) { + node.containerShape( + dev.darkpyonix.composerust.design.ContainerStyle( + container = style.container, + content = androidx.compose.ui.graphics.Color.Unspecified, + shape = style.shape, + elevation = style.elevation, + borderWidth = style.borderWidth, + borderColor = style.borderColor, + horizontalPadding = 0.dp, + verticalPadding = 0.dp, + separator = null, + scrim = style.scrim, + typeRole = dev.darkpyonix.composerust.protocol.TypeRole.Body, + ), + theme, + ) + } else { + theme.overlay?.sheetCornerRadius?.takeIf { it > 0 }?.let { + if (bottom) { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, it.dp, 0.dp, 0.dp) + } else { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, 0.dp, 0.dp, it.dp) + } + } ?: style.shape + } + + val sheetElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.sheetElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val raised = if (node.setsOwnElevation() || sheetElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, sheetElevation, shape, theme) + } + + val containerBg = if (node.setsOwnBackground()) Modifier else Modifier.background(style.container) + + val outline = if (style.borderWidth.value > 0f) { + Modifier.border(style.borderWidth, style.borderColor, shape) + } else { + Modifier + } + + val explicitSheetPad = theme.overlay?.sheetPadding + val sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) + } else { + Modifier.padding(style.padding) + } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = close, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( Modifier .testTag(sheetScrimTestTag(node.id)) .fillMaxSize() - .background(style.scrim) + .background(scrim) // Pressing what the sheet covers is the other way of asking to close it, // and it gets the same answer: one dismissal. A raw tap detector rather // than `clickable`, because a scrim is not a button and saying it is one @@ -103,17 +168,13 @@ internal fun HostSheet( } else { Modifier.fillMaxHeight().fillMaxWidth(style.widthFraction) } - val outline = if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, style.borderColor, style.shape) - } else { - Modifier - } // Two boxes rather than one: the outer is the sheet itself, which is what is // dragged and what the design system sized, and the inner carries whatever // Modifiers the Host put on the node, which belong inside its surface. Box( Modifier .testTag(sheetTestTag(node.id)) + .modalOverlaySurface(node.id, surfaceRequester) .then(sized) .offset { if (bottom) { @@ -122,8 +183,9 @@ internal fun HostSheet( IntOffset(travel.roundToInt(), 0) } } - .clip(style.shape) - .background(style.container) + .then(raised) + .clip(shape) + .then(containerBg) .then(outline) .draggable( orientation = if (bottom) Orientation.Vertical else Orientation.Horizontal, @@ -138,7 +200,7 @@ internal fun HostSheet( // swallowed here rather than reaching the scrim behind. .pointerInput(Unit) { detectTapGestures { } }, ) { - Column(modifier.fillMaxWidth().padding(style.padding)) { + Column(modifier.fillMaxWidth().then(sheetPadding)) { style.handle?.let { handle -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( diff --git a/compose-rust/android-kotlin/shared/HostSlider.kt b/compose-rust/android-kotlin/shared/HostSlider.kt index 5f886f69e..bdb3ffb95 100644 --- a/compose-rust/android-kotlin/shared/HostSlider.kt +++ b/compose-rust/android-kotlin/shared/HostSlider.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -71,6 +72,7 @@ internal fun HostSlider( onChange = report, modifier = modifier, theme = theme, + look = rememberControlLook(node, theme), ) } diff --git a/compose-rust/android-kotlin/shared/HostTextField.kt b/compose-rust/android-kotlin/shared/HostTextField.kt index 0408e2a40..a7ad44015 100644 --- a/compose-rust/android-kotlin/shared/HostTextField.kt +++ b/compose-rust/android-kotlin/shared/HostTextField.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key @@ -60,6 +62,10 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.node.HostText import dev.darkpyonix.composerust.design.LocalGlassDepth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier /** * The uncontrolled TextField. @@ -88,15 +94,22 @@ internal fun HostTextField( } var appliedHostRevision by remember(nodeId) { mutableStateOf(0L) } var focused by remember(nodeId) { mutableStateOf(false) } + var reportedComposing by remember(nodeId) { mutableStateOf(false) } + val focusRequester = remember(nodeId) { FocusRequester() } + val overlayFocusStack = LocalOverlayFocusStack.current val multiline = node.flag(PropertyKind.Multiline, default = false) val enabled = node.flag(PropertyKind.Enabled, default = true) val changeHandler = node.handler(PropertyKind.OnValueChange) val submitHandler = node.handler(PropertyKind.OnSubmit) val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + val compositionHandler = node.handler(PropertyKind.OnCompositionChange) val placeholder = node.text(PropertyKind.Placeholder) val hostText = node.hostText + val focusRequest = node.focusRequestRevision val composing = value.composition != null // The field takes its type role and its colours from the design system, the same way a // Text does. @@ -130,6 +143,24 @@ internal fun HostTextField( } } + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focusRequester.requestFocus() + } + + val applyValueChange: (TextFieldValue) -> Unit = { next -> + val nowComposing = next.composition != null + value = next + if (nowComposing != reportedComposing) { + reportedComposing = nowComposing + if (compositionHandler != null) { + dispatcher.dispatch( + HostEvent.CompositionChanged(nodeId, compositionHandler, nowComposing), + ) + } + } + } + hostTextFieldValueHook?.invoke(applyValueChange) + // Change notification, at most one a frame. // // An edit is told to the Host on the next frame, carrying the latest text, so keys that @@ -166,8 +197,14 @@ internal fun HostTextField( } } + val heldKeys = remember { HeldKeys() } val inputModifier = modifier + .focusRequester(focusRequester) .onFocusChanged { state -> + overlayFocusStack.noteFocused(focusRequester, state.isFocused) + if (!focused && state.isFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) } @@ -181,22 +218,31 @@ internal fun HostTextField( // guard that keeps Enter away from Rust during a Korean composition. .onPreviewKeyEvent { event -> val composingNow = value.composition != null - val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) - if (consumedByHandler) { + if (composingNow && event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + applyValueChange(value.copy(composition = null)) true - } else if (!isDispatchableKeyDown(event, composingNow)) { - false - } else if (!shouldSubmitOnEnter( - composing = composingNow, - multiline = multiline, - shiftPressed = event.isShiftPressed, - hasSubmitHandler = submitHandler != null, - ) - ) { - false } else { - dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + // The key-down handler sees every event first, key-ups included, so the held + // keys it tracks can tell a repeat from a first press. + val consumedByHandler = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher, heldKeys) + if (consumedByHandler) { + true + } else if (keyUpHandler != null && isDispatchableKeyUp(event, composingNow)) { + dispatchKeyUp(nodeId, keyUpHandler, event, composingNow, dispatcher) + } else if (!isEnterKeyDown(event, composingNow)) { + false + } else if (!shouldSubmitOnEnter( + composing = composingNow, + multiline = multiline, + shiftPressed = event.isShiftPressed, + hasSubmitHandler = submitHandler != null, + ) + ) { + false + } else { + dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + } } } @@ -230,6 +276,12 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.Height || it is dev.darkpyonix.composerust.protocol.Modifier.Size } + val setsOwnHeight = node.modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.FillMaxHeight || + it is dev.darkpyonix.composerust.protocol.Modifier.Height || + it is dev.darkpyonix.composerust.protocol.Modifier.Size || + it is dev.darkpyonix.composerust.protocol.Modifier.RequiredSize + } // What the node named for itself replaces what the design system would have drawn // The system's frame is the default for a field that said nothing, and a field @@ -245,16 +297,11 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole || it is dev.darkpyonix.composerust.protocol.Modifier.CornerEach } - val ownRoom = node.modifiers.any { - it is dev.darkpyonix.composerust.protocol.Modifier.Padding || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingEach || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingRole - } val frameShape = if (ownShape) node.modifiers.resolvedShape(theme) else frame.shape BasicTextField( value = value, - onValueChange = { value = it }, + onValueChange = applyValueChange, modifier = inputModifier, enabled = enabled, singleLine = !multiline, @@ -270,19 +317,14 @@ internal fun HostTextField( // could only put the caret in by hitting the words. .fillMaxWidth() .let { if (fillsHeight) it.fillMaxHeight() else it } - .defaultMinSize(minHeight = frame.minHeight) + .let { if (setsOwnHeight) it else it.defaultMinSize(minHeight = frame.minHeight) } .clip(frameShape) .let { if (ownFill) it else it.background(if (focused) frame.containerFocused else frame.container) } .let { if (ownLine) it else it.fieldBorder(frame.copy(shape = frameShape), focused) } .let { if (ownLine || ownFill) it else it.fieldUnderline(frame.underline, focused) } - .let { - if (ownRoom) it else it.padding( - horizontal = frame.horizontalPadding, - vertical = frame.verticalPadding, - ) - }, + .padding(node.contentPadding(theme, frame.horizontalPadding, frame.verticalPadding)), contentAlignment = if (multiline) Alignment.TopStart else Alignment.CenterStart, ) { if (value.text.isEmpty() && placeholder.isNotEmpty()) { @@ -310,6 +352,12 @@ internal fun HostTextField( */ private const val PLACEHOLDER_ALPHA = 0.55f +/** + * Test hook that receives the value-change callback of the mounted [HostTextField] so a test + * can drive an IME composition transition directly through `TextFieldValue.composition`. + */ +internal var hostTextFieldValueHook: (((TextFieldValue) -> Unit) -> Unit)? = null + /** The box around a field, or nothing where the system draws none in this state. */ private fun Modifier.fieldBorder(style: FieldStyle, focused: Boolean): Modifier { val width = if (focused) style.borderWidthFocused else style.borderWidth @@ -388,3 +436,19 @@ private class FontChainTransformation(private val chain: FontChain) : VisualTran override fun hashCode(): Int = chain.hashCode() } + +internal fun Node.contentPadding( + theme: ResolvedTheme, + defaultHorizontal: Dp, + defaultVertical: Dp, +): PaddingValues { + val padEach = modifiers.filterIsInstance().lastOrNull() + if (padEach != null) { + return PaddingValues(padEach.start.dp, padEach.top.dp, padEach.end.dp, padEach.bottom.dp) + } + val pad = modifiers.filterIsInstance().lastOrNull() + if (pad != null) return PaddingValues(pad.value.dp) + val padRole = modifiers.filterIsInstance().lastOrNull() + if (padRole != null) return PaddingValues(theme.space(padRole.role)) + return PaddingValues(horizontal = defaultHorizontal, vertical = defaultVertical) +} diff --git a/compose-rust/android-kotlin/shared/HostToggle.kt b/compose-rust/android-kotlin/shared/HostToggle.kt index 42355f360..c3ae14060 100644 --- a/compose-rust/android-kotlin/shared/HostToggle.kt +++ b/compose-rust/android-kotlin/shared/HostToggle.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.design.ToggleRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind @@ -47,5 +48,6 @@ internal fun HostToggle( }, modifier = modifier, theme = theme, + look = rememberControlLook(node, theme), ) } diff --git a/compose-rust/android-kotlin/shared/KeyEvents.kt b/compose-rust/android-kotlin/shared/KeyEvents.kt index b5d64e2df..a77aa4072 100644 --- a/compose-rust/android-kotlin/shared/KeyEvents.kt +++ b/compose-rust/android-kotlin/shared/KeyEvents.kt @@ -1,6 +1,8 @@ package dev.darkpyonix.composerust.foundation +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed @@ -10,6 +12,7 @@ import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Key as ProtocolKey import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent @@ -26,21 +29,133 @@ import dev.darkpyonix.composerust.runtime.EventDispatcher * a Host handler clear the field and the syllable being composed would vanish. */ -/** The protocol key for a Compose key, or null when the schema cannot name it. */ -internal fun protocolKey(key: Key): ProtocolKey? = when (key) { +/** + * The protocol key for a Compose key. The schema names Enter; every other key is `Other`, + * and the event's `code` and `text` say which one it was. + */ +internal fun protocolKey(key: Key): ProtocolKey = when (key) { Key.Enter, Key.NumPadEnter -> ProtocolKey.Enter - // The schema's `Key` enum has only `Enter`, so no other key can cross the boundary. - // Every additional key needs a schema variant, which changes the schema hash and so - // requires both sides to be rebuilt together. - else -> null + else -> ProtocolKey.Other +} + +/** What a physical key is called on the wire: its USB HID usage and its DOM `key` string. */ +internal class KeyName(val code: Int, val text: String) + +private val namedKeys: Map = buildMap { + // USB HID usage IDs of the keyboard page (0x07); the DOM `key` value is the second word. + fun name(key: Key, code: Int, text: String) { + put(key, KeyName(code, text)) + } + val letters = listOf( + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, + Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z, + ) + letters.forEachIndexed { index, key -> name(key, 0x04 + index, ('a' + index).toString()) } + val digits = listOf( + Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, + Key.Zero, + ) + digits.forEachIndexed { index, key -> name(key, 0x1E + index, ((index + 1) % 10).toString()) } + val functions = listOf( + Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6, Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, + Key.F12, + ) + functions.forEachIndexed { index, key -> name(key, 0x3A + index, "F${index + 1}") } + name(Key.Enter, 0x28, "Enter") + name(Key.Escape, 0x29, "Escape") + name(Key.Backspace, 0x2A, "Backspace") + name(Key.Tab, 0x2B, "Tab") + name(Key.Spacebar, 0x2C, " ") + name(Key.Minus, 0x2D, "-") + name(Key.Equals, 0x2E, "=") + name(Key.LeftBracket, 0x2F, "[") + name(Key.RightBracket, 0x30, "]") + name(Key.Backslash, 0x31, "\\") + name(Key.Semicolon, 0x33, ";") + name(Key.Apostrophe, 0x34, "'") + name(Key.Grave, 0x35, "`") + name(Key.Comma, 0x36, ",") + name(Key.Period, 0x37, ".") + name(Key.Slash, 0x38, "/") + name(Key.CapsLock, 0x39, "CapsLock") + name(Key.Insert, 0x49, "Insert") + name(Key.MoveHome, 0x4A, "Home") + name(Key.PageUp, 0x4B, "PageUp") + name(Key.Delete, 0x4C, "Delete") + name(Key.MoveEnd, 0x4D, "End") + name(Key.PageDown, 0x4E, "PageDown") + name(Key.DirectionRight, 0x4F, "ArrowRight") + name(Key.DirectionLeft, 0x50, "ArrowLeft") + name(Key.DirectionDown, 0x51, "ArrowDown") + name(Key.DirectionUp, 0x52, "ArrowUp") + name(Key.NumPadEnter, 0x58, "Enter") + name(Key.CtrlLeft, 0xE0, "Control") + name(Key.ShiftLeft, 0xE1, "Shift") + name(Key.AltLeft, 0xE2, "Alt") + name(Key.MetaLeft, 0xE3, "Meta") + name(Key.CtrlRight, 0xE4, "Control") + name(Key.ShiftRight, 0xE5, "Shift") + name(Key.AltRight, 0xE6, "Alt") + name(Key.MetaRight, 0xE7, "Meta") +} + +/** + * The HID usage and DOM `key` string of a key press. A letter is upper case while Shift is + * held, as the DOM reports it. A key the table does not know keeps a zero usage and the + * character it typed, or `"Unidentified"` when it typed none. + */ +internal fun keyName(key: Key, shift: Boolean, codePoint: Int = 0): KeyName { + val known = namedKeys[key] + if (known != null) { + val isLetter = known.text.length == 1 && known.text[0] in 'a'..'z' + return if (isLetter && shift) KeyName(known.code, known.text.uppercase()) else known + } + val typed = if (codePoint > 0x1F && codePoint != 0x7F) { + codePointToString(codePoint) + } else { + "Unidentified" + } + return KeyName(0, typed) +} + +private fun codePointToString(codePoint: Int): String = + if (codePoint < 0x10000) { + codePoint.toChar().toString() + } else { + val offset = codePoint - 0x10000 + charArrayOf((0xD800 + (offset shr 10)).toChar(), (0xDC00 + (offset and 0x3FF)).toChar()) + .concatToString() + } + +/** + * Which keys are held down at one place that listens for keys. + * + * A key-down for a key that has not been released is the platform repeating it. Compose's + * key event carries no repeat flag, so the renderer tells repeats from first presses by + * watching the releases. + */ +internal class HeldKeys { + private val held = HashSet() + + /** Notes one event and says whether it is a key-down repeating a held key. */ + fun observe(type: KeyEventType, key: Key): Boolean = when (type) { + KeyEventType.KeyDown -> !held.add(key.keyCode) + KeyEventType.KeyUp -> { + held.remove(key.keyCode) + false + } + else -> false + } + + fun clear() = held.clear() } /** * Whether a key press may be offered to the Host at all. * - * Key-down only (a Host handler is `on_key_down`), a key the schema can name, and **never - * while an IME composition is in progress**. Kept free of Compose types so the rule that - * protects a Korean composition can be asserted directly, the way [shouldSubmitOnEnter] is. + * Key-down only (a Host handler is `on_key_down`) and **never while an IME composition is + * in progress**. Kept free of Compose types so the rule that protects a Korean composition + * can be asserted directly, the way [shouldSubmitOnEnter] is. */ internal fun shouldDispatchKeyDown( composing: Boolean, @@ -56,11 +171,16 @@ internal fun isDispatchableKeyDown(event: ComposeKeyEvent, composing: Boolean): key = protocolKey(event.key), ) +/** Whether the press is an Enter key-down outside a composition, the one that can submit. */ +internal fun isEnterKeyDown(event: ComposeKeyEvent, composing: Boolean): Boolean = + isDispatchableKeyDown(event, composing) && protocolKey(event.key) == ProtocolKey.Enter + /** * Sends one key press to the Host and returns its consumption result. * * Returns false without calling the Host whenever [isDispatchableKeyDown] says no, so a - * composition keystroke never reaches Rust. + * composition keystroke never reaches Rust. [held] sees every event, key-ups included, so + * a repeated key-down is marked as one. */ internal fun dispatchKeyDown( nodeId: Int, @@ -68,18 +188,73 @@ internal fun dispatchKeyDown( event: ComposeKeyEvent, composing: Boolean, dispatcher: EventDispatcher, + held: HeldKeys = HeldKeys(), ): Boolean { + val repeat = held.observe(event.type, event.key) if (!isDispatchableKeyDown(event, composing)) return false - val key = protocolKey(event.key) ?: return false + val name = keyName(event.key, event.isShiftPressed, event.utf16CodePoint) return dispatcher.dispatch( HostEvent.KeyDown( nodeId = nodeId, handlerId = handlerId, - key = key, + key = protocolKey(event.key), shiftKey = event.isShiftPressed, ctrlKey = event.isCtrlPressed, altKey = event.isAltPressed, metaKey = event.isMetaPressed, + repeat = repeat, + code = name.code, + text = name.text, + ), + ) +} + +/** + * Whether a key release may be offered to the Host at all. + * + * Key-up only, and never while an IME composition is in progress. + */ +internal fun shouldDispatchKeyUp( + composing: Boolean, + isKeyUp: Boolean, + key: ProtocolKey?, +): Boolean = !composing && isKeyUp && key != null + +/** [shouldDispatchKeyUp] applied to a Compose key event. */ +internal fun isDispatchableKeyUp(event: ComposeKeyEvent, composing: Boolean): Boolean = + shouldDispatchKeyUp( + composing = composing, + isKeyUp = event.type == KeyEventType.KeyUp, + key = protocolKey(event.key), + ) + +/** + * Sends one key release to the Host and returns its consumption result. + * + * Returns false without calling the Host whenever [isDispatchableKeyUp] says no, so a + * composition keystroke never reaches Rust. + */ +internal fun dispatchKeyUp( + nodeId: Int, + handlerId: Long, + event: ComposeKeyEvent, + composing: Boolean, + dispatcher: EventDispatcher, +): Boolean { + if (!isDispatchableKeyUp(event, composing)) return false + val name = keyName(event.key, event.isShiftPressed, event.utf16CodePoint) + return dispatcher.dispatch( + HostEvent.KeyUp( + nodeId = nodeId, + handlerId = handlerId, + key = protocolKey(event.key), + shiftKey = event.isShiftPressed, + ctrlKey = event.isCtrlPressed, + altKey = event.isAltPressed, + metaKey = event.isMetaPressed, + repeat = false, + code = name.code, + text = name.text, ), ) } @@ -93,8 +268,32 @@ internal fun dispatchKeyDown( */ internal fun Modifier.hostKeyEvents( nodeId: Int, - handlerId: Long, + keyDownHandler: Long?, + keyUpHandler: Long?, dispatcher: EventDispatcher, -): Modifier = onKeyEvent { event -> - dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher) +): Modifier = composed { + val held = remember { HeldKeys() } + onKeyEvent { event -> + // The key-down dispatch sees every event, key-ups included, so it can tell a repeat + // from a first press; it sends nothing for an event that is not a key-down. + val consumed = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composing = false, dispatcher = dispatcher, held = held) + when { + consumed -> true + keyUpHandler != null && isDispatchableKeyUp(event, composing = false) -> + dispatchKeyUp(nodeId, keyUpHandler, event, composing = false, dispatcher = dispatcher) + else -> false + } + } } + +internal fun Modifier.hostKeyEvents( + nodeId: Int, + handlerId: Long, + dispatcher: EventDispatcher, +): Modifier = hostKeyEvents( + nodeId = nodeId, + keyDownHandler = handlerId, + keyUpHandler = null, + dispatcher = dispatcher, +) diff --git a/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt b/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt index 1c1dbfecf..e642cc06b 100644 --- a/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt +++ b/compose-rust/android-kotlin/shared/Material3ControlWidgets.kt @@ -45,7 +45,15 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { + // The library's Switch and Checkbox have fixed metrics, so a size or a colour the + // application named for them is drawn by the shared control drawing instead, which + // also owns the transition and the toggle semantics. + if (look.appliesTo(role)) { + DrawnControlWidgets.Toggle(role, checked, enabled, onChange, modifier, theme, look) + return + } val primary = theme.color(ColorRole.Primary) val onPrimary = theme.color(ColorRole.OnPrimary) val outline = theme.color(ColorRole.Outline) @@ -101,6 +109,7 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val primary = named ?: theme.color(ColorRole.Primary) Slider( @@ -111,9 +120,9 @@ internal object Material3ControlWidgets : ControlWidgets { valueRange = range, steps = steps, colors = SliderDefaults.colors( - thumbColor = primary, - activeTrackColor = primary, - inactiveTrackColor = theme.color(ColorRole.SurfaceVariant), + thumbColor = look.thumb ?: primary, + activeTrackColor = look.activeTrack ?: primary, + inactiveTrackColor = look.track ?: theme.color(ColorRole.SurfaceVariant), ), ) } diff --git a/compose-rust/android-kotlin/shared/ModifierChain.kt b/compose-rust/android-kotlin/shared/ModifierChain.kt index 48f256014..7c32bc334 100644 --- a/compose-rust/android-kotlin/shared/ModifierChain.kt +++ b/compose-rust/android-kotlin/shared/ModifierChain.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.ui import androidx.compose.animation.animateContentSize import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.PointerEventPass import dev.darkpyonix.composerust.runtime.LocalWindowDragExclusion @@ -53,6 +54,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect @@ -106,6 +111,7 @@ internal fun List.toComposeModifier( containerShape: Shape? = null, pressColor: Color = theme.color(ColorRole.OnSurface), allowPress: Boolean = true, + allowPadding: Boolean = true, ): Modifier { // The last Shape or ShapeRole in the list is what clips, what the border // follows and what the background fills, whatever their order in the chain. @@ -164,20 +170,31 @@ internal fun List.toComposeModifier( awaitEachGesture { if (value.enabled) { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed) { + // A no-drag node under the press has marked it already (children see the + // Main pass first), so the window stays where it is. + val excludedHere = exclusion?.isMarked(down.id.value, down.uptimeMillis) == true + if (!down.isConsumed && !excludedHere) { down.consume() drag() } } else { // Mark this down for the strip without consuming it, so ancestors can click. - val excluded = awaitFirstDown(requireUnconsumed = false); exclusion?.downKey = excluded.id.value to excluded.uptimeMillis + val excluded = awaitFirstDown(requireUnconsumed = false) + exclusion?.mark(excluded.id.value, excluded.uptimeMillis) + // Forget the press once it ends, so a later press that happens to + // share an id and a timestamp is not taken for this one. + waitForUpOrCancellation() + exclusion?.clear() } } } } is ProtocolModifier.PressFeedback -> chain + // Applied with the node's accessible name in one place, `accessibility`. + is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain - is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) + is ProtocolModifier.Padding -> + if (allowPadding) attachPressableIfNeeded(chain).padding(value.value.dp) else chain is ProtocolModifier.MatchParentSize -> chain is ProtocolModifier.FillMaxWidth -> chain.fillMaxWidth() is ProtocolModifier.FillMaxHeight -> chain.fillMaxHeight() @@ -198,9 +215,17 @@ internal fun List.toComposeModifier( } is ProtocolModifier.PaddingEach -> - attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + } else { + chain + } is ProtocolModifier.PaddingRole -> - attachPressableIfNeeded(chain).padding(theme.space(value.role)) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(theme.space(value.role)) + } else { + chain + } is ProtocolModifier.Shape -> chain.clip(shape) is ProtocolModifier.ShapeRole -> chain.clip(shape) is ProtocolModifier.Border -> theme.brush(value.paint)?.let { brush -> @@ -295,6 +320,18 @@ internal fun List.toComposeModifier( } } + // CSS 3D `transform`: one matrix, drawn through the layers that reproduce it + // exactly, so the layout is untouched and a pointer is tested against the shape + // that is drawn. The plan is worked out here, once for each value of the record. + is ProtocolModifier.Transform3D -> { + val plan = Transform3DPlan() + plan.solve( + floatArrayOf(value.m0, value.m1, value.m2, value.m3, value.m4, value.m5, value.m6, value.m7, value.m8, value.m9, value.m10, value.m11, value.m12, value.m13, value.m14, value.m15), + value.backfaceHidden, + ) + chain.perspectiveTransform(plan, value.originX, value.originY) + } + // The node and everything in it are drawn into one layer first, and the layer is // faded once. Fading each child separately would show where they overlap, which // CSS `opacity` does not. @@ -786,7 +823,11 @@ internal fun Modifier.pressable( } val ink = theme.color(ColorRole.OnSurface) val ring = theme.color(ColorRole.Primary) + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(nodeId) { FocusRequester() } this + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback(source, enabled, theme, shape, pressColor, feedback) .hoverable(source, enabled) .clickable( diff --git a/compose-rust/android-kotlin/shared/NodeTable.kt b/compose-rust/android-kotlin/shared/NodeTable.kt index 22296a8a8..e74429f50 100644 --- a/compose-rust/android-kotlin/shared/NodeTable.kt +++ b/compose-rust/android-kotlin/shared/NodeTable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import dev.darkpyonix.composerust.foundation.ChromeInsets import dev.darkpyonix.composerust.foundation.MessageQueue import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.Theme @@ -46,6 +47,7 @@ class Node internal constructor(val id: Int, val widget: WidgetKind) { internal val children = mutableStateListOf() internal var parentId: Int = NodeTable.ROOT_ID internal var hostText by mutableStateOf(null) + internal var focusRequestRevision by mutableStateOf(0L) /** * A code editor's document and everything over it. Created with the node and kept for @@ -92,6 +94,8 @@ data class TableError(val code: Int, val message: String) { const val MISPLACED_WINDOW = 13 /** A node moved from the content of one `Window` into another, or out of its window. */ const val MOVE_BETWEEN_WINDOWS = 14 + /** A file dialog request whose number is still waiting for its answer. The command is dropped. */ + const val DUPLICATE_FILE_DIALOG = 15 } } @@ -165,12 +169,29 @@ class NodeTable { val notifications: dev.darkpyonix.composerust.ui.platform.NotificationCenter = dev.darkpyonix.composerust.ui.platform.NotificationCenter() + /** + * The file dialogs the Host has asked for, and their answers on the way back. + * + * Not cleared with the tree, for the reason the notifications are not: a request the Host + * is waiting on is still owed its one answer after a resync. + */ + val fileDialogs: dev.darkpyonix.composerust.ui.platform.FileDialogCenter = + dev.darkpyonix.composerust.ui.platform.FileDialogCenter() + private var revision = 0L + /** Increments on each `RequestFocus(0)` so the root composition clears active focus. */ + var focusClearRevision by mutableStateOf(0L) + private set + /** Null until the Host sends its first `SetTheme` record. */ var theme: Theme? by mutableStateOf(null) private set + /** Null until the Host sends an overlay tokens record. */ + var overlayTokens: OverlayTokens? by mutableStateOf(null) + private set + /** Null until the Host sends its first `SetWindow` record. */ var window: Window? by mutableStateOf(null) private set @@ -195,6 +216,16 @@ class NodeTable { return level?.toInt()?.coerceIn(-8, 8) } + /** + * Whether the window [windowId] asked for every whole-dp change of its size. Node 0 is + * the single window of `SetWindow`, which asks through its settings; a `Window` node + * asks through its `report_size` property. + */ + fun reportSizeFor(windowId: Int): Boolean { + if (windowId == ROOT_ID) return window?.reportSize == true + return node(windowId)?.flag(PropertyKind.ReportSize, false) == true + } + /** * The `Window` node the node [nodeId] is drawn in, itself included, or null for a node * that sits in none (every node of a single window application). @@ -253,6 +284,9 @@ class NodeTable { * start over and anything kept from before would collide with them. */ fun clear() { + // The windows go with the nodes, and a dialog open on one of them ends with a cancel. + nodes.filterValues { it.widget == WidgetKind.Window }.keys.toList() + .forEach(fileDialogs::windowRemoved) animations.clear() nodes.clear() codeNodes.clear() @@ -267,6 +301,7 @@ class NodeTable { window = null materials = 0 revision = 0 + focusClearRevision = 0 } fun apply(mutation: Mutation) { @@ -290,6 +325,7 @@ class NodeTable { fail(TableError.INVALID_PALETTE, "theme palette: $problem") } } + is Mutation.SetOverlayTokens -> overlayTokens = mutation.overlay // Read before there is a window to apply it to. The platform layer asks for // this out of the first batch and builds the window from it, so by the time // the batch is applied the window already looks the way it says. Keeping it @@ -301,6 +337,7 @@ class NodeTable { // the batch. is Mutation.SetWindowTheme -> setWindowTheme(mutation) is Mutation.FocusWindow -> focusWindow(mutation) + is Mutation.RequestFocus -> requestFocus(mutation) // The bytes are read here, once, and never again: what a frame carries is the // id. A kind this Renderer cannot read, or a release of something that was // never registered, is reported and the rest of the batch still applies. @@ -325,6 +362,10 @@ class NodeTable { is Mutation.WithdrawNotification -> notifications.withdraw(mutation.key) is Mutation.RequestNotificationPermission -> notifications.requestPermission() + // Not a node either. A request on a window that is not there is answered as + // unavailable, so the Host's one answer per request holds, and is reported too. + is Mutation.ShowFileDialog -> showFileDialog(mutation) + // Played on this side's frame clock until it ends. Whether the node has // somewhere to play it is asked once the whole batch is in. is Mutation.StartAnimation -> animations.start(mutation.animation) @@ -387,6 +428,36 @@ class NodeTable { focusRequests += mutation.nodeId } + private fun showFileDialog(mutation: Mutation.ShowFileDialog) { + val windowKnown = mutation.window == ROOT_ID || + nodes[mutation.window]?.widget == WidgetKind.Window + when (fileDialogs.request(mutation, windowKnown)) { + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Accepted -> Unit + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Duplicate -> fail( + TableError.DUPLICATE_FILE_DIALOG, + "ShowFileDialog request ${mutation.request} is already waiting for its answer", + ) + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.UnknownWindow -> fail( + TableError.UNKNOWN_NODE, + "ShowFileDialog for unknown window ${mutation.window}", + ) + } + } + + private fun requestFocus(mutation: Mutation.RequestFocus) { + if (mutation.nodeId == ROOT_ID) { + revision += 1 + focusClearRevision = revision + return + } + val node = nodes[mutation.nodeId] ?: return fail( + TableError.UNKNOWN_NODE, + "RequestFocus for unknown node ${mutation.nodeId}", + ) + revision += 1 + node.focusRequestRevision = revision + } + /** The code editor a command names, or null after reporting that it names none. */ private fun codeNode(nodeId: Int, command: String): Pair? { val node = nodes[nodeId] ?: run { @@ -608,7 +679,10 @@ class NodeTable { private fun removeSubtree(nodeId: Int) { val node = nodes.remove(nodeId) ?: return codeNodes.remove(nodeId) - if (node.widget == WidgetKind.Window) windowThemes.remove(nodeId) + if (node.widget == WidgetKind.Window) { + windowThemes.remove(nodeId) + fileDialogs.windowRemoved(nodeId) + } animations.removeNode(nodeId) materials -= node.modifiers.count { it is ProtocolModifier.Material } node.children.toList().forEach(::removeSubtree) @@ -694,12 +768,30 @@ class NodeTable { internal fun supportsProperty(widget: WidgetKind, property: PropertyKind): Boolean = when (property) { + // The name assistive technology reads can be given to any node. + PropertyKind.AccessibleName -> true + + // Colours and size of the selection controls. + PropertyKind.TrackColor, + PropertyKind.ThumbColor, + -> widget == WidgetKind.Switch || widget == WidgetKind.Slider + + PropertyKind.ActiveTrackColor -> widget == WidgetKind.Slider + + PropertyKind.ContainerColor, + PropertyKind.CheckColor, + -> widget == WidgetKind.Checkbox + + PropertyKind.ControlSize -> widget == WidgetKind.Switch || widget == WidgetKind.Checkbox + // Event properties carry handler ids and are valid on any widget. PropertyKind.OnClick, PropertyKind.OnValueChange, PropertyKind.OnSubmit, PropertyKind.OnFocusLost, + PropertyKind.OnFocusGained, PropertyKind.OnKeyDown, + PropertyKind.OnKeyUp, PropertyKind.OnRangeRequested, PropertyKind.OnDismiss, -> true @@ -888,6 +980,11 @@ class NodeTable { widget == WidgetKind.Menu || // A sheet is an overlay on the same terms as a dialog. widget == WidgetKind.Sheet + + // A custom scrim colour for modal and popup overlays. + PropertyKind.Scrim -> widget == WidgetKind.Dialog || + widget == WidgetKind.Menu || + widget == WidgetKind.Sheet // A Dropdown's selection is a position in its own children, the same thing // a tab strip's selection is. PropertyKind.SelectedIndex -> @@ -977,9 +1074,11 @@ class NodeTable { PropertyKind.OnVisibleRangeChange, PropertyKind.OnEditorFocusChange, PropertyKind.OnFoldChange, - PropertyKind.OnCompositionChange, -> widget == WidgetKind.CodeEditor + PropertyKind.OnCompositionChange -> + widget == WidgetKind.CodeEditor || widget == WidgetKind.TextField + // Files over a node and files let go on it. Only the widget that exists // to receive them, because a handler is attached whether or not a screen // supplied one: on any container these would cost every container in the @@ -999,6 +1098,7 @@ class NodeTable { PropertyKind.WindowSize, PropertyKind.WindowMinSize, PropertyKind.WindowResizable, + PropertyKind.ReportSize, PropertyKind.WindowChrome, PropertyKind.WindowTitleBar, PropertyKind.WindowIcon, diff --git a/compose-rust/android-kotlin/shared/Protocol.gen.kt b/compose-rust/android-kotlin/shared/Protocol.gen.kt index 88e2476c8..93583d781 100644 --- a/compose-rust/android-kotlin/shared/Protocol.gen.kt +++ b/compose-rust/android-kotlin/shared/Protocol.gen.kt @@ -8,9 +8,9 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Scrim, Progress } -enum class Key { Enter } +enum class Key { Enter, Other } enum class WindowSizeClass { Compact, Medium, Expanded } @@ -28,6 +28,8 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList, TabList, Tree, TreeItem, Menu, MenuItem, TextBox, Heading, Navigation, List, ListItem, Group } + enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } enum class TextAlign { Start, Center, End, Justify } @@ -116,6 +118,10 @@ enum class WindowMode { Single, Windows } enum class LastWindowClosed { Exit, KeepRunning } +enum class FileDialogKind { OpenFile, OpenFiles, OpenFolder, OpenFolders, Save } + +enum class FileDialogStatus { Chosen, Cancelled, Unavailable } + /** * The `TypeRole` property of text with no rung of the ladder at all: CSS text, whose * size, weight and font are its own. Present and zero; an absent property is the @@ -189,6 +195,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -243,6 +263,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) sealed interface PropertyValue { @@ -1021,9 +1046,11 @@ sealed interface Modifier { data class Clip(val enabled: Boolean) : Modifier data class Alpha(val value: kotlin.Float) : Modifier data class Transform(val a: kotlin.Float, val b: kotlin.Float, val c: kotlin.Float, val d: kotlin.Float, val e: kotlin.Float, val f: kotlin.Float, val originX: kotlin.Float, val originY: kotlin.Float) : Modifier + data class Transform3D(val m0: kotlin.Float, val m1: kotlin.Float, val m2: kotlin.Float, val m3: kotlin.Float, val m4: kotlin.Float, val m5: kotlin.Float, val m6: kotlin.Float, val m7: kotlin.Float, val m8: kotlin.Float, val m9: kotlin.Float, val m10: kotlin.Float, val m11: kotlin.Float, val m12: kotlin.Float, val m13: kotlin.Float, val m14: kotlin.Float, val m15: kotlin.Float, val originX: kotlin.Float, val originY: kotlin.Float, val backfaceHidden: Boolean) : Modifier data object MatchParentSize : Modifier data class WindowDrag(val enabled: Boolean) : Modifier data class PressFeedback(val scale: kotlin.Float, val tintAlpha: kotlin.Float) : Modifier + data class Semantics(val role: dev.darkpyonix.composerust.protocol.SemanticsRole, val state: Int) : Modifier } /** How the segment from one keyframe to the next is timed. */ @@ -1246,6 +1273,29 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation + + /** + * Asks the platform for a file dialog on the window [window] (0 is the implicit root + * window). Answered exactly once, later, by a `FileDialogResult` event carrying + * [request]. [filters] is one string: a label and its comma-joined extensions as a pair, + * pairs joined by NUL. + */ + data class ShowFileDialog( + val request: Int, + val window: Int, + val kind: FileDialogKind, + val defaultFilter: Int, + val title: String, + val directory: String, + val name: String, + val filters: String, + ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } sealed interface HostEvent { @@ -1257,7 +1307,7 @@ sealed interface HostEvent { data class TextSubmitted(override val nodeId: Int, override val handlerId: Long, val text: String) : HostEvent data class FocusLost(override val nodeId: Int, override val handlerId: Long) : HostEvent data class ProtocolError(override val nodeId: Int, override val handlerId: Long, val code: Int, val message: String) : HostEvent - data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent + data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent data class RangeRequested(override val nodeId: Int, override val handlerId: Long, val start: Int, val count: Int) : HostEvent data class ValueChanged(override val nodeId: Int, override val handlerId: Long, val value: Double) : HostEvent data class WindowSizeChanged(override val nodeId: Int, override val handlerId: Long, val widthDp: kotlin.Float, val heightDp: kotlin.Float, val sizeClass: WindowSizeClass, val heightClass: WindowHeightClass, val safeAreaTop: kotlin.Float, val safeAreaLeading: kotlin.Float, val safeAreaTrailing: kotlin.Float) : HostEvent @@ -1292,13 +1342,16 @@ sealed interface HostEvent { data class WindowClosed(override val nodeId: Int, override val handlerId: Long) : HostEvent data class WindowRequested(override val nodeId: Int, override val handlerId: Long, val request: Int, val key: String) : HostEvent data class ApplicationReopen(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class FocusGained(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class KeyUp(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent + data class FileDialogResult(override val nodeId: Int, override val handlerId: Long, val request: Int, val status: FileDialogStatus, val filter: Int, val paths: String) : HostEvent } class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 554621546583655284L + const val SCHEMA_HASH: Long = 2307080319921313708L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1326,6 +1379,9 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 + private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1475,16 +1531,51 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } + TAG_SHOW_FILE_DIALOG -> { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -1511,6 +1602,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -1713,7 +1805,7 @@ object Protocol { is HostEvent.TextSubmitted -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.FocusLost -> null is HostEvent.ProtocolError -> event.message.toByteArray(StandardCharsets.UTF_8) - is HostEvent.KeyDown -> null + is HostEvent.KeyDown -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.RangeRequested -> null is HostEvent.ValueChanged -> null is HostEvent.WindowSizeChanged -> null @@ -1748,6 +1840,9 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FocusGained -> null + is HostEvent.KeyUp -> event.text.toByteArray(StandardCharsets.UTF_8) + is HostEvent.FileDialogResult -> event.paths.toByteArray(StandardCharsets.UTF_8) } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1755,7 +1850,7 @@ object Protocol { is HostEvent.TextSubmitted -> 24 is HostEvent.FocusLost -> 16 is HostEvent.ProtocolError -> 28 - is HostEvent.KeyDown -> 20 + is HostEvent.KeyDown -> 32 is HostEvent.RangeRequested -> 24 is HostEvent.ValueChanged -> 24 is HostEvent.WindowSizeChanged -> 44 @@ -1790,6 +1885,9 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FocusGained -> 16 + is HostEvent.KeyUp -> 32 + is HostEvent.FileDialogResult -> 36 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1836,6 +1934,9 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FocusGained -> 48 + is HostEvent.KeyUp -> 49 + is HostEvent.FileDialogResult -> 50 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -1858,7 +1959,9 @@ object Protocol { if (event.altKey) modifiers = modifiers or 0x04 if (event.metaKey) modifiers = modifiers or 0x08 out.put(modifiers.toByte()) - out.put(0.toByte()) + out.put((if (event.repeat) 1 else 0).toByte()) + out.putInt(event.code) + writeStringReference(out, recordLength, text!!) } is HostEvent.RangeRequested -> { out.putInt(event.start) @@ -2003,6 +2106,25 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FocusGained -> Unit + is HostEvent.KeyUp -> { + out.putShort(keyTag(event.key).toShort()) + var modifiers = 0 + if (event.shiftKey) modifiers = modifiers or 0x01 + if (event.ctrlKey) modifiers = modifiers or 0x02 + if (event.altKey) modifiers = modifiers or 0x04 + if (event.metaKey) modifiers = modifiers or 0x08 + out.put(modifiers.toByte()) + out.put((if (event.repeat) 1 else 0).toByte()) + out.putInt(event.code) + writeStringReference(out, recordLength, text!!) + } + is HostEvent.FileDialogResult -> { + out.putInt(event.request) + out.putInt(fileDialogStatusTag(event.status)) + out.putInt(event.filter) + writeStringReference(out, recordLength, text!!) + } } if (text != null) out.put(text) return out.position() - start @@ -2186,12 +2308,24 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 117 -> PropertyKind.OnFocusGained + 118 -> PropertyKind.OnKeyUp + 119 -> PropertyKind.AccessibleName + 121 -> PropertyKind.TrackColor + 122 -> PropertyKind.ThumbColor + 123 -> PropertyKind.ActiveTrackColor + 124 -> PropertyKind.ContainerColor + 125 -> PropertyKind.CheckColor + 126 -> PropertyKind.ControlSize + 127 -> PropertyKind.ReportSize + 128 -> PropertyKind.Scrim 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } private fun keyTag(key: Key): Int = when (key) { Key.Enter -> 1 + Key.Other -> 2 } private fun windowSizeClassTag(sizeClass: WindowSizeClass): Int = when (sizeClass) { @@ -2228,6 +2362,12 @@ object Protocol { NotificationPermission.Unsupported -> 4 } + private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) { + FileDialogStatus.Chosen -> 1 + FileDialogStatus.Cancelled -> 2 + FileDialogStatus.Unavailable -> 3 + } + private fun animationEventKindTag(value: AnimationEventKind): Int = when (value) { AnimationEventKind.Ready -> 1 AnimationEventKind.Active -> 2 @@ -2417,6 +2557,29 @@ object Protocol { else -> throw ProtocolException("unknown MaterialRole tag $tag", offset) } + private fun semanticsRole(tag: Int, offset: Int): SemanticsRole = when (tag) { + 1 -> SemanticsRole.None + 2 -> SemanticsRole.Button + 3 -> SemanticsRole.Checkbox + 4 -> SemanticsRole.Switch + 5 -> SemanticsRole.RadioButton + 6 -> SemanticsRole.Tab + 7 -> SemanticsRole.Image + 8 -> SemanticsRole.DropdownList + 9 -> SemanticsRole.TabList + 10 -> SemanticsRole.Tree + 11 -> SemanticsRole.TreeItem + 12 -> SemanticsRole.Menu + 13 -> SemanticsRole.MenuItem + 14 -> SemanticsRole.TextBox + 15 -> SemanticsRole.Heading + 16 -> SemanticsRole.Navigation + 17 -> SemanticsRole.List + 18 -> SemanticsRole.ListItem + 19 -> SemanticsRole.Group + else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) + } + private fun spaceRole(tag: Int, offset: Int): SpaceRole = when (tag) { 1 -> SpaceRole.None 2 -> SpaceRole.Xs @@ -2808,6 +2971,22 @@ object Protocol { else -> throw ProtocolException("unknown LastWindowClosed tag $tag", offset) } + private fun fileDialogKind(tag: Int, offset: Int): FileDialogKind = when (tag) { + 1 -> FileDialogKind.OpenFile + 2 -> FileDialogKind.OpenFiles + 3 -> FileDialogKind.OpenFolder + 4 -> FileDialogKind.OpenFolders + 5 -> FileDialogKind.Save + else -> throw ProtocolException("unknown FileDialogKind tag $tag", offset) + } + + private fun fileDialogStatus(tag: Int, offset: Int): FileDialogStatus = when (tag) { + 1 -> FileDialogStatus.Chosen + 2 -> FileDialogStatus.Cancelled + 3 -> FileDialogStatus.Unavailable + else -> throw ProtocolException("unknown FileDialogStatus tag $tag", offset) + } + private fun paint(bits: Long, offset: Int): Paint { val value = bits.toInt() return when (val kind = (bits ushr 32).toInt()) { @@ -2861,9 +3040,11 @@ object Protocol { 24 -> 0 25 -> 0 26 -> 2 + 27 -> 8 31 -> 0 32 -> 0 33 -> 0 + 34 -> 0 else -> throw ProtocolException("unknown modifier tag $tag", offset) } @@ -2898,9 +3079,11 @@ object Protocol { 24 -> Modifier.Clip(flag(first, offset)) 25 -> Modifier.Alpha(kotlin.Float.fromBits(first.toInt())) 26 -> Modifier.Transform(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset)) + 27 -> Modifier.Transform3D(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 32).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 32) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 40).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 40) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 48).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 48) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 56).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 56) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 64).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 64) ushr 32).toInt()), offset), flag(readU64(batch, base, available, at + 72), offset)) 31 -> Modifier.MatchParentSize 32 -> Modifier.WindowDrag(flag(first, offset)) 33 -> Modifier.PressFeedback(kotlin.Float.fromBits(first.toInt()), kotlin.Float.fromBits((first ushr 32).toInt())) + 34 -> Modifier.Semantics(semanticsRole(first.toInt(), offset), second.toInt()) else -> throw ProtocolException("unknown modifier tag $tag", offset) } } diff --git a/compose-rust/android-kotlin/shared/RenderNode.kt b/compose-rust/android-kotlin/shared/RenderNode.kt index a4893d68e..78d2dc932 100644 --- a/compose-rust/android-kotlin/shared/RenderNode.kt +++ b/compose-rust/android-kotlin/shared/RenderNode.kt @@ -1,5 +1,7 @@ package dev.darkpyonix.composerust.ui.node +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -12,21 +14,35 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.focusable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable @@ -36,6 +52,8 @@ import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.distinctUntilChanged import kotlin.math.roundToInt @@ -43,6 +61,8 @@ import dev.darkpyonix.composerust.protocol.ColorRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.runtime.LocalWindowNodeId +import dev.darkpyonix.composerust.runtime.WindowDropTargets import dev.darkpyonix.composerust.runtime.LocalZoom import dev.darkpyonix.composerust.runtime.islandDensity import dev.darkpyonix.composerust.protocol.TypeRole @@ -50,6 +70,7 @@ import dev.darkpyonix.composerust.protocol.WidgetKind import dev.darkpyonix.composerust.design.ContainerRole import androidx.compose.ui.graphics.Shape import dev.darkpyonix.composerust.foundation.containerShape +import dev.darkpyonix.composerust.ui.accessibility import dev.darkpyonix.composerust.ui.pressable import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ToggleRole @@ -175,7 +196,28 @@ fun RenderNode( } else { null } - val chain = profiled + // The key handlers go ahead of the node's own modifiers. A modifier that makes the node + // focusable (a click, say) is a focus target, and a key event reaches only the handlers + // outside it on the way down. Placed after it, a handler would see keys from focused + // descendants only and never the keys pressed while the node itself has focus. + // The TextField wires its own key handling, because it has an editor to intercept and a + // composition to protect; everything else routes keys here. + val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val keyed = if ((keyDownHandler == null && keyUpHandler == null) || + node.widget == WidgetKind.TextField + ) { + profiled + } else { + // Plain containers and text are not focus targets on their own, so a node that + // listens for keys is made one, ahead of its modifiers like the handlers. Without it + // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. + // Widgets that are controls bring their own focus handling and are left alone. + val focusTarget = node.widget in KeyFocusTargets + profiled.hostKeyEvents(nodeId, keyDownHandler, keyUpHandler, dispatcher) + .let { if (focusTarget) it.focusable() else it } + } + val chain = keyed .then( node.modifiers.toComposeModifier( nodeId, @@ -187,35 +229,115 @@ fun RenderNode( containerShape = if (clickHandler != null) node.pressedShape(theme) else null, pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), allowPress = node.widget != WidgetKind.TextField, + allowPadding = node.widget != WidgetKind.TextField, ), ) .testTag(nodeTestTag(nodeId)) - // The TextField wires its own key handling, because it has an editor to intercept and a - // composition to protect; everything else routes keys here. - val keyDownHandler = node.handler(PropertyKind.OnKeyDown) - val pressable = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { + .accessibility( + node, + dispatcher, + clickHandler + ?: (node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + as? dev.darkpyonix.composerust.protocol.Modifier.Clickable)?.handlerId + ?: node.handler(PropertyKind.OnClick), + ) + // Focus-gained and focus-lost for non-TextField nodes: `hasFocus` covers both the node + // itself and any focusable descendant inside it. + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) + val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + var hasFocusWithin by remember(nodeId) { mutableStateOf(false) } + val withFocusEvents = if ((focusGainedHandler == null && focusLostHandler == null) || + node.widget == WidgetKind.TextField + ) { chain } else { - chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + chain.onFocusChanged { state -> + val nowFocused = state.hasFocus + if (!hasFocusWithin && nowFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } + if (hasFocusWithin && !nowFocused && focusLostHandler != null) { + dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) + } + hasFocusWithin = nowFocused + } } - val modifier = platformFileDrop(pressable, node, dispatcher) + val pressable = withFocusEvents + val focusRequest = node.focusRequestRevision + val focusRequester = if (focusRequest > 0L && + node.widget != WidgetKind.TextField && + node.widget != WidgetKind.CodeEditor + ) { + remember(nodeId) { FocusRequester() } + } else { + null + } + if (focusRequester != null) { + LaunchedEffect(focusRequest) { + focusRequester.requestFocus() + } + } + val focusableModifier = if (focusRequester == null) { + pressable + } else { + val withRequester = pressable.focusRequester(focusRequester) + if (clickHandler == null && + node.widget in CLICKABLE_CONTAINERS && + node.modifiers.none { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + ) { + withRequester.focusable() + } else { + withRequester + } + } + val windowId = LocalWindowNodeId.current + val subscribed = windowId != 0 && node.handler(PropertyKind.OnWindowDragOver) != null + val density = LocalDensity.current.density + if (subscribed) { + DisposableEffect(windowId, nodeId) { + onDispose { WindowDropTargets.remove(windowId, nodeId) } + } + } + var depth = 0 + var ancestor = node.parentId + while (ancestor != NodeTable.ROOT_ID && depth <= 1024) { + depth++ + ancestor = table.node(ancestor)?.parentId ?: NodeTable.ROOT_ID + } + val dropModifier = if (subscribed) focusableModifier.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + WindowDropTargets.update(windowId, nodeId, bounds.left, bounds.top, bounds.right, bounds.bottom, density, depth) + } else focusableModifier + val modifier = platformFileDrop(dropModifier, node, dispatcher) // A layout made of glass tells what is inside it so. A field in a glass bar is part of // the bar in the systems that draw glass, and it can only take that shape if it knows // what it is sitting on. val glass = node.isMadeOfGlass(theme) ProvideInheritedColor(nodeId, node, table, theme) { when (node.widget) { - WidgetKind.Column -> Column( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.width(IntrinsicSize.Min) else modifier, - verticalArrangement = node.verticalArrangement(theme), - horizontalAlignment = node.horizontalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } - - WidgetKind.Row -> Row( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.height(IntrinsicSize.Min) else modifier, - horizontalArrangement = node.horizontalArrangement(theme), - verticalAlignment = node.verticalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } + WidgetKind.Column, WidgetKind.Row -> { + val horizontal = node.widget == WidgetKind.Row + val sizedModifier = if (node.usesIntrinsicCrossAxis(table)) { + if (horizontal) modifier.height(IntrinsicSize.Min) else modifier.width(IntrinsicSize.Min) + } else modifier + val weights = node.children.map { table.node(it)?.modifiers?.weightOf() } + val intrinsicSafe = node.children.map { child -> !table.hasScrollingDescendant(child) } + if (weights.any { it != null }) { + WeightedStack( + node, table, dispatcher, sizedModifier, horizontal, weights, intrinsicSafe, + node.horizontalArrangement(theme), node.verticalArrangement(theme), + node.horizontalAlignment(), node.verticalAlignment(), glass, + ) + } else if (horizontal) { + Row(sizedModifier, node.horizontalArrangement(theme), node.verticalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } else { + Column(sizedModifier, node.verticalArrangement(theme), node.horizontalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } + } // A place files may be dropped is laid out as a plain box. Being this widget is // the whole of the difference: the drop target is set up for it and for nothing @@ -244,7 +366,7 @@ fun RenderNode( // The controls are drawn through `Unselectable`, which leaves them out of a // selectable region around them: a label is pressed, not copied. Outside a region // it adds nothing. - WidgetKind.Button -> Unselectable { HostButton(node, modifier, dispatcher, theme) } + WidgetKind.Button -> Unselectable { HostButton(node, modifier, table, dispatcher, theme) } WidgetKind.TextField -> Unselectable { HostTextField(node, modifier, dispatcher, table.assets) } WidgetKind.LazyColumn -> HostLazyColumn(node, modifier, table, dispatcher) @@ -728,6 +850,160 @@ internal fun OnGlass(glass: Boolean, content: @Composable () -> Unit) { CompositionLocalProvider(LocalGlassDepth provides LocalGlassDepth.current + 1, content = content) } +/** Measures weighted children at least as large as their main-axis minimum intrinsic size. */ +@Composable +private fun WeightedStack( + node: Node, + table: NodeTable, + dispatcher: EventDispatcher, + modifier: Modifier, + horizontal: Boolean, + weights: List, + intrinsicSafe: List, + horizontalArrangement: Arrangement.Horizontal, + verticalArrangement: Arrangement.Vertical, + horizontalAlignment: Alignment.Horizontal, + verticalAlignment: Alignment.Vertical, + glass: Boolean, +) { + val policy = remember(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) { + WeightedStackPolicy(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) + } + Layout( + content = { + OnGlass(glass) { + node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } + } + }, + modifier = modifier.clipToBounds(), + measurePolicy = policy, + ) +} + +private class WeightedStackPolicy( + private val horizontal: Boolean, + private val weights: List, + private val intrinsicSafe: List, + private val horizontalArrangement: Arrangement.Horizontal, + private val verticalArrangement: Arrangement.Vertical, + private val horizontalAlignment: Alignment.Horizontal, + private val verticalAlignment: Alignment.Vertical, +) : MeasurePolicy { + private fun spacing(scope: androidx.compose.ui.unit.Density): Int = with(scope) { + (if (horizontal) horizontalArrangement.spacing else verticalArrangement.spacing).roundToPx() + } + + override fun MeasureScope.measure(measurables: List, constraints: Constraints): MeasureResult { + val mainMax = if (horizontal) constraints.maxWidth else constraints.maxHeight + val mainMin = if (horizontal) constraints.minWidth else constraints.minHeight + val crossMax = if (horizontal) constraints.maxHeight else constraints.maxWidth + val gap = spacing(this) + val places = arrayOfNulls(measurables.size) + var fixed = 0 + val gaps = gap * (measurables.size - 1).coerceAtLeast(0) + fun childConstraints(min: Int, max: Int) = if (horizontal) { + Constraints(minWidth = min, maxWidth = max, maxHeight = crossMax) + } else { + Constraints(minHeight = min, maxHeight = max, maxWidth = crossMax) + } + measurables.forEachIndexed { index, measurable -> + if (weights[index] == null) { + val available = if (mainMax == Constraints.Infinity) mainMax else (mainMax - fixed - gaps).coerceAtLeast(0) + val place = measurable.measure(childConstraints(0, available)) + places[index] = place + fixed += if (horizontal) place.width else place.height + } + } + val target = if (mainMax == Constraints.Infinity) mainMin else mainMax + val remaining = (target - fixed - gaps).coerceAtLeast(0) + val totalWeight = weights.filterNotNull().sum() + var distributed = 0 + var remainingWeight = totalWeight + measurables.forEachIndexed { index, measurable -> + val weight = weights[index] ?: return@forEachIndexed + val share = if (remainingWeight == weight) remaining - distributed else (remaining * weight / totalWeight).roundToInt().coerceAtMost((remaining - distributed).coerceAtLeast(0)) + distributed += share + remainingWeight -= weight + val intrinsic = intrinsic(index) { if (horizontal) measurable.minIntrinsicWidth(crossMax) else measurable.minIntrinsicHeight(crossMax) } + val size = maxOf(share, intrinsic).coerceAtMost(Constraints.Infinity - 1) + places[index] = measurable.measure(childConstraints(size, size)) + } + val children = places.map { it!! } + val sizes = children.map { if (horizontal) it.width else it.height }.toIntArray() + val contentMain = (sizes.fold(0L) { sum, size -> sum + size } + gaps).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + val main = if (horizontal) constraints.constrainWidth(contentMain) else constraints.constrainHeight(contentMain) + val contentCross = children.maxOfOrNull { if (horizontal) it.height else it.width } ?: 0 + val cross = if (horizontal) constraints.constrainHeight(contentCross) else constraints.constrainWidth(contentCross) + val positions = IntArray(children.size) + if (horizontal) with(horizontalArrangement) { arrange(main, sizes, layoutDirection, positions) } + else with(verticalArrangement) { arrange(main, sizes, positions) } + return layout(if (horizontal) main else cross, if (horizontal) cross else main) { + children.forEachIndexed { index, place -> + if (horizontal) place.place(positions[index], verticalAlignment.align(place.height, cross)) + else place.place(horizontalAlignment.align(place.width, cross, layoutDirection), positions[index]) + } + } + } + + private fun IntrinsicMeasureScope.mainIntrinsic(children: List, cross: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val sizes = children.mapIndexed { index, child -> intrinsic(index) { + if (horizontal) { + if (min) child.minIntrinsicWidth(cross) else child.maxIntrinsicWidth(cross) + } else { + if (min) child.minIntrinsicHeight(cross) else child.maxIntrinsicHeight(cross) + } + } } + val unit = sizes.indices.filter { weights[it] != null }.maxOfOrNull { sizes[it] / weights[it]!! } ?: 0f + val weighted = (unit * weights.filterNotNull().sum()).roundToInt() + return (sizes.indices.filter { weights[it] == null }.sumOf { sizes[it].toLong() } + weighted + gap) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + + private fun IntrinsicMeasureScope.crossIntrinsic(children: List, main: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val fixed = children.indices.filter { weights[it] == null }.map { index -> + val child = children[index] + intrinsic(index) { if (horizontal) child.maxIntrinsicWidth(Constraints.Infinity) else child.maxIntrinsicHeight(Constraints.Infinity) } + }.fold(0L) { sum, value -> sum + value } + val remaining = if (main == Constraints.Infinity) Constraints.Infinity else (main.toLong() - fixed - gap).coerceAtLeast(0).toInt() + val total = weights.filterNotNull().sum() + return children.indices.maxOfOrNull { index -> + val child = children[index] + val weight = weights[index] + val available = if (weight == null || remaining == Constraints.Infinity) Constraints.Infinity + else (remaining * weight / total).roundToInt() + val minimum = intrinsic(index) { if (horizontal) child.minIntrinsicWidth(Constraints.Infinity) else child.minIntrinsicHeight(Constraints.Infinity) } + val size = if (weight == null) available else maxOf(available, minimum) + intrinsic(index) { if (horizontal) { + if (min) child.minIntrinsicHeight(size) else child.maxIntrinsicHeight(size) + } else { + if (min) child.minIntrinsicWidth(size) else child.maxIntrinsicWidth(size) + } } + } ?: 0 + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, true) else crossIntrinsic(measurables, height, true) + override fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, false) else crossIntrinsic(measurables, height, false) + override fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, true) else mainIntrinsic(measurables, width, true) + override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, false) else mainIntrinsic(measurables, width, false) + + private inline fun intrinsic(index: Int, query: () -> Int): Int { + if (!intrinsicSafe[index]) return 0 + return try { query() } catch (_: IllegalStateException) { 0 } + } +} + +private fun NodeTable.hasScrollingDescendant(id: Int): Boolean { + val node = node(id) ?: return false + if (node.widget in setOf(WidgetKind.LazyColumn, WidgetKind.LazyRow, WidgetKind.LazyGrid, WidgetKind.ScrollColumn, WidgetKind.ScrollRow)) return true + return node.children.any { hasScrollingDescendant(it) } +} + /** * Children of a Column: weight is applied here because `Modifier.weight` exists only inside * `ColumnScope`. Every layout that stacks its children vertically draws them through this, @@ -753,7 +1029,7 @@ private fun ColumnScope.WeightedChild(childId: Int, table: NodeTable, dispatcher } @Composable -private fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { +internal fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { node.children.forEach { childId -> key(childId) { WeightedChild(childId, table, dispatcher) } } } @@ -829,3 +1105,6 @@ object LayoutProfile { fun snapshot(): List> = totals.entries.map { Triple(it.key, it.value.nanos / 1e6, it.value.count) }.sortedByDescending { it.second } } + +/** The containers that become focus targets when they listen for keys. */ +private val KeyFocusTargets = setOf(WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget) diff --git a/compose-rust/android-kotlin/shared/TextResolution.kt b/compose-rust/android-kotlin/shared/TextResolution.kt index c284b2b53..70bf067b0 100644 --- a/compose-rust/android-kotlin/shared/TextResolution.kt +++ b/compose-rust/android-kotlin/shared/TextResolution.kt @@ -143,6 +143,7 @@ internal fun resolveText( * resolving a colour through the design system is the dearest thing done here. */ forMeasure: Boolean = false, + intrinsicWordBreak: WordBreak? = null, ): ResolvedText { val problems = mutableListOf() // Sizes are sp. Text in CSS pixels is not given smaller sp to undo the font scale: @@ -203,7 +204,7 @@ internal fun resolveText( val badRuns = if (input.runs.isEmpty()) null else runsProblem(input.runs, input.text.encodeToByteArray().size) val runs = if (badRuns == null) input.runs else emptyList() - val mode = when (input.wordBreak) { + val mode = when (intrinsicWordBreak ?: input.wordBreak) { WordBreak.KeepAll -> BreakMode.KeepAll WordBreak.BreakAll -> BreakMode.BreakAll WordBreak.Normal, null -> BreakMode.Normal @@ -408,24 +409,7 @@ private fun annotate( val runChain = input.runFonts[index] ?.takeIf { input.noRole && run.role == null } ?.let { resolveFontChain(it, assets, problems) } - val family = runChain?.primary - val style = SpanStyle( - color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, - // Behind this run's letters only, line by line where it wraps. The line's - // own background is the node's, and this is the part of it that changed. - background = runBackground(run, theme), - fontWeight = if (run.bold) FontWeight.Bold else null, - fontStyle = if (run.italic) FontStyle.Italic else null, - fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, - fontFamily = family, - textDecoration = when { - run.underline && run.strikethrough -> - TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) - run.underline -> TextDecoration.Underline - run.strikethrough -> TextDecoration.LineThrough - else -> null - }, - ) + val style = spanStyle(run, runChain?.primary, theme, sized) addStyle(style, from, to) runChain?.spans(broken.text.substring(from, to))?.forEach { span -> addStyle(span.item, from + span.start, from + span.end) @@ -435,6 +419,36 @@ private fun annotate( } } +/** + * The Compose style of one span record. Measuring and drawing both build their spans here, + * so what a span changes about its letters is decided in one place and the two cannot + * drift apart. + */ +private fun spanStyle( + run: TextRun, + family: FontFamily?, + theme: ResolvedTheme, + sized: (Float) -> TextUnit, +): SpanStyle { + return SpanStyle( + color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, + // Behind this run's letters only, line by line where it wraps. The line's + // own background is the node's, and this is the part of it that changed. + background = runBackground(run, theme), + fontWeight = if (run.bold) FontWeight.Bold else null, + fontStyle = if (run.italic) FontStyle.Italic else null, + fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, + fontFamily = family, + textDecoration = when { + run.underline && run.strikethrough -> + TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) + run.underline -> TextDecoration.Underline + run.strikethrough -> TextDecoration.LineThrough + else -> null + }, + ) +} + private fun tabId(index: Int): String = "compose-rust-tab-$index" /** diff --git a/compose-rust/android-kotlin/shared/Transform3DLayers.kt b/compose-rust/android-kotlin/shared/Transform3DLayers.kt new file mode 100644 index 000000000..73b5d00bb --- /dev/null +++ b/compose-rust/android-kotlin/shared/Transform3DLayers.kt @@ -0,0 +1,304 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.sqrt + +/** + * What this file is bound to. + * + * Everything below relies on how a Compose layer turns its properties into a matrix, and + * that is not part of Compose's documented surface. It was read from the Compose release + * named here, in `prepareTransformationMatrix` of `ui-graphics`: a layer moves its origin + * to zero, applies the rotation about Z, then Y, then X, then the scale, then the + * perspective that exists only while X or Y is rotated, and moves the origin back and adds + * the translation. The perspective term is `-1 / (cameraDistance * 72)`. + * + * A test compares [COMPOSE_VERSION] with the version the renderer module depends on, so + * moving to another Compose release fails that test until this file has been read against + * the new release. + */ +internal object Compose3DBinding { + const val COMPOSE_VERSION = "1.11.1-ext.2" + + /** Pixels of camera depth for one unit of `cameraDistance`. */ + const val PIXELS_PER_CAMERA_UNIT = 72f +} + +/** + * How a 4x4 CSS matrix is drawn with Compose layers, and nothing approximate about it. + * + * A node is flat: its content lies in the plane z = 0 and what CSS draws is the image of + * that plane. So only nine entries of the matrix matter, those of the homography + * + * ``` + * x' = (m0 x + m4 y + m12) / w y' = (m1 x + m5 y + m13) / w w = m3 x + m7 y + m15 + * ``` + * + * and every such homography is drawn exactly by the layers Compose has. After dividing by + * `m15`, the translation `t` is `(m12, m13)`, which leaves a homography `K` with a 2x2 + * linear part `F` and a perspective row `(g, h)`. + * + * - **No perspective** (`g = h = 0`): an affine map, drawn by the two layers of + * [affineTransform]. + * - **One layer** ([Kind.Single]): the one `graphicsLayer` whose rotationX, rotationY, + * rotationZ, scale, translation and cameraDistance produce `K`. A layer's own matrix is + * `S Q P` with `Q` a rotation, so two rows of `Q` are the two rows of `F` with the + * perspective row scaled in, and the three unknown scales and depth follow from those + * two rows being orthonormal. The depth falls out of `g h D^2 = -(F row 0 . F row 1)` + * when both `g` and `h` are present. With only one of them the depth is free, and it is + * taken to be the one that leaves that axis unscaled, which is the camera distance of + * the CSS `perspective()` that produced the matrix. + * - **Stacked** ([Kind.Stacked]): where no single layer matches (a skew together with + * perspective is the usual case), a layer with the same perspective row and the + * rotations that give it, followed by the affine layers of the remaining 2x2 map. Layers + * compose exactly, so the picture is the same one. + * + * Every layer is a `graphicsLayer`, so the layout is untouched and Compose maps a pointer + * through each layer's inverse, which is why input follows the drawn shape. + * + * Values are in dp. [Kind.Hidden] is a node that is not drawn and takes no input: a matrix + * with no inverse, a `w` that is not positive at the origin, or a back face with + * backface-visibility hidden. + */ +internal class Transform3DPlan { + enum class Kind { Hidden, Affine, Single, Stacked } + + var kind = Kind.Hidden + private set + + /** The perspective layer of [Kind.Single] and [Kind.Stacked], angles in degrees. */ + var rotationX = 0f + private set + var rotationY = 0f + private set + var rotationZ = 0f + private set + var scaleX = 1f + private set + var scaleY = 1f + private set + + /** Camera distance in dp. */ + var depth = 0f + private set + + /** The perspective layer's own translation, dp. Nonzero only for [Kind.Single]. */ + var translationX = 0f + private set + var translationY = 0f + private set + + /** `a, b, c, d, e, f` of the affine layers of [Kind.Affine] and [Kind.Stacked]. */ + val affine = FloatArray(6) + var affineOneLayer = true + private set + + /** Whether the node faces away from the viewer: the map reverses orientation. */ + var facingAway = false + private set + + /** + * Works out how [m], sixteen values in the column-major order of CSS `matrix3d`, is + * drawn. Allocates only the intermediate arithmetic, none of it kept. + */ + fun solve(m: FloatArray, backfaceHidden: Boolean) { + kind = Kind.Hidden + facingAway = false + affineOneLayer = true + val w0 = m[15].toDouble() + if (!(w0 > 0.0)) return + val inverse = 1.0 / w0 + val tx = m[12] * inverse + val ty = m[13] * inverse + val g = m[3] * inverse + val h = m[7] * inverse + val a00 = m[0] * inverse - tx * g + val a01 = m[1] * inverse - ty * g + val a10 = m[4] * inverse - tx * h + val a11 = m[5] * inverse - ty * h + val determinant = a00 * a11 - a01 * a10 + if (determinant == 0.0 || !determinant.isFinite()) return + facingAway = determinant < 0.0 + if (backfaceHidden && facingAway) return + + if (g == 0.0 && h == 0.0) { + affine[0] = a00.toFloat() + affine[1] = a01.toFloat() + affine[2] = a10.toFloat() + affine[3] = a11.toFloat() + affine[4] = tx.toFloat() + affine[5] = ty.toFloat() + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = if (affine.all { it.isFinite() }) Kind.Affine else Kind.Hidden + return + } + if (solveSingleLayer(a00, a01, a10, a11, g, h, tx, ty)) return + solveStacked(a00, a01, a10, a11, g, h, tx, ty) + } + + private fun solveSingleLayer( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ): Boolean { + val depthSquared = when { + g != 0.0 && h != 0.0 -> -(a00 * a10 + a01 * a11) / (g * h) + g != 0.0 -> unscaledDepthSquared(a00 * a00 + a01 * a01, g) + else -> unscaledDepthSquared(a10 * a10 + a11 * a11, h) + } + if (!(depthSquared > 0.0) || !depthSquared.isFinite()) return false + val distance = sqrt(depthSquared) + val row0x = a00 + val row0y = a01 + val row0z = -g * distance + val row1x = a10 + val row1y = a11 + val row1z = -h * distance + val length0 = sqrt(row0x * row0x + row0y * row0y + row0z * row0z) + val length1 = sqrt(row1x * row1x + row1y * row1y + row1z * row1z) + if (!(length0 > 1e-12) || !(length1 > 1e-12)) return false + val q00 = row0x / length0 + val q01 = row0y / length0 + val q02 = row0z / length0 + val q10 = row1x / length1 + val q11 = row1y / length1 + val q12 = row1z / length1 + // The two rows have to be at right angles, or no rotation has them. + if (abs(q00 * q10 + q01 * q11 + q02 * q12) > ORTHOGONAL_TOLERANCE) return false + // The third row of the rotation is the cross product of the first two. + val q20 = q01 * q12 - q02 * q11 + val q21 = q02 * q10 - q00 * q12 + val q22 = q00 * q11 - q01 * q10 + val sinY = q20.coerceIn(-1.0, 1.0) + val cosY = sqrt(1.0 - sinY * sinY) + val x: Double + val z: Double + if (cosY > GIMBAL_LIMIT) { + x = atan2(-q21, q22) + z = atan2(-q10, q00) + } else { + // Looking along the Y axis, where X and Z turn the same way: all of it is Z. + x = 0.0 + z = atan2(q01, q11) + } + val results = floatArrayOf( + degrees(x), degrees(asin(sinY)), degrees(z), + length0.toFloat(), length1.toFloat(), distance.toFloat(), + tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return false + rotationX = results[0] + rotationY = results[1] + rotationZ = results[2] + scaleX = results[3] + scaleY = results[4] + depth = results[5] + translationX = results[6] + translationY = results[7] + kind = Kind.Single + return true + } + + /** + * The depth at which the one perspective axis leaves its own scale at one, which is the + * camera of the CSS `perspective()` that gave the matrix. A row already longer than + * one cannot have that, and takes the depth that turns it by a half right angle. + */ + private fun unscaledDepthSquared(rowLengthSquared: Double, perspective: Double): Double = + if (rowLengthSquared < 1.0) { + (1.0 - rowLengthSquared) / (perspective * perspective) + } else { + rowLengthSquared / (perspective * perspective) + } + + private fun solveStacked( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ) { + // A layer with this perspective row and no scale: sin(rotX) = -h D, and + // sin(rotY) cos(rotX) = g D. D keeps both below one half, so the cosines are fine. + val distance = 0.5 / max(abs(g), abs(h)) + val sinX = -h * distance + val cosX = sqrt(1.0 - sinX * sinX) + val sinY = g * distance / cosX + val cosY = sqrt(1.0 - sinY * sinY) + // That layer's own 2x2 map is [[cosY, sinY sinX], [0, cosX]], with determinant + // cosY cosX. What is left for the affine layers is its inverse times the target. + val determinant = cosY * cosX + val n00 = (cosX * a00 - sinY * sinX * a10) / determinant + val n01 = (cosX * a01 - sinY * sinX * a11) / determinant + val n10 = cosY * a10 / determinant + val n11 = cosY * a11 / determinant + val results = floatArrayOf( + degrees(asin(sinX)), degrees(asin(sinY)), distance.toFloat(), + n00.toFloat(), n01.toFloat(), n10.toFloat(), n11.toFloat(), tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return + rotationX = results[0] + rotationY = results[1] + rotationZ = 0f + scaleX = 1f + scaleY = 1f + depth = results[2] + translationX = 0f + translationY = 0f + for (index in 0 until 6) affine[index] = results[3 + index] + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = Kind.Stacked + } + + private fun degrees(radians: Double): Float = (radians * 180.0 / PI).toFloat() + + private companion object { + const val ORTHOGONAL_TOLERANCE = 1e-4 + + /** Below this cosine of the Y angle the X and Z angles cannot be told apart. */ + const val GIMBAL_LIMIT = 1e-6 + } +} + +/** + * Draws this node and its content through [plan], turning about the fraction of the node's + * size [originX], [originY]. Which layers that takes, and why they are exact, is on + * [Transform3DPlan]. + */ +internal fun Modifier.perspectiveTransform(plan: Transform3DPlan, originX: Float, originY: Float): Modifier { + val origin = TransformOrigin(originX, originY) + return when (plan.kind) { + Transform3DPlan.Kind.Hidden -> graphicsLayer { + // A layer that scales to nothing has no inverse, so nothing is drawn and no + // pointer is ever inside it. + transformOrigin = origin + alpha = 0f + scaleX = 0f + scaleY = 0f + } + Transform3DPlan.Kind.Affine -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + } + Transform3DPlan.Kind.Single -> graphicsLayer { perspectiveLayer(plan, origin) } + Transform3DPlan.Kind.Stacked -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + }.graphicsLayer { perspectiveLayer(plan, origin) } + } +} + +private fun GraphicsLayerScope.perspectiveLayer(plan: Transform3DPlan, origin: TransformOrigin) { + transformOrigin = origin + translationX = plan.translationX.dp.toPx() + translationY = plan.translationY.dp.toPx() + rotationX = plan.rotationX + rotationY = plan.rotationY + rotationZ = plan.rotationZ + scaleX = plan.scaleX + scaleY = plan.scaleY + cameraDistance = plan.depth.dp.toPx() / Compose3DBinding.PIXELS_PER_CAMERA_UNIT +} diff --git a/compose-rust/android-kotlin/shared/WindowSize.kt b/compose-rust/android-kotlin/shared/WindowSize.kt index b0da631ac..bee09da52 100644 --- a/compose-rust/android-kotlin/shared/WindowSize.kt +++ b/compose-rust/android-kotlin/shared/WindowSize.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass +import kotlin.math.floor /** * The width in dp at which each size class begins. @@ -79,6 +80,8 @@ class WindowSizeReporter(private val nodeId: Int = 0) { private var lastClass: WindowSizeClass = WindowSizeClass.Compact private var lastSafeArea = WindowSafeArea() private var lastHeightClass: WindowHeightClass = WindowHeightClass.Compact + private var lastWidthDp = 0 + private var lastHeightDp = 0 /** * Reports one measurement. Returns whether an event was sent. @@ -86,11 +89,29 @@ class WindowSizeReporter(private val nodeId: Int = 0) { * Nothing is allocated when neither class has changed, which is the common case. The * two classes ride in one record, so a window that gets wider and shorter in the same * drag costs one event rather than two. + * + * A window that asked for [everyDpChange] also reports when its width or height, each + * rounded to the nearest whole dp (a half rounds up), differs from the rounded values of + * the last event sent. The rounded integers are compared, never the raw floats, so jitter + * under one dp is ignored. The event still carries the measured, unrounded size. */ - fun report(widthDp: Float, heightDp: Float, dispatcher: EventDispatcher, safeArea: WindowSafeArea = WindowSafeArea()): Boolean { + fun report( + widthDp: Float, + heightDp: Float, + dispatcher: EventDispatcher, + safeArea: WindowSafeArea = WindowSafeArea(), + everyDpChange: Boolean = false, + ): Boolean { val sizeClass = windowSizeClassOf(widthDp) val heightClass = windowHeightClassOf(heightDp) - if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) return false + if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) { + if (!everyDpChange) return false + if (floor(widthDp + 0.5f).toInt() == lastWidthDp && floor(heightDp + 0.5f).toInt() == lastHeightDp) return false + } + if (everyDpChange) { + lastWidthDp = floor(widthDp + 0.5f).toInt() + lastHeightDp = floor(heightDp + 0.5f).toInt() + } lastClass = sizeClass lastHeightClass = heightClass lastSafeArea = safeArea @@ -117,3 +138,23 @@ class WindowSizeReporter(private val nodeId: Int = 0) { * zoom - carry this as their node id, and the theme and zoom level are looked up under it. */ val LocalWindowNodeId = staticCompositionLocalOf { 0 } + +/** Bounds are in window pixels, matching the Linux backend's screen coordinates. */ +internal object WindowDropTargets { + private data class Target(val left: Float, val top: Float, val right: Float, val bottom: Float, val density: Float, val depth: Int) + private val targets = LinkedHashMap, Target>() + + fun update(windowId: Int, nodeId: Int, left: Float, top: Float, right: Float, bottom: Float, density: Float, depth: Int) { + targets[windowId to nodeId] = Target(left, top, right, bottom, density, depth) + } + + fun remove(windowId: Int, nodeId: Int) { targets.remove(windowId to nodeId) } + + fun find(windowId: Int, x: Float, y: Float): Pair>? { + val target = targets.entries.toList().asReversed().filter { (key, rect) -> + key.first == windowId && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom + }.maxByOrNull { it.value.depth } ?: return null + val rect = target.value + return target.key.second to ((x - rect.left) / rect.density to (y - rect.top) / rect.density) + } +} diff --git a/compose-rust/benches/fr39/candidate/src/main.rs b/compose-rust/benches/fr39/candidate/src/main.rs index b80fb8915..306579a2f 100644 --- a/compose-rust/benches/fr39/candidate/src/main.rs +++ b/compose-rust/benches/fr39/candidate/src/main.rs @@ -24,7 +24,7 @@ extern "C" fn request_frame() { /// The same sweep screen as the Dioxus path's, written with composables: a column of keyed /// texts that all read the same counter, and the button that moves it. -#[composable] +#[Composable] fn sweep() { let count = remember(|| mutable_state_of(0_u64)); let slots = SLOTS.load(Ordering::Relaxed); diff --git a/compose-rust/build.rs b/compose-rust/build.rs index 08e260121..b14a38a2d 100644 --- a/compose-rust/build.rs +++ b/compose-rust/build.rs @@ -16,6 +16,10 @@ mod windows_crt { include!("build/windows_crt.rs"); } +mod windows_manifest { + include!("build/windows_manifest.rs"); +} + use windows_crt::{ WINDOWS_CRT_ENV, WindowsCrt, blank_runtime_directives, find_msvc_library, unreconcilable_message, windows_crt, @@ -76,6 +80,10 @@ fn main() { println!("cargo:rerun-if-changed=build/sha256.rs"); println!("cargo:rerun-if-changed=build/elf.rs"); println!("cargo:rerun-if-changed=build/windows_crt.rs"); + println!("cargo:rerun-if-changed=build/windows_manifest.rs"); + println!("cargo:rerun-if-changed=build/application.manifest"); + + embed_application_manifest(); if std::env::var_os("CARGO_FEATURE_NATIVE_RENDERER").is_none() { return; @@ -191,13 +199,16 @@ fn main() { ("macos", _) => Some(renderer_build.join("macos")), ("linux", "x86_64") => Some(renderer_build.join("linux")), ("linux", "aarch64") => Some(renderer_build.join("linux-arm64")), + // What build-windows.sh writes: the Kotlin/Native static kit. Windows has no + // GraalVM DLL to fall back to. + ("windows", _) => Some(renderer_build.join("windows")), _ => None, }; let shared_dir = renderer_build.join("native-image/dist/lib"); let workspace_dirs: Vec<&Path> = static_dir .as_deref() .into_iter() - .chain(std::iter::once(shared_dir.as_path())) + .chain((target_os != "windows").then_some(shared_dir.as_path())) .collect(); let cache_root = cache_root(); @@ -235,6 +246,7 @@ fn main() { let lib_dir = renderer.lib_dir; check_schema_agreement(&lib_dir); match renderer.kind { + RendererKind::Static if target_os == "windows" => link_windows_native(&lib_dir), RendererKind::Static => link_static(&target_os, &target, &lib_dir), RendererKind::Shared => link_shared(&target_os, &lib_dir), } @@ -274,6 +286,8 @@ fn static_link(target_os: &str) -> Option { // The notifications an application posts go through the system's own // notification centre. "UserNotifications", + // The file open and save sheets name the types a filter lets through. + "UniformTypeIdentifiers", ], libraries: &["c++", "z"], arm64_libraries: &[], @@ -342,63 +356,13 @@ fn link_static(target_os: &str, target: &str, lib_dir: &Path) { println!("cargo:rustc-cfg=renderer_linked"); } -/// Links a shared renderer: the GraalVM native image, which Windows ships and a checkout -/// can still build for macOS and Linux. +/// Links a shared renderer: the GraalVM native image, which a checkout can still build for +/// macOS and Linux. Windows has none; its renderer is the static kit. fn link_shared(target_os: &str, lib_dir: &Path) { println!("cargo:rerun-if-changed={}", lib_dir.display()); println!("cargo:rustc-link-search=native={}", lib_dir.display()); println!("cargo:rustc-cfg=renderer_linked"); - if target_os == "windows" { - // MSVC links against the import library the native image produced beside the DLL, - // and it carries the same `lib` prefix the image was named with. - println!("cargo:rustc-link-lib=dylib=libcompose_rust_renderer"); - // The exports the Renderer looks up are not asked for here. - // - // They were, with `/EXPORT:` link arguments, and that reached this package's own - // binaries and nothing else: Cargo does not pass a dependency's link arguments on - // to whatever depends on it. So every application built on this crate linked with - // no export table, the Renderer could not reach back into it, and the window came - // up empty. It looked like it worked because the only Windows binaries anyone had - // run were this package's own examples. - // - // The directive travels inside the rlib now, in the `.drectve` section that the - // MSVC linker reads out of every object it links. See the `global_asm!` block in - // src/boundary.rs. - // The subsystem is not set here, and the reason is the same one the export - // directive ran into: a build script's link arguments reach this package's own - // targets and stop. Setting it here broke the crate's own cdylib, which has no - // `main` for `/ENTRY:mainCRTStartup` to point at, and would not have reached a - // single application even if it had worked. - // - // An application says it for itself, with `#![windows_subsystem = "windows"]` at - // the top of its `main.rs`. That is one line, it is the ordinary way to do it in - // Rust, and the samples here carry it. Without it a window application also opens - // a console, and closing that console kills it. - // - // The renderer attaches to a parent console at startup so that saying it costs no - // diagnostics: run from a terminal the messages still appear, run from Explorer - // there is no terminal to open. - // Windows has no rpath and no name inside the file that the loader consults: a - // DLL is found on the loader's search path and nowhere else. The other platforms - // name the library after where it sits, which does nothing here. - // - // Telling the person building to put a directory on PATH was the whole of the fix - // and it was not enough. It makes adding this crate to a Cargo.toml two steps - // instead of one, and it breaks things that have nothing to do with drawing: the - // code generator in this package links the renderer only because it lives in the - // same package, and on Windows it died on startup with STATUS_DLL_NOT_FOUND - // before it had generated a line. - // - // The loader searches the directory the executable is in, so the renderer is put - // there. Cargo does not tell a build script where that is, but OUT_DIR is - // `//build/-/out`, so three levels up is the - // profile directory where binaries land, and `examples/` and `deps/` beside it - // are where examples and tests land. - copy_renderer_beside_executables(lib_dir); - return; - } - println!("cargo:rustc-link-lib=dylib=compose_rust_renderer"); // No rpath. It would not reach an application that merely depends on this crate, // because Cargo does not pass a dependency's link arguments on, and emitting one here @@ -460,10 +424,10 @@ fn link_windows_native(dir: &Path) { ] { if !dir.join(piece).is_file() { panic!( - "\n\ncompose-rust: DXC_WINDOWS_NATIVE_LIB is {}, which has no {piece} ({what}).\n\n\ + "\n\ncompose-rust: the Windows renderer kit at {} has no {piece} ({what}).\n\n\ renderer/desktop/scripts/build-windows.sh writes the whole \ - directory; build the renderer again, or point DXC_WINDOWS_NATIVE_LIB at the \ - directory it wrote.\n\n", + directory; build the renderer again, or set DXC_WINDOWS_NATIVE_LIB (or \ + COMPOSE_RUST_RENDERER_DIR) to the directory it wrote.\n\n", dir.display() ); } @@ -725,6 +689,43 @@ fn renamed_var(name: &str, old_name: &str) -> Option { Some(value) } +/// Gives every Windows executable that links this crate the application manifest in +/// `build/application.manifest` (per-monitor v2 DPI awareness, `asInvoker`). +/// +/// A build script's link arguments stop at its own package, which is why this is not a +/// `/MANIFESTINPUT:` argument: it would reach this crate's examples and no application. +/// A native library does travel, to the final link of everything that depends on the +/// crate. So the manifest is written as a compiled resource file and named as one. The +/// file is called `.lib` because that is the name the link line asks for, and the MSVC +/// linker identifies its inputs by their contents, so it takes the file as the resource +/// it is and always includes it, where an object inside a real library would be dropped +/// for defining no symbol anyone asked for. +/// +/// An application that embeds a manifest of its own (with `/MANIFEST:EMBED`, or a +/// resource script through embed-resource or winres) would then have two, and the link +/// fails with CVT1100, duplicate resource MANIFEST. It turns this off by leaving the +/// `windows-manifest` feature out: +/// `compose-rust = { version = "...", default-features = false, features = ["native-renderer"] }`. +/// Its own manifest should then declare per-monitor v2 awareness itself. +fn embed_application_manifest() { + if std::env::var_os("CARGO_FEATURE_WINDOWS_MANIFEST").is_none() { + return; + } + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if !(windows && msvc) { + return; + } + let out_dir = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo sets OUT_DIR")); + let resource = + windows_manifest::manifest_resource(windows_manifest::APPLICATION_MANIFEST.as_bytes()); + let path = out_dir.join("compose_rust_manifest.lib"); + std::fs::write(&path, resource) + .unwrap_or_else(|e| panic!("compose-rust: cannot write {}: {e}", path.display())); + println!("cargo:rustc-link-search=native={}", out_dir.display()); + println!("cargo:rustc-link-lib=dylib=compose_rust_manifest"); +} + /// Where downloads are cached. This is a property of the machine running the build, not /// of the target being built for, so it reads the host's own environment. fn cache_root() -> Option { @@ -1141,100 +1142,6 @@ fn read_hash(path: &Path) -> Option { .filter(|value| !value.is_empty()) } -/// Puts the renderer where a Windows loader and AWT will both find it. -/// -/// Two separate requirements, and satisfying only the first is worse than satisfying -/// neither, because the program then starts and dies later with a message about a file -/// nobody asked for. -/// -/// The loader searches the directory the executable is in, so the libraries are copied -/// there flat. That alone is what a program needs in order to start, and it is what the -/// code generator in this package needs, which links the renderer only because it shares a -/// package with it and died on startup before generating a line. -/// -/// AWT then wants a distribution around it. The renderer sets `java.home` to the parent of -/// wherever it was loaded from, and AWT resolves its own libraries against `java.home/bin`. -/// A flat copy beside an executable in `target/release/examples` therefore sends AWT to -/// `target/release/bin`, which is a directory nobody made. So the distribution is copied -/// whole, keeping `bin` and `lib`, into the parent of each place an executable lands. -/// -/// Cargo does not tell a build script where binaries land. OUT_DIR is -/// `//build/-/out`, so three levels up is the profile -/// directory, `examples/` and `deps/` beside it are where examples and tests land, and the -/// parents of those two are the profile directory and the target directory. -fn copy_renderer_beside_executables(lib_dir: &Path) { - let Some(out_dir) = std::env::var_os("OUT_DIR") else { - return; - }; - let out_dir = PathBuf::from(out_dir); - let Some(profile_dir) = out_dir - .parent() - .and_then(Path::parent) - .and_then(Path::parent) - else { - return; - }; - - // Flat, for the loader: every executable's own directory. - for directory in [ - profile_dir.to_path_buf(), - profile_dir.join("examples"), - profile_dir.join("deps"), - ] { - copy_newer_files(lib_dir, &directory); - } - - // Whole, for AWT: the parent of every executable's directory. The distribution root is - // the parent of the directory the libraries are in, which is `bin` on Windows and - // `lib` everywhere else. - let Some(distribution) = lib_dir.parent() else { - return; - }; - let mut roots = vec![profile_dir.to_path_buf()]; - if let Some(target_dir) = profile_dir.parent() { - roots.push(target_dir.to_path_buf()); - } - for root in roots { - for child in ["bin", "lib"] { - let source = distribution.join(child); - if source.is_dir() { - copy_newer_files(&source, &root.join(child)); - } - } - } -} - -/// Copies the files of one directory into another, skipping what is already newer there. -/// -/// These are tens of megabytes and they are copied into five places, so every build would -/// otherwise move half a gigabyte to no purpose. -fn copy_newer_files(from: &Path, to: &Path) { - let Ok(entries) = std::fs::read_dir(from) else { - return; - }; - if std::fs::create_dir_all(to).is_err() { - return; - } - for entry in entries.flatten() { - let source = entry.path(); - if !source.is_file() { - continue; - } - let destination = to.join(entry.file_name()); - let newer = match (source.metadata(), destination.metadata()) { - (Ok(from), Ok(to)) => match (from.modified(), to.modified()) { - (Ok(from), Ok(to)) => from > to, - _ => true, - }, - (Ok(_), Err(_)) => true, - _ => false, - }; - if newer { - let _ = std::fs::copy(&source, &destination); - } - } -} - fn copy_tree(from: &Path, to: &Path) -> std::io::Result<()> { std::fs::create_dir_all(to)?; for entry in std::fs::read_dir(from)? { diff --git a/compose-rust/build/application.manifest b/compose-rust/build/application.manifest new file mode 100644 index 000000000..3afbd67ab --- /dev/null +++ b/compose-rust/build/application.manifest @@ -0,0 +1,16 @@ + + + + + + + + + + + + true/pm + PerMonitorV2 + + + diff --git a/compose-rust/build/renderer_dir.rs b/compose-rust/build/renderer_dir.rs index 8baa9fd80..e39a917cb 100644 --- a/compose-rust/build/renderer_dir.rs +++ b/compose-rust/build/renderer_dir.rs @@ -45,8 +45,9 @@ pub const PUBLISHED_TARGETS: &[&str] = &["macos-aarch64", "windows-x64", "linux- /// The archive is what the release ships for macOS and Linux. It is Kotlin/Native, with /// Compose, Skia and ICU inside it, and it is linked into the application, so the /// application is one executable that needs nothing but the system's own libraries. The -/// shared library is the GraalVM native image, which is what Windows still ships and what -/// a checkout can still build and point the build at. +/// shared library is the GraalVM native image, which a checkout can still build for macOS +/// and Linux. Windows has no shared renderer: its distribution is the static kit that +/// `build-windows.sh` writes (the archive with `native/`, `gcc/` and `skiko/` beside it). /// /// Which one a distribution is, is read from what is in it rather than from the platform. /// That is what lets a crate version whose release still carries shared libraries go on @@ -63,7 +64,7 @@ pub enum RendererKind { /// The release publishes these targets as a static archive. The others are a shared /// library for now; the build script says which in its messages, and links whichever /// the distribution holds. -pub const STATIC_TARGETS: &[&str] = &["macos-aarch64", "linux-x64", "linux-arm64"]; +pub const STATIC_TARGETS: &[&str] = &["macos-aarch64", "windows-x64", "linux-x64", "linux-arm64"]; /// The static archive a distribution of the Renderer contains when it is one. Kotlin/Native /// names it the same way on every platform it builds one for. @@ -86,6 +87,16 @@ pub fn renderer_file(target_os: &str, kind: RendererKind) -> &'static str { /// at. The archive wins where a directory somehow holds both, because it is what makes the /// application one file. pub fn find_renderer_in(dir: &Path, target_os: &str) -> Option<(PathBuf, RendererKind)> { + // The Windows kit is the directory itself: the archive sits at its root with the MSVC + // bridge libraries beside it, and linking needs all of them, so a `lib/` or `bin/` + // holding only an archive is not a kit. There is no Windows DLL to find. + if target_os == "windows" { + let file = renderer_static_file(target_os); + return dir + .join(file) + .is_file() + .then(|| (dir.to_path_buf(), RendererKind::Static)); + } for kind in [RendererKind::Static, RendererKind::Shared] { let file = renderer_file(target_os, kind); for candidate in [dir.join("lib"), dir.join("bin"), dir.to_path_buf()] { @@ -102,9 +113,6 @@ pub fn find_renderer_in(dir: &Path, target_os: &str) -> Option<(PathBuf, Rendere /// what its loader will look for. pub fn renderer_lib_file(target_os: &str) -> &'static str { match target_os { - // The Windows build names the image `libcompose_rust_renderer`, so the DLL keeps - // the `lib` prefix that Windows itself would not have added. - "windows" => "libcompose_rust_renderer.dll", "macos" => "libcompose_rust_renderer.dylib", _ => "libcompose_rust_renderer.so", } @@ -112,14 +120,9 @@ pub fn renderer_lib_file(target_os: &str) -> &'static str { /// Where a platform's build script puts the renderer inside the distribution. /// -/// Windows keeps the DLL beside the AWT and Skia DLLs in `bin`, because the loader -/// searches the directory of the module that needs them and they have to be found -/// together. The other platforms put the shared library in `lib`. -pub fn renderer_lib_subdir(target_os: &str) -> &'static str { - match target_os { - "windows" => "bin", - _ => "lib", - } +/// Every shared library goes in `lib`. Windows has none: its renderer is the static kit. +pub fn renderer_lib_subdir(_target_os: &str) -> &'static str { + "lib" } /// One line holding the crate version the artifact was built for. The release packaging @@ -1026,7 +1029,7 @@ pub fn every_failure_message(sample_dir: &Path) -> Vec { "linux-x64", ), unexpected_layout_message(&tarball, sample_dir, "macos"), - no_static_link_message("windows-x64", sample_dir), + no_static_link_message("freebsd-x64", sample_dir), version_mismatch_message(sample_dir, "0.1.0", version, target), build_it_yourself(version, target), ] diff --git a/compose-rust/build/windows_manifest.rs b/compose-rust/build/windows_manifest.rs new file mode 100644 index 000000000..670654b83 --- /dev/null +++ b/compose-rust/build/windows_manifest.rs @@ -0,0 +1,62 @@ +// The application manifest every Windows program built on this crate carries, and the +// compiled resource file that carries it. +// +// Windows reads a program's DPI awareness from the manifest embedded in the executable, +// before any of its code runs. The renderer also asks for per-monitor v2 awareness at +// startup, and that works for drawing, but it is a request made at run time and so +// invisible to anything that inspects the file: the Windows App Certification Kit +// reports such a program as not DPI aware, which is what a Store submission is checked +// against. Declaring it in the manifest says the same thing earlier. Once a process has +// its awareness from the manifest, the renderer's own calls fail harmlessly (awareness +// cannot be changed after it is set), so the two agree rather than compete. +// +// The resource is written here rather than compiled with rc.exe. A `.res` file is a +// short fixed format, and writing it needs no Windows SDK on the build machine, so it +// works the same for a cross build through lld-link. +// +// Included from build.rs and from tests/windows_manifest.rs, which is how it is tested. + +/// The manifest: per-monitor v2 DPI awareness, the same awareness the renderer asks for, +/// and `asInvoker`, so Windows never guesses from a file name that the program is an +/// installer wanting elevation. +pub const APPLICATION_MANIFEST: &str = include_str!("application.manifest"); + +/// `RT_MANIFEST`. +const RT_MANIFEST: u16 = 24; +/// `CREATEPROCESS_MANIFEST_RESOURCE_ID`, the manifest the loader reads for an executable. +const MANIFEST_ID: u16 = 1; +/// MOVEABLE | PURE | DISCARDABLE, what rc.exe writes for a manifest. +const MEMORY_FLAGS: u16 = 0x1030; +/// English (United States), what rc.exe writes when the script names no language. +const LANGUAGE: u16 = 0x0409; + +fn entry(out: &mut Vec, kind: u16, name: u16, flags: u16, language: u16, data: &[u8]) { + let header_size: u32 = 32; + out.extend_from_slice(&(data.len() as u32).to_le_bytes()); + out.extend_from_slice(&header_size.to_le_bytes()); + // Type and name as ordinals: 0xFFFF followed by the number. + out.extend_from_slice(&0xFFFFu16.to_le_bytes()); + out.extend_from_slice(&kind.to_le_bytes()); + out.extend_from_slice(&0xFFFFu16.to_le_bytes()); + out.extend_from_slice(&name.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // DataVersion + out.extend_from_slice(&flags.to_le_bytes()); + out.extend_from_slice(&language.to_le_bytes()); + out.extend_from_slice(&0u32.to_le_bytes()); // Version + out.extend_from_slice(&0u32.to_le_bytes()); // Characteristics + out.extend_from_slice(data); + while out.len() % 4 != 0 { + out.push(0); + } +} + +/// A `.res` file holding `manifest` as the executable's manifest resource. +/// +/// It opens with the empty entry every `.res` file starts with, which is also how the +/// MSVC linker recognises the file as a resource file whatever it is named. +pub fn manifest_resource(manifest: &[u8]) -> Vec { + let mut out = Vec::with_capacity(64 + manifest.len() + 3); + entry(&mut out, 0, 0, 0, 0, &[]); + entry(&mut out, RT_MANIFEST, MANIFEST_ID, MEMORY_FLAGS, LANGUAGE, manifest); + out +} diff --git a/compose-rust/macros/Cargo.toml b/compose-rust/macros/Cargo.toml index 29892ba8b..ce63a6157 100644 --- a/compose-rust/macros/Cargo.toml +++ b/compose-rust/macros/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "compose-rust-macros" -version = "0.0.6" +version = "0.0.7" edition.workspace = true license.workspace = true rust-version.workspace = true -description = "The #[composable] attribute for compose-rust's slot table runtime." +description = "The #[Composable] attribute for compose-rust's slot table runtime." repository = "https://github.com/DarkPyonix/compose-rust" keywords = ["ui", "compose", "macro"] categories = ["gui"] @@ -19,3 +19,9 @@ bench = false proc-macro2 = "1" quote = "1" syn = { version = "2", features = ["full", "visit", "visit-mut"] } + +[dev-dependencies] +# The compile-fail fixtures in tests/ui expand `#[Composable]` the way an application does, +# so they need the crate the expansion names. A dev-dependency cycle is allowed. +compose-rust = { path = ".." } +trybuild = "1" diff --git a/compose-rust/macros/src/call_macro.rs b/compose-rust/macros/src/call_macro.rs new file mode 100644 index 000000000..712580d70 --- /dev/null +++ b/compose-rust/macros/src/call_macro.rs @@ -0,0 +1,528 @@ +//! The call macro `#[Composable]` generates next to a composable function. +//! +//! `#[Composable] fn Name(a: A, b: B, content: impl Fn())` keeps its function form and also +//! gets a macro of the same name that takes the arguments positionally first and by name +//! after that, fills in `#[default(..)]` values, and expands to one call of the function. +//! +//! The generated `macro_rules!` has one rule. It forwards the function's name, one entry per +//! parameter (its name and, when it has one, its default expression) and the caller's +//! argument tokens, untouched, to the hidden proc macro [`check`]. The proc macro reads the +//! arguments, so an error is reported with `syn::Error::new_spanned` on the caller's own +//! tokens and points at the offending argument. A `compile_error!` written in a +//! `macro_rules!` body would point at the `#[Composable]` attribute instead. +//! +//! On success the proc macro emits `Name(arg0, arg1, ..)`. Nothing is bound to a local, so +//! the arguments stay inside the function call where closures take their parameter types +//! from the function's bounds, and they are evaluated in parameter order. +//! +//! The rule names the proc macro as `::compose_rust::__compose_call_check!`, which works +//! from any crate (and inside compose-rust through `extern crate self`). The function is +//! called by its own name, so the caller's scope decides what it means. + +use proc_macro2::{Group, Punct, Spacing, TokenStream, TokenTree}; +use quote::ToTokens; +use quote::{format_ident, quote}; +use syn::parse::{Parse, ParseStream}; +use syn::{Attribute, Expr, Ident, ItemFn, Token, Type, Visibility}; + +/// One parameter, as the call macro sees it. +pub(crate) struct CallParam { + pub ident: Ident, + pub ty: Type, + /// The default expression, normalised so it can be expanded at a call site. `None` is a + /// required parameter. + pub default: Option, +} + +/// The default of `modifier: Modifier` when the parameter has no `#[default]`. +pub(crate) fn implicit_default(ident: &Ident, ty: &Type) -> Option { + let is_modifier = matches!(ty, Type::Path(path) + if path.qself.is_none() + && path.path.segments.last().is_some_and(|segment| segment.ident == "Modifier")); + (ident == "modifier" && is_modifier).then(|| quote!(::compose_rust::ui::Modifier::new())) +} + +/// Rewrites what a default expression cannot keep when it moves to the call site: a path +/// that starts at `crate` has to keep meaning the crate that wrote the default. +pub(crate) fn normalise_default(tokens: TokenStream) -> TokenStream { + tokens + .into_iter() + .flat_map(|tree| match tree { + TokenTree::Ident(ident) if ident == "crate" => vec![ + TokenTree::Punct(Punct::new('$', Spacing::Alone)), + TokenTree::Ident(ident), + ], + TokenTree::Group(group) => { + let mut rebuilt = Group::new(group.delimiter(), normalise_default(group.stream())); + rebuilt.set_span(group.span()); + vec![TokenTree::Group(rebuilt)] + } + other => vec![other], + }) + .collect() +} + +/// Token text with the spaces `TokenStream::to_string` puts around punctuation taken out. +fn tidy(mut text: String) -> String { + for (from, to) in [ + ("{ }", "{}"), + (" :: ", "::"), + (":: ", "::"), + (" < ", "<"), + (" <", "<"), + ("< ", "<"), + (" >", ">"), + (" ,", ","), + (" (", "("), + ("( ", "("), + (" )", ")"), + ("& ", "&"), + ] { + text = text.replace(from, to); + } + text +} + +/// The text of a type for the macro's documentation. +fn type_text(ty: &Type) -> String { + tidy(quote!(#ty).to_string()) +} + +fn default_text(default: &TokenStream) -> String { + tidy(default.to_string().replace("$ crate", "crate")) +} + +/// The documentation lines of the macro: the function's own text, then every parameter in +/// declaration order. rust-analyzer shows a macro's docs but not the signature of the +/// function it calls, so this is what an editor hover has to go on. +fn documentation(function: &ItemFn, params: &[CallParam]) -> Vec { + let mut lines: Vec = Vec::new(); + for attribute in &function.attrs { + if !attribute.path().is_ident("doc") { + continue; + } + if let syn::Meta::NameValue(meta) = &attribute.meta { + if let syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + }) = &meta.value + { + lines.push(text.value()); + } + } + } + if !lines.is_empty() { + lines.push(String::new()); + } + let name = &function.sig.ident; + lines.push(format!( + " Call macro of `{name}`: positional arguments first, then `name = value` in any order." + )); + lines.push(String::new()); + lines.push(" Parameters, in declaration order:".to_string()); + lines.push(String::new()); + for param in params { + let ident = ¶m.ident; + let ty = type_text(¶m.ty); + let fate = match ¶m.default { + Some(default) => format!("default `{}`", default_text(default)), + None => "required".to_string(), + }; + lines.push(format!(" * `{ident}: {ty}`, {fate}")); + } + if params.is_empty() { + lines.push(" * none".to_string()); + } + lines +} + +/// Everything the call macro adds to the function: the exported macro and the re-export +/// under the function's name. The caller decides whether a function gets them at all: a +/// method has no name of its own to share with a macro. +pub(crate) fn generate(function: &ItemFn, params: &[CallParam]) -> TokenStream { + let name = &function.sig.ident; + let hidden = format_ident!("__compose_call_{}", name); + let entries = params.iter().map(|param| { + let ident = ¶m.ident; + match ¶m.default { + Some(default) => quote!((#ident [ #default ])), + None => quote!((#ident)), + } + }); + let rule = quote! { + ($($arguments:tt)*) => { + ::compose_rust::__compose_call_check! { #name [ #(#entries)* ] $($arguments)* } + }; + }; + + let docs = documentation(function, params); + let docs = docs.iter().map(|line| quote!(#[doc = #line])); + let cfgs: Vec<&Attribute> = function + .attrs + .iter() + .filter(|attribute| attribute.path().is_ident("cfg")) + .collect(); + let visibility = match &function.vis { + Visibility::Inherited => quote!(), + other => quote!(#other), + }; + quote! { + #(#cfgs)* + #(#docs)* + #[macro_export] + #[doc(hidden)] + macro_rules! #hidden { + #rule + } + #(#cfgs)* + #[allow(unused_imports)] + #visibility use #hidden as #name; + } +} + +/// The input of [`check`]: `Name [ (param) (param [default]) .. ] arguments..`. +struct CheckInput { + name: Ident, + params: Vec<(Ident, Option)>, + arguments: TokenStream, +} + +impl Parse for CheckInput { + fn parse(input: ParseStream) -> syn::Result { + let name: Ident = input.parse()?; + let list; + syn::bracketed!(list in input); + let mut params = Vec::new(); + while !list.is_empty() { + let entry; + syn::parenthesized!(entry in list); + let ident: Ident = entry.parse()?; + let default = if entry.is_empty() { + None + } else { + let tokens; + syn::bracketed!(tokens in entry); + Some(tokens.parse::()?) + }; + params.push((ident, default)); + } + Ok(CheckInput { + name, + params, + arguments: input.parse()?, + }) + } +} + +/// One argument as the caller wrote it. +enum Argument { + Positional(Expr), + Named(Ident, Expr), + Block(proc_macro2::Group), +} + +fn parse_arguments(input: ParseStream) -> syn::Result> { + let mut arguments = Vec::new(); + while !input.is_empty() { + if input.peek(syn::token::Brace) { + let group: proc_macro2::Group = input.parse()?; + arguments.push(Argument::Block(group)); + // A block ends the list, with an optional comma after it. + let _: Option = input.parse()?; + if !input.is_empty() { + return Err(input.error("nothing can follow the trailing block")); + } + break; + } + if input.peek(Ident) && input.peek2(Token![=]) && !input.peek2(Token![==]) { + let ident: Ident = input.parse()?; + let _: Token![=] = input.parse()?; + arguments.push(Argument::Named(ident, input.parse()?)); + } else { + arguments.push(Argument::Positional(input.parse()?)); + } + if input.is_empty() { + break; + } + let _: Token![,] = input.parse()?; + } + Ok(arguments) +} + +/// Reads the caller's arguments against the parameter list and emits the call, or the +/// error on the offending argument's own tokens. +pub(crate) fn check(input: TokenStream) -> syn::Result { + let CheckInput { + name, + params, + arguments, + } = syn::parse2(input)?; + let names_list = params + .iter() + .map(|(ident, _)| format!("`{ident}`")) + .collect::>() + .join(", "); + let count = params.len(); + let has_content = params.last().is_some_and(|(ident, _)| ident == "content"); + // Where a missing argument is reported: the arguments the caller did write, or the + // call itself when there are none. + let whole = arguments.clone(); + let arguments = syn::parse::Parser::parse2(parse_arguments, arguments)?; + + let mut slots: Vec> = vec![None; count]; + let mut positional = 0; + let mut named_seen = false; + for argument in arguments { + match argument { + Argument::Positional(expr) => { + if named_seen { + return Err(syn::Error::new_spanned( + &expr, + format!( + "a positional argument of {name}! follows a named one; name it too, the parameters are: {names_list}" + ), + )); + } + if positional >= count { + return Err(syn::Error::new_spanned( + &expr, + format!( + "{name}! takes {count} argument(s) and was given more; the parameters are: {names_list}" + ), + )); + } + slots[positional] = Some(expr.into_token_stream()); + positional += 1; + } + Argument::Named(ident, expr) => { + named_seen = true; + let Some(index) = params.iter().position(|(param, _)| *param == ident) else { + return Err(syn::Error::new_spanned( + &ident, + format!( + "{name}! has no parameter `{ident}`; the parameters are: {names_list}" + ), + )); + }; + if slots[index].is_some() { + return Err(syn::Error::new_spanned( + &ident, + format!("argument `{ident}` of {name}! is given twice"), + )); + } + slots[index] = Some(expr.into_token_stream()); + } + Argument::Block(group) => { + named_seen = true; + if !has_content { + return Err(syn::Error::new_spanned( + &group, + format!( + "{name}! has no `content` parameter, so it takes no trailing block; the parameters are: {names_list}" + ), + )); + } + let last = count - 1; + if slots[last].is_some() { + return Err(syn::Error::new_spanned( + &group, + format!("argument `content` of {name}! is given twice"), + )); + } + slots[last] = Some(quote!(|| #group)); + } + } + } + + let mut values = Vec::with_capacity(count); + for ((ident, default), slot) in params.iter().zip(slots) { + match (slot, default) { + (Some(tokens), _) => values.push(tokens), + (None, Some(default)) => values.push(default.clone()), + (None, None) => { + let span = if whole.is_empty() { + proc_macro2::Span::call_site() + } else { + whole + .clone() + .into_iter() + .last() + .map_or_else(proc_macro2::Span::call_site, |tree| tree.span()) + }; + return Err(syn::Error::new( + span, + format!("missing required argument `{ident}` for {name}!"), + )); + } + } + } + Ok(quote!(#name(#(#values),*))) +} + +#[cfg(test)] +mod tests { + use super::*; + use syn::{Item, parse_quote}; + + fn expand(function: ItemFn, params: Vec) -> Vec { + let tokens = generate(&function, ¶ms); + syn::parse2::(tokens) + .expect("the generated items parse") + .items + } + + fn docs_of(item: &Item) -> Vec { + let Item::Macro(item) = item else { + panic!("the first item is the macro") + }; + item.attrs + .iter() + .filter_map(|attribute| match &attribute.meta { + syn::Meta::NameValue(meta) if attribute.path().is_ident("doc") => { + match &meta.value { + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(text), + .. + }) => Some(text.value()), + _ => None, + } + } + _ => None, + }) + .collect() + } + + fn sample() -> (ItemFn, Vec) { + let function: ItemFn = parse_quote! { + /// Draws a field. + pub fn Field(value: String, label: impl Fn(), single_line: bool) {} + }; + let params = vec![ + CallParam { + ident: parse_quote!(value), + ty: parse_quote!(String), + default: None, + }, + CallParam { + ident: parse_quote!(label), + ty: parse_quote!(impl Fn()), + default: Some(quote!(|| {})), + }, + CallParam { + ident: parse_quote!(single_line), + ty: parse_quote!(bool), + default: Some(quote!(false)), + }, + ]; + (function, params) + } + + #[test] + fn fr39_call_macro_doc_lists_every_parameter_in_declaration_order() { + let (function, params) = sample(); + let items = expand(function, params); + let docs = docs_of(&items[0]).join("\n"); + let value = docs.find("`value: String`, required").expect("value"); + let label = docs + .find("`label: impl Fn()`, default `|| {}`") + .expect("label"); + let line = docs + .find("`single_line: bool`, default `false`") + .expect("single_line"); + assert!(value < label && label < line, "{docs}"); + } + + #[test] + fn fr39_call_macro_doc_starts_with_the_function_docs() { + let (function, params) = sample(); + let items = expand(function, params); + assert_eq!(docs_of(&items[0])[0], " Draws a field."); + } + + #[test] + fn fr39_call_macro_doc_shows_the_implicit_modifier_default() { + let function: ItemFn = parse_quote!( + pub fn Panel(modifier: Modifier) {} + ); + let ident: Ident = parse_quote!(modifier); + let ty: Type = parse_quote!(Modifier); + let params = vec![CallParam { + default: implicit_default(&ident, &ty), + ident, + ty, + }]; + let docs = docs_of(&expand(function, params)[0]).join("\n"); + assert!( + docs.contains("default `::compose_rust::ui::Modifier::new()`"), + "{docs}" + ); + } + + #[test] + fn fr39_call_macro_is_hidden_exported_and_reexported_at_the_function_visibility() { + let (function, params) = sample(); + let items = expand(function, params); + let Item::Macro(item) = &items[0] else { + panic!() + }; + assert_eq!(item.mac.path.segments.last().unwrap().ident, "macro_rules"); + assert_eq!(item.ident.as_ref().unwrap(), "__compose_call_Field"); + let text = quote!(#(#items)*).to_string(); + assert!(text.contains("macro_export")); + assert!(text.contains("doc (hidden)")); + let Item::Use(reexport) = &items[1] else { + panic!() + }; + assert!(matches!(reexport.vis, Visibility::Public(_))); + } + + #[test] + fn fr39_call_macro_default_paths_keep_meaning_the_defining_crate() { + let normalised = normalise_default(quote!(crate::theme::DEFAULT)).to_string(); + assert_eq!(normalised, "$ crate :: theme :: DEFAULT"); + } + + fn run(arguments: TokenStream) -> Result { + let input = quote!(Field [ (value) (label [ || {} ]) (single_line [ false ]) ] #arguments); + check(input) + .map(|tokens| tokens.to_string()) + .map_err(|error| error.to_string()) + } + + #[test] + fn fr39_call_macro_check_emits_the_call_in_parameter_order_with_defaults() { + let text = run(quote!(single_line = true, value = a)) + .unwrap() + .replace(' ', ""); + assert_eq!(text, "Field(a,||{},true)"); + } + + #[test] + fn fr39_call_macro_check_names_the_offending_argument() { + assert!( + run(quote!(a, nope = 1)) + .unwrap_err() + .contains("no parameter `nope`") + ); + assert!( + run(quote!(a, value = 1)) + .unwrap_err() + .contains("given twice") + ); + assert!( + run(quote!(single_line = true, a)) + .unwrap_err() + .contains("follows a named one") + ); + assert!( + run(quote!(a, b, c, d)) + .unwrap_err() + .contains("was given more") + ); + assert!( + run(quote!(single_line = true)) + .unwrap_err() + .contains("missing required argument `value`") + ); + } +} diff --git a/compose-rust/macros/src/lib.rs b/compose-rust/macros/src/lib.rs index d03b8f2b8..168ec5b18 100644 --- a/compose-rust/macros/src/lib.rs +++ b/compose-rust/macros/src/lib.rs @@ -1,4 +1,4 @@ -//! `#[composable]`: what the Compose compiler plugin does to a function, done from outside +//! `#[Composable]`: what the Compose compiler plugin does to a function, done from outside //! the compiler. //! //! The plugin does three things to a `@Composable` function, and this does the same three @@ -39,6 +39,9 @@ //! `Clone + PartialEq` is found out at compile time by method resolution and treated the //! same way. Nothing here needs the function's author to say which is which. +mod call_macro; + +use call_macro::{CallParam, implicit_default, normalise_default}; use proc_macro::TokenStream; use proc_macro2::Span; use quote::{format_ident, quote}; @@ -52,7 +55,7 @@ use syn::{ /// Turns a function into a composable. /// /// ```ignore -/// #[composable] +/// #[Composable] /// fn Greeting(name: String) { /// let clicks = remember(|| mutable_state_of(0)); /// Text(format!("Hello, {name}: {}", clicks.get())); @@ -60,23 +63,131 @@ use syn::{ /// } /// ``` /// -/// `#[composable(nonrestartable)]` keeps the groups and the skipping but never re-runs the +/// `#[Composable(nonrestartable)]` keeps the groups and the skipping but never re-runs the /// function on its own: a state it reads re-runs its caller instead. That is what an /// associated function needs, because the runtime re-runs a function by calling it by its /// bare name. #[proc_macro_attribute] -pub fn composable(attribute: TokenStream, item: TokenStream) -> TokenStream { +#[allow(non_snake_case)] +pub fn Composable(attribute: TokenStream, item: TokenStream) -> TokenStream { let options = match parse_options(attribute.into()) { Ok(options) => options, Err(error) => return error.to_compile_error().into(), }; let mut function = parse_macro_input!(item as ItemFn); + let defaults = match take_defaults(&mut function) { + Ok(defaults) => defaults, + Err(error) => return error.to_compile_error().into(), + }; + // A function that re-runs itself by bare name is an associated function, and a method + // has no name of its own to share with a macro, so only the restartable form gets one. + let call_params = options + .restartable + .then(|| call_params(&function, defaults)) + .flatten(); match expand(&mut function, &options) { - Ok(()) => quote!(#function).into(), + Ok(()) => { + let call_macro = call_params.map(|params| call_macro::generate(&function, ¶ms)); + quote!(#function #call_macro).into() + } + Err(error) => error.to_compile_error().into(), + } +} + +/// The expansion of every generated call macro. Hidden: it is the generated +/// `macro_rules!`'s way of reading its arguments with the caller's spans, not an API. +#[doc(hidden)] +#[proc_macro] +pub fn __compose_call_check(input: TokenStream) -> TokenStream { + match call_macro::check(input.into()) { + Ok(tokens) => tokens.into(), Err(error) => error.to_compile_error().into(), } } +/// Removes the `#[default(expr)]` attributes from the parameters and returns the +/// expressions, one entry per parameter. +fn take_defaults(function: &mut ItemFn) -> syn::Result>> { + let mut defaults = Vec::new(); + for input in &mut function.sig.inputs { + let attrs = match input { + FnArg::Receiver(receiver) => &mut receiver.attrs, + FnArg::Typed(typed) => &mut typed.attrs, + }; + let mut default = None; + let mut kept = Vec::new(); + for attribute in attrs.drain(..) { + if !attribute.path().is_ident("default") { + kept.push(attribute); + continue; + } + if default.is_some() { + return Err(syn::Error::new_spanned( + attribute, + "a parameter takes one #[default(..)]", + )); + } + default = Some(attribute.parse_args::().map_err(|error| { + syn::Error::new( + error.span(), + "#[default(..)] takes the default value as an expression, \ + like #[default(false)]", + ) + })?); + } + *attrs = kept; + if default.is_some() && matches!(input, FnArg::Receiver(_)) { + return Err(syn::Error::new_spanned(input, "self has no default")); + } + defaults.push(default); + } + Ok(defaults) +} + +/// The parameters as the call macro needs them, or `None` for a function the macro cannot +/// name: one with a receiver, a pattern instead of a name, or `Self` in a type. +fn call_params(function: &ItemFn, defaults: Vec>) -> Option> { + let mut params = Vec::new(); + for (input, default) in function.sig.inputs.iter().zip(defaults) { + let FnArg::Typed(typed) = input else { + return None; + }; + let Pat::Ident(pattern) = &*typed.pat else { + return None; + }; + if pattern.by_ref.is_some() || pattern.subpat.is_some() || mentions_self(&typed.ty) { + return None; + } + let ident = pattern.ident.clone(); + let ty = (*typed.ty).clone(); + let default = match default { + Some(expr) => Some(normalise_default(quote!(#expr))), + None => implicit_default(&ident, &ty), + }; + params.push(CallParam { ident, ty, default }); + } + Some(params) +} + +fn mentions_self(ty: &Type) -> bool { + struct Find(bool); + impl<'ast> Visit<'ast> for Find { + fn visit_path(&mut self, path: &'ast syn::Path) { + if path + .segments + .first() + .is_some_and(|first| first.ident == "Self") + { + self.0 = true; + } + visit::visit_path(self, path); + } + } + let mut find = Find(false); + find.visit_type(ty); + find.0 +} + struct Options { restartable: bool, } @@ -93,7 +204,7 @@ fn parse_options(attribute: proc_macro2::TokenStream) -> syn::Result { } else { Err(syn::Error::new( ident.span(), - "#[composable] takes no argument except `nonrestartable`", + "#[Composable] takes no argument except `nonrestartable`", )) } } diff --git a/compose-rust/macros/tests/ui.rs b/compose-rust/macros/tests/ui.rs new file mode 100644 index 000000000..f0100f7dc --- /dev/null +++ b/compose-rust/macros/tests/ui.rs @@ -0,0 +1,10 @@ +//! What the generated call macro accepts and what it refuses, compiled the way an +//! application compiles it. The `.stderr` files next to the fixtures are the messages a +//! caller reads, so a change to one is a change to what the macro tells its users. + +#[test] +fn fr39_call_macro_compile_fail_points_at_the_offending_argument() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_*.rs"); + cases.compile_fail("tests/ui/fail_*.rs"); +} diff --git a/compose-rust/macros/tests/ui/fail_block_without_content.rs b/compose-rust/macros/tests/ui/fail_block_without_content.rs new file mode 100644 index 000000000..7293c5706 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_block_without_content.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String) { + let _ = value; +} + +fn main() { + Field!(String::new(), { let _ = 1; }); +} diff --git a/compose-rust/macros/tests/ui/fail_block_without_content.stderr b/compose-rust/macros/tests/ui/fail_block_without_content.stderr new file mode 100644 index 000000000..3d20ce8c5 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_block_without_content.stderr @@ -0,0 +1,5 @@ +error: Field! has no `content` parameter, so it takes no trailing block; the parameters are: `value` + --> tests/ui/fail_block_without_content.rs:9:27 + | +9 | Field!(String::new(), { let _ = 1; }); + | ^^^^^^^^^^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_default_type_mismatch.rs b/compose-rust/macros/tests/ui/fail_default_type_mismatch.rs new file mode 100644 index 000000000..458cbbf8d --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_default_type_mismatch.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(#[default("not a bool")] single_line: bool) { + let _ = single_line; +} + +fn main() { + Field!(); +} diff --git a/compose-rust/macros/tests/ui/fail_default_type_mismatch.stderr b/compose-rust/macros/tests/ui/fail_default_type_mismatch.stderr new file mode 100644 index 000000000..99218d999 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_default_type_mismatch.stderr @@ -0,0 +1,17 @@ +error[E0308]: mismatched types + --> tests/ui/fail_default_type_mismatch.rs:4:20 + | +4 | fn Field(#[default("not a bool")] single_line: bool) { + | ----- ^^^^^^^^^^^^ expected `bool`, found `&str` + | | + | arguments to this function are incorrect +... +9 | Field!(); + | -------- in this macro invocation + | +note: function defined here + --> tests/ui/fail_default_type_mismatch.rs:4:4 + | +4 | fn Field(#[default("not a bool")] single_line: bool) { + | ^^^^^ ----------------- + = note: this error originates in the macro `Field` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/compose-rust/macros/tests/ui/fail_duplicate.rs b/compose-rust/macros/tests/ui/fail_duplicate.rs new file mode 100644 index 000000000..b713b293e --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(String::new(), single_line = true, single_line = false); +} diff --git a/compose-rust/macros/tests/ui/fail_duplicate.stderr b/compose-rust/macros/tests/ui/fail_duplicate.stderr new file mode 100644 index 000000000..99fe306a0 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate.stderr @@ -0,0 +1,5 @@ +error: argument `single_line` of Field! is given twice + --> tests/ui/fail_duplicate.rs:9:47 + | +9 | Field!(String::new(), single_line = true, single_line = false); + | ^^^^^^^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_duplicate_of_positional.rs b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.rs new file mode 100644 index 000000000..2b0b090d6 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(String::new(), value = String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr new file mode 100644 index 000000000..5321f2e5d --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_duplicate_of_positional.stderr @@ -0,0 +1,5 @@ +error: argument `value` of Field! is given twice + --> tests/ui/fail_duplicate_of_positional.rs:9:27 + | +9 | Field!(String::new(), value = String::new()); + | ^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_method_has_no_macro.rs b/compose-rust/macros/tests/ui/fail_method_has_no_macro.rs new file mode 100644 index 000000000..d8b009e78 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_method_has_no_macro.rs @@ -0,0 +1,15 @@ +use compose_rust::Composable; + +struct Screen; + +impl Screen { + #[Composable(nonrestartable)] + fn show(label: String) { + let _ = label; + } +} + +fn main() { + Screen::show(String::new()); + show!(String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_method_has_no_macro.stderr b/compose-rust/macros/tests/ui/fail_method_has_no_macro.stderr new file mode 100644 index 000000000..7fb6ddb4d --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_method_has_no_macro.stderr @@ -0,0 +1,5 @@ +error: cannot find macro `show` in this scope + --> tests/ui/fail_method_has_no_macro.rs:14:5 + | +14 | show!(String::new()); + | ^^^^ diff --git a/compose-rust/macros/tests/ui/fail_missing_required.rs b/compose-rust/macros/tests/ui/fail_missing_required.rs new file mode 100644 index 000000000..801b0f91d --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_missing_required.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(single_line = true); +} diff --git a/compose-rust/macros/tests/ui/fail_missing_required.stderr b/compose-rust/macros/tests/ui/fail_missing_required.stderr new file mode 100644 index 000000000..eff90dc4b --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_missing_required.stderr @@ -0,0 +1,5 @@ +error: missing required argument `value` for Field! + --> tests/ui/fail_missing_required.rs:9:26 + | +9 | Field!(single_line = true); + | ^^^^ diff --git a/compose-rust/macros/tests/ui/fail_positional_after_named.rs b/compose-rust/macros/tests/ui/fail_positional_after_named.rs new file mode 100644 index 000000000..2fb9d1837 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_positional_after_named.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(single_line = true, String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_positional_after_named.stderr b/compose-rust/macros/tests/ui/fail_positional_after_named.stderr new file mode 100644 index 000000000..604070fad --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_positional_after_named.stderr @@ -0,0 +1,5 @@ +error: a positional argument of Field! follows a named one; name it too, the parameters are: `value`, `single_line` + --> tests/ui/fail_positional_after_named.rs:9:32 + | +9 | Field!(single_line = true, String::new()); + | ^^^^^^^^^^^^^ diff --git a/compose-rust/macros/tests/ui/fail_renamed_import.rs b/compose-rust/macros/tests/ui/fail_renamed_import.rs new file mode 100644 index 000000000..303bc38c0 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_renamed_import.rs @@ -0,0 +1,19 @@ +use compose_rust::Composable; + +mod ui { + use compose_rust::Composable; + + #[Composable] + pub fn Field(value: String) { + let _ = value; + } +} + +use ui::Field as Input; + +fn main() { + // The function form works under the new name. The macro form finds the function by its + // own name, which this scope does not have. + Input(String::new()); + Input!(String::new()); +} diff --git a/compose-rust/macros/tests/ui/fail_renamed_import.stderr b/compose-rust/macros/tests/ui/fail_renamed_import.stderr new file mode 100644 index 000000000..46676f88e --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_renamed_import.stderr @@ -0,0 +1,22 @@ +error[E0425]: cannot find function, tuple struct or tuple variant `Field` in this scope + --> tests/ui/fail_renamed_import.rs:7:12 + | + 7 | pub fn Field(value: String) { + | ^^^^^ not found in this scope +... +18 | Input!(String::new()); + | --------------------- in this macro invocation + | + = note: this error originates in the macro `Input` (in Nightly builds, run with -Z macro-backtrace for more info) +help: consider importing this function + | + 1 + use crate::ui::Field; + | + +warning: unused import: `compose_rust::Composable` + --> tests/ui/fail_renamed_import.rs:1:5 + | +1 | use compose_rust::Composable; + | ^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/compose-rust/macros/tests/ui/fail_too_many_positional.rs b/compose-rust/macros/tests/ui/fail_too_many_positional.rs new file mode 100644 index 000000000..0e2e4b438 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_too_many_positional.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String) { + let _ = value; +} + +fn main() { + Field!(String::new(), 1); +} diff --git a/compose-rust/macros/tests/ui/fail_too_many_positional.stderr b/compose-rust/macros/tests/ui/fail_too_many_positional.stderr new file mode 100644 index 000000000..8f918e756 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_too_many_positional.stderr @@ -0,0 +1,5 @@ +error: Field! takes 1 argument(s) and was given more; the parameters are: `value` + --> tests/ui/fail_too_many_positional.rs:9:27 + | +9 | Field!(String::new(), 1); + | ^ diff --git a/compose-rust/macros/tests/ui/fail_turbofish.rs b/compose-rust/macros/tests/ui/fail_turbofish.rs new file mode 100644 index 000000000..7984ae407 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_turbofish.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn List(items: Vec) { + let _ = items; +} + +fn main() { + List!::(vec![1]); +} diff --git a/compose-rust/macros/tests/ui/fail_turbofish.stderr b/compose-rust/macros/tests/ui/fail_turbofish.stderr new file mode 100644 index 000000000..b714e2ef2 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_turbofish.stderr @@ -0,0 +1,5 @@ +error: expected one of `(`, `[`, or `{`, found `::` + --> tests/ui/fail_turbofish.rs:9:10 + | +9 | List!::(vec![1]); + | ^^ expected one of `(`, `[`, or `{` diff --git a/compose-rust/macros/tests/ui/fail_unknown_name.rs b/compose-rust/macros/tests/ui/fail_unknown_name.rs new file mode 100644 index 000000000..cd5446eb3 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_unknown_name.rs @@ -0,0 +1,10 @@ +use compose_rust::Composable; + +#[Composable] +fn Field(value: String, #[default(false)] single_line: bool) { + let _ = (value, single_line); +} + +fn main() { + Field!(String::new(), singel_line = true); +} diff --git a/compose-rust/macros/tests/ui/fail_unknown_name.stderr b/compose-rust/macros/tests/ui/fail_unknown_name.stderr new file mode 100644 index 000000000..f743ad3c0 --- /dev/null +++ b/compose-rust/macros/tests/ui/fail_unknown_name.stderr @@ -0,0 +1,5 @@ +error: Field! has no parameter `singel_line`; the parameters are: `value`, `single_line` + --> tests/ui/fail_unknown_name.rs:9:27 + | +9 | Field!(String::new(), singel_line = true); + | ^^^^^^^^^^^ diff --git a/compose-rust/macros/tests/ui/pass_forms.rs b/compose-rust/macros/tests/ui/pass_forms.rs new file mode 100644 index 000000000..423910817 --- /dev/null +++ b/compose-rust/macros/tests/ui/pass_forms.rs @@ -0,0 +1,43 @@ +use compose_rust::Composable; +use compose_rust::ui::Modifier; + +#[Composable] +pub fn Field( + value: String, + on_change: impl Fn(String) + 'static, + modifier: Modifier, + #[default(|| {})] label: impl Fn() + 'static, + #[default(false)] single_line: bool, + #[default(|| {})] content: impl Fn() + 'static, +) { + let _ = (value, on_change, modifier, label, single_line, content); +} + +#[Composable] +fn Required(first: i32, second: i32) { + let _ = (first, second); +} + +#[Composable] +fn Generic(items: Vec, #[default(Vec::new())] more: Vec) { + let _ = (items, more); +} + +fn main() { + // Function form: every argument, no defaults. + Field(String::new(), |_| {}, Modifier::new(), || {}, true, || {}); + // Positional prefix, then names in any order; closures infer from the bounds. + Field!(String::new(), |text| drop(text), single_line = true, label = || {}); + Field!(String::new(), on_change = |text| drop(text)); + // A trailing block is the content closure. + Field!(String::new(), |text| drop(text), { let _ = 1; }); + Field!(String::new(), |text| drop(text), single_line = true, { let _ = 1; }); + Field!(content = || {}, on_change = |text| drop(text), value = String::new()); + // Parameters can be given by name alone, in any order. + Required!(1, second = 2); + Required!(second = 2, first = 1); + // Generics are inferred, in both forms. + Generic(vec![1, 2], Vec::new()); + Generic!(vec![1, 2]); + Generic!(vec!["a"], more = vec!["b"]); +} diff --git a/compose-rust/schema-hash.txt b/compose-rust/schema-hash.txt index 0c88702d2..03b6c45d6 100644 --- a/compose-rust/schema-hash.txt +++ b/compose-rust/schema-hash.txt @@ -1 +1 @@ -0x07b2695bace58374 +0x20046553d93457ac diff --git a/compose-rust/src/bin/codegen.rs b/compose-rust/src/bin/codegen.rs index 8f3b535d7..11706b017 100644 --- a/compose-rust/src/bin/codegen.rs +++ b/compose-rust/src/bin/codegen.rs @@ -1,12 +1,13 @@ use compose_rust::codegen::{ ANDROID_BRIDGE_RELATIVE_PATH, ANDROID_FAST_NATIVE_RELATIVE_PATH, EVENT_VECTOR_RELATIVE_PATH, GENERATED_RELATIVE_PATH, JNI_RUST_RELATIVE_PATH, LINUX_HOST_DEF_RELATIVE_PATH, - MUTATION_VECTOR_RELATIVE_PATH, SCHEMA_HASH_RELATIVE_PATH, VECTOR_DESCRIPTION_RELATIVE_PATH, - WASM_RUST_RELATIVE_PATH, WEB_BRIDGE_RELATIVE_PATH, WEB_LOADER_RELATIVE_PATH, - generate_android_bridge_kotlin, generate_event_vector, generate_fast_native_kotlin, - generate_jni_rust, generate_kotlin, generate_linux_host_def, generate_mutation_vector, - generate_schema_hash, generate_vector_description, generate_wasm_rust, - generate_web_bridge_kotlin, generate_web_loader_js, + MUTATION_VECTOR_RELATIVE_PATH, SCHEMA_HASH_RELATIVE_PATH, TRANSFORM_3D_VECTOR_RELATIVE_PATH, + VECTOR_DESCRIPTION_RELATIVE_PATH, WASM_RUST_RELATIVE_PATH, WEB_BRIDGE_RELATIVE_PATH, + WEB_LOADER_RELATIVE_PATH, generate_android_bridge_kotlin, generate_event_vector, + generate_fast_native_kotlin, generate_jni_rust, generate_kotlin, generate_linux_host_def, + generate_mutation_vector, generate_schema_hash, generate_transform_3d_vector, + generate_vector_description, generate_wasm_rust, generate_web_bridge_kotlin, + generate_web_loader_js, }; use std::path::{Path, PathBuf}; @@ -34,6 +35,10 @@ fn run() -> Result<(), Box> { manifest_dir.join(MUTATION_VECTOR_RELATIVE_PATH), &generate_mutation_vector()?, )?; + write( + manifest_dir.join(TRANSFORM_3D_VECTOR_RELATIVE_PATH), + &generate_transform_3d_vector()?, + )?; write( manifest_dir.join(EVENT_VECTOR_RELATIVE_PATH), &generate_event_vector()?, diff --git a/compose-rust/src/boundary.rs b/compose-rust/src/boundary.rs index d5fc87728..fc8732c4b 100644 --- a/compose-rust/src/boundary.rs +++ b/compose-rust/src/boundary.rs @@ -564,6 +564,30 @@ impl Host { self.render_and_finish(0) } + /// Hands the end of a file dialog to the callback that asked for it. + /// + /// No node and no handler: a dialog is not in the tree, and the request number names the + /// callback. An answer nobody is waiting for is dropped. + fn file_dialog_result( + &mut self, + request: u32, + status: crate::schema::FileDialogStatus, + filter: u32, + paths: &str, + ) -> Result<(&[u8], i64), ProtocolError> { + let mut delivered = false; + { + let _dispatch_guard = EventDispatchGuard::enter(); + self.runtime.run_in_context(&mut || { + delivered = crate::file_dialog::deliver(request, status, filter, paths) + }); + } + if !delivered { + return self.empty_batch(); + } + self.render_and_finish(0) + } + /// Tells the components that listen that the application was opened with no window. fn application_reopen(&mut self) -> Result<(&[u8], i64), ProtocolError> { let mut delivered = false; @@ -678,6 +702,18 @@ impl Host { } return self.application_reopen(); } + // The end of a file dialog: the Host's own business, found by request number. + EventPayload::FileDialogResult { + request, + status, + filter, + paths, + } => { + if event.node_id != 0 || event.handler_id != 0 { + return Err(ProtocolError::InvalidValueKind(0)); + } + return self.file_dialog_result(request, status, filter, paths); + } _ => {} } // The window's size belongs to no node and no handler: the Renderer measures the @@ -846,6 +882,9 @@ impl Host { ); }); self.flush_notifications(); + // File dialogs an application asked for, from a handler, a component or a worker. + let batch = self.runtime.batch_mut(); + crate::file_dialog::drain(|request| batch.file_dialog(request)); // Animations an application started from a handler, a component or a worker. let batch = self.runtime.batch_mut(); crate::animation::drain(|mutation| batch.write(mutation)); diff --git a/compose-rust/src/codegen.rs b/compose-rust/src/codegen.rs index 9f5fb1571..da7e6dc6e 100644 --- a/compose-rust/src/codegen.rs +++ b/compose-rust/src/codegen.rs @@ -36,6 +36,7 @@ pub const GENERATED_RELATIVE_PATH: &str = "../renderer/desktop/src/protocol/Prot pub const SCHEMA_HASH_RELATIVE_PATH: &str = "schema-hash.txt"; pub const MUTATION_VECTOR_RELATIVE_PATH: &str = "tests/vectors/mutations.bin"; pub const EVENT_VECTOR_RELATIVE_PATH: &str = "tests/vectors/events.bin"; +pub const TRANSFORM_3D_VECTOR_RELATIVE_PATH: &str = "tests/vectors/transform3d.bin"; pub const VECTOR_DESCRIPTION_RELATIVE_PATH: &str = "tests/vectors/vectors.json"; const KOTLIN_PACKAGE: &str = "dev.darkpyonix.composerust.protocol"; @@ -147,6 +148,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -201,6 +216,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) "#, @@ -462,6 +482,29 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation + + /** + * Asks the platform for a file dialog on the window [window] (0 is the implicit root + * window). Answered exactly once, later, by a `FileDialogResult` event carrying + * [request]. [filters] is one string: a label and its comma-joined extensions as a pair, + * pairs joined by NUL. + */ + data class ShowFileDialog( + val request: Int, + val window: Int, + val kind: FileDialogKind, + val defaultFilter: Int, + val title: String, + val directory: String, + val name: String, + val filters: String, + ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } "#, @@ -484,7 +527,7 @@ sealed interface Mutation { output.push_str(", val code: Int, val message: String"); } EventPayloadType::KeyDown => { - output.push_str(", val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean"); + output.push_str(", val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String"); } EventPayloadType::Range => output.push_str(", val start: Int, val count: Int"), EventPayloadType::Double => output.push_str(", val value: Double"), @@ -543,6 +586,11 @@ sealed interface Mutation { ", val kind: Int, val window: Int, val x: kotlin.Float, val y: kotlin.Float", ), EventPayloadType::WindowRequest => output.push_str(", val request: Int, val key: String"), + // The filter is the position of the filter in use, or -1 (the wire's 0xFFFFFFFF) + // when there is none. + EventPayloadType::FileDialogResult => output.push_str( + ", val request: Int, val status: FileDialogStatus, val filter: Int, val paths: String", + ), } output.push_str(") : HostEvent\n"); } @@ -593,6 +641,9 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 + private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -742,16 +793,51 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } + TAG_SHOW_FILE_DIALOG -> { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -778,6 +864,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -979,7 +1066,7 @@ object Protocol { ); for event in EVENT_SCHEMA { match event.payload { - EventPayloadType::Text => { + EventPayloadType::Text | EventPayloadType::KeyDown => { writeln!( output, " is HostEvent.{} -> event.text.toByteArray(StandardCharsets.UTF_8)", @@ -1003,6 +1090,14 @@ object Protocol { ) .unwrap(); } + EventPayloadType::FileDialogResult => { + writeln!( + output, + " is HostEvent.{} -> event.paths.toByteArray(StandardCharsets.UTF_8)", + event.name + ) + .unwrap(); + } EventPayloadType::None => { writeln!( output, @@ -1019,8 +1114,7 @@ object Protocol { ) .unwrap(); } - EventPayloadType::KeyDown - | EventPayloadType::Range + EventPayloadType::Range | EventPayloadType::Double | EventPayloadType::WindowSize | EventPayloadType::DesignSystem @@ -1061,7 +1155,7 @@ object Protocol { EventPayloadType::None => 16, EventPayloadType::Text => 24, EventPayloadType::ProtocolError => 28, - EventPayloadType::KeyDown => 20, + EventPayloadType::KeyDown => 32, EventPayloadType::Range => 24, EventPayloadType::Double => 24, EventPayloadType::WindowSize => 44, @@ -1094,6 +1188,8 @@ object Protocol { | EventPayloadType::WindowDragOver => 32, // A word and a string reference, the protocol error's shape. EventPayloadType::WindowRequest => 28, + // The request, the status and the filter, then the paths' reference. + EventPayloadType::FileDialogResult => 36, }; writeln!( output, @@ -1171,7 +1267,13 @@ object Protocol { " if (event.metaKey) modifiers = modifiers or 0x08\n", ); output.push_str(" out.put(modifiers.toByte())\n"); - output.push_str(" out.put(0.toByte())\n"); + output.push_str( + " out.put((if (event.repeat) 1 else 0).toByte())\n", + ); + output.push_str(" out.putInt(event.code)\n"); + output.push_str( + " writeStringReference(out, recordLength, text!!)\n", + ); output.push_str(" }\n"); } EventPayloadType::Range => { @@ -1376,6 +1478,18 @@ object Protocol { output.push_str(" out.putFloat(event.y)\n"); output.push_str(" }\n"); } + EventPayloadType::FileDialogResult => { + writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap(); + output.push_str(" out.putInt(event.request)\n"); + output.push_str( + " out.putInt(fileDialogStatusTag(event.status))\n", + ); + output.push_str(" out.putInt(event.filter)\n"); + output.push_str( + " writeStringReference(out, recordLength, text!!)\n", + ); + output.push_str(" }\n"); + } EventPayloadType::WindowRequest => { writeln!(output, " is HostEvent.{} -> {{", event.name).unwrap(); output.push_str(" out.putInt(event.request)\n"); @@ -1538,6 +1652,19 @@ object Protocol { .unwrap(); } output.push_str(" }\n\n"); + // The dialog's status travels out too: the Renderer is the side that learns how it ended. + output.push_str( + " private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) {\n", + ); + for variant in crate::schema::FILE_DIALOG_STATUS_SCHEMA { + writeln!( + output, + " FileDialogStatus.{} -> {}", + variant.name, variant.tag + ) + .unwrap(); + } + output.push_str(" }\n\n"); // The enums the Renderer writes into animation events and the motion setting. for (function, name, variants) in [ ( @@ -2870,7 +2997,8 @@ pub fn generate_mutation_vector() -> Result, ProtocolError> { .with_size(800, 600) .with_min_size(320, 240) .resizable(false) - .with_zoom_level(-2), + .with_zoom_level(-2) + .with_report_size(true), ), Mutation::Create { node_id: 20, @@ -2986,6 +3114,19 @@ pub fn generate_mutation_vector() -> Result, ProtocolError> { index: 8, modifier: Modifier::MatchParentSize, }, + Mutation::RequestFocus { node_id: 1 }, + // A dialog on the implicit root window, with every string filled and a list of two + // filters in the one NUL separated string. + Mutation::ShowFileDialog { + request: 7, + window: 0, + kind: crate::schema::FileDialogKind::OpenFiles, + default_filter: 1, + title: "파일 고르기", + directory: "/home/한글", + name: "보고서.txt", + filters: "이미지\0png,jpg\0문서\0pdf,txt", + }, ]; let mut encoder = BatchEncoder::default(); for mutation in &mutations { @@ -2994,6 +3135,29 @@ pub fn generate_mutation_vector() -> Result, ProtocolError> { Ok(encoder.finish()?.to_vec()) } +/// The matrix the 3D transform vector carries: sixteen different values, so a value read +/// from the wrong half of the wrong word cannot pass. +pub const TRANSFORM_3D_VECTOR_MATRIX: [f32; 16] = [ + 0.5, -0.25, 1.5, 0.125, -2.0, 3.0, 0.75, -0.5, 4.0, -1.25, 2.5, -0.0625, 12.0, -6.0, 8.0, 1.0, +]; + +/// One batch holding one `Transform3D` on node 3, the only record 92 bytes long. It is a +/// file of its own so that every byte of the main mutation vector stays where it was. +pub fn generate_transform_3d_vector() -> Result, ProtocolError> { + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetModifier { + node_id: 3, + index: 0, + modifier: Modifier::Transform3D { + m: TRANSFORM_3D_VECTOR_MATRIX, + origin_x: 0.5, + origin_y: 0.25, + backface_hidden: true, + }, + })?; + Ok(encoder.finish()?.to_vec()) +} + pub fn generate_event_vector() -> Result, ProtocolError> { let events = [ HostEvent { @@ -3033,6 +3197,9 @@ pub fn generate_event_vector() -> Result, ProtocolError> { ctrl_key: true, alt_key: true, meta_key: true, + repeat: true, + code: 0x28, + text: "Enter", }, }, HostEvent { @@ -3258,6 +3425,16 @@ pub fn generate_event_vector() -> Result, ProtocolError> { handler_id: 0, payload: EventPayload::ApplicationReopen, }, + HostEvent { + node_id: 0, + handler_id: 0, + payload: EventPayload::FileDialogResult { + request: 7, + status: crate::schema::FileDialogStatus::Chosen, + filter: 1, + paths: "/Users/한글/문서.txt\0/tmp/a b.png", + }, + }, ]; let mut output = Vec::new(); let mut encoded = Vec::new(); @@ -3277,53 +3454,64 @@ pub fn generate_vector_description() -> String { "mutations": {{ "file": "mutations.bin", "description": "One batch covering every record, property value, modifier layout, drawing command, asset, message, notification command, palette entry, text run, split pane property, the HTML and CSS drawing elements, the animations the Renderer plays and code editor record", - "recordCount": 103, + "recordCount": 104, "palette": [ {{ "role": "Primary", "scheme": "Light", "argb": "ffe8590c" }}, {{ "role": "Primary", "scheme": "Dark", "argb": "ffff8a4c" }}, {{ "role": "SyntaxKeyword", "scheme": "Light", "argb": "80112233" }} ], "spanRecordLength": 36, - "strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7", "fn main() {{\n let 한 = \"😀\";\n}}\n", "mut ", "문서", "보조 창", "ws/1:2", "tab/3"], + "strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7", "fn main() {{\n let 한 = \"😀\";\n}}\n", "mut ", "문서", "보조 창", "ws/1:2", "tab/3", "파일 고르기", "/home/한글", "보고서.txt", "이미지\u0000png,jpg\u0000문서\u0000pdf,txt"], "assets": [{{ "assetId": 5, "kind": "Png", "bytes": "89504e47" }}] }}, + "transform3d": {{ + "file": "transform3d.bin", + "description": "One batch with one 92 byte Transform3D modifier on node 3: the column-major matrix, the origin (0.5, 0.25) and backface-visibility hidden", + "recordCount": 1, + "length": 104, + "matrix": [0.5, -0.25, 1.5, 0.125, -2.0, 3.0, 0.75, -0.5, 4.0, -1.25, 2.5, -0.0625, 12.0, -6.0, 8.0, 1.0], + "originX": 0.5, + "originY": 0.25, + "backfaceHidden": true + }}, "events": {{ "file": "events.bin", - "description": "Thirty-three independently decodable event records concatenated in schema order", + "description": "Thirty-four independently decodable event records concatenated in schema order", "records": [ {{ "type": "Clicked", "offset": 0, "length": 16, "nodeId": 7, "handlerId": 11 }}, {{ "type": "TextChanged", "offset": 16, "length": 30, "nodeId": 8, "handlerId": 12, "text": "한글" }}, {{ "type": "TextSubmitted", "offset": 46, "length": 28, "nodeId": 8, "handlerId": 13, "text": "done" }}, {{ "type": "FocusLost", "offset": 74, "length": 16, "nodeId": 8, "handlerId": 14 }}, {{ "type": "ProtocolError", "offset": 90, "length": 35, "nodeId": 0, "handlerId": 0, "code": 9, "message": "bad tag" }}, - {{ "type": "KeyDown", "offset": 125, "length": 20, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true }}, - {{ "type": "RangeRequested", "offset": 145, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }}, - {{ "type": "ValueChanged", "offset": 169, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }}, - {{ "type": "WindowSizeChanged", "offset": 193, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }}, - {{ "type": "NotificationActivated", "offset": 237, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }}, - {{ "type": "NotificationPermissionChanged", "offset": 274, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }}, - {{ "type": "CodeChanged", "offset": 294, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }}, - {{ "type": "CodeEditRejected", "offset": 342, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }}, - {{ "type": "CodeHovered", "offset": 386, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }}, - {{ "type": "CodeSaveRequested", "offset": 422, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }}, - {{ "type": "DecorationActivated", "offset": 446, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }}, - {{ "type": "AnimationEvent", "offset": 470, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }}, - {{ "type": "ReducedMotionChanged", "offset": 510, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }}, - {{ "type": "Pointer", "offset": 530, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }}, - {{ "type": "SelectionChanged", "offset": 602, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }}, - {{ "type": "VisibleRangeChanged", "offset": 638, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }}, - {{ "type": "EditorFocusChanged", "offset": 666, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }}, - {{ "type": "FoldChanged", "offset": 690, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }}, - {{ "type": "CompositionChanged", "offset": 714, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }}, - {{ "type": "WindowCloseRequested", "offset": 738, "length": 16, "nodeId": 20, "handlerId": 201 }}, - {{ "type": "WindowFocusChanged", "offset": 754, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }}, - {{ "type": "WindowMoved", "offset": 774, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }}, - {{ "type": "WindowDrag", "offset": 806, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }}, - {{ "type": "WindowDragOver", "offset": 838, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }}, - {{ "type": "WindowUnavailable", "offset": 870, "length": 16, "nodeId": 20, "handlerId": 205 }}, - {{ "type": "WindowClosed", "offset": 886, "length": 16, "nodeId": 20, "handlerId": 206 }}, - {{ "type": "WindowRequested", "offset": 902, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }}, - {{ "type": "ApplicationReopen", "offset": 936, "length": 16, "nodeId": 0, "handlerId": 0 }} + {{ "type": "KeyDown", "offset": 125, "length": 37, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true, "repeat": true, "code": 40, "text": "Enter" }}, + {{ "type": "RangeRequested", "offset": 162, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }}, + {{ "type": "ValueChanged", "offset": 186, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }}, + {{ "type": "WindowSizeChanged", "offset": 210, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }}, + {{ "type": "NotificationActivated", "offset": 254, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }}, + {{ "type": "NotificationPermissionChanged", "offset": 291, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }}, + {{ "type": "CodeChanged", "offset": 311, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }}, + {{ "type": "CodeEditRejected", "offset": 359, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }}, + {{ "type": "CodeHovered", "offset": 403, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }}, + {{ "type": "CodeSaveRequested", "offset": 439, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }}, + {{ "type": "DecorationActivated", "offset": 463, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }}, + {{ "type": "AnimationEvent", "offset": 487, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }}, + {{ "type": "ReducedMotionChanged", "offset": 527, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }}, + {{ "type": "Pointer", "offset": 547, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }}, + {{ "type": "SelectionChanged", "offset": 619, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }}, + {{ "type": "VisibleRangeChanged", "offset": 655, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }}, + {{ "type": "EditorFocusChanged", "offset": 683, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }}, + {{ "type": "FoldChanged", "offset": 707, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }}, + {{ "type": "CompositionChanged", "offset": 731, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }}, + {{ "type": "WindowCloseRequested", "offset": 755, "length": 16, "nodeId": 20, "handlerId": 201 }}, + {{ "type": "WindowFocusChanged", "offset": 771, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }}, + {{ "type": "WindowMoved", "offset": 791, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }}, + {{ "type": "WindowDrag", "offset": 823, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }}, + {{ "type": "WindowDragOver", "offset": 855, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }}, + {{ "type": "WindowUnavailable", "offset": 887, "length": 16, "nodeId": 20, "handlerId": 205 }}, + {{ "type": "WindowClosed", "offset": 903, "length": 16, "nodeId": 20, "handlerId": 206 }}, + {{ "type": "WindowRequested", "offset": 919, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }}, + {{ "type": "ApplicationReopen", "offset": 953, "length": 16, "nodeId": 0, "handlerId": 0 }}, + {{ "type": "FileDialogResult", "offset": 969, "length": 73, "nodeId": 0, "handlerId": 0, "request": 7, "status": "Chosen", "filter": 1, "paths": "/Users/한글/문서.txt\u0000/tmp/a b.png" }} ] }} }} diff --git a/compose-rust/src/file_dialog.rs b/compose-rust/src/file_dialog.rs new file mode 100644 index 000000000..08f80402b --- /dev/null +++ b/compose-rust/src/file_dialog.rs @@ -0,0 +1,464 @@ +//! File dialogs: asking the user to pick or name a file from the platform's own panel. +//! +//! The Host does not call the platform. The Renderer owns the windows and the platform's +//! panel, so a request crosses as a command record in an ordinary batch, and the answer +//! comes back as an ordinary event. A dialog is not a node: it has no place in the tree and +//! it is gone once it has answered. +//! +//! Every request gets exactly one answer, whether the user chose, cancelled, or this run has +//! no way to show a panel. The Host numbers each request, remembers the callback under that +//! number, calls it once when the matching event arrives, and drops it. An answer for a +//! number nobody is waiting on is ignored. +//! +//! ```ignore +//! FileDialog::open_files() +//! .title("Choose files to send") +//! .filter("Images", &["png", "jpg"]) +//! .show(|result| { +//! if let FileDialogResult::Chosen { paths, .. } = result { +//! send(paths); +//! } +//! }); +//! ``` +//! +//! [`FileDialog::show`] belongs on the Renderer's UI thread, from a handler or a component, +//! because its callback runs there. A worker thread asks through [`FileDialogSender`], whose +//! callback has to be `Send` for the same reason: it is stored by the Host and called on the +//! UI thread. Nothing here is a queue on the boundary. The queue is the Host's own and it is +//! written into the next batch inside a call. + +use crate::schema::{FileDialogKind, FileDialogStatus}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Mutex, MutexGuard}; + +/// How a dialog ended. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum FileDialogResult { + /// The user chose. `paths` holds at least one absolute path, exactly as the platform + /// gave it. `filter` is the position of the filter that was in use, when the request + /// carried filters. + Chosen { + paths: Vec, + filter: Option, + }, + /// The user closed the dialog, or the window it belonged to went away while it was open. + Cancelled, + /// This run cannot show a dialog: the platform has none yet, the window does not exist, + /// or the answer could not be delivered whole. Not an error, and not a cancel. + Unavailable, +} + +/// One file dialog, built and then shown. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FileDialog { + kind: FileDialogKind, + window: u32, + title: String, + directory: String, + name: String, + filters: Vec<(String, Vec)>, + default_filter: usize, +} + +impl FileDialog { + fn new(kind: FileDialogKind) -> Self { + Self { + kind, + window: 0, + title: String::new(), + directory: String::new(), + name: String::new(), + filters: Vec::new(), + default_filter: 0, + } + } + + /// Choose one file. + pub fn open_file() -> Self { + Self::new(FileDialogKind::OpenFile) + } + + /// Choose one or more files. + pub fn open_files() -> Self { + Self::new(FileDialogKind::OpenFiles) + } + + /// Choose one folder. + pub fn open_folder() -> Self { + Self::new(FileDialogKind::OpenFolder) + } + + /// Choose one or more folders. + pub fn open_folders() -> Self { + Self::new(FileDialogKind::OpenFolders) + } + + /// Choose a path to save to. The platform asks before overwriting; the Renderer only + /// hands back the path and never writes the file. + pub fn save() -> Self { + Self::new(FileDialogKind::Save) + } + + /// The dialog's title. Empty is the platform's default. + pub fn title(mut self, title: impl Into) -> Self { + self.title = title.into(); + self + } + + /// The folder the dialog opens in. It should be absolute. One that is empty, missing + /// or unreadable falls back to the platform's default, and is not an error. + pub fn directory(mut self, directory: impl Into) -> Self { + self.directory = directory.into(); + self + } + + /// The file name a save dialog starts with. Ignored by the open kinds. A path + /// separator in it keeps only what follows the last one. + pub fn name(mut self, name: impl Into) -> Self { + self.name = name.into(); + self + } + + /// Adds a filter: a label and the extensions it allows, without dots and compared + /// without regard to case. Ignored when choosing folders. Without any filter every file + /// can be chosen. + pub fn filter(mut self, label: impl Into, extensions: &[&str]) -> Self { + self.filters.push(( + label.into(), + extensions + .iter() + .map(|extension| (*extension).to_owned()) + .collect(), + )); + self + } + + /// The position of the filter selected first. A position past the last filter selects + /// the first. + pub fn default_filter(mut self, position: usize) -> Self { + self.default_filter = position; + self + } + + /// The `Window` node the dialog belongs to and is modal for. 0 is the implicit root + /// window of an application with one window. + pub fn window(mut self, node_id: u32) -> Self { + self.window = node_id; + self + } + + /// The window the dialog belongs to, by handle. + pub fn in_window(self, window: crate::windows::WindowHandle) -> Self { + self.window(window.id()) + } + + /// Asks for the dialog. `on_result` is called once, on the UI thread, when it ends, and + /// is then dropped. + /// + /// Call this on the UI thread, from a component or an event handler. From a worker use + /// [`FileDialogSender`]. + pub fn show(self, on_result: impl FnOnce(FileDialogResult) + 'static) { + let request = next_request(); + let callback: LocalCallback = Box::new(on_result); + LOCAL.with_borrow_mut(|callbacks| { + callbacks.insert(request, callback); + }); + submit(self.into_request(request)); + } + + fn into_request(self, id: u32) -> Request { + Request { + id, + window: self.window, + kind: self.kind, + default_filter: u16::try_from(self.default_filter).unwrap_or(u16::MAX), + title: self.title, + directory: self.directory, + name: self.name, + filters: encode_filters(&self.filters), + } + } +} + +/// A handle a worker thread asks for dialogs through. +/// +/// `Send`, `Copy` and free to make: the queue it feeds belongs to the Host. The callback is +/// stored by the Host and run on the UI thread, so it has to be `Send`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct FileDialogSender { + _private: (), +} + +impl FileDialogSender { + pub const fn new() -> Self { + Self { _private: () } + } + + /// Asks for `dialog` and calls `on_result` once on the UI thread when it ends. + pub fn show( + &self, + dialog: FileDialog, + on_result: impl FnOnce(FileDialogResult) + Send + 'static, + ) { + let request = next_request(); + let callback: RemoteCallback = Box::new(on_result); + remote().push((request, callback)); + submit(dialog.into_request(request)); + } +} + +/// One request waiting for the next batch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Request { + pub(crate) id: u32, + pub(crate) window: u32, + pub(crate) kind: FileDialogKind, + pub(crate) default_filter: u16, + pub(crate) title: String, + pub(crate) directory: String, + pub(crate) name: String, + pub(crate) filters: String, +} + +/// The filters as the wire carries them: each label, then its extensions joined by commas, +/// all separated by NUL. A NUL or a comma inside a part would shift every later part, so +/// they are dropped from the parts rather than sent. +fn encode_filters(filters: &[(String, Vec)]) -> String { + let mut out = String::new(); + for (label, extensions) in filters { + if !out.is_empty() { + out.push('\0'); + } + out.extend(label.chars().filter(|c| *c != '\0')); + out.push('\0'); + let mut first = true; + for extension in extensions { + if !first { + out.push(','); + } + first = false; + out.extend(extension.chars().filter(|c| *c != '\0' && *c != ',')); + } + } + out +} + +type LocalCallback = Box; +type RemoteCallback = Box; + +thread_local! { + /// Callbacks asked for on the UI thread, which need not be `Send`. + static LOCAL: RefCell> = RefCell::new(HashMap::new()); +} + +/// Callbacks a worker asked for, kept until the UI thread delivers the answer. +static REMOTE: Mutex> = Mutex::new(Vec::new()); + +/// Requests waiting for the next batch. Shared across threads, because a worker asks. +static QUEUE: Mutex> = Mutex::new(Vec::new()); + +/// Whether the queue has anything in it, read without taking the lock, so a batch that has +/// nothing to write pays one atomic load. +static PENDING: AtomicBool = AtomicBool::new(false); + +/// The next request number. 0 is skipped so a zeroed record never looks like a request. +static NEXT_REQUEST: AtomicU32 = AtomicU32::new(1); + +fn next_request() -> u32 { + loop { + let id = NEXT_REQUEST.fetch_add(1, Ordering::Relaxed); + if id != 0 { + return id; + } + } +} + +fn queue() -> MutexGuard<'static, Vec> { + // A worker that panicked while holding the lock does not make the queue wrong. + QUEUE.lock().unwrap_or_else(|poison| poison.into_inner()) +} + +fn remote() -> MutexGuard<'static, Vec<(u32, RemoteCallback)>> { + REMOTE.lock().unwrap_or_else(|poison| poison.into_inner()) +} + +fn submit(request: Request) { + queue().push(request); + PENDING.store(true, Ordering::Release); + // Inside a Host call on the UI thread the batch that call produces carries it, so a + // frame request would only buy an empty frame. + if !crate::boundary::in_host_call() { + crate::boundary::request_frame_from_worker(); + } +} + +/// Hands every queued request to `emit` and empties the queue. +pub(crate) fn drain(mut emit: impl FnMut(&Request)) { + if !PENDING.swap(false, Ordering::AcqRel) { + return; + } + let mut taken = std::mem::take(&mut *queue()); + for request in &taken { + emit(request); + } + taken.clear(); + let mut guard = queue(); + if guard.is_empty() { + *guard = taken; + } else { + // Something was asked for while these were being written, and is still waiting. + PENDING.store(true, Ordering::Release); + } +} + +/// Hands an answer to the callback that asked for it. Returns whether there was one. +/// +/// A request that is not waiting, because it was already answered or never asked for, finds +/// no callback and is ignored. `paths` is the wire string: paths separated by NUL. +pub(crate) fn deliver(request: u32, status: FileDialogStatus, filter: u32, paths: &str) -> bool { + let result = match status { + FileDialogStatus::Chosen => { + let paths: Vec = paths + .split('\0') + .filter(|path| !path.is_empty()) + .map(str::to_owned) + .collect(); + // A choice with nothing chosen is not one the Renderer sends. Reporting it as a + // pick would hand the application an empty list to index into. + if paths.is_empty() { + FileDialogResult::Unavailable + } else { + FileDialogResult::Chosen { + paths, + filter: (filter != u32::MAX).then_some(filter as usize), + } + } + } + FileDialogStatus::Cancelled => FileDialogResult::Cancelled, + FileDialogStatus::Unavailable => FileDialogResult::Unavailable, + }; + // Taken out before it runs: the callback may ask for another dialog, which edits the + // table being read here. + if let Some(callback) = LOCAL.with_borrow_mut(|callbacks| callbacks.remove(&request)) { + callback(result); + return true; + } + let callback = { + let mut waiting = remote(); + waiting + .iter() + .position(|(id, _)| *id == request) + .map(|at| waiting.swap_remove(at).1) + }; + match callback { + Some(callback) => { + callback(result); + true + } + None => false, + } +} + +/// Forgets every callback and anything still queued. Used between tests. +#[doc(hidden)] +pub fn reset_file_dialogs() { + queue().clear(); + PENDING.store(false, Ordering::Release); + remote().clear(); + LOCAL.with_borrow_mut(HashMap::clear); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::rc::Rc; + + #[test] + fn fr72_filters_are_one_nul_separated_string() { + assert_eq!(encode_filters(&[]), ""); + let filters = vec![ + ( + "Images".to_owned(), + vec!["png".to_owned(), "jpg".to_owned()], + ), + ("Docs".to_owned(), vec!["pdf".to_owned(), "txt".to_owned()]), + ]; + assert_eq!(encode_filters(&filters), "Images\0png,jpg\0Docs\0pdf,txt"); + let hostile = vec![("A\0B".to_owned(), vec!["p,n\0g".to_owned()])]; + assert_eq!(encode_filters(&hostile), "AB\0png"); + } + + #[test] + fn fr72_paths_are_split_on_nul_and_a_stray_answer_is_ignored() { + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = Rc::clone(&seen); + let request = next_request(); + let callback: LocalCallback = + Box::new(move |result: FileDialogResult| sink.borrow_mut().push(result)); + LOCAL.with_borrow_mut(|callbacks| { + callbacks.insert(request, callback); + }); + assert!(deliver( + request, + FileDialogStatus::Chosen, + 1, + "/home/한글/문서.txt\0/tmp/a b.png" + )); + assert_eq!( + *seen.borrow(), + vec![FileDialogResult::Chosen { + paths: vec!["/home/한글/문서.txt".to_owned(), "/tmp/a b.png".to_owned()], + filter: Some(1), + }] + ); + // Answered once; the callback is gone. + assert!(!deliver(request, FileDialogStatus::Cancelled, u32::MAX, "")); + assert!(!deliver( + u32::MAX, + FileDialogStatus::Cancelled, + u32::MAX, + "" + )); + assert_eq!(seen.borrow().len(), 1); + } + + #[test] + fn fr72_a_pick_with_no_paths_is_unavailable_not_chosen() { + let seen = Rc::new(Cell::new(None)); + let sink = Rc::clone(&seen); + let request = next_request(); + let callback: LocalCallback = + Box::new(move |result: FileDialogResult| sink.set(Some(result))); + LOCAL.with_borrow_mut(|callbacks| { + callbacks.insert(request, callback); + }); + deliver(request, FileDialogStatus::Chosen, u32::MAX, ""); + assert_eq!(seen.take(), Some(FileDialogResult::Unavailable)); + } + + #[test] + fn fr72_a_worker_callback_is_called_once_with_its_own_answer() { + let seen = std::sync::Arc::new(Mutex::new(Vec::new())); + let sink = std::sync::Arc::clone(&seen); + let request = next_request(); + let callback: RemoteCallback = + Box::new(move |result: FileDialogResult| sink.lock().unwrap().push(result)); + remote().push((request, callback)); + assert!(deliver( + request, + FileDialogStatus::Unavailable, + u32::MAX, + "" + )); + assert!(!deliver( + request, + FileDialogStatus::Unavailable, + u32::MAX, + "" + )); + assert_eq!(*seen.lock().unwrap(), vec![FileDialogResult::Unavailable]); + } +} diff --git a/compose-rust/src/input.rs b/compose-rust/src/input.rs index 3e12ea0f1..1d50a3a3c 100644 --- a/compose-rust/src/input.rs +++ b/compose-rust/src/input.rs @@ -16,6 +16,9 @@ pub struct KeyEvent { ctrl_key: bool, alt_key: bool, meta_key: bool, + repeat: bool, + code: u32, + text: String, consumed: Rc>, } @@ -27,10 +30,37 @@ impl KeyEvent { ctrl_key, alt_key, meta_key, + repeat: false, + code: 0, + text: String::new(), consumed: Rc::new(Cell::new(false)), } } + /// Adds what the wire carries beyond the modifiers: whether a held key is repeating, + /// the physical key as a USB HID usage value, and the DOM `key` string. + pub fn with_details(mut self, repeat: bool, code: u32, text: &str) -> Self { + self.repeat = repeat; + self.code = code; + self.text = text.to_owned(); + self + } + + /// True when the event comes from a held key repeating. + pub fn repeat(&self) -> bool { + self.repeat + } + + /// The physical key as a USB HID usage value, the same on every layout. + pub fn code(&self) -> u32 { + self.code + } + + /// The DOM `key` string, such as `"ArrowDown"` or `"a"`. + pub fn text(&self) -> &str { + &self.text + } + pub fn key(&self) -> Key { self.key } diff --git a/compose-rust/src/lib.rs b/compose-rust/src/lib.rs index 4edd04751..55615d8f0 100644 --- a/compose-rust/src/lib.rs +++ b/compose-rust/src/lib.rs @@ -1,6 +1,6 @@ #![deny(unsafe_op_in_unsafe_fn)] -// `#[composable]` expands into paths that start at `::compose_rust`, so that an application +// `#[Composable]` expands into paths that start at `::compose_rust`, so that an application // that depends on this crate alone can use it. This makes the same paths resolve inside the // crate, where its own composables are written. extern crate self as compose_rust; @@ -33,6 +33,7 @@ pub mod codegen; pub mod design; pub mod drawing; mod extensions; +pub mod file_dialog; pub mod fonts; pub mod highlight; pub mod input; @@ -68,8 +69,11 @@ pub use code::{ Folds, GutterDecoration, GutterDecorations, Position, SaveRequest, SelectionChange, SyntaxSpan, SyntaxSpans, VisibleRange, }; +#[doc(hidden)] +pub use compose_rust_macros::__compose_call_check; pub use design::design_system; pub use drawing::{DrawCommand, DrawList, DrawListBuilder}; +pub use file_dialog::{FileDialog, FileDialogResult, FileDialogSender}; pub use fonts::{FontRef, FontRefs, FontStack}; pub use input::{FileDrop, KeyEvent, PointerEvent, PointerKinds, RangeRequest, pointer_button}; pub use message::{Message, show_message}; @@ -78,15 +82,15 @@ pub use notification::{ request_notification_permission, withdraw_notification, }; pub use palette::{Palette, PaletteViolation}; -pub use runtime::{Batch, ComposeHost, Recomposer, Runtime, application, composable, launch}; +pub use runtime::{Batch, Composable, ComposeHost, Recomposer, Runtime, application, launch}; pub use schema::{ Alignment, Arrangement, AssetKind, ButtonKind, ButtonVariant, Chrome, Color, ColorRole, ColorScheme, DecorationKind, DesignSystem, EventPayload, GenericFamily, GutterDecorationKind, HoverPhase, IconRole, Key, LoopMode, MaterialRole, MessageDuration, Modifier, MotionRole, NotificationImportance, NotificationPermission, NotificationPresentation, OverflowWrap, Paint, - PropertyKind, RevealAlign, SCHEMA_HASH, Selection, Severity, ShapeRole, SpaceRole, TextAlign, - TextOverflow, Theme, TileMode, TypeRole, WidgetKind, WindowHeightClass, WindowSizeClass, - WordBreak, + PropertyKind, RevealAlign, SCHEMA_HASH, Selection, SemanticsRole, Severity, ShapeRole, + SpaceRole, TextAlign, TextOverflow, Theme, TileMode, TypeRole, WidgetKind, WindowHeightClass, + WindowSizeClass, WordBreak, }; pub use schema::{ AnimatedProperty, Animation, AnimationControl, AnimationEventKind, AnimationEvents, diff --git a/compose-rust/src/measure.rs b/compose-rust/src/measure.rs index 582d453c7..dde41d690 100644 --- a/compose-rust/src/measure.rs +++ b/compose-rust/src/measure.rs @@ -285,6 +285,14 @@ struct PendingRecord { bytes: [u8; MEASURE_RECORD_LEN], } +/// Where an entry of a side table keeps the list it points at. +#[derive(Clone, Copy)] +struct SideTable { + entry_len: usize, + list_offset_at: usize, + list_count_at: usize, +} + /// A set of questions for one call, built up and then asked at once. /// /// Reused across frames: clearing keeps every buffer's capacity, so once warm, asking the @@ -447,6 +455,22 @@ impl MeasureRequests { /// Copies a `SpanFonts` table without its count, moving every offset inside it, and /// answers where the entries start and how many there are. fn copy_span_font_table(&mut self, table: &[u8]) -> (u32, u32) { + self.copy_side_table( + table, + SideTable { + entry_len: SPAN_FONT_ENTRY_LEN, + list_offset_at: SPAN_FONT_OFFSET_AT, + list_count_at: 8, + }, + ) + } + + /// Copies a side table of fixed-size entries without its count. Each entry points at a + /// list of font records elsewhere in the table: the entry's list offset is moved to + /// where the list now sits, the names the list points at move with it, and every moved + /// field is marked to be counted from the start of the buffer. Answers where the + /// entries start and how many there are. + fn copy_side_table(&mut self, table: &[u8], layout: SideTable) -> (u32, u32) { let at = self.payload.len(); if table.len() < 4 { return (at as u32, 0); @@ -455,14 +479,15 @@ impl MeasureRequests { self.payload.extend_from_slice(&table[4..]); let moved = (at as u32).wrapping_sub(4); for entry in 0..entries as usize { - let field = at + entry * SPAN_FONT_ENTRY_LEN + SPAN_FONT_OFFSET_AT; + let start = at + entry * layout.entry_len; + let field = start + layout.list_offset_at; if field + 4 > self.payload.len() { break; } let list = read_u32(&self.payload, field).wrapping_add(moved); self.payload[field..field + 4].copy_from_slice(&list.to_le_bytes()); self.payload_patches.push(field as u32); - let count = read_u32(&self.payload, at + entry * SPAN_FONT_ENTRY_LEN + 8); + let count = read_u32(&self.payload, start + layout.list_count_at); self.move_font_records(list as usize, count, moved); } (at as u32, entries) @@ -786,3 +811,61 @@ fn fake_lines(text: &str, width: f32, advance: f32) -> Vec { } lines } + +#[cfg(test)] +mod side_table_tests { + use super::*; + + /// One entry (index, list offset, list count) followed by one system font record + /// and its name, laid out as the table is on the wire. + fn table() -> Vec { + let mut t = Vec::new(); + t.extend_from_slice(&1u32.to_le_bytes()); + t.extend_from_slice(&7u32.to_le_bytes()); + t.extend_from_slice(&(4 + SPAN_FONT_ENTRY_LEN as u32).to_le_bytes()); + t.extend_from_slice(&1u32.to_le_bytes()); + let name_at = 4 + SPAN_FONT_ENTRY_LEN as u32 + FONT_REF_LEN as u32; + t.extend_from_slice(&FONT_REF_SYSTEM.to_le_bytes()); + t.extend_from_slice(&name_at.to_le_bytes()); + t.resize(4 + SPAN_FONT_ENTRY_LEN + FONT_REF_LEN, 0); + t.extend_from_slice(b"name"); + t + } + + #[test] + fn pr2_side_table_copy_moves_offsets_and_keeps_the_bytes() { + let mut requests = MeasureRequests::new(); + requests.payload.extend_from_slice(&[0xAA; 10]); + let table = table(); + let (at, entries) = requests.copy_span_font_table(&table); + assert_eq!((at, entries), (10, 1)); + let moved = 10u32 - 4; + let copied = &requests.payload[10..]; + assert_eq!(copied.len(), table.len() - 4); + assert_eq!( + read_u32(copied, 0), + 7, + "fields that are not offsets are untouched" + ); + assert_eq!( + read_u32(copied, SPAN_FONT_OFFSET_AT), + table_u32(&table, 4 + SPAN_FONT_OFFSET_AT) + moved + ); + assert_eq!(read_u32(copied, 8), 1); + let record = SPAN_FONT_ENTRY_LEN; + assert_eq!( + read_u32(copied, record + 4), + table_u32(&table, 4 + record + 4) + moved + ); + assert_eq!(&copied[copied.len() - 4..], b"name"); + assert_eq!(requests.payload_patches.len(), 2); + assert_eq!( + requests.copy_span_font_table(&[]), + (requests.payload.len() as u32, 0) + ); + } + + fn table_u32(table: &[u8], at: usize) -> u32 { + read_u32(table, at) + } +} diff --git a/compose-rust/src/protocol.rs b/compose-rust/src/protocol.rs index 5b55ed320..a60644c5e 100644 --- a/compose-rust/src/protocol.rs +++ b/compose-rust/src/protocol.rs @@ -1,10 +1,11 @@ //! Fixed-layout little-endian boundary protocol. use crate::schema::{ - AssetKind, ColorScheme, DesignSystem, Key, MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, - Modifier, MotionRole, NotificationImportance, NotificationPermission, NotificationPresentation, - Paint, PropertyKind, Selection, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, WidgetKind, - modifier_extra_words, + AssetKind, ColorScheme, DesignSystem, FileDialogKind, FileDialogStatus, Key, + MAX_MODIFIER_WORDS, MaterialRole, MessageDuration, Modifier, MotionRole, + NotificationImportance, NotificationPermission, NotificationPresentation, Paint, PropertyKind, + SEMANTICS_STATE_MASK, Selection, SemanticsRole, ShapeRole, SpaceRole, TYPE_ROLE_COUNT, Theme, + WidgetKind, modifier_extra_words, }; use core::fmt; @@ -35,6 +36,16 @@ const TAG_SET_FOLDED: u16 = 22; // 23 and 24 are the windows': a theme for one window, and bringing one forward. const TAG_SET_WINDOW_THEME: u16 = 23; const TAG_FOCUS_WINDOW: u16 = 24; +const TAG_REQUEST_FOCUS: u16 = 25; +// 26 asks for a platform file dialog. Its answer is the event numbered 50. +const TAG_SHOW_FILE_DIALOG: u16 = 26; +/// `ShowFileDialog` after its header: the request, the window, the kind and the default +/// filter, then four string references. 44 bytes, so the record is 48. +const SHOW_FILE_DIALOG_PAYLOAD_LEN: usize = 4 + 4 + 2 + 2 + 4 * 8; +// 27 sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. +const TAG_SET_OVERLAY_TOKENS: u16 = 27; +/// 9 u16 fields (18 bytes) + 1 u16 reserved (2 bytes) + 1 u32 scrim (4 bytes) = 24 bytes. +const OVERLAY_TOKENS_PAYLOAD_LEN: usize = 24; /// The head of a `StartAnimation` record, before its keyframes. const ANIMATION_HEADER_LEN: usize = 52; /// A keyframe before its value: offset, timing, step position, flags and four numbers. @@ -240,6 +251,29 @@ pub enum Mutation<'a> { FocusWindow { node_id: u32, }, + /// Moves input focus to `node_id`, or clears focus when `node_id` is 0. + RequestFocus { + node_id: u32, + }, + /// Asks the platform to show a file dialog on a window and answers later, once, with a + /// `FileDialogResult` event that carries `request`. + /// + /// `window` is a `Window` node id, or 0 for the implicit root window. `filters` is one + /// string: a label and its comma-joined extensions as a pair, pairs joined by NUL + /// (`"Images\0png,jpg\0Docs\0pdf,txt"`). `default_filter` is the position of the + /// filter selected first. + ShowFileDialog { + request: u32, + window: u32, + kind: FileDialogKind, + default_filter: u16, + title: &'a str, + directory: &'a str, + name: &'a str, + filters: &'a str, + }, + /// Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + SetOverlayTokens(crate::schema::OverlayTokens), } #[derive(Clone, Debug, Eq, PartialEq)] @@ -327,6 +361,12 @@ const EVENT_WINDOW_UNAVAILABLE: u16 = 44; const EVENT_WINDOW_CLOSED: u16 = 45; const EVENT_WINDOW_REQUESTED: u16 = 46; const EVENT_APPLICATION_REOPEN: u16 = 47; +// 48 and 49 are the node focus-gained and key-up events. +const EVENT_FOCUS_GAINED: u16 = 48; +const EVENT_KEY_UP: u16 = 49; +const EVENT_FILE_DIALOG_RESULT: u16 = 50; +/// `FileDialogResult` in full: the 16 byte header, three words and the paths' reference. +const FILE_DIALOG_RESULT_LEN: usize = 36; /// Where `SetWindow` keeps the mode and the last window policy: in the bits of the /// `resizable` word above the flag. @@ -403,13 +443,15 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { crate::schema::EventPayload::TextSubmitted(read_string(bytes, 16, record_len)?) } EVENT_FOCUS_LOST if record_len == 16 => crate::schema::EventPayload::FocusLost, + EVENT_FOCUS_GAINED if record_len == 16 => crate::schema::EventPayload::FocusGained, EVENT_PROTOCOL_ERROR if record_len == 28 => crate::schema::EventPayload::ProtocolError { code: read_u32(bytes, 16)?, message: read_string(bytes, 20, record_len)?, }, - EVENT_KEY_DOWN if record_len == 20 => { + EVENT_KEY_DOWN if record_len == 32 => { let raw_key = read_u16(bytes, 16)?; let modifiers = *bytes.get(18).ok_or(ProtocolError::Truncated)?; + let repeat = *bytes.get(19).ok_or(ProtocolError::Truncated)? != 0; crate::schema::EventPayload::KeyDown { key: Key::try_from(raw_key) .map_err(|()| ProtocolError::InvalidValueKind(raw_key))?, @@ -417,6 +459,26 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { ctrl_key: modifiers & MODIFIER_CTRL != 0, alt_key: modifiers & MODIFIER_ALT != 0, meta_key: modifiers & MODIFIER_META != 0, + repeat, + code: read_u32(bytes, 20)?, + text: read_string(bytes, 24, record_len)?, + } + } + // A key-up has the key-down's layout, so a Host handler reads both the same way. + EVENT_KEY_UP if record_len == 32 => { + let raw_key = read_u16(bytes, 16)?; + let modifiers = *bytes.get(18).ok_or(ProtocolError::Truncated)?; + let repeat = *bytes.get(19).ok_or(ProtocolError::Truncated)? != 0; + crate::schema::EventPayload::KeyUp { + key: Key::try_from(raw_key) + .map_err(|()| ProtocolError::InvalidValueKind(raw_key))?, + shift_key: modifiers & MODIFIER_SHIFT != 0, + ctrl_key: modifiers & MODIFIER_CTRL != 0, + alt_key: modifiers & MODIFIER_ALT != 0, + meta_key: modifiers & MODIFIER_META != 0, + repeat, + code: read_u32(bytes, 20)?, + text: read_string(bytes, 24, record_len)?, } } EVENT_RANGE_REQUESTED if record_len == 24 => crate::schema::EventPayload::RangeRequested { @@ -642,6 +704,22 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { EVENT_APPLICATION_REOPEN if record_len == 16 => { crate::schema::EventPayload::ApplicationReopen } + EVENT_FILE_DIALOG_RESULT if record_len == FILE_DIALOG_RESULT_LEN => { + let raw_status = read_u32(bytes, 20)?; + let status = u16::try_from(raw_status) + .ok() + .and_then(|tag| FileDialogStatus::try_from(tag).ok()) + .ok_or(ProtocolError::InvalidValueKind( + raw_status.min(u32::from(u16::MAX)) as u16, + ))?; + crate::schema::EventPayload::FileDialogResult { + request: read_u32(bytes, 16)?, + status, + filter: read_u32(bytes, 24)?, + paths: read_string(bytes, 28, record_len)?, + } + } + EVENT_FILE_DIALOG_RESULT => return Err(ProtocolError::InvalidRecordLength), EVENT_WINDOW_CLOSE_REQUESTED..=EVENT_APPLICATION_REOPEN => { return Err(ProtocolError::InvalidRecordLength); } @@ -666,7 +744,9 @@ pub fn decode_event(bytes: &[u8]) -> Result, ProtocolError> { | EVENT_ANIMATION | EVENT_POINTER | EVENT_REDUCED_MOTION_CHANGED - | EVENT_ZOOM_CHANGED => { + | EVENT_ZOOM_CHANGED + | EVENT_FOCUS_GAINED + | EVENT_KEY_UP => { return Err(ProtocolError::InvalidRecordLength); } other => return Err(ProtocolError::InvalidTag(other)), @@ -687,10 +767,13 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P ctrl_key, alt_key, meta_key, + repeat, + code, + text, } = event.payload { output.extend_from_slice(&EVENT_KEY_DOWN.to_le_bytes()); - output.extend_from_slice(&20_u16.to_le_bytes()); + output.extend_from_slice(&32_u16.to_le_bytes()); output.extend_from_slice(&event.node_id.to_le_bytes()); output.extend_from_slice(&event.handler_id.to_le_bytes()); output.extend_from_slice(&(key as u16).to_le_bytes()); @@ -699,7 +782,41 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P | (u8::from(alt_key) * MODIFIER_ALT) | (u8::from(meta_key) * MODIFIER_META); output.push(modifiers); - output.push(0); + output.push(u8::from(repeat)); + output.extend_from_slice(&code.to_le_bytes()); + let len = u32::try_from(text.len()).map_err(|_| ProtocolError::LengthOverflow)?; + output.extend_from_slice(&32_u32.to_le_bytes()); + output.extend_from_slice(&len.to_le_bytes()); + output.extend_from_slice(text.as_bytes()); + return Ok(()); + } + if let crate::schema::EventPayload::KeyUp { + key, + shift_key, + ctrl_key, + alt_key, + meta_key, + repeat, + code, + text, + } = event.payload + { + output.extend_from_slice(&EVENT_KEY_UP.to_le_bytes()); + output.extend_from_slice(&32_u16.to_le_bytes()); + output.extend_from_slice(&event.node_id.to_le_bytes()); + output.extend_from_slice(&event.handler_id.to_le_bytes()); + output.extend_from_slice(&(key as u16).to_le_bytes()); + let modifiers = (u8::from(shift_key) * MODIFIER_SHIFT) + | (u8::from(ctrl_key) * MODIFIER_CTRL) + | (u8::from(alt_key) * MODIFIER_ALT) + | (u8::from(meta_key) * MODIFIER_META); + output.push(modifiers); + output.push(u8::from(repeat)); + output.extend_from_slice(&code.to_le_bytes()); + let len = u32::try_from(text.len()).map_err(|_| ProtocolError::LengthOverflow)?; + output.extend_from_slice(&32_u32.to_le_bytes()); + output.extend_from_slice(&len.to_le_bytes()); + output.extend_from_slice(text.as_bytes()); return Ok(()); } if let crate::schema::EventPayload::ValueChanged(value) = event.payload { @@ -979,6 +1096,28 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P } _ => {} } + if let crate::schema::EventPayload::FileDialogResult { + request, + status, + filter, + paths, + } = event.payload + { + put_event_header( + output, + EVENT_FILE_DIALOG_RESULT, + FILE_DIALOG_RESULT_LEN as u16, + event, + ); + output.extend_from_slice(&request.to_le_bytes()); + output.extend_from_slice(&u32::from(status as u16).to_le_bytes()); + output.extend_from_slice(&filter.to_le_bytes()); + let len = u32::try_from(paths.len()).map_err(|_| ProtocolError::LengthOverflow)?; + output.extend_from_slice(&(FILE_DIALOG_RESULT_LEN as u32).to_le_bytes()); + output.extend_from_slice(&len.to_le_bytes()); + output.extend_from_slice(paths.as_bytes()); + return Ok(()); + } if let crate::schema::EventPayload::ZoomChanged { k, os, level } = event.payload { output.extend_from_slice(&EVENT_ZOOM_CHANGED.to_le_bytes()); output.extend_from_slice(&28_u16.to_le_bytes()); @@ -1007,6 +1146,7 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P (EVENT_TEXT_SUBMITTED, 24, Some(*value), None) } crate::schema::EventPayload::FocusLost => (EVENT_FOCUS_LOST, 16, None, None), + crate::schema::EventPayload::FocusGained => (EVENT_FOCUS_GAINED, 16, None, None), crate::schema::EventPayload::FilesEntered => (EVENT_FILES_ENTERED, 16, None, None), crate::schema::EventPayload::FilesDropped(paths) => { (EVENT_FILES_DROPPED, 24, Some(*paths), None) @@ -1036,6 +1176,7 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P (EVENT_NOTIFICATION_ACTIVATED, 28, Some(*key), Some(*action)) } crate::schema::EventPayload::KeyDown { .. } + | crate::schema::EventPayload::KeyUp { .. } | crate::schema::EventPayload::RangeRequested { .. } | crate::schema::EventPayload::ValueChanged(_) | crate::schema::EventPayload::WindowSizeChanged { .. } @@ -1054,7 +1195,8 @@ pub fn encode_event(event: &HostEvent<'_>, output: &mut Vec) -> Result<(), P | crate::schema::EventPayload::WindowFocusChanged { .. } | crate::schema::EventPayload::WindowMoved { .. } | crate::schema::EventPayload::WindowDrag { .. } - | crate::schema::EventPayload::WindowDragOver { .. } => unreachable!(), + | crate::schema::EventPayload::WindowDragOver { .. } + | crate::schema::EventPayload::FileDialogResult { .. } => unreachable!(), crate::schema::EventPayload::AnimationEvent { .. } | crate::schema::EventPayload::Pointer { .. } | crate::schema::EventPayload::ReducedMotionChanged(_) @@ -1347,6 +1489,44 @@ impl BatchEncoder { self.begin_record(TAG_FOCUS_WINDOW, 4); self.put_u32(*node_id); } + Mutation::RequestFocus { node_id } => { + self.begin_record(TAG_REQUEST_FOCUS, 4); + self.put_u32(*node_id); + } + Mutation::ShowFileDialog { + request, + window, + kind, + default_filter, + title, + directory, + name, + filters, + } => { + self.begin_record(TAG_SHOW_FILE_DIALOG, SHOW_FILE_DIALOG_PAYLOAD_LEN as u16); + self.put_u32(*request); + self.put_u32(*window); + self.put_u16(*kind as u16); + self.put_u16(*default_filter); + self.put_string_ref(title)?; + self.put_string_ref(directory)?; + self.put_string_ref(name)?; + self.put_string_ref(filters)?; + } + Mutation::SetOverlayTokens(overlay) => { + self.begin_record(TAG_SET_OVERLAY_TOKENS, OVERLAY_TOKENS_PAYLOAD_LEN as u16); + self.put_u16(overlay.sheet_corner_radius); + self.put_u16(overlay.dialog_corner_radius); + self.put_u16(overlay.popup_corner_radius); + self.put_u16(overlay.sheet_elevation); + self.put_u16(overlay.popup_elevation); + self.put_u16(overlay.dialog_elevation); + self.put_u16(overlay.sheet_padding); + self.put_u16(overlay.dialog_padding); + self.put_u16(overlay.popup_padding); + self.put_u16(0); + self.put_u32(overlay.scrim); + } Mutation::SetWindow(window) => { self.begin_record(TAG_SET_WINDOW, 28); self.put_u16(window.chrome as u16); @@ -1355,11 +1535,13 @@ impl BatchEncoder { self.put_u16(window.height); self.put_u16(window.min_width); self.put_u16(window.min_height); - // Bit 0 is `resizable`. The mode and the last window policy ride in the - // bits above it that were always zero, so a window that asks for one - // window writes the bytes it always wrote. + // Bit 0 is `resizable` and bit 1 asks for every whole-dp size change. The + // mode and the last window policy ride in the bits above them that were + // always zero, so a window that asks for none writes the bytes it always + // wrote. self.put_u16( u16::from(window.resizable) + | (u16::from(window.report_size) << 1) | ((window.mode as u16) << WINDOW_MODE_SHIFT) | ((window.last_window_closed as u16) << WINDOW_POLICY_SHIFT), ); @@ -1665,6 +1847,7 @@ fn decode_theme(bytes: &[u8], payload: usize, records_len: usize) -> Result Result>, ProtocolError> { TAG_FOCUS_WINDOW if len == 8 => Mutation::FocusWindow { node_id: read_u32(bytes, payload)?, }, + TAG_REQUEST_FOCUS if len == 8 => Mutation::RequestFocus { + node_id: read_u32(bytes, payload)?, + }, + TAG_SHOW_FILE_DIALOG if len == 4 + SHOW_FILE_DIALOG_PAYLOAD_LEN => { + let raw_kind = read_u16(bytes, payload + 8)?; + Mutation::ShowFileDialog { + request: read_u32(bytes, payload)?, + window: read_u32(bytes, payload + 4)?, + kind: FileDialogKind::try_from(raw_kind) + .map_err(|()| ProtocolError::InvalidValueKind(raw_kind))?, + default_filter: read_u16(bytes, payload + 10)?, + title: read_string(bytes, payload + 12, records_len)?, + directory: read_string(bytes, payload + 20, records_len)?, + name: read_string(bytes, payload + 28, records_len)?, + filters: read_string(bytes, payload + 36, records_len)?, + } + } + TAG_SHOW_FILE_DIALOG => return Err(ProtocolError::InvalidRecordLength), + TAG_SET_OVERLAY_TOKENS if len == 4 + OVERLAY_TOKENS_PAYLOAD_LEN => { + let sheet_corner_radius = read_u16(bytes, payload)?; + let dialog_corner_radius = read_u16(bytes, payload + 2)?; + let popup_corner_radius = read_u16(bytes, payload + 4)?; + let sheet_elevation = read_u16(bytes, payload + 6)?; + let popup_elevation = read_u16(bytes, payload + 8)?; + let dialog_elevation = read_u16(bytes, payload + 10)?; + let sheet_padding = read_u16(bytes, payload + 12)?; + let dialog_padding = read_u16(bytes, payload + 14)?; + let popup_padding = read_u16(bytes, payload + 16)?; + let scrim = read_u32(bytes, payload + 20)?; + Mutation::SetOverlayTokens(crate::schema::OverlayTokens { + sheet_corner_radius, + dialog_corner_radius, + popup_corner_radius, + sheet_elevation, + popup_elevation, + dialog_elevation, + sheet_padding, + dialog_padding, + popup_padding, + scrim, + }) + } + TAG_SET_OVERLAY_TOKENS => return Err(ProtocolError::InvalidRecordLength), TAG_SET_WINDOW if len == 32 => { let chrome = read_u16(bytes, payload)?; let title_bar = read_u16(bytes, payload + 2)?; @@ -1793,10 +2019,11 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { // nibbles above the second byte. Anything else standing is a record the // sides disagree on. let flags = read_u16(bytes, payload + 12)?; - if flags & 0x00FE != 0 { + if flags & 0x00FC != 0 { return Err(ProtocolError::InvalidValueKind(flags)); } let resizable = flags & 1; + let report_size = flags & 0x0002 != 0; let mode_tag = (flags >> WINDOW_MODE_SHIFT) & 0xF; let policy_tag = flags >> WINDOW_POLICY_SHIFT; let zoom = read_u16(bytes, payload + 14)?; @@ -1824,6 +2051,7 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { min_height: read_u16(bytes, payload + 10)?, resizable: resizable == 1, zoom_level, + report_size, mode: crate::schema::WindowMode::try_from(mode_tag) .map_err(|()| ProtocolError::InvalidValueKind(flags))?, last_window_closed: crate::schema::LastWindowClosed::try_from(policy_tag) @@ -1954,7 +2182,7 @@ pub fn decode_batch(bytes: &[u8]) -> Result>, ProtocolError> { | TAG_START_ANIMATION | TAG_CONTROL_ANIMATION | TAG_SET_SELECTION..=TAG_SET_FOLDED - | TAG_SET_WINDOW_THEME..=TAG_FOCUS_WINDOW => { + | TAG_SET_WINDOW_THEME..=TAG_SET_OVERLAY_TOKENS => { return Err(ProtocolError::InvalidRecordLength); } other => return Err(ProtocolError::InvalidTag(other)), @@ -2181,7 +2409,7 @@ const fn unpack_high(word: u64) -> f32 { /// The value's words with nothing past the first two, which is every modifier but the few /// whose schema entry gives them more. const fn two_words(first: u64, second: u64) -> [u64; MAX_MODIFIER_WORDS] { - [first, second, 0, 0, 0, 0] + [first, second, 0, 0, 0, 0, 0, 0, 0, 0] } fn float_word(value: f32) -> u64 { @@ -2253,6 +2481,10 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { right_paint.to_bits(), bottom_paint.to_bits(), left_paint.to_bits(), + 0, + 0, + 0, + 0, ], ), Modifier::CornerEach { @@ -2282,6 +2514,10 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { 0, 0, 0, + 0, + 0, + 0, + 0, ], ), Modifier::WindowDrag(enabled) => (32, two_words(u64::from(*enabled), 0)), @@ -2290,6 +2526,7 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { Modifier::PressFeedback { scale, tint_alpha } => { (33, two_words(pack_floats(*scale, *tint_alpha), 0)) } + Modifier::Semantics { role, state } => (34, two_words(*role as u64, u64::from(*state))), Modifier::Transform { a, b, @@ -2308,6 +2545,30 @@ fn modifier_fields(modifier: &Modifier) -> (u16, [u64; MAX_MODIFIER_WORDS]) { pack_floats(*origin_x, *origin_y), 0, 0, + 0, + 0, + 0, + 0, + ], + ), + Modifier::Transform3D { + m, + origin_x, + origin_y, + backface_hidden, + } => ( + 27, + [ + pack_floats(m[0], m[1]), + pack_floats(m[2], m[3]), + pack_floats(m[4], m[5]), + pack_floats(m[6], m[7]), + pack_floats(m[8], m[9]), + pack_floats(m[10], m[11]), + pack_floats(m[12], m[13]), + pack_floats(m[14], m[15]), + pack_floats(*origin_x, *origin_y), + u64::from(*backface_hidden), ], ), } @@ -2323,7 +2584,18 @@ fn decode_paint(bits: u64) -> Result { } fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { - let [first, second, third, fourth, fifth, sixth] = *words; + let [ + first, + second, + third, + fourth, + fifth, + sixth, + seventh, + eighth, + ninth, + tenth, + ] = *words; match tag { 0 => Ok(Modifier::Empty), 1 => Ok(Modifier::Padding(f32::from_bits(first as u32))), @@ -2435,11 +2707,61 @@ fn decode_modifier(tag: u16, words: &[u64; MAX_MODIFIER_WORDS]) -> Result { + let values = [ + unpack_low(first), + unpack_high(first), + unpack_low(second), + unpack_high(second), + unpack_low(third), + unpack_high(third), + unpack_low(fourth), + unpack_high(fourth), + unpack_low(fifth), + unpack_high(fifth), + unpack_low(sixth), + unpack_high(sixth), + unpack_low(seventh), + unpack_high(seventh), + unpack_low(eighth), + unpack_high(eighth), + unpack_low(ninth), + unpack_high(ninth), + ]; + // The same refusal as the 2D matrix: a NaN or an infinity would draw nothing + // on one side and something on the other. + if values.iter().any(|value| !value.is_finite()) { + return Err(ProtocolError::InvalidModifier(27)); + } + let backface_hidden = match tenth { + 0 => false, + 1 => true, + _ => return Err(ProtocolError::InvalidModifier(27)), + }; + let mut m = [0.0; 16]; + m.copy_from_slice(&values[..16]); + Ok(Modifier::Transform3D { + m, + origin_x: values[16], + origin_y: values[17], + backface_hidden, + }) + } 31 => Ok(Modifier::MatchParentSize), 33 => Ok(Modifier::PressFeedback { scale: unpack_low(first), tint_alpha: unpack_high(first), }), + 34 => { + let state = u32::try_from(second) + .ok() + .filter(|state| state & !SEMANTICS_STATE_MASK == 0) + .ok_or(ProtocolError::InvalidModifier(34))?; + Ok(Modifier::Semantics { + role: decode_role::(first)?, + state, + }) + } other => Err(ProtocolError::InvalidModifier(other)), } } @@ -2606,6 +2928,84 @@ mod tests { ); } + #[test] + fn fr68_semantics_round_trips_and_refuses_unknown_state_bits() { + let expected = Modifier::Semantics { + role: SemanticsRole::Switch, + state: (2 << crate::schema::SEMANTICS_CHECKED_SHIFT) + | (1 << crate::schema::SEMANTICS_SELECTED_SHIFT), + }; + let mutation = Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: expected.clone(), + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&mutation).unwrap(); + assert_eq!(decode_batch(encoder.finish().unwrap()).unwrap(), [mutation]); + + let words = [SemanticsRole::Button as u64, 0x2000, 0, 0, 0, 0, 0, 0, 0, 0]; + assert_eq!( + decode_modifier(34, &words), + Err(ProtocolError::InvalidModifier(34)) + ); + } + + #[test] + fn fr71_every_new_role_round_trips_and_is_exposed() { + use crate::schema::*; + let roles = [ + (SemanticsRole::TabList, 9), + (SemanticsRole::Tree, 10), + (SemanticsRole::TreeItem, 11), + (SemanticsRole::Menu, 12), + (SemanticsRole::MenuItem, 13), + (SemanticsRole::TextBox, 14), + (SemanticsRole::Heading, 15), + (SemanticsRole::Navigation, 16), + (SemanticsRole::List, 17), + (SemanticsRole::ListItem, 18), + (SemanticsRole::Group, 19), + ]; + let state = (2 << SEMANTICS_EXPANDED_SHIFT) + | (2 << SEMANTICS_DISABLED_SHIFT) + | (1 << SEMANTICS_FOCUSED_SHIFT) + | SEMANTICS_HIDDEN_BIT + | SEMANTICS_CLICK_ACTION_BIT + | SEMANTICS_FOCUS_ACTION_BIT; + assert_eq!(state & !SEMANTICS_STATE_MASK, 0); + for (role, value) in roles { + assert_eq!(role as u64, value, "{role:?} changed its wire value"); + let mutation = Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: Modifier::Semantics { role, state }, + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&mutation).unwrap(); + assert_eq!(decode_batch(encoder.finish().unwrap()).unwrap(), [mutation]); + } + assert!( + ROLE_ENUM_SCHEMA + .iter() + .any(|e| e.name == "SemanticsRole" && e.variants.len() == 19), + "the schema must list all 19 roles" + ); + } + + #[test] + fn nfr7_unknown_semantics_bit_is_a_protocol_error() { + // Bit 13 is the first one above the mask. + let words = [SemanticsRole::Group as u64, 1 << 13, 0, 0, 0, 0, 0, 0, 0, 0]; + assert_eq!( + decode_modifier(34, &words), + Err(ProtocolError::InvalidModifier(34)) + ); + // The highest bit the mask allows still decodes. + let words = [SemanticsRole::Group as u64, 1 << 12, 0, 0, 0, 0, 0, 0, 0, 0]; + assert!(decode_modifier(34, &words).is_ok()); + } + #[test] fn fr51_press_feedback_round_trips() { let expected = [Mutation::SetModifier { @@ -2843,6 +3243,9 @@ mod tests { ctrl_key: false, alt_key: true, meta_key: false, + repeat: true, + code: 0x28, + text: "Enter", }, }, ]; @@ -2874,6 +3277,45 @@ mod tests { assert_eq!(decode_event(&bytes).unwrap(), event); } + #[test] + fn nfr7_malformed_key_down_text_is_a_protocol_error() { + let event = HostEvent { + node_id: 1, + handler_id: 2, + payload: crate::EventPayload::KeyDown { + key: Key::Enter, + shift_key: false, + ctrl_key: false, + alt_key: false, + meta_key: false, + repeat: false, + code: 0x51, + text: "ArrowDown", + }, + }; + let mut bytes = Vec::new(); + encode_event(&event, &mut bytes).unwrap(); + assert_eq!(decode_event(&bytes).unwrap(), event); + // The text points past the end of the record. + let mut past_end = bytes.clone(); + past_end[28..32].copy_from_slice(&200_u32.to_le_bytes()); + assert_eq!( + decode_event(&past_end), + Err(ProtocolError::InvalidStringRange) + ); + // The text starts inside the header, before the string area. + let mut inside_header = bytes.clone(); + inside_header[24..28].copy_from_slice(&8_u32.to_le_bytes()); + assert_eq!( + decode_event(&inside_header), + Err(ProtocolError::InvalidStringRange) + ); + // The text is not UTF-8. + let mut not_utf8 = bytes; + not_utf8[32] = 0xff; + assert_eq!(decode_event(¬_utf8), Err(ProtocolError::InvalidUtf8)); + } + #[test] fn fr19_9_short_window_size_record_is_a_protocol_error() { let mut bytes = vec![0; 32]; @@ -3210,4 +3652,506 @@ mod tests { Some(8) ); } + + /// Bit 1 of the flags word asks the implicit window for every whole-dp size change. A + /// window that does not ask encodes the bytes it always did. + #[test] + fn fr20_8_set_window_report_size_bit_roundtrips() { + for report in [false, true] { + let window = crate::schema::Window::new() + .with_title("size") + .with_report_size(report); + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetWindow(window)).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + let flags = read_u16(&bytes, ENVELOPE_LEN + 16).unwrap(); + assert_eq!(flags, 1 | (u16::from(report) << 1)); + assert_eq!( + decode_batch(&bytes).unwrap(), + vec![Mutation::SetWindow(window)] + ); + } + // The default window, byte by byte from the record layout. + let window = crate::schema::Window::new(); + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetWindow(window)).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + let record = &bytes[ENVELOPE_LEN..]; + assert_eq!(record.len(), 32); + assert_eq!(read_u16(record, 0).unwrap(), TAG_SET_WINDOW); + assert_eq!(read_u16(record, 2).unwrap(), 32); + assert_eq!(read_u16(record, 4).unwrap(), window.chrome as u16); + assert_eq!(read_u16(record, 6).unwrap(), window.title_bar as u16); + for at in [8, 10, 12, 14] { + assert_eq!(read_u16(record, at).unwrap(), 0); + } + // Resizable only: the mode and the policy are the defaults for the platform. + let expected_flags = 1 + | ((window.mode as u16) << WINDOW_MODE_SHIFT) + | ((window.last_window_closed as u16) << WINDOW_POLICY_SHIFT); + assert_eq!(read_u16(record, 16).unwrap(), expected_flags); + assert_eq!(read_u16(record, 18).unwrap(), 0); + assert_eq!(read_u32(record, 28).unwrap(), 0); + } + + /// The other low bits of the flags word are still nobody's, so a record that sets + /// one is a protocol error. + #[test] + fn fr20_8_set_window_rejects_unknown_flag_bits() { + let mut encoder = BatchEncoder::default(); + encoder + .encode(&Mutation::SetWindow(crate::schema::Window::new())) + .unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + let flags = read_u16(&bytes, ENVELOPE_LEN + 16).unwrap(); + for bit in 2..8 { + let bad = flags | (1 << bit); + let mut copy = bytes.clone(); + copy[ENVELOPE_LEN + 16..ENVELOPE_LEN + 18].copy_from_slice(&bad.to_le_bytes()); + assert_eq!( + decode_batch(©), + Err(ProtocolError::InvalidValueKind(bad)), + "bit {bit}" + ); + } + } + + #[test] + fn fr65_request_focus_round_trips_in_eight_bytes() { + for node_id in [0_u32, 7, u32::MAX] { + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::RequestFocus { node_id }).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + assert_eq!(bytes.len(), ENVELOPE_LEN + 8); + assert_eq!(read_u16(&bytes, ENVELOPE_LEN).unwrap(), TAG_REQUEST_FOCUS); + assert_eq!(read_u16(&bytes, ENVELOPE_LEN + 2).unwrap(), 8); + assert_eq!(read_u32(&bytes, ENVELOPE_LEN + 4).unwrap(), node_id); + assert_eq!( + decode_batch(&bytes).unwrap(), + vec![Mutation::RequestFocus { node_id }] + ); + } + let mut encoder = BatchEncoder::default(); + encoder + .encode(&Mutation::RequestFocus { node_id: 7 }) + .unwrap(); + let mut bad = encoder.finish().unwrap().to_vec(); + bad.extend_from_slice(&[0; 4]); + let records_len = u32::try_from(bad.len()).unwrap(); + bad[4..8].copy_from_slice(&records_len.to_le_bytes()); + bad[ENVELOPE_LEN + 2..ENVELOPE_LEN + 4].copy_from_slice(&12_u16.to_le_bytes()); + assert_eq!(decode_batch(&bad), Err(ProtocolError::InvalidRecordLength)); + } + + /// The request and the result are fixed records. Both survive a round trip with + /// Korean text and NUL separated lists in them, an unknown kind or status is a + /// protocol error, and no prefix of either record decodes or panics. + #[test] + fn fr72_file_dialog_records_round_trip_and_unknown_enums_are_errors() { + let filters = "이미지\0png,jpg\0Docs\0pdf,txt"; + for kind in [ + FileDialogKind::OpenFile, + FileDialogKind::OpenFiles, + FileDialogKind::OpenFolder, + FileDialogKind::OpenFolders, + FileDialogKind::Save, + ] { + let request = Mutation::ShowFileDialog { + request: 7, + window: 20, + kind, + default_filter: 1, + title: "파일 고르기", + directory: "/home/한글", + name: "보고서.txt", + filters, + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&request).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + assert_eq!( + read_u16(&bytes, ENVELOPE_LEN).unwrap(), + TAG_SHOW_FILE_DIALOG + ); + assert_eq!(read_u16(&bytes, ENVELOPE_LEN + 2).unwrap(), 48); + assert_eq!(decode_batch(&bytes).unwrap(), vec![request]); + } + + let empty = Mutation::ShowFileDialog { + request: u32::MAX, + window: 0, + kind: FileDialogKind::Save, + default_filter: 0, + title: "", + directory: "", + name: "", + filters: "", + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&empty).unwrap(); + assert_eq!( + decode_batch(encoder.finish().unwrap()).unwrap(), + vec![empty] + ); + + let full = Mutation::ShowFileDialog { + request: 1, + window: 2, + kind: FileDialogKind::OpenFile, + default_filter: 3, + title: "t", + directory: "/d", + name: "n", + filters, + }; + let mut encoder = BatchEncoder::default(); + encoder.encode(&full).unwrap(); + let bytes = encoder.finish().unwrap().to_vec(); + for raw_kind in [0_u16, 6, 9, u16::MAX] { + let mut bad = bytes.clone(); + bad[ENVELOPE_LEN + 12..ENVELOPE_LEN + 14].copy_from_slice(&raw_kind.to_le_bytes()); + assert_eq!( + decode_batch(&bad), + Err(ProtocolError::InvalidValueKind(raw_kind)) + ); + } + let mut short = bytes.clone(); + short[ENVELOPE_LEN + 2..ENVELOPE_LEN + 4].copy_from_slice(&44_u16.to_le_bytes()); + assert_eq!( + decode_batch(&short), + Err(ProtocolError::InvalidRecordLength) + ); + for cut in 0..bytes.len() { + assert!( + decode_batch(&bytes[..cut]).is_err(), + "a batch cut at {cut} bytes decoded" + ); + } + + let paths = "/Users/한글/문서.txt\0/tmp/b c.png"; + for (status, filter) in [ + (FileDialogStatus::Chosen, 1_u32), + (FileDialogStatus::Cancelled, u32::MAX), + (FileDialogStatus::Unavailable, u32::MAX), + ] { + let result = HostEvent { + node_id: 0, + handler_id: 0, + payload: crate::EventPayload::FileDialogResult { + request: 41, + status, + filter, + paths: if status == FileDialogStatus::Chosen { + paths + } else { + "" + }, + }, + }; + let mut bytes = Vec::new(); + encode_event(&result, &mut bytes).unwrap(); + assert_eq!(read_u16(&bytes, 0).unwrap(), EVENT_FILE_DIALOG_RESULT); + assert_eq!( + usize::from(read_u16(&bytes, 2).unwrap()), + FILE_DIALOG_RESULT_LEN + ); + assert_eq!(decode_event(&bytes).unwrap(), result); + } + + let result = HostEvent { + node_id: 0, + handler_id: 0, + payload: crate::EventPayload::FileDialogResult { + request: 3, + status: FileDialogStatus::Chosen, + filter: 0, + paths, + }, + }; + let mut bytes = Vec::new(); + encode_event(&result, &mut bytes).unwrap(); + for raw_status in [0_u32, 4, 0x1_0000, u32::MAX] { + let mut bad = bytes.clone(); + bad[20..24].copy_from_slice(&raw_status.to_le_bytes()); + assert_eq!( + decode_event(&bad), + Err(ProtocolError::InvalidValueKind( + raw_status.min(u32::from(u16::MAX)) as u16 + )) + ); + } + let mut wrong_length = bytes.clone(); + wrong_length[2..4].copy_from_slice(&32_u16.to_le_bytes()); + assert_eq!( + decode_event(&wrong_length), + Err(ProtocolError::InvalidRecordLength) + ); + let mut not_utf8 = bytes.clone(); + let last = not_utf8.len() - 1; + not_utf8[last] = 0xFF; + assert_eq!(decode_event(¬_utf8), Err(ProtocolError::InvalidUtf8)); + for cut in 0..bytes.len() { + assert!( + decode_event(&bytes[..cut]).is_err(), + "an event cut at {cut} bytes decoded" + ); + } + } + + #[test] + fn fr65_text_field_on_composition_change_delivers_bool() { + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let seen: Rc>> = Rc::new(RefCell::new(Vec::new())); + let calls = Arc::new(AtomicUsize::new(0)); + let calls_for_root = Arc::clone(&calls); + let mut host = crate::ComposeHost::with_content(move || { + let calls_inner = Arc::clone(&calls_for_root); + crate::ui::TextField().on_composition_change(move |composing| { + calls_inner.fetch_add(if composing { 10 } else { 1 }, Ordering::SeqCst); + }); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + let prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnCompositionChange, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_composition_change must emit OnCompositionChange"); + let mut wire = Vec::new(); + encode_event( + &HostEvent { + node_id: prop.0, + handler_id: prop.1, + payload: crate::EventPayload::CompositionChanged { composing: true }, + }, + &mut wire, + ) + .unwrap(); + host.dispatch_event(&wire).unwrap(); + encode_event( + &HostEvent { + node_id: prop.0, + handler_id: prop.1, + payload: crate::EventPayload::CompositionChanged { composing: false }, + }, + &mut wire, + ) + .unwrap(); + host.dispatch_event(&wire).unwrap(); + assert_eq!(calls.load(Ordering::SeqCst), 11); + drop(seen); + } + + #[test] + fn fr45_focus_gained_and_lost_pair() { + use std::sync::Arc; + use std::sync::Mutex; + + let trace: Arc>> = Arc::new(Mutex::new(Vec::new())); + let trace_for_root = Arc::clone(&trace); + let mut host = crate::ComposeHost::with_content(move || { + let gained_trace = Arc::clone(&trace_for_root); + let lost_trace = Arc::clone(&trace_for_root); + crate::ui::TextField() + .on_focus_gained(move || { + gained_trace.lock().unwrap().push("gained"); + }) + .on_focus_lost(move || { + lost_trace.lock().unwrap().push("lost"); + }); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + let gained_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnFocusGained, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_focus_gained must emit OnFocusGained"); + let lost_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnFocusLost, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_focus_lost must emit OnFocusLost"); + assert_eq!(gained_prop.0, lost_prop.0); + assert_eq!(PropertyKind::OnFocusGained as u16, 117); + + let gained_event = HostEvent { + node_id: gained_prop.0, + handler_id: gained_prop.1, + payload: crate::EventPayload::FocusGained, + }; + let mut wire = Vec::new(); + encode_event(&gained_event, &mut wire).unwrap(); + assert_eq!(wire.len(), 16); + assert_eq!(read_u16(&wire, 0).unwrap(), EVENT_FOCUS_GAINED); + assert_eq!(EVENT_FOCUS_GAINED, 48); + assert_eq!(decode_event(&wire).unwrap(), gained_event); + host.dispatch_event(&wire).unwrap(); + + let lost_event = HostEvent { + node_id: lost_prop.0, + handler_id: lost_prop.1, + payload: crate::EventPayload::FocusLost, + }; + encode_event(&lost_event, &mut wire).unwrap(); + assert_eq!(decode_event(&wire).unwrap(), lost_event); + host.dispatch_event(&wire).unwrap(); + + assert_eq!(*trace.lock().unwrap(), vec!["gained", "lost"]); + } + + #[test] + fn fr45_key_up_follows_key_down() { + use std::sync::Arc; + use std::sync::Mutex; + + let trace: Arc>> = + Arc::new(Mutex::new(Vec::new())); + let trace_for_root = Arc::clone(&trace); + let mut host = crate::ComposeHost::with_content(move || { + let down_trace = Arc::clone(&trace_for_root); + let up_trace = Arc::clone(&trace_for_root); + crate::ui::TextField() + .on_key_down(move |event| { + down_trace.lock().unwrap().push(( + "down", + event.key(), + event.shift_key(), + event.ctrl_key(), + event.alt_key(), + event.meta_key(), + )); + }) + .on_key_up(move |event| { + up_trace.lock().unwrap().push(( + "up", + event.key(), + event.shift_key(), + event.ctrl_key(), + event.alt_key(), + event.meta_key(), + )); + }); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + let down_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnKeyDown, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_key_down must emit OnKeyDown"); + let up_prop = mutations + .iter() + .find_map(|mutation| match mutation { + Mutation::SetProp { + node_id, + property: PropertyKind::OnKeyUp, + value: PropertyValue::Integer(handler), + } => Some((*node_id, *handler as u64)), + _ => None, + }) + .expect("TextField with on_key_up must emit OnKeyUp"); + assert_eq!(down_prop.0, up_prop.0); + assert_eq!(PropertyKind::OnKeyUp as u16, 118); + + let down_event = HostEvent { + node_id: down_prop.0, + handler_id: down_prop.1, + payload: crate::EventPayload::KeyDown { + key: Key::Enter, + shift_key: true, + ctrl_key: false, + alt_key: true, + meta_key: false, + repeat: false, + code: 0x28, + text: "Enter", + }, + }; + let mut wire = Vec::new(); + encode_event(&down_event, &mut wire).unwrap(); + assert_eq!(decode_event(&wire).unwrap(), down_event); + host.dispatch_event(&wire).unwrap(); + + let up_event = HostEvent { + node_id: up_prop.0, + handler_id: up_prop.1, + payload: crate::EventPayload::KeyUp { + key: Key::Enter, + shift_key: true, + ctrl_key: false, + alt_key: true, + meta_key: false, + repeat: false, + code: 0x28, + text: "Enter", + }, + }; + encode_event(&up_event, &mut wire).unwrap(); + assert_eq!(wire.len(), 37); + assert_eq!(read_u16(&wire, 0).unwrap(), EVENT_KEY_UP); + assert_eq!(EVENT_KEY_UP, 49); + assert_eq!(decode_event(&wire).unwrap(), up_event); + host.dispatch_event(&wire).unwrap(); + + assert_eq!( + *trace.lock().unwrap(), + vec![ + ("down", Key::Enter, true, false, true, false), + ("up", Key::Enter, true, false, true, false), + ] + ); + } + + #[test] + fn fr45_unregistered_nodes_get_no_events() { + let mut host = crate::ComposeHost::with_content(|| { + crate::ui::TextField(); + crate::ui::Box().content(|| {}); + }); + let init = host.rebuild().unwrap().to_vec(); + let mutations = decode_batch(&init).unwrap(); + for mutation in &mutations { + if let Mutation::SetProp { property, .. } = mutation { + assert_ne!( + *property, + PropertyKind::OnFocusGained, + "unregistered node must not emit OnFocusGained" + ); + assert_ne!( + *property, + PropertyKind::OnKeyUp, + "unregistered node must not emit OnKeyUp" + ); + } + } + } } diff --git a/compose-rust/src/runtime/composer.rs b/compose-rust/src/runtime/composer.rs index d6e21c6bf..00a52d742 100644 --- a/compose-rust/src/runtime/composer.rs +++ b/compose-rust/src/runtime/composer.rs @@ -40,6 +40,9 @@ pub(crate) struct Group { pub(crate) generation: u32, pub(crate) alive: bool, pub(crate) slots: Vec>, + /// The states made by the remember call in each slot, by slot index. They are freed + /// when the slot is replaced or the group leaves the composition. + pub(crate) owned: Vec<(usize, super::state::StateKey)>, pub(crate) children: Vec, /// The Renderer node this group created, if it is a widget's group. pub(crate) node: Option, @@ -119,6 +122,8 @@ pub(crate) enum Observer { pub(crate) enum OwnedAttr { None, Text(String), + /// A text kept for the life of the program, such as an interned accessible name. + Label(&'static str), Float(f64), Int(i64), Bool(bool), @@ -134,6 +139,7 @@ impl OwnedAttr { match self { Self::None => AttrValue::None, Self::Text(text) => AttrValue::Text(text), + Self::Label(text) => AttrValue::Text(text), Self::Float(value) => AttrValue::Float(*value), Self::Int(value) => AttrValue::Int(*value), Self::Bool(value) => AttrValue::Bool(*value), @@ -258,6 +264,7 @@ impl Composer { generation: 0, alive: true, slots: Vec::new(), + owned: Vec::new(), children: Vec::new(), node: None, scope: Some(Box::new(Scope::new())), @@ -346,6 +353,7 @@ impl Composer { generation: 0, alive: true, slots: Vec::new(), + owned: Vec::new(), children: Vec::new(), node: None, scope: None, @@ -576,6 +584,15 @@ impl Composer { /// Stores a fresh value in the current call site's slot, discarding whatever a call /// site of another type left there. pub(crate) fn remember_new(&mut self, value: T) { + self.remember_new_owning(value, Vec::new()); + } + + /// Like `remember_new`, and the states in `owned` are freed with the slot. + pub(crate) fn remember_new_owning( + &mut self, + value: T, + owned: Vec, + ) { let Some(frame) = self.frames.last_mut() else { return; }; @@ -586,9 +603,28 @@ impl Composer { if index < slots.len() { let old = std::mem::replace(&mut slots[index], Box::new(value)); self.forget_slot(old); + self.release_owned(group, Some(index)); } else { slots.push(Box::new(value)); } + self.groups[group as usize] + .owned + .extend(owned.into_iter().map(|key| (index, key))); + } + + /// Frees the states a group's slots own, all of them or those of one slot. The values + /// go to the graveyard, so they are dropped with the composer free. + fn release_owned(&mut self, group: GroupId, slot: Option) { + let owned = std::mem::take(&mut self.groups[group as usize].owned); + let (gone, kept): (Vec<_>, Vec<_>) = owned + .into_iter() + .partition(|(index, _)| slot.is_none_or(|slot| slot == *index)); + self.groups[group as usize].owned = kept; + for (_, key) in gone { + if let Some(value) = super::state::release_state(key) { + self.graveyard.push(value); + } + } } /// Mutable access to the current slot if it holds a `T`, advancing past it. @@ -654,6 +690,7 @@ impl Composer { for slot in slots.into_iter().rev() { self.forget_slot(slot); } + self.release_owned(group, None); if let Some(scope) = self.groups[group as usize].scope.take() { let generation = self.groups[group as usize].generation; for read in scope.reads.iter().chain(scope.previous_reads.iter()) { @@ -1183,3 +1220,18 @@ pub const fn call_site(path: &str, ordinal: u32) -> u64 { hash ^= ordinal as u64; hash.wrapping_mul(0x100_0000_01b3) } + +impl Drop for Composer { + fn drop(&mut self) { + // A composition that ends while groups are still in it frees their states too. + // The values are dropped last, with no lock held, because a destructor may use + // another state. + let mut values = Vec::new(); + for group in &mut self.groups { + for (_, key) in group.owned.drain(..) { + values.extend(super::state::release_state(key)); + } + } + drop(values); + } +} diff --git a/compose-rust/src/runtime/host.rs b/compose-rust/src/runtime/host.rs index b859c2a53..40f973f31 100644 --- a/compose-rust/src/runtime/host.rs +++ b/compose-rust/src/runtime/host.rs @@ -44,9 +44,9 @@ impl Recomposer { let (window, design, permission) = { let composer = composition.composer.borrow(); ( - composer.ambient.window.clone(), - composer.ambient.design.clone(), - composer.ambient.permission.clone(), + composer.ambient.window, + composer.ambient.design, + composer.ambient.permission, ) }; // The values themselves stay in the modules the Host publishes them to. A state @@ -270,7 +270,7 @@ impl crate::LaunchBuilder { /// ends the process with a failing status if the renderer loop could not run at all. /// /// ```ignore - /// #[composable] + /// #[Composable] /// fn app() { /// Text("Hello"); /// } @@ -298,7 +298,7 @@ impl crate::LaunchBuilder { /// Runs a composable as the application, with every launch choice left to its default. /// /// ```ignore -/// #[composable] +/// #[Composable] /// fn counter() { /// let count = remember(|| mutable_state_of(0)); /// let more = count.clone(); @@ -323,7 +323,10 @@ fn deliver(callback: &EventCallback, payload: &EventPayload<'_>) -> Result (*handler.borrow_mut())(), (EventCallback::Text(handler), EventPayload::TextChanged(text)) | (EventCallback::Text(handler), EventPayload::TextSubmitted(text)) => { @@ -337,9 +340,23 @@ fn deliver(callback: &EventCallback, payload: &EventPayload<'_>) -> Result { - let event = KeyEvent::new(*key, *shift_key, *ctrl_key, *alt_key, *meta_key); + let event = KeyEvent::new(*key, *shift_key, *ctrl_key, *alt_key, *meta_key) + .with_details(*repeat, *code, text); (*handler.borrow_mut())(event.clone()); return Ok(i64::from(event.consumed())); } @@ -382,6 +399,9 @@ fn deliver(callback: &EventCallback, payload: &EventPayload<'_>) -> Result { (*handler.borrow_mut())(*value) } + (EventCallback::Value(handler), EventPayload::CompositionChanged { composing }) => { + (*handler.borrow_mut())(if *composing { 1.0 } else { 0.0 }) + } (EventCallback::Range(handler), EventPayload::RangeRequested { start, count }) => { (*handler.borrow_mut())(RangeRequest::new(*start, *count)) } diff --git a/compose-rust/src/runtime/mod.rs b/compose-rust/src/runtime/mod.rs index c52ca88c1..bfd8bdf20 100644 --- a/compose-rust/src/runtime/mod.rs +++ b/compose-rust/src/runtime/mod.rs @@ -5,7 +5,7 @@ //! use compose_rust::runtime::*; //! use compose_rust::ui::*; //! -//! #[composable] +//! #[Composable] //! fn Counter() { //! let count = remember(|| mutable_state_of(0)); //! Column().content(|| { @@ -28,7 +28,7 @@ //! changed becomes a record. //! //! **Positional memoisation.** [`remember`] belongs to its call site: the group path the -//! `#[composable]` macro builds around every function and every branch, and the order of +//! `#[Composable]` macro builds around every function and every branch, and the order of //! calls inside each group. A branch that stops being taken takes what it remembered with //! it; a loop that gets shorter drops the iterations it lost. Where order is not identity, //! because items move, [`key`] says which item is which. @@ -43,7 +43,7 @@ pub(crate) mod host; mod seam; pub(crate) mod state; -pub use compose_rust_macros::composable; +pub use compose_rust_macros::Composable; pub use effects::{ CoroutineScope, DisposableEffectResult, delay, disposable_effect, launched_effect, on_dispose, remember_coroutine_scope, side_effect, with_frame_nanos, with_worker, @@ -51,7 +51,7 @@ pub use effects::{ pub use host::{ComposeHost, Recomposer, application, launch, recomposer_for}; pub use seam::{Batch, Runtime}; pub use state::{ - DerivedState, MutableState, SnapshotMutationPolicy, State, current_design_system, + DerivedState, MutableState, SnapshotMutationPolicy, State, StateError, current_design_system, current_notification_permission, current_window_size, derived_state_of, mutable_state_of, mutable_state_of_with_policy, never_equal_policy, on_application_reopen, on_notification_activated, on_window_requested, remember_node_size, structural_equality_policy, @@ -183,7 +183,9 @@ pub(crate) fn group_guard(key: u64) -> GroupGuard { /// The value is returned by clone, so what is remembered is usually a handle: a /// [`MutableState`], an `Rc`, a derived state. Outside a composition nothing is kept and /// `init` runs every time. +#[track_caller] pub fn remember(init: impl FnOnce() -> T) -> T { + let site = std::panic::Location::caller(); let found = with_composer_if_idle(|composer| { if composer.composing() { Some(composer.remembered::()) @@ -195,8 +197,10 @@ pub fn remember(init: impl FnOnce() -> T) -> T { match found { Some(Some(value)) => value, Some(None) => { + let capture = state::Capture::begin(site); let value = init(); - with_composer(|composer| composer.remember_new(value.clone())); + let owned = capture.finish(); + with_composer(|composer| composer.remember_new_owning(value.clone(), owned)); value } None => init(), @@ -205,11 +209,13 @@ pub fn remember(init: impl FnOnce() -> T) -> T { /// Like [`remember`], but computed again whenever `key` changes. Compose's /// `remember(key) { }`. +#[track_caller] pub fn remember_with_key(key: K, init: impl FnOnce() -> T) -> T where K: PartialEq + Clone + 'static, T: Clone + 'static, { + let site = std::panic::Location::caller(); let found = with_composer_if_idle(|composer| { if composer.composing() { Some(composer.remembered_if::<(K, T)>(|(stored, _)| *stored == key)) @@ -221,9 +227,11 @@ where match found { Some(Some((_, value))) => value, Some(None) => { + let capture = state::Capture::begin(site); let value = init(); + let owned = capture.finish(); // The cursor did not move, so this replaces the stale value where it stood. - with_composer(|composer| composer.remember_new((key, value.clone()))); + with_composer(|composer| composer.remember_new_owning((key, value.clone()), owned)); value } None => init(), @@ -300,7 +308,7 @@ pub fn current_recompose_scope() -> Option { .flatten() } -/// What `#[composable]` expands into. Not for calling by hand: the macro's guarantees are +/// What `#[Composable]` expands into. Not for calling by hand: the macro's guarantees are /// what make these safe to use. #[doc(hidden)] pub mod __private { diff --git a/compose-rust/src/runtime/seam.rs b/compose-rust/src/runtime/seam.rs index 3a58880b5..cf9e76124 100644 --- a/compose-rust/src/runtime/seam.rs +++ b/compose-rust/src/runtime/seam.rs @@ -86,6 +86,14 @@ impl Batch { /// The root theme record. Written once per rebuild, never per frame. pub fn set_theme(&mut self, theme: Theme) { self.write(Mutation::SetTheme(theme)); + if let Some(overlay) = theme.overlay { + self.write(Mutation::SetOverlayTokens(*overlay)); + } + } + + /// Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + pub fn set_overlay_tokens(&mut self, tokens: crate::schema::OverlayTokens) { + self.write(Mutation::SetOverlayTokens(tokens)); } /// The root window record. Written once per rebuild, never per frame. @@ -162,6 +170,21 @@ impl Batch { } } + /// Writes one file dialog request. Like a notification it names no node: the dialog is + /// not in the tree, and `window` only says which window it is modal for. + pub(crate) fn file_dialog(&mut self, request: &crate::file_dialog::Request) { + self.write(Mutation::ShowFileDialog { + request: request.id, + window: request.window, + kind: request.kind, + default_filter: request.default_filter, + title: &request.title, + directory: &request.directory, + name: &request.name, + filters: &request.filters, + }); + } + /// Writes one edit the application asked of a code editor: replace `range` of the /// document as it stood at `base_version` with `text`. The Renderer moves it past what /// the reader typed since, or refuses it and reports `request_id` back. @@ -240,6 +263,11 @@ impl Batch { pub fn append_text_node(&mut self, node_id: u32, text: &str) { self.write(Mutation::AppendText { node_id, text }); } + + /// Requests input focus for `node_id`, or clears focus when `node_id` is 0. + pub fn request_focus(&mut self, node_id: u32) { + self.write(Mutation::RequestFocus { node_id }); + } } /// What builds and changes the tree, seen from the boundary. diff --git a/compose-rust/src/runtime/state.rs b/compose-rust/src/runtime/state.rs index 60cbd5ba2..ef60ef235 100644 --- a/compose-rust/src/runtime/state.rs +++ b/compose-rust/src/runtime/state.rs @@ -14,9 +14,12 @@ use super::composer::Observer; use super::{with_composer, with_composer_if_idle}; use crate::schema::{DesignSystem, NotificationPermission}; use crate::window::WindowSize; +use std::any::Any; use std::cell::{Cell, RefCell}; use std::collections::HashMap; use std::fmt; +use std::marker::PhantomData; +use std::panic::Location; use std::rc::{Rc, Weak}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; @@ -70,43 +73,202 @@ struct StateCell { runtimes: Mutex>>, } -/// A value that composition observes. Compose's `MutableState`. +impl StateCell { + fn notify(&self) { + let mut told = false; + lock(&self.runtimes).retain(|runtime| match runtime.upgrade() { + Some(signal) => { + signal.push_dirty(self.id); + told = true; + true + } + None => false, + }); + // Inside a Host call the change is picked up before the call returns. Anywhere + // else, a worker above all, nothing would look until something asked for a frame. + if told && !crate::boundary::in_host_call() { + crate::boundary::request_frame_from_worker(); + } + } +} + +// ----- the arena --------------------------------------------------------------------- + +/// A place in the arena: the slot's index, and the generation it had when the state was +/// put there. A slot that is freed and used again has a higher generation, so a handle +/// to the old state never reaches the new one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct StateKey { + index: u32, + generation: u32, +} + +struct Entry { + generation: u32, + cell: Option>, +} + +struct Arena { + entries: Vec, + free: Vec, +} + +/// Every state's value lives here, not in the handle. One lock for the table, and a lock +/// of its own for each value, so a long read of one state does not hold up the others. +static ARENA: Mutex = Mutex::new(Arena { + entries: Vec::new(), + free: Vec::new(), +}); + +/// The handles that have been warned about, for a write after the state was freed. +static WARNED: Mutex> = Mutex::new(Vec::new()); + +fn allocate(cell: Arc) -> StateKey { + let mut arena = lock(&ARENA); + if let Some(index) = arena.free.pop() { + let entry = &mut arena.entries[index as usize]; + entry.cell = Some(cell); + return StateKey { + index, + generation: entry.generation, + }; + } + let index = arena.entries.len() as u32; + arena.entries.push(Entry { + generation: 0, + cell: Some(cell), + }); + StateKey { + index, + generation: 0, + } +} + +/// Frees a state and gives its value back to be dropped by the caller, once, with no lock +/// held. `None` when the key was already freed. +pub(crate) fn release_state(key: StateKey) -> Option> { + let mut arena = lock(&ARENA); + let entry = arena.entries.get_mut(key.index as usize)?; + if entry.generation != key.generation { + return None; + } + let cell = entry.cell.take()?; + entry.generation = entry.generation.wrapping_add(1); + arena.free.push(key.index); + Some(Box::new(cell)) +} + +// ----- who owns a state -------------------------------------------------------------- + +thread_local! { + /// The states made since each open `remember` began, and where that call is. + static PENDING: RefCell> = const { RefCell::new(Vec::new()) }; + static SITES: RefCell>> = const { RefCell::new(Vec::new()) }; +} + +/// The span of a `remember` call's initialiser. States made inside it belong to the +/// slot the call fills. +pub(crate) struct Capture { + mark: usize, +} + +impl Capture { + pub(crate) fn begin(site: &'static Location<'static>) -> Self { + let mark = PENDING.with_borrow(Vec::len); + SITES.with_borrow_mut(|sites| sites.push(site)); + Self { mark } + } + + /// The states made since `begin`. + pub(crate) fn finish(self) -> Vec { + PENDING.with_borrow_mut(|pending| pending.split_off(self.mark.min(pending.len()))) + } +} + +impl Drop for Capture { + fn drop(&mut self) { + SITES.with_borrow_mut(|sites| { + sites.pop(); + }); + PENDING.with_borrow_mut(|pending| pending.truncate(self.mark)); + } +} + +/// Why a state could not be read or written. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StateError { + /// The `remember` call that made the state has left the composition, and the state + /// went with it. + Released, +} + +impl fmt::Display for StateError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Released => formatter.write_str( + "Released: the state left the composition with the remember call that made it", + ), + } + } +} + +impl std::error::Error for StateError {} + +/// A handle to a value that composition observes. Compose's `MutableState`. /// -/// Cloning gives another handle to the same value. Two handles compare equal when they -/// are the same state, which is what makes a state a stable parameter: passing the same -/// state to a composable again does not make it run. +/// The handle is `Copy`: it names a place in an arena, and the value is owned by the +/// composition. The value lives as long as the `remember` call that made it stays in the +/// composition and is dropped, once, when that call's group is removed. A handle used +/// after that is released: `set` and `update` do nothing, `get` panics, and the `try_` +/// forms return [`StateError::Released`]. A slot reused for another state has a new +/// generation, so an old handle never reaches the new value. /// -/// It is `Send` and `Sync` when the value is `Send`, so a worker thread can hold one and -/// write it. The write is visible at once; the composables that read it run again on the -/// next frame, which the write asks for. +/// Two handles are equal when they name the same state, which is what makes a state a +/// stable parameter: passing the same state to a composable again does not make it run. +/// Equal values in two different states are not equal handles. +/// +/// A handle is `Send` and `Sync`, so a worker thread can take one by `move` and write it. +/// The write is visible at once; the composables that read it run again on the next +/// frame, which the write asks for. A read on a worker clones the value and subscribes to +/// nothing. /// /// Reading takes a lock for as long as the read lasts. Reading the same state again inside /// [`MutableState::with`] would wait on itself. pub struct MutableState { - cell: Arc>, + key: StateKey, + /// The `remember` call that made the state, for the message of a read that fails. + site: &'static Location<'static>, + marker: PhantomData T>, } impl Clone for MutableState { fn clone(&self) -> Self { - Self { - cell: Arc::clone(&self.cell), - } + *self } } +impl Copy for MutableState {} + impl PartialEq for MutableState { fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.cell, &other.cell) + self.key == other.key } } impl Eq for MutableState {} -impl fmt::Debug for MutableState { +impl std::hash::Hash for MutableState { + fn hash(&self, state: &mut H) { + self.key.hash(state); + } +} + +impl fmt::Debug for MutableState { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter - .debug_tuple("MutableState") - .field(&*lock(&self.cell.value)) + .debug_struct("MutableState") + .field("index", &self.key.index) + .field("generation", &self.key.generation) .finish() } } @@ -118,83 +280,190 @@ impl fmt::Debug for MutableState { /// Text(format!("{}", count.get())); /// Button("More").on_click(move || count.update(|count| *count += 1)); /// ``` -pub fn mutable_state_of(value: T) -> MutableState { +/// +/// Made inside `remember`, the state belongs to that call. Made anywhere else, it is not +/// freed until the program ends. +#[track_caller] +pub fn mutable_state_of(value: T) -> MutableState { mutable_state_of_with_policy(value, structural_equality_policy()) } /// A state with a policy of its own for what counts as a change. -pub fn mutable_state_of_with_policy( +#[track_caller] +pub fn mutable_state_of_with_policy( value: T, policy: SnapshotMutationPolicy, ) -> MutableState { + let here = Location::caller(); + let cell: Arc = Arc::new(StateCell { + id: next_state_id(), + value: Mutex::new(value), + policy, + runtimes: Mutex::new(Vec::new()), + }); + let key = allocate(cell); + let site = SITES.with_borrow(|sites| sites.last().copied()); + if site.is_some() { + PENDING.with_borrow_mut(|pending| pending.push(key)); + } MutableState { - cell: Arc::new(StateCell { - id: next_state_id(), - value: Mutex::new(value), - policy, - runtimes: Mutex::new(Vec::new()), - }), + key, + site: site.unwrap_or(here), + marker: PhantomData, } } -impl MutableState { - /// The value, recorded as read by the scope composing now. +impl MutableState { + fn resolve(&self) -> Option>> { + let cell = { + let arena = lock(&ARENA); + let entry = arena.entries.get(self.key.index as usize)?; + if entry.generation != self.key.generation { + return None; + } + entry.cell.clone()? + }; + cell.downcast::>().ok() + } + + fn released(&self) -> ! { + panic!( + "Released: this MutableState was used after the remember call at {}:{} left the composition", + self.site.file(), + self.site.line() + ) + } + + fn warn_released(&self) { + if !cfg!(debug_assertions) { + return; + } + { + let mut warned = lock(&WARNED); + if warned.contains(&self.key) { + return; + } + warned.push(self.key); + } + eprintln!( + "compose-rust: a write to a released MutableState was ignored (made by the remember call at {}:{})", + self.site.file(), + self.site.line() + ); + } + + /// How many warnings this handle has caused: one in a debug build after its first + /// write while released, never in a release build. + #[doc(hidden)] + pub fn released_warning_count(&self) -> usize { + lock(&WARNED).iter().filter(|key| **key == self.key).count() + } + + /// Whether the state is still in the composition. + pub fn is_released(&self) -> bool { + self.resolve().is_none() + } + + /// The value, recorded as read by the scope composing now. Panics if the state is + /// released. pub fn get(&self) -> T where T: Clone, { - self.track(); - lock(&self.cell.value).clone() + self.try_get().unwrap_or_else(|_| self.released()) + } + + /// Like [`MutableState::get`], and `Err(StateError::Released)` if the state is + /// released. + pub fn try_get(&self) -> Result + where + T: Clone, + { + let cell = self.resolve().ok_or(StateError::Released)?; + Self::track(&cell); + let value = lock(&cell.value).clone(); + Ok(value) } /// The value, without recording a read. A scope that only peeks is not run again when - /// the value changes. + /// the value changes. Panics if the state is released. pub fn get_untracked(&self) -> T where T: Clone, { - lock(&self.cell.value).clone() + match self.resolve() { + Some(cell) => lock(&cell.value).clone(), + None => self.released(), + } } - /// Reads the value in place, recorded as read. + /// Reads the value in place, recorded as read. Panics if the state is released. pub fn with(&self, read: impl FnOnce(&T) -> R) -> R { - self.track(); - read(&lock(&self.cell.value)) + match self.resolve() { + Some(cell) => { + Self::track(&cell); + let value = lock(&cell.value); + read(&value) + } + None => self.released(), + } } - /// Replaces the value. Under the default policy an equal value changes nothing. + /// Replaces the value. Under the default policy an equal value changes nothing. Does + /// nothing if the state is released. pub fn set(&self, value: T) { + if self.try_set(value).is_err() { + self.warn_released(); + } + } + + /// Like [`MutableState::set`], and `Err(StateError::Released)` if the state is + /// released. + pub fn try_set(&self, value: T) -> Result<(), StateError> { + let cell = self.resolve().ok_or(StateError::Released)?; { - let mut current = lock(&self.cell.value); - if (self.cell.policy.equivalent)(¤t, &value) { - return; + let mut current = lock(&cell.value); + if (cell.policy.equivalent)(¤t, &value) { + return Ok(()); } *current = value; } - self.notify(); + cell.notify(); + Ok(()) } /// Changes the value in place. Always a change: there is no old value left to compare - /// against without a copy, and a caller who wants the comparison can use `set`. + /// against without a copy, and a caller who wants the comparison can use `set`. Does + /// nothing if the state is released. pub fn update(&self, change: impl FnOnce(&mut T)) { - change(&mut lock(&self.cell.value)); - self.notify(); + if self.try_update(change).is_err() { + self.warn_released(); + } + } + + /// Like [`MutableState::update`], and `Err(StateError::Released)` if the state is + /// released. + pub fn try_update(&self, change: impl FnOnce(&mut T)) -> Result<(), StateError> { + let cell = self.resolve().ok_or(StateError::Released)?; + change(&mut lock(&cell.value)); + cell.notify(); + Ok(()) } /// This state as a read-only [`State`]. pub fn as_state(&self) -> MutableState { - self.clone() + *self } - fn track(&self) { + fn track(cell: &Arc>) { let fresh = with_composer_if_idle(|composer| { composer - .record_read(self.cell.id) + .record_read(cell.id) .then(|| Arc::clone(&composer.signal)) }) .flatten(); if let Some(signal) = fresh { - let mut runtimes = lock(&self.cell.runtimes); + let mut runtimes = lock(&cell.runtimes); if !runtimes .iter() .any(|known| std::ptr::eq(known.as_ptr(), Arc::as_ptr(&signal))) @@ -203,23 +472,6 @@ impl MutableState { } } } - - fn notify(&self) { - let mut told = false; - lock(&self.cell.runtimes).retain(|runtime| match runtime.upgrade() { - Some(signal) => { - signal.push_dirty(self.cell.id); - told = true; - true - } - None => false, - }); - // Inside a Host call the change is picked up before the call returns. Anywhere - // else, a worker above all, nothing would look until something asked for a frame. - if told && !crate::boundary::in_host_call() { - crate::boundary::request_frame_from_worker(); - } - } } /// Something composition can read. Compose's `State`. @@ -228,7 +480,7 @@ pub trait State { fn value(&self) -> T; } -impl State for MutableState { +impl State for MutableState { fn value(&self) -> T { self.get() } @@ -392,6 +644,23 @@ pub(crate) struct WindowAmbient { _subscription: crate::window::WindowSubscription, } +impl Drop for WindowAmbient { + fn drop(&mut self) { + drop(release_state(self.state.key)); + } +} + +impl Drop for Ambient { + fn drop(&mut self) { + let values = [ + release_state(self.window.key), + release_state(self.design.key), + release_state(self.permission.key), + ]; + drop(values); + } +} + impl Ambient { pub(crate) fn new() -> Self { Self { @@ -481,7 +750,7 @@ pub fn current_window_size() -> WindowSize { return state.get(); } } - match with_composer_if_idle(|composer| composer.ambient.window.clone()) { + match with_composer_if_idle(|composer| composer.ambient.window) { Some(state) => state.get(), None => crate::window::window_size(), } @@ -491,10 +760,12 @@ pub fn current_window_size() -> WindowSize { fn window_state(window: u32) -> Option> { with_composer_if_idle(|composer| { if let Some(entry) = composer.ambient.windows.get(&window) { - return entry.state.clone(); + return entry.state; } let state = mutable_state_of(crate::window::window_size_of(window)); - let follow = state.clone(); + // The ambient owns this state, not a `remember` call that happens to be open. + PENDING.with_borrow_mut(|pending| pending.retain(|key| *key != state.key)); + let follow = state; let subscription = crate::window::subscribe_window( window, Arc::new(move || follow.set(crate::window::window_size_of(window))), @@ -502,7 +773,7 @@ fn window_state(window: u32) -> Option> { composer.ambient.windows.insert( window, WindowAmbient { - state: state.clone(), + state, _subscription: subscription, }, ); @@ -516,7 +787,7 @@ fn window_state(window: u32) -> Option> { /// for the screen that is a different screen under a different system, such as a /// calculator whose keys differ. pub fn current_design_system() -> DesignSystem { - match with_composer_if_idle(|composer| composer.ambient.design.clone()) { + match with_composer_if_idle(|composer| composer.ambient.design) { Some(state) => state.get(), None => crate::design::design_system(), } @@ -524,7 +795,7 @@ pub fn current_design_system() -> DesignSystem { /// Whether notifications may be shown, recorded as read. pub fn current_notification_permission() -> NotificationPermission { - match with_composer_if_idle(|composer| composer.ambient.permission.clone()) { + match with_composer_if_idle(|composer| composer.ambient.permission) { Some(state) => state.get(), None => crate::notification::notification_permission(), } @@ -555,20 +826,25 @@ pub fn remember_node_size() -> crate::window::NodeSize { return None; } if let Some(slot) = composer.slot_mut::() { - return Some((slot.token, slot.state.clone())); + return Some((slot.token, slot.state)); } let token = crate::window::next_node_token(); + let capture = Capture::begin(Location::caller()); let state = mutable_state_of(crate::window::node_size(token)); - let follow = state.clone(); + let owned = capture.finish(); + let follow = state; let subscription = crate::window::subscribe_node( token, std::sync::Arc::new(move || follow.set(crate::window::node_size(token))), ); - composer.remember_new(NodeSizeSlot { - token, - state: state.clone(), - _subscription: subscription, - }); + composer.remember_new_owning( + NodeSizeSlot { + token, + state, + _subscription: subscription, + }, + owned, + ); Some((token, state)) }) .flatten(); diff --git a/compose-rust/src/schema.rs b/compose-rust/src/schema.rs index 954b160bc..dd90e7ed7 100644 --- a/compose-rust/src/schema.rs +++ b/compose-rust/src/schema.rs @@ -47,6 +47,18 @@ pub enum FieldSlot { SixthLow, SixthHigh, Sixth, + SeventhLow, + SeventhHigh, + Seventh, + EighthLow, + EighthHigh, + Eighth, + NinthLow, + NinthHigh, + Ninth, + TenthLow, + TenthHigh, + Tenth, } impl FieldSlot { @@ -65,6 +77,14 @@ impl FieldSlot { Self::FifthHigh => (4, true), Self::SixthLow | Self::Sixth => (5, false), Self::SixthHigh => (5, true), + Self::SeventhLow | Self::Seventh => (6, false), + Self::SeventhHigh => (6, true), + Self::EighthLow | Self::Eighth => (7, false), + Self::EighthHigh => (7, true), + Self::NinthLow | Self::Ninth => (8, false), + Self::NinthHigh => (8, true), + Self::TenthLow | Self::Tenth => (9, false), + Self::TenthHigh => (9, true), } } } @@ -88,7 +108,7 @@ pub struct VariantSchema { } /// The most words any modifier's value takes, the two every record has included. -pub const MAX_MODIFIER_WORDS: usize = 6; +pub const MAX_MODIFIER_WORDS: usize = 10; /// The `extra_words` the schema gives a modifier tag, or `None` for a tag it does not list. pub const fn modifier_extra_words(tag: u16) -> Option { @@ -170,6 +190,9 @@ pub enum EventPayloadType { /// A window the platform or the user asked the application for: a request number, then /// the `RestoreKey` as a string reference. The one window event that carries a string. WindowRequest, + /// What became of one file dialog: the request number, the status, the filter in use + /// and the paths as one string reference. + FileDialogResult, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -183,13 +206,13 @@ pub struct EventSchema { pub const SCHEMA_DESCRIPTOR: &str = concat!( "compose-rust/v1;", "widgets=Column,Row,Box,Text,TextField,Button,Spacer,LazyColumn,ScrollColumn,Image,Icon,Checkbox,RadioButton,Switch,Slider,ProgressIndicator,Divider,Card,Surface,Dialog,Menu,Tabs,TopAppBar,LazyRow,Tooltip,Canvas,DatePicker,TimePicker,Dropdown,Navigation,NavigationItem,Sheet,Scaffold,ScaffoldSlot,LazyGrid,FileDropTarget,ScrollRow,Chip,FloatingAction,Badge,SelectionContainer,SplitPane,CodeEditor,AbsoluteBox,Window;", - "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key;", - "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,MatchParentSize,WindowDrag,PressFeedback;", - "keys=Enter;", - "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen;", + "properties=text,placeholder,enabled,multiline,on_click,on_value_change,on_submit,on_focus_lost,on_key_down,item_count,item_key,on_range_requested,type_role,font_size,font_weight,line_height,letter_spacing,color,text_align,max_lines,overflow,arrangement,spacing,space_role,alignment,variant,asset,checked,steps,determinate,circular,vertical,open,on_dismiss,selected_index,commands,value,min,max,icon,slot,columns,min_column_width,spans,on_files_entered,on_files_dropped,section,count,collapsible,button_kind,decorations,syntax_spans,tab_width,on_edit_rejected,on_hover,on_save,on_decoration_click,folds,gutter_decorations,on_selection_change,font,word_break,overflow_wrap,tab_size,absolute_size,soft_wrap,span_fonts,on_visible_range_change,on_editor_focus_change,on_fold_change,on_composition_change,on_pointer,pointer_kinds,cursor,user_select,island_zoom,window_title,window_size,window_min_size,window_resizable,window_chrome,window_title_bar,window_icon,window_position,window_restore_key,window_zoom_level,drag_from_pointer,on_window_close_requested,on_window_focus_changed,on_window_moved,on_window_drag,on_window_unavailable,on_window_closed,on_window_drag_over,tear_off_key,on_focus_gained,on_key_up,accessible_name,track_color,thumb_color,active_track_color,container_color,check_color,control_size,report_size,scrim;", + "modifiers=Empty,Padding,FillMaxWidth,FillMaxHeight,Width,Height,Size,Background,Clickable,PaddingRole,PaddingEach,Weight,Shape,ShapeRole,Border,Elevation,ObserveSize,Motion,Material,Offset,RequiredSize,BorderEach,CornerEach,Shadow,Clip,Alpha,Transform,Transform3D,MatchParentSize,WindowDrag,PressFeedback,Semantics;", + "keys=Enter,Other;", + "events=Clicked,TextChanged,TextSubmitted,FocusLost,ProtocolError,KeyDown,RangeRequested,ValueChanged,WindowSizeChanged,DesignSystemResolved,FilesEntered,FilesDropped,NotificationActivated,NotificationPermissionChanged,CodeChanged,CodeEditRejected,CodeHovered,CodeSaveRequested,DecorationActivated,AnimationEvent,ReducedMotionChanged,Pointer,ZoomChanged,SelectionChanged,VisibleRangeChanged,EditorFocusChanged,FoldChanged,CompositionChanged,WindowCloseRequested,WindowFocusChanged,WindowMoved,WindowDrag,WindowDragOver,WindowUnavailable,WindowClosed,WindowRequested,ApplicationReopen,FocusGained,KeyUp,FileDialogResult;", "windowsizeclasses=Compact,Medium,Expanded;", "windowheightclasses=Compact,Medium,Expanded;", - "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow" + "commands=Create,SetProp,SetModifier,Insert,Move,Remove,SetText,AppendText,SetTheme,SetWindow,RegisterAsset,ReleaseAsset,ShowMessage,PostNotification,WithdrawNotification,RequestNotificationPermission,EditCode,StartAnimation,ControlAnimation,SetSelection,RevealRange,SetFolded,SetWindowTheme,FocusWindow,RequestFocus,ShowFileDialog,SetOverlayTokens" ); const fn hash_bytes(mut hash: u64, bytes: &[u8]) -> u64 { @@ -273,6 +296,18 @@ const fn schema_hash() -> u64 { FieldSlot::SixthLow => 15, FieldSlot::SixthHigh => 16, FieldSlot::Sixth => 17, + FieldSlot::SeventhLow => 18, + FieldSlot::SeventhHigh => 19, + FieldSlot::Seventh => 20, + FieldSlot::EighthLow => 21, + FieldSlot::EighthHigh => 22, + FieldSlot::Eighth => 23, + FieldSlot::NinthLow => 24, + FieldSlot::NinthHigh => 25, + FieldSlot::Ninth => 26, + FieldSlot::TenthLow => 27, + FieldSlot::TenthHigh => 28, + FieldSlot::Tenth => 29, }], ); field_index += 1; @@ -341,6 +376,7 @@ const fn schema_hash() -> u64 { EventPayloadType::WindowDrag => 26, EventPayloadType::WindowDragOver => 27, EventPayloadType::WindowRequest => 28, + EventPayloadType::FileDialogResult => 29, }], ); index += 1; @@ -557,6 +593,8 @@ pub struct Selection { // Compose key identities supported by the M0 schema. define_wire_enum!(KEY_SCHEMA, Key { Enter = 1, + // Any key the enum does not name. The event's `code` and `text` say which one. + Other = 2, }); define_wire_enum!(WINDOW_SIZE_CLASS_SCHEMA, WindowSizeClass { @@ -822,6 +860,30 @@ define_wire_enum!(MATERIAL_ROLE_SCHEMA, MaterialRole { Chrome = 4, }); +// What a node is to assistive technology. Compose's own `Role` names, so the Renderer maps +// each one to the platform's. `None` leaves the role the widget already has. +define_wire_enum!(SEMANTICS_ROLE_SCHEMA, SemanticsRole { + None = 1, + Button = 2, + Checkbox = 3, + Switch = 4, + RadioButton = 5, + Tab = 6, + Image = 7, + DropdownList = 8, + TabList = 9, + Tree = 10, + TreeItem = 11, + Menu = 12, + MenuItem = 13, + TextBox = 14, + Heading = 15, + Navigation = 16, + List = 17, + ListItem = 18, + Group = 19, +}); + // Density roles, because dp density differs per design system. define_wire_enum!(SPACE_ROLE_SCHEMA, SpaceRole { None = 1, @@ -1253,6 +1315,10 @@ pub const ROLE_ENUM_SCHEMA: &[RoleEnumSchema] = &[ name: "MaterialRole", variants: MATERIAL_ROLE_SCHEMA, }, + RoleEnumSchema { + name: "SemanticsRole", + variants: SEMANTICS_ROLE_SCHEMA, + }, RoleEnumSchema { name: "SpaceRole", variants: SPACE_ROLE_SCHEMA, @@ -1429,6 +1495,14 @@ pub const ROLE_ENUM_SCHEMA: &[RoleEnumSchema] = &[ name: "LastWindowClosed", variants: LAST_WINDOW_CLOSED_SCHEMA, }, + RoleEnumSchema { + name: "FileDialogKind", + variants: FILE_DIALOG_KIND_SCHEMA, + }, + RoleEnumSchema { + name: "FileDialogStatus", + variants: FILE_DIALOG_STATUS_SCHEMA, + }, ]; /// Every place that takes a colour takes a `Paint`, so colour is expressed once. @@ -1519,6 +1593,25 @@ define_wire_enum!(LAST_WINDOW_CLOSED_SCHEMA, LastWindowClosed { KeepRunning = 1, }); +// What a file dialog asks the user to pick. Closed: each kind is a different platform panel +// with different rules about how many paths come back, so a free-form set of flags would +// allow combinations no panel can honour. +define_wire_enum!(FILE_DIALOG_KIND_SCHEMA, FileDialogKind { + OpenFile = 1, + OpenFiles = 2, + OpenFolder = 3, + OpenFolders = 4, + Save = 5, +}); + +// How a file dialog ended. `Unavailable` means this run has no way to show one, which is an +// answer and not an error, and is never used for a user's cancel. +define_wire_enum!(FILE_DIALOG_STATUS_SCHEMA, FileDialogStatus { + Chosen = 1, + Cancelled = 2, + Unavailable = 3, +}); + /// What an application may decide about its own window. /// /// The window belongs to the Renderer, so this is short and stays short. What is here is @@ -1556,6 +1649,10 @@ pub struct Window { /// last time. A level set here is applied, remembered and reported back the way one /// chosen with the keys is. Kept inside `-8..=8`. pub zoom_level: Option, + /// Whether the implicit single window reports every whole-dp change of its size, + /// not only a change of size class. Off by default. Windows made with `Window` + /// nodes opt in through the node's `report_size` property instead. + pub report_size: bool, /// One window (the default) or several. In the second the root's children are /// `Window` nodes and the rest of this record describes no window. pub mode: WindowMode, @@ -1576,6 +1673,7 @@ impl Window { min_height: 0, resizable: true, zoom_level: None, + report_size: false, mode: WindowMode::Single, last_window_closed: LastWindowClosed::Exit, } @@ -1650,6 +1748,12 @@ impl Window { self } + /// Asks the implicit single window to report every whole-dp change of its size. + pub const fn with_report_size(mut self, report: bool) -> Self { + self.report_size = report; + self + } + /// Starts the window at an application zoom level, kept inside `-8..=8`. pub const fn with_zoom_level(mut self, level: i8) -> Self { self.zoom_level = Some(if level < -8 { @@ -1693,11 +1797,100 @@ pub struct Theme { /// platform. A palette built while the application runs is handed to `use_theme`, /// which owns it, so application code never has to give it that lifetime itself. pub palette: Option<&'static crate::palette::Palette>, + /// Application-level overlay theme tokens for Dialog, Menu, Dropdown, and Sheet. + pub overlay: Option<&'static OverlayTokens>, } /// How many type roles there are, which is how many font slots a theme carries. pub const TYPE_ROLE_COUNT: usize = 9; +/// Application-level overlay theme tokens for Dialog, Menu, Dropdown, and Sheet. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct OverlayTokens { + pub sheet_corner_radius: u16, + pub dialog_corner_radius: u16, + pub popup_corner_radius: u16, + pub sheet_elevation: u16, + pub popup_elevation: u16, + pub dialog_elevation: u16, + pub sheet_padding: u16, + pub dialog_padding: u16, + pub popup_padding: u16, + pub scrim: u32, +} + +impl OverlayTokens { + pub const fn new() -> Self { + Self { + sheet_corner_radius: 0, + dialog_corner_radius: 0, + popup_corner_radius: 0, + sheet_elevation: 0, + popup_elevation: 0, + dialog_elevation: 0, + sheet_padding: 0, + dialog_padding: 0, + popup_padding: 0, + scrim: 0, + } + } + + pub const fn sheet_corner_radius(mut self, radius: u16) -> Self { + self.sheet_corner_radius = radius; + self + } + + pub const fn dialog_corner_radius(mut self, radius: u16) -> Self { + self.dialog_corner_radius = radius; + self + } + + pub const fn popup_corner_radius(mut self, radius: u16) -> Self { + self.popup_corner_radius = radius; + self + } + + pub const fn sheet_elevation(mut self, elevation: u16) -> Self { + self.sheet_elevation = elevation; + self + } + + pub const fn popup_elevation(mut self, elevation: u16) -> Self { + self.popup_elevation = elevation; + self + } + + pub const fn dialog_elevation(mut self, elevation: u16) -> Self { + self.dialog_elevation = elevation; + self + } + + pub const fn sheet_padding(mut self, padding: u16) -> Self { + self.sheet_padding = padding; + self + } + + pub const fn dialog_padding(mut self, padding: u16) -> Self { + self.dialog_padding = padding; + self + } + + pub const fn popup_padding(mut self, padding: u16) -> Self { + self.popup_padding = padding; + self + } + + pub const fn scrim(mut self, color: Color) -> Self { + self.scrim = color.to_argb(); + self + } + + pub const fn scrim_argb(mut self, argb: u32) -> Self { + self.scrim = argb; + self + } +} + impl Theme { /// The same design system on every platform. pub const fn unified(design_system: DesignSystem) -> Self { @@ -1708,6 +1901,7 @@ impl Theme { adaptive: false, fonts: [0; TYPE_ROLE_COUNT], palette: None, + overlay: None, } } @@ -1720,6 +1914,7 @@ impl Theme { adaptive: true, fonts: [0; TYPE_ROLE_COUNT], palette: None, + overlay: None, } } @@ -1775,6 +1970,12 @@ impl Theme { self } + /// Overrides overlay geometry, spacing, elevation and scrim for Dialog, Menu, Dropdown and Sheet. + pub const fn with_overlay_tokens(mut self, overlay: &'static OverlayTokens) -> Self { + self.overlay = Some(overlay); + self + } + /// The font this role resolves to, or `None` for the system font. pub const fn font(&self, role: TypeRole) -> Option { if let TypeRole::None = role { @@ -1936,6 +2137,25 @@ pub enum Modifier { origin_x: f32, origin_y: f32, }, + /// Draws this node and everything in it through a 4x4 matrix, the way CSS 3D + /// `transform` does, without changing its layout. Pointer input follows the drawn + /// shape. + /// + /// `m` is column-major, in the order of CSS `matrix3d(...)`: element `m[4 * column + + /// row]`. A point `(x, y, z, 1)` goes to `M * (x, y, z, 1)`, and the screen position is + /// the first two results divided by the fourth. Translations are in dp. A perspective + /// term is already inside the matrix, so the Renderer is never sent one separately. The + /// origin means what it means for [`Modifier::Transform`] and its z is zero. + /// + /// `backface_hidden` is CSS `backface-visibility: hidden`: a node whose back is turned + /// to the viewer is not drawn and takes no input. A matrix with no inverse draws + /// nothing. No value may be NaN or infinite. + Transform3D { + m: [f32; 16], + origin_x: f32, + origin_y: f32, + backface_hidden: bool, + }, /// Matches the bounds of a Box parent without contributing to its size. MatchParentSize, /// Controls the scale and tint of a pressed surface. @@ -1943,8 +2163,41 @@ pub enum Modifier { scale: f32, tint_alpha: f32, }, + /// What this node is to assistive technology: its role, and whether it is checked or + /// selected. `state` holds two tri-state fields, `SEMANTICS_CHECKED_SHIFT` and + /// `SEMANTICS_SELECTED_SHIFT` bits up, each 0 for unset, 1 for off and 2 for on. The + /// accessible name is the `AccessibleName` property, because a string does not fit in + /// a modifier's words. Above those, `expanded`, `disabled` and `focused` are tri-state + /// fields in the same encoding, `SEMANTICS_HIDDEN_BIT` leaves the node out of the + /// accessibility tree, and the two action bits expose a click and a focus action. + Semantics { + role: SemanticsRole, + state: u32, + }, } +/// Where the checked field sits in `Modifier::Semantics::state`. +pub const SEMANTICS_CHECKED_SHIFT: u32 = 0; +/// Where the selected field sits in `Modifier::Semantics::state`. +pub const SEMANTICS_SELECTED_SHIFT: u32 = 2; +/// Where the expanded field sits in `Modifier::Semantics::state`: 0 unset, 1 collapsed, +/// 2 expanded. +pub const SEMANTICS_EXPANDED_SHIFT: u32 = 4; +/// Where the disabled field sits in `Modifier::Semantics::state`: 0 unset, 1 enabled, +/// 2 disabled. +pub const SEMANTICS_DISABLED_SHIFT: u32 = 6; +/// Where the focused field sits in `Modifier::Semantics::state`: 0 unset, 1 not focused, +/// 2 focused. +pub const SEMANTICS_FOCUSED_SHIFT: u32 = 8; +/// The bit that excludes the node from the accessibility tree. +pub const SEMANTICS_HIDDEN_BIT: u32 = 1 << 10; +/// The bit that exposes a click action on the node. +pub const SEMANTICS_CLICK_ACTION_BIT: u32 = 1 << 11; +/// The bit that exposes a focus action on the node. +pub const SEMANTICS_FOCUS_ACTION_BIT: u32 = 1 << 12; +/// Every bit `Modifier::Semantics::state` may set. +pub const SEMANTICS_STATE_MASK: u32 = 0b1_1111_1111_1111; + impl Modifier { /// A border with a width and a paint for each side, in CSS order: top, right, bottom, /// left. @@ -2401,6 +2654,103 @@ const TRANSFORM_FIELDS: &[FieldSchema] = &[ slot: FieldSlot::FourthHigh, }, ]; +const TRANSFORM_3D_FIELDS: &[FieldSchema] = &[ + FieldSchema { + name: "m0", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FirstLow, + }, + FieldSchema { + name: "m1", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FirstHigh, + }, + FieldSchema { + name: "m2", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SecondLow, + }, + FieldSchema { + name: "m3", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SecondHigh, + }, + FieldSchema { + name: "m4", + ty: FieldType::FiniteFloat, + slot: FieldSlot::ThirdLow, + }, + FieldSchema { + name: "m5", + ty: FieldType::FiniteFloat, + slot: FieldSlot::ThirdHigh, + }, + FieldSchema { + name: "m6", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FourthLow, + }, + FieldSchema { + name: "m7", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FourthHigh, + }, + FieldSchema { + name: "m8", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FifthLow, + }, + FieldSchema { + name: "m9", + ty: FieldType::FiniteFloat, + slot: FieldSlot::FifthHigh, + }, + FieldSchema { + name: "m10", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SixthLow, + }, + FieldSchema { + name: "m11", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SixthHigh, + }, + FieldSchema { + name: "m12", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SeventhLow, + }, + FieldSchema { + name: "m13", + ty: FieldType::FiniteFloat, + slot: FieldSlot::SeventhHigh, + }, + FieldSchema { + name: "m14", + ty: FieldType::FiniteFloat, + slot: FieldSlot::EighthLow, + }, + FieldSchema { + name: "m15", + ty: FieldType::FiniteFloat, + slot: FieldSlot::EighthHigh, + }, + FieldSchema { + name: "originX", + ty: FieldType::FiniteFloat, + slot: FieldSlot::NinthLow, + }, + FieldSchema { + name: "originY", + ty: FieldType::FiniteFloat, + slot: FieldSlot::NinthHigh, + }, + FieldSchema { + name: "backfaceHidden", + ty: FieldType::Bool, + slot: FieldSlot::Tenth, + }, +]; const ENABLED_FIELD: &[FieldSchema] = &[FieldSchema { name: "enabled", ty: FieldType::Bool, @@ -2420,6 +2770,19 @@ const PRESS_FEEDBACK_FIELDS: &[FieldSchema] = &[ }, ]; +const SEMANTICS_FIELDS: &[FieldSchema] = &[ + FieldSchema { + name: "role", + ty: FieldType::Role("SemanticsRole"), + slot: FieldSlot::FirstLow, + }, + FieldSchema { + name: "state", + ty: FieldType::U32, + slot: FieldSlot::SecondLow, + }, +]; + pub const MODIFIER_SCHEMA: &[VariantSchema] = &[ VariantSchema { name: "Empty", @@ -2588,6 +2951,14 @@ pub const MODIFIER_SCHEMA: &[VariantSchema] = &[ extra_words: 2, fields: TRANSFORM_FIELDS, }, + // Sixteen matrix values, the origin and a flag are ten words, eight more than the two + // every record has: 92 bytes. + VariantSchema { + name: "Transform3D", + tag: 27, + extra_words: 8, + fields: TRANSFORM_3D_FIELDS, + }, VariantSchema { name: "MatchParentSize", tag: 31, @@ -2606,6 +2977,12 @@ pub const MODIFIER_SCHEMA: &[VariantSchema] = &[ extra_words: 0, fields: PRESS_FEEDBACK_FIELDS, }, + VariantSchema { + name: "Semantics", + tag: 34, + extra_words: 0, + fields: SEMANTICS_FIELDS, + }, ]; #[derive(Clone, Debug, PartialEq)] @@ -2858,6 +3235,29 @@ crate::extensions::define_property_schema_with_extensions!(define_wire_enum; PRO // into a window of its own, named by it. OnWindowDragOver = 167, TearOffKey = 168, + OnFocusGained = 117, + OnKeyUp = 118, + // The name assistive technology reads for a node, a string. On a widget it overrides + // the visible label, and on an icon-only button it beats the name the icon's meaning + // would give. 117 and 118 are reserved for another pending change, and 119 is the next + // free number; 120 is `IslandZoom`. + AccessibleName = 119, + // Colours and size of the selection controls. A node that does not set one leaves + // the design system's value, so a control that sends none draws as it always has. + // The colours are `Paint` bits; the size is two f32 (width, height) in dp. + // 120 is the island zoom, so these start at 121. + TrackColor = 121, + ThumbColor = 122, + ActiveTrackColor = 123, + ContainerColor = 124, + CheckColor = 125, + ControlSize = 126, + // Opt in to a `WindowSizeChanged` whenever the window's width or height, rounded to + // whole dp, changes, and not only when the size class does. A `Window` node's boolean; + // absent is false. + ReportSize = 127, + // Scrim colour for modal overlays (Dialog, Sheet, Menu), as a `Paint` bit value. + Scrim = 128, }); #[derive(Clone, Debug, PartialEq)] @@ -2876,6 +3276,12 @@ pub enum EventPayload<'a> { ctrl_key: bool, alt_key: bool, meta_key: bool, + /// True when the event comes from a held key repeating. + repeat: bool, + /// The physical key as a USB HID usage value, the same on every layout. + code: u32, + /// The DOM `key` string, such as `"ArrowDown"` or `"a"`. + text: &'a str, }, /// The Renderer asks the Host to materialise the visible item range. RangeRequested { @@ -3094,6 +3500,33 @@ pub enum EventPayload<'a> { /// The user opened the application again while it had no window. Delivered to the /// root. ApplicationReopen, + /// The node or a descendant inside it gained keyboard focus. + FocusGained, + /// A key was released while the node held focus. + KeyUp { + key: Key, + shift_key: bool, + ctrl_key: bool, + alt_key: bool, + meta_key: bool, + /// Always false: a release does not repeat. Present so a key-up carries the same + /// fields as a key-down. + repeat: bool, + /// The physical key as a USB HID usage value, the same on every layout. + code: u32, + /// The DOM `key` string, such as `"ArrowDown"` or `"a"`. + text: &'a str, + }, + /// A file dialog ended. `request` is the number the Host gave it, `filter` is the + /// position of the filter in use (`u32::MAX` when there is none) and `paths` holds the + /// chosen paths separated by one NUL byte, empty unless the status is `Chosen`. + /// Delivered to the Host itself, with no node and no handler. + FileDialogResult { + request: u32, + status: FileDialogStatus, + filter: u32, + paths: &'a str, + }, } pub const EVENT_SCHEMA: &[EventSchema] = &[ @@ -3310,6 +3743,23 @@ pub const EVENT_SCHEMA: &[EventSchema] = &[ tag: 47, payload: EventPayloadType::None, }, + EventSchema { + name: "FocusGained", + tag: 48, + payload: EventPayloadType::None, + }, + EventSchema { + name: "KeyUp", + tag: 49, + payload: EventPayloadType::KeyDown, + }, + // Like the notification events this addresses the Host itself: a dialog is not in the tree, so there is no + // node and no handler to name, and the request number finds the callback. + EventSchema { + name: "FileDialogResult", + tag: 50, + payload: EventPayloadType::FileDialogResult, + }, ]; /// One argument of a boundary operation. diff --git a/compose-rust/src/ui/lazy.rs b/compose-rust/src/ui/lazy.rs index 22310504d..57e19ff64 100644 --- a/compose-rust/src/ui/lazy.rs +++ b/compose-rust/src/ui/lazy.rs @@ -169,7 +169,7 @@ impl LazyList { // them and nothing else. let range = remember(|| mutable_state_of((0_usize, 0_usize))); let total = scope.total(); - let request = range.clone(); + let request = range; let listener = EventCallback::Range(Rc::new(RefCell::new(move |asked: RangeRequest| { request.set((asked.start(), asked.count())); }))); @@ -230,8 +230,8 @@ fn Items(range: MutableState<(usize, usize)>, intervals: Rc>) { return; } { - let (range, intervals) = (range.clone(), Rc::clone(&intervals)); - scope.set_restart(move || Items(range.clone(), Rc::clone(&intervals))); + let (range, intervals) = (range, Rc::clone(&intervals)); + scope.set_restart(move || Items(range, Rc::clone(&intervals))); } let total: usize = intervals.iter().map(|interval| interval.count).sum(); let (start, count) = range.get(); diff --git a/compose-rust/src/ui/mod.rs b/compose-rust/src/ui/mod.rs index 99d853d62..b610d1a45 100644 --- a/compose-rust/src/ui/mod.rs +++ b/compose-rust/src/ui/mod.rs @@ -5,7 +5,7 @@ //! use compose_rust::runtime::*; //! use compose_rust::ui::*; //! -//! #[composable] +//! #[Composable] //! fn Greeting(name: String) { //! Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| { //! Text(format!("Hello, {name}")).type_role(TypeRole::Title); @@ -34,10 +34,11 @@ pub use crate::brush::{Brush, Stop, brush}; pub use crate::drawing::{DrawCommand, DrawList, DrawListBuilder}; pub use crate::message::{Message, show_message}; pub use crate::runtime::{application, launch}; +pub use crate::schema::SemanticsRole as Role; pub use crate::schema::{ Alignment, Arrangement, ButtonKind, ButtonVariant, Color, ColorRole, ColorScheme, DesignSystem, - IconRole, Key, MaterialRole, MessageDuration, MotionRole, Paint, ShapeRole, SpaceRole, - TextAlign, TextOverflow, Theme, TypeRole, WindowHeightClass, WindowSizeClass, + IconRole, Key, MaterialRole, MessageDuration, MotionRole, Paint, SemanticsRole, ShapeRole, + SpaceRole, TextAlign, TextOverflow, Theme, TypeRole, WindowHeightClass, WindowSizeClass, }; pub use crate::spans::TextSpans; pub use crate::theme::{ThemeHandle, current_theme}; @@ -85,6 +86,193 @@ impl Shadow { } } +/// How a node presents itself to assistive technology. Each tri-state field is unset until +/// it is given a value, and an unset one sends nothing. The flags are off until set. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SemanticsState { + pub checked: Option, + pub selected: Option, + pub expanded: Option, + pub disabled: Option, + pub focused: Option, + pub hidden: bool, + pub click_action: bool, + pub focus_action: bool, +} + +impl SemanticsState { + const NONE: Self = Self { + checked: None, + selected: None, + expanded: None, + disabled: None, + focused: None, + hidden: false, + click_action: false, + focus_action: false, + }; + + pub const fn checked(checked: bool) -> Self { + Self { + checked: Some(checked), + ..Self::NONE + } + } + + pub const fn selected(selected: bool) -> Self { + Self { + selected: Some(selected), + ..Self::NONE + } + } + + pub const fn expanded(expanded: bool) -> Self { + Self { + expanded: Some(expanded), + ..Self::NONE + } + } + + pub const fn disabled(disabled: bool) -> Self { + Self { + disabled: Some(disabled), + ..Self::NONE + } + } + + pub const fn focused(focused: bool) -> Self { + Self { + focused: Some(focused), + ..Self::NONE + } + } + + pub const fn hidden(hidden: bool) -> Self { + Self { + hidden, + ..Self::NONE + } + } + + pub const fn click_action(exposed: bool) -> Self { + Self { + click_action: exposed, + ..Self::NONE + } + } + + pub const fn focus_action(exposed: bool) -> Self { + Self { + focus_action: exposed, + ..Self::NONE + } + } + + /// The state word the `Semantics` modifier carries. + pub(crate) fn bits(self) -> u32 { + use crate::schema::*; + let tri = |value: Option| value.map_or(0_u32, |on| if on { 2 } else { 1 }); + (tri(self.checked) << SEMANTICS_CHECKED_SHIFT) + | (tri(self.selected) << SEMANTICS_SELECTED_SHIFT) + | (tri(self.expanded) << SEMANTICS_EXPANDED_SHIFT) + | (tri(self.disabled) << SEMANTICS_DISABLED_SHIFT) + | (tri(self.focused) << SEMANTICS_FOCUSED_SHIFT) + | if self.hidden { SEMANTICS_HIDDEN_BIT } else { 0 } + | if self.click_action { + SEMANTICS_CLICK_ACTION_BIT + } else { + 0 + } + | if self.focus_action { + SEMANTICS_FOCUS_ACTION_BIT + } else { + 0 + } + } +} + +/// The most distinct accessible names a program can keep. Names are kept for the life of +/// the program so a `Modifier` can stay `Copy`; the cap keeps a name built from data +/// (an id, a counter) from growing that memory without end. +pub(crate) const MAX_LABELS: usize = 4096; + +/// The distinct accessible names seen so far, each leaked once. +struct LabelTable { + kept: std::sync::RwLock>, + warned: std::sync::atomic::AtomicBool, +} + +impl LabelTable { + fn new() -> Self { + Self { + kept: Default::default(), + warned: Default::default(), + } + } + + /// The kept copy of `label`. A name already kept is found under a shared lock with no + /// allocation. A new name is kept until the table holds `cap` names; past that it is + /// dropped (and reported in debug builds) so memory stays bounded. + fn intern(&self, label: &str, cap: usize) -> Option<&'static str> { + use std::sync::atomic::Ordering; + + if label.is_empty() { + return None; + } + { + let kept = self.kept.read().unwrap_or_else(|p| p.into_inner()); + if let Some(found) = kept.get(label) { + return Some(*found); + } + } + let mut kept = self.kept.write().unwrap_or_else(|p| p.into_inner()); + if let Some(found) = kept.get(label) { + return Some(*found); + } + if kept.len() >= cap { + if cfg!(debug_assertions) && !self.warned.swap(true, Ordering::Relaxed) { + eprintln!( + "compose-rust: more than {cap} distinct accessible names; \ + names past the limit are left off. Use a fixed set of names." + ); + } + return None; + } + let leaked: &'static str = Box::leak(label.to_owned().into_boxed_str()); + kept.insert(leaked); + Some(leaked) + } + + #[cfg(test)] + fn len(&self) -> usize { + self.kept.read().unwrap_or_else(|p| p.into_inner()).len() + } +} + +fn keep_label(label: &str) -> Option<&'static str> { + use std::cell::Cell; + use std::sync::OnceLock; + + static LABELS: OnceLock = OnceLock::new(); + thread_local! { + // The name this thread kept last. A name written on every frame is found here + // without taking any lock. + static LAST: Cell> = const { Cell::new(None) }; + } + if let Some(last) = LAST.with(Cell::get) { + if last == label { + return Some(last); + } + } + let kept = LABELS + .get_or_init(LabelTable::new) + .intern(label, MAX_LABELS); + if kept.is_some() { + LAST.with(|last| last.set(kept)); + } + kept +} + /// What sits around a widget: its size, its surface, its outline, how far its content /// is inset, and what the Renderer reports about it. Compose's `Modifier`. /// @@ -116,6 +304,9 @@ pub struct Modifier { elevation: Option, alpha: Option, press_feedback: Option<(f32, f32)>, + semantics_role: Option, + semantics_label: Option<&'static str>, + semantics_state: SemanticsState, transform: Option<[f32; 8]>, animated_alpha: Option>, animated_background: Option>, @@ -156,6 +347,9 @@ impl Modifier { elevation: None, alpha: None, press_feedback: None, + semantics_role: None, + semantics_label: None, + semantics_state: SemanticsState::NONE, transform: None, animated_alpha: None, animated_background: None, @@ -358,6 +552,79 @@ impl Modifier { self } + /// The name assistive technology reads for this node. On a widget with a visible label + /// it replaces that label; an empty name is the same as none. + /// + /// Names are kept for the life of the program, and at most 4096 distinct names are + /// kept. A name past that limit is left off (debug builds print a warning once), so + /// use a fixed set of names rather than one built from changing data. + pub fn label(mut self, label: &str) -> Self { + self.semantics_label = keep_label(label); + self + } + + /// What this node is to assistive technology, for a node that is not a widget with + /// a role of its own: a `Box` that is pressed is a button. + pub const fn role(mut self, role: Role) -> Self { + self.semantics_role = Some(role); + self + } + + /// Whether the node is checked, as a toggle announces it. + pub const fn checked(mut self, checked: bool) -> Self { + self.semantics_state.checked = Some(checked); + self + } + + /// Whether the node is the selected one of its group. + pub const fn selected(mut self, selected: bool) -> Self { + self.semantics_state.selected = Some(selected); + self + } + + /// Whether the node is expanded, as a disclosure or a tree item announces it. + pub const fn expanded(mut self, expanded: bool) -> Self { + self.semantics_state.expanded = Some(expanded); + self + } + + /// Whether the node is disabled for assistive technology. + pub const fn disabled(mut self, disabled: bool) -> Self { + self.semantics_state.disabled = Some(disabled); + self + } + + /// Whether the node holds the focus, as assistive technology reads it. + pub const fn focused(mut self, focused: bool) -> Self { + self.semantics_state.focused = Some(focused); + self + } + + /// Leaves the node out of the accessibility tree when true. + pub const fn hidden(mut self, hidden: bool) -> Self { + self.semantics_state.hidden = hidden; + self + } + + /// Exposes a click action on the node, which runs the node's click handler. + pub const fn click_action(mut self, exposed: bool) -> Self { + self.semantics_state.click_action = exposed; + self + } + + /// Exposes a focus action on the node. + pub const fn focus_action(mut self, exposed: bool) -> Self { + self.semantics_state.focus_action = exposed; + self + } + + /// The role, the name and the state in one call. + pub fn semantics(self, role: Role, label: &str, state: SemanticsState) -> Self { + let mut named = self.role(role).label(label); + named.semantics_state = state; + named + } + /// Compose's `graphicsLayer`: a rotation, a scale and a translation about an origin, /// drawn without moving the layout, and an opacity for the whole group. A pointer is /// tested against the shape that is drawn. @@ -469,6 +736,19 @@ pub(crate) fn optional_text(value: Option) -> OwnedAttr { value.map_or(OwnedAttr::None, OwnedAttr::Text) } +/// The role and state of a Modifier as the one integer the `semantics` attribute carries, +/// or none when neither was given. +fn semantics_bits(modifier: &Modifier) -> Option { + let state = modifier.semantics_state; + if modifier.semantics_role.is_none() && state == SemanticsState::NONE { + return None; + } + let role = i64::from(u16::from( + modifier.semantics_role.unwrap_or(SemanticsRole::None), + )); + Some(role | (i64::from(state.bits()) << 16)) +} + /// Writes the Modifier attributes, in the order the `rsx!` widgets write them. pub(crate) fn push_modifiers(modifier: &Modifier, mods: Mods, out: &mut Attrs) { let container = mods == Mods::Container; @@ -556,6 +836,20 @@ pub(crate) fn push_modifiers(modifier: &Modifier, mods: Mods, out: &mut Attrs) { OwnedAttr::Bytes([scale.to_le_bytes(), alpha.to_le_bytes()].concat()) }), ); + // The role and state travel as one integer, the role in the low 16 bits; the name is a + // property of the node because a string does not fit in a modifier's words. + push( + false, + "semantics", + semantics_bits(modifier).map_or(OwnedAttr::None, OwnedAttr::Int), + ); + push( + false, + "accessible_name", + modifier + .semantics_label + .map_or(OwnedAttr::None, OwnedAttr::Label), + ); push( false, "alpha", @@ -730,3 +1024,32 @@ mod fr19_9_tests { } } } + +#[cfg(test)] +mod fr68_label_tests { + use super::*; + + #[test] + fn fr68_repeated_labels_do_not_grow_the_table() { + let table = LabelTable::new(); + let first = table.intern("Refresh", MAX_LABELS).unwrap(); + for _ in 0..10_000 { + let again = table.intern("Refresh", MAX_LABELS).unwrap(); + assert!(std::ptr::eq(first, again)); + } + assert_eq!(table.len(), 1); + } + + #[test] + fn fr68_labels_past_the_cap_are_dropped() { + let table = LabelTable::new(); + for n in 0..MAX_LABELS { + assert!(table.intern(&format!("label {n}"), MAX_LABELS).is_some()); + } + assert_eq!(table.len(), MAX_LABELS); + assert_eq!(table.intern("one too many", MAX_LABELS), None); + assert_eq!(table.len(), MAX_LABELS); + // A name already kept is still found. + assert!(table.intern("label 0", MAX_LABELS).is_some()); + } +} diff --git a/compose-rust/src/ui/widgets.rs b/compose-rust/src/ui/widgets.rs index 964d95889..b7f5a52c8 100644 --- a/compose-rust/src/ui/widgets.rs +++ b/compose-rust/src/ui/widgets.rs @@ -200,6 +200,71 @@ macro_rules! click_handler { }; } +/// Optional focus and key handlers on a node. A node that registers none of these +/// emits no focus or key listener property on the wire. +#[derive(Default)] +struct FocusAndKeyHandlers { + on_focus_gained: Option>, + on_focus_lost: Option>, + on_key_down: Option>, + on_key_up: Option>, +} + +fn focus_and_key_input( + mut handlers: FocusAndKeyHandlers, + listeners: &mut Vec<(&'static str, EventCallback)>, +) { + if let Some(handler) = handlers.on_focus_gained.take() { + listeners.push(("onfocusgained", unit(Some(handler)))); + } + if let Some(handler) = handlers.on_focus_lost.take() { + listeners.push(("onfocuslost", unit(Some(handler)))); + } + if let Some(handler) = handlers.on_key_down.take() { + listeners.push(( + "onkeydown", + EventCallback::Key(Rc::new(RefCell::new(handler))), + )); + } + if let Some(handler) = handlers.on_key_up.take() { + listeners.push(( + "onkeyup", + EventCallback::Key(Rc::new(RefCell::new(handler))), + )); + } +} + +macro_rules! focus_and_key_handlers { + ($name:ident) => { + impl $name { + /// Runs when this node or a descendant inside it gains keyboard focus. + pub fn on_focus_gained(mut self, handler: impl FnMut() + 'static) -> Self { + self.focus_and_keys.on_focus_gained = Some(std::boxed::Box::new(handler)); + self + } + + /// Runs when this node and its descendants lose keyboard focus. + pub fn on_focus_lost(mut self, handler: impl FnMut() + 'static) -> Self { + self.focus_and_keys.on_focus_lost = Some(std::boxed::Box::new(handler)); + self + } + + /// Sees each key pressed while this node or a descendant inside it holds focus; + /// calling `consume` on the event keeps the Renderer from acting on it. + pub fn on_key_down(mut self, handler: impl FnMut(KeyEvent) + 'static) -> Self { + self.focus_and_keys.on_key_down = Some(std::boxed::Box::new(handler)); + self + } + + /// Sees each key released while this node or a descendant inside it holds focus. + pub fn on_key_up(mut self, handler: impl FnMut(KeyEvent) + 'static) -> Self { + self.focus_and_keys.on_key_up = Some(std::boxed::Box::new(handler)); + self + } + } + }; +} + /// Composes the description when the statement it was written in ends. macro_rules! composed_on_drop { ($name:ident $(<$lifetime:lifetime>)?) => { @@ -271,6 +336,7 @@ macro_rules! linear { linear: Linear, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, tear_off: Option, done: bool, } @@ -282,6 +348,7 @@ macro_rules! linear { linear: Linear::default(), pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), tear_off: None, done: false, } @@ -329,6 +396,7 @@ macro_rules! linear { let mut subscription = Attrs::new(); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); tear_off_input(tear_off, &mut subscription, &mut listeners); emit( widget_key($key), @@ -347,6 +415,7 @@ macro_rules! linear { container!($name); pointer_subscription!($name); click_handler!($name); + focus_and_key_handlers!($name); tear_off!($name); }; } @@ -377,6 +446,7 @@ pub struct ComposeBox { alignment: Option, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, tear_off: Option, done: bool, } @@ -388,6 +458,7 @@ pub fn Box() -> ComposeBox { alignment: None, pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), tear_off: None, done: false, } @@ -408,6 +479,7 @@ impl ComposeBox { let mut subscription = Attrs::new(); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); tear_off_input(tear_off, &mut subscription, &mut listeners); emit( widget_key("compose_rust::ui::Box"), @@ -426,6 +498,7 @@ impl ComposeBox { container!(ComposeBox); pointer_subscription!(ComposeBox); click_handler!(ComposeBox); +focus_and_key_handlers!(ComposeBox); tear_off!(ComposeBox); /// A plain container with the standard Modifiers and nothing of its own. @@ -436,6 +509,7 @@ macro_rules! plain_container { modifier: Modifier, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, done: bool, } @@ -445,6 +519,7 @@ macro_rules! plain_container { modifier: Modifier::new(), pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), done: false, } } @@ -457,6 +532,7 @@ macro_rules! plain_container { let mut subscription = Attrs::new(); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); emit( widget_key($key), WidgetKind::$widget, @@ -473,6 +549,7 @@ macro_rules! plain_container { container!($name); pointer_subscription!($name); click_handler!($name); + focus_and_key_handlers!($name); }; } @@ -550,8 +627,8 @@ fn scroll_state_input( attrs.push(("determinate", OwnedAttr::Bool(animate))); attrs.push(("count", OwnedAttr::Int(revision))); } - let offset = state.offset.clone(); - let max_offset = state.max_offset.clone(); + let offset = state.offset; + let max_offset = state.max_offset; listeners.push(( "onrangerequest", EventCallback::Range(Rc::new(RefCell::new(move |range: crate::RangeRequest| { @@ -575,6 +652,7 @@ macro_rules! scroll_container { scroll_state: Option, pointer: Option, on_click: Option>, + focus_and_keys: FocusAndKeyHandlers, done: bool, } @@ -585,6 +663,7 @@ macro_rules! scroll_container { scroll_state: None, pointer: None, on_click: None, + focus_and_keys: FocusAndKeyHandlers::default(), done: false, } } @@ -606,6 +685,7 @@ macro_rules! scroll_container { scroll_state_input(scroll_state, &mut subscription, &mut listeners); pointer_input(pointer, &mut subscription, &mut listeners); click_input(self.on_click.take(), &mut listeners); + focus_and_key_input(std::mem::take(&mut self.focus_and_keys), &mut listeners); emit( widget_key($key), WidgetKind::$widget, @@ -622,6 +702,7 @@ macro_rules! scroll_container { container!($name); pointer_subscription!($name); click_handler!($name); + focus_and_key_handlers!($name); }; } @@ -868,11 +949,19 @@ pub struct TextFieldScope { enabled: bool, multiline: bool, type_role: Option, + font_size: Option, + font_weight: Option, + line_height: Option, + letter_spacing: Option, + color: Option, font: FontRefs, on_value_change: Option>, on_submit: Option>, + on_focus_gained: Option>, on_focus_lost: Option>, on_key_down: Option>, + on_key_up: Option>, + on_composition_change: Option>, done: bool, } @@ -887,11 +976,19 @@ pub fn TextField() -> TextFieldScope { enabled: true, multiline: false, type_role: None, + font_size: None, + font_weight: None, + line_height: None, + letter_spacing: None, + color: None, font: FontRefs::default(), on_value_change: None, on_submit: None, + on_focus_gained: None, on_focus_lost: None, on_key_down: None, + on_key_up: None, + on_composition_change: None, done: false, } } @@ -927,6 +1024,36 @@ impl TextFieldScope { self } + /// Explicit font size in sp. Overrides the type role's size. + pub fn font_size(mut self, sp: f32) -> Self { + self.font_size = Some(sp); + self + } + + /// Explicit font weight (e.g. 400, 500, 600, 700). Overrides the type role's weight. + pub fn font_weight(mut self, weight: u16) -> Self { + self.font_weight = Some(weight); + self + } + + /// Explicit line height in sp. + pub fn line_height(mut self, sp: f32) -> Self { + self.line_height = Some(sp); + self + } + + /// Explicit letter spacing in sp. + pub fn letter_spacing(mut self, sp: f32) -> Self { + self.letter_spacing = Some(sp); + self + } + + /// Explicit text colour. + pub fn color(mut self, paint: Paint) -> Self { + self.color = Some(paint); + self + } + /// The fonts to try for the field's text, in order. Taken only with `TypeRole::None`. pub fn font(mut self, font: FontRefs) -> Self { self.font = font; @@ -943,6 +1070,11 @@ impl TextFieldScope { self } + pub fn on_focus_gained(mut self, handler: impl FnMut() + 'static) -> Self { + self.on_focus_gained = Some(std::boxed::Box::new(handler)); + self + } + pub fn on_focus_lost(mut self, handler: impl FnMut() + 'static) -> Self { self.on_focus_lost = Some(std::boxed::Box::new(handler)); self @@ -955,22 +1087,54 @@ impl TextFieldScope { self } + /// Sees each key released while the field holds focus. + pub fn on_key_up(mut self, handler: impl FnMut(KeyEvent) + 'static) -> Self { + self.on_key_up = Some(std::boxed::Box::new(handler)); + self + } + + /// Reports when an input method begins or ends an active composition in the field. + pub fn on_composition_change(mut self, handler: impl FnMut(bool) + 'static) -> Self { + self.on_composition_change = Some(std::boxed::Box::new(handler)); + self + } + fn compose(&mut self, content: impl FnOnce()) { let modifier = self.modifier; let text = std::mem::take(&mut self.text); let placeholder = std::mem::take(&mut self.placeholder); let (enabled, multiline, type_role) = (self.enabled, self.multiline, self.type_role); + let (font_size, font_weight, line_height) = + (self.font_size, self.font_weight, self.line_height); + let (letter_spacing, color) = (self.letter_spacing, self.color); let font = std::mem::take(&mut self.font); let key_down: EventCallback = match self.on_key_down.take() { Some(handler) => EventCallback::Key(Rc::new(RefCell::new(handler))), None => EventCallback::Key(Rc::new(RefCell::new(|_: KeyEvent| {}))), }; - let listeners = vec![ + let mut listeners = vec![ ("onvaluechange", text_handler(self.on_value_change.take())), ("onsubmit", text_handler(self.on_submit.take())), ("onfocuslost", unit(self.on_focus_lost.take())), ("onkeydown", key_down), ]; + if let Some(handler) = self.on_focus_gained.take() { + listeners.push(("onfocusgained", unit(Some(handler)))); + } + if let Some(handler) = self.on_key_up.take() { + listeners.push(( + "onkeyup", + EventCallback::Key(Rc::new(RefCell::new(handler))), + )); + } + if let Some(mut handler) = self.on_composition_change.take() { + listeners.push(( + "oncompositionchange", + EventCallback::Value(Rc::new(RefCell::new(move |value: f64| { + handler(value != 0.0); + }))), + )); + } emit( widget_key("compose_rust::ui::TextField"), WidgetKind::TextField, @@ -985,6 +1149,21 @@ impl TextFieldScope { attrs.push(("enabled", OwnedAttr::Bool(enabled))); attrs.push(("multiline", OwnedAttr::Bool(multiline))); attrs.push(("type_role", role_or_zero(type_role))); + if let Some(font_size) = font_size { + attrs.push(("font_size", OwnedAttr::Float(font_size as f64))); + } + if let Some(font_weight) = font_weight { + attrs.push(("font_weight", OwnedAttr::Int(font_weight as i64))); + } + if let Some(line_height) = line_height { + attrs.push(("line_height", OwnedAttr::Float(line_height as f64))); + } + if let Some(letter_spacing) = letter_spacing { + attrs.push(("letter_spacing", OwnedAttr::Float(letter_spacing as f64))); + } + if let Some(color) = color { + attrs.push(("color", super::paint(Some(color)))); + } attrs.push(("font", font_bytes(&font))); }, listeners, @@ -1008,6 +1187,79 @@ fn lines_and_overflow(max_lines: Option, overflow: Option, at } } +/// The colours and size a Switch, Slider or Checkbox was asked to draw with. Each is sent +/// only when it was set, so a control that sets none sends the properties it always has +/// and the design system draws it. +#[derive(Clone, Copy, Default)] +struct ControlLook { + track: Option, + thumb: Option, + active_track: Option, + container: Option, + check: Option, + size: Option<(f32, f32)>, +} + +impl ControlLook { + fn push(&self, attrs: &mut Attrs) { + let paints = [ + ("track_color", self.track), + ("thumb_color", self.thumb), + ("active_track_color", self.active_track), + ("container_color", self.container), + ("check_color", self.check), + ]; + for (name, paint) in paints { + if let Some(paint) = paint { + attrs.push((name, super::paint(Some(paint)))); + } + } + if let Some((width, height)) = self.size { + let mut bytes = Vec::with_capacity(8); + bytes.extend_from_slice(&width.to_bits().to_le_bytes()); + bytes.extend_from_slice(&height.to_bits().to_le_bytes()); + attrs.push(("control_size", OwnedAttr::Bytes(bytes))); + } + } +} + +/// Adds a colour setter that fills one slot of `look`. +macro_rules! control_colour { + ($name:ident, $slot:ident, $setter:ident, $doc:literal) => { + impl $name { + #[doc = $doc] + pub fn $setter(mut self, paint: Paint) -> Self { + self.look.$slot = Some(paint); + self + } + } + }; +} + +/// Writes a widget's own accessible name. It replaces one the Modifier gave, so the name +/// said on the widget is the one that reaches assistive technology. +fn push_accessible_name(name: Option, attrs: &mut Attrs) { + if let Some(name) = name.filter(|name| !name.is_empty()) { + attrs.retain(|(key, _)| *key != "accessible_name"); + attrs.push(("accessible_name", OwnedAttr::Text(name))); + } +} + +/// Adds `accessible_name` to a builder that has an `accessible_name` field. +macro_rules! accessible_name { + ($name:ident) => { + impl $name { + /// The name assistive technology reads. With no visible label it is the + /// control's name; with one it replaces the label, and on an icon-only button + /// it is read instead of the icon's meaning. + pub fn accessible_name(mut self, name: impl Into) -> Self { + self.accessible_name = Some(name.into()); + self + } + } + }; +} + /// A `Button` and its builder. pub struct ButtonScope { modifier: Modifier, @@ -1019,6 +1271,9 @@ pub struct ButtonScope { color: Option, max_lines: Option, overflow: Option, + accessible_name: Option, + text_align: Option, + font_size: Option, on_click: Option>, done: bool, } @@ -1039,12 +1294,31 @@ pub fn Button(text: impl Into) -> ButtonScope { color: None, max_lines: None, overflow: None, + accessible_name: None, + text_align: None, + font_size: None, on_click: None, done: false, } } +accessible_name!(ButtonScope); + impl ButtonScope { + /// Where the label sits in the button. A button that never calls this sends nothing + /// and keeps its label centred. + pub fn text_align(mut self, align: TextAlign) -> Self { + self.text_align = Some(align); + self + } + + /// The label's size in sp. A button that never calls this sends nothing and takes the + /// design system's size for its variant. + pub fn font_size(mut self, sp: f32) -> Self { + self.font_size = Some(sp); + self + } + /// The meaning of the glyph on it, never a picture. pub fn icon(mut self, icon: IconRole) -> Self { self.icon = Some(icon); @@ -1104,6 +1378,8 @@ impl ButtonScope { let (icon, enabled, variant, kind, color) = (self.icon, self.enabled, self.variant, self.kind, self.color); let (max_lines, overflow) = (self.max_lines, self.overflow); + let accessible_name = self.accessible_name.take(); + let (text_align, font_size) = (self.text_align, self.font_size); let listeners = vec![("onclick", unit(self.on_click.take()))]; emit( widget_key("compose_rust::ui::Button"), @@ -1117,6 +1393,13 @@ impl ButtonScope { attrs.push(("button_kind", role_or_zero(kind))); attrs.push(("color", super::paint(color))); lines_and_overflow(max_lines, overflow, attrs); + push_accessible_name(accessible_name, attrs); + if text_align.is_some() { + attrs.push(("text_align", role_or_zero(text_align))); + } + if font_size.is_some() { + attrs.push(("font_size", dp_or_zero(font_size))); + } }, listeners, content, @@ -1124,7 +1407,7 @@ impl ButtonScope { } } -composed_on_drop!(ButtonScope); +container!(ButtonScope); /// A two-state control, for Checkbox, RadioButton and Switch alike: the Host owns the /// value, the control draws exactly what it is told and reports what the user asked for. @@ -1135,6 +1418,8 @@ macro_rules! toggle { modifier: Modifier, checked: bool, enabled: bool, + accessible_name: Option, + look: ControlLook, on_change: Option>, done: bool, } @@ -1145,11 +1430,15 @@ macro_rules! toggle { modifier: Modifier::new(), checked: $setter, enabled: true, + accessible_name: None, + look: ControlLook::default(), on_change: None, done: false, } } + accessible_name!($name); + impl $name { pub fn enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; @@ -1166,6 +1455,8 @@ macro_rules! toggle { fn compose(&mut self, content: impl FnOnce()) { let modifier = self.modifier; let (checked, enabled) = (self.checked, self.enabled); + let accessible_name = self.accessible_name.take(); + let look = self.look; let handler: EventCallback = match self.on_change.take() { Some(mut handler) => EventCallback::Value(Rc::new(RefCell::new( // Off is 0.0 and on is anything else, so a Renderer that reports a @@ -1181,6 +1472,8 @@ macro_rules! toggle { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("checked", OwnedAttr::Bool(checked))); attrs.push(("enabled", OwnedAttr::Bool(enabled))); + push_accessible_name(accessible_name, attrs); + look.push(attrs); }, vec![("onchange", handler)], content, @@ -1192,6 +1485,44 @@ macro_rules! toggle { }; } +control_colour!( + SwitchScope, + track, + track_color, + "The track, whichever state it is in. The Host owns the state, so it passes the colour \ + that goes with it." +); +control_colour!( + SwitchScope, + thumb, + thumb_color, + "The thumb that travels along the track." +); + +impl SwitchScope { + /// The track's width and height in dp. The thumb scales with the height. + pub fn size(mut self, width: f32, height: f32) -> Self { + self.look.size = Some((width, height)); + self + } +} + +control_colour!( + CheckboxScope, + container, + container_color, + "The box when it is checked." +); +control_colour!(CheckboxScope, check, check_color, "The check mark."); + +impl CheckboxScope { + /// The length of the box's side in dp. + pub fn size(mut self, side: f32) -> Self { + self.look.size = Some((side, side)); + self + } +} + toggle!( CheckboxScope, Checkbox, @@ -1223,6 +1554,8 @@ pub struct SliderScope { max: f32, steps: u32, enabled: bool, + accessible_name: Option, + look: ControlLook, on_change: Option>, done: bool, } @@ -1238,11 +1571,28 @@ pub fn Slider(value: f32) -> SliderScope { max: 1.0, steps: 0, enabled: true, + accessible_name: None, + look: ControlLook::default(), on_change: None, done: false, } } +accessible_name!(SliderScope); +control_colour!( + SliderScope, + track, + track_color, + "The part of the track that is not filled." +); +control_colour!(SliderScope, thumb, thumb_color, "The thumb."); +control_colour!( + SliderScope, + active_track, + active_track_color, + "The part of the track up to the thumb." +); + impl SliderScope { /// The two ends. Compose's `valueRange`. pub fn range(mut self, min: f32, max: f32) -> Self { @@ -1283,6 +1633,8 @@ impl SliderScope { self.steps, self.enabled, ); + let accessible_name = self.accessible_name.take(); + let look = self.look; let handler = match self.on_change.take() { Some(mut handler) => EventCallback::Value(Rc::new(RefCell::new(move |value: f64| { handler(value as f32) @@ -1300,6 +1652,8 @@ impl SliderScope { attrs.push(("max", OwnedAttr::Float(f64::from(max)))); attrs.push(("steps", OwnedAttr::Int(i64::from(steps)))); attrs.push(("enabled", OwnedAttr::Bool(enabled))); + push_accessible_name(accessible_name, attrs); + look.push(attrs); }, vec![("onchange", handler)], content, @@ -1857,6 +2211,7 @@ pub struct ModalScope { modifier: Modifier, widget: WidgetKind, open: bool, + scrim: Option, on_dismiss: Option>, done: bool, } @@ -1868,6 +2223,7 @@ pub fn Dialog(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Dialog, open, + scrim: None, on_dismiss: None, done: false, } @@ -1880,6 +2236,7 @@ pub fn Sheet(open: bool) -> ModalScope { modifier: Modifier::new(), widget: WidgetKind::Sheet, open, + scrim: None, on_dismiss: None, done: false, } @@ -1891,6 +2248,12 @@ impl ModalScope { self } + /// Sets the modal scrim colour, overriding the design system default. + pub fn scrim(mut self, paint: impl Into) -> Self { + self.scrim = Some(paint.into()); + self + } + fn compose(&mut self, content: impl FnOnce()) { let modifier = self.modifier; let (widget, open) = (self.widget, self.open); @@ -1899,12 +2262,16 @@ impl ModalScope { } else { widget_key("compose_rust::ui::Sheet") }; + let scrim = self.scrim; emit( key, widget, |attrs| { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("open", OwnedAttr::Bool(open))); + if let Some(scrim) = scrim { + attrs.push(("scrim", super::paint(Some(scrim)))); + } }, vec![("ondismiss", unit(self.on_dismiss.take()))], content, @@ -1918,6 +2285,7 @@ container!(ModalScope); pub struct MenuScope<'a> { modifier: Modifier, expanded: bool, + scrim: Option, on_dismiss: Option>, anchor: Option>, done: bool, @@ -1929,6 +2297,7 @@ pub fn Menu<'a>(expanded: bool) -> MenuScope<'a> { MenuScope { modifier: Modifier::new(), expanded, + scrim: None, on_dismiss: None, anchor: None, done: false, @@ -1941,6 +2310,12 @@ impl<'a> MenuScope<'a> { self } + /// Sets the popup scrim colour, overriding the design system default. + pub fn scrim(mut self, paint: impl Into) -> Self { + self.scrim = Some(paint.into()); + self + } + /// The widget the menu hangs off. It is the first child on the wire. pub fn anchor(mut self, anchor: impl FnOnce() + 'a) -> Self { self.anchor = Some(std::boxed::Box::new(anchor)); @@ -1951,12 +2326,16 @@ impl<'a> MenuScope<'a> { let modifier = self.modifier; let expanded = self.expanded; let anchor = self.anchor.take(); + let scrim = self.scrim; emit( widget_key("compose_rust::ui::Menu"), WidgetKind::Menu, |attrs| { push_modifiers(&modifier, Mods::Standard, attrs); attrs.push(("open", OwnedAttr::Bool(expanded))); + if let Some(scrim) = scrim { + attrs.push(("scrim", super::paint(Some(scrim)))); + } }, vec![("ondismiss", unit(self.on_dismiss.take()))], || { @@ -2773,6 +3152,7 @@ pub struct WindowScope { size: Option<(f32, f32)>, min_size: Option<(f32, f32)>, resizable: Option, + report_size: Option, chrome: Option, title_bar: Option, icon: Option, @@ -2809,6 +3189,7 @@ pub fn Window(on_close_request: impl FnMut() + 'static) -> WindowScope { size: None, min_size: None, resizable: None, + report_size: None, chrome: None, title_bar: None, icon: None, @@ -2866,6 +3247,15 @@ impl WindowScope { self } + /// Asks to be told every time the window's width or height, rounded to whole dp, + /// changes. Without it the size is reported only when its class or the safe area + /// changes. Turn it on only for a window that lays out by its exact size: each + /// whole-dp change while the user drags an edge costs one call into the Host. + pub fn report_size(mut self, report: bool) -> Self { + self.report_size = Some(report); + self + } + /// Who draws the caption. pub fn chrome(mut self, chrome: crate::schema::Chrome) -> Self { self.chrome = Some(chrome); @@ -2981,6 +3371,9 @@ impl WindowScope { if let Some(resizable) = self.resizable { properties.push(("window_resizable", OwnedAttr::Bool(resizable))); } + if let Some(report) = self.report_size { + properties.push(("report_size", OwnedAttr::Bool(report))); + } if let Some(chrome) = self.chrome { properties.push(("window_chrome", OwnedAttr::Int(i64::from(chrome as u16)))); } diff --git a/compose-rust/src/writer.rs b/compose-rust/src/writer.rs index d10f2d476..91d1796ee 100644 --- a/compose-rust/src/writer.rs +++ b/compose-rust/src/writer.rs @@ -28,7 +28,7 @@ const SPANS_BIT: u32 = 63; /// How many Modifier slots a node has. The slot numbers are assigned in `modifier_for`, /// and this is one past the last of them. -const MODIFIER_SLOTS: usize = 25; +const MODIFIER_SLOTS: usize = 26; /// The first of the slots a node's shadow layers take, and how many there are. const SHADOW_BASE: u16 = 8; @@ -345,6 +345,7 @@ impl NodeWriter { const MATCH_PARENT_SIZE: u16 = 22; const WINDOW_DRAG: u16 = 23; const PRESS_FEEDBACK: u16 = 24; + const SEMANTICS: u16 = 25; let float = |value: &AttrValue<'_>| match value { AttrValue::Float(number) => Some(*number as f32), @@ -379,6 +380,7 @@ impl NodeWriter { "motion" => Some(MOTION), "material" => Some(MATERIAL), "press_feedback" => Some(PRESS_FEEDBACK), + "semantics" => Some(SEMANTICS), _ => None, }; let slot = slot_of(name)?; @@ -558,6 +560,17 @@ impl NodeWriter { Modifier::PressFeedback { scale, tint_alpha }, ))) } + // The role in the low 16 bits and the state above them, as one integer. + "semantics" => { + let packed = u32::try_from(integer(value)?).ok()?; + let state = packed >> 16; + if state & !crate::schema::SEMANTICS_STATE_MASK != 0 { + return Some(None); + } + let role = + crate::SemanticsRole::try_from(u16::try_from(packed & 0xffff).ok()?).ok()?; + Some(Some((SEMANTICS, Modifier::Semantics { role, state }))) + } "offset" => { let AttrValue::Bytes(bytes) = value else { return Some(None); @@ -861,7 +874,11 @@ pub(crate) fn event_property(name: &str) -> Option { "valuechange" | "onvaluechange" => Some(PropertyKind::OnValueChange), "submit" | "onsubmit" => Some(PropertyKind::OnSubmit), "focuslost" | "onfocuslost" => Some(PropertyKind::OnFocusLost), + "focusgained" | "onfocusgained" | "focusin" | "onfocusin" => { + Some(PropertyKind::OnFocusGained) + } "keydown" | "onkeydown" => Some(PropertyKind::OnKeyDown), + "keyup" | "onkeyup" => Some(PropertyKind::OnKeyUp), "rangerequest" | "onrangerequest" => Some(PropertyKind::OnRangeRequested), "dismiss" | "ondismiss" => Some(PropertyKind::OnDismiss), "filesentered" | "onfilesentered" => Some(PropertyKind::OnFilesEntered), @@ -874,6 +891,7 @@ pub(crate) fn event_property(name: &str) -> Option { "windowunavailable" | "onwindowunavailable" => Some(PropertyKind::OnWindowUnavailable), "windowclosed" | "onwindowclosed" => Some(PropertyKind::OnWindowClosed), "windowdragover" | "onwindowdragover" => Some(PropertyKind::OnWindowDragOver), + "compositionchange" | "oncompositionchange" => Some(PropertyKind::OnCompositionChange), "change" | "onchange" => Some(PropertyKind::OnValueChange), _ => None, } @@ -928,4 +946,15 @@ mod tests { Some(ProtocolError::MisplacedWindow) ); } + + #[test] + fn nfr7_unknown_semantics_bit_is_a_protocol_error() { + let mut writer = NodeWriter::new(); + let role = i64::from(u16::from(crate::SemanticsRole::Group)); + let accepted = + writer.modifier_for(1, "semantics", &AttrValue::Int(role | (1 << (16 + 12)))); + assert!(matches!(accepted, Some(Some(_)))); + let refused = writer.modifier_for(1, "semantics", &AttrValue::Int(role | (1 << (16 + 13)))); + assert!(matches!(refused, Some(None))); + } } diff --git a/compose-rust/tests/action_keys.rs b/compose-rust/tests/action_keys.rs index 229102362..4a140a0e2 100644 --- a/compose-rust/tests/action_keys.rs +++ b/compose-rust/tests/action_keys.rs @@ -34,7 +34,7 @@ use compose_rust::ComposeHost; use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn Pad() { Button("7").kind(ButtonKind::ActionKey); Button("plain"); @@ -43,7 +43,7 @@ fn Pad() { /// Only a button that names a kind sends one. #[test] fn fr30_1_only_a_button_that_names_a_kind_sends_one() { - let mut host = ComposeHost::with_content(|| Pad()); + let mut host = ComposeHost::with_content(Pad); let batch = host.rebuild().unwrap().to_vec(); let kinds: Vec<_> = decode_batch(&batch) .unwrap() diff --git a/compose-rust/tests/boundary_calls.rs b/compose-rust/tests/boundary_calls.rs index 84c848437..2640b6334 100644 --- a/compose-rust/tests/boundary_calls.rs +++ b/compose-rust/tests/boundary_calls.rs @@ -315,6 +315,9 @@ fn fr12_key_consumption_is_returned_and_does_not_leak() { ctrl_key: false, alt_key: false, meta_key: false, + repeat: false, + code: 0x28, + text: "Enter", }, }); // SAFETY: The encoded event and output storage remain live for the call. diff --git a/compose-rust/tests/call_macro.rs b/compose-rust/tests/call_macro.rs new file mode 100644 index 000000000..03ed1fa7d --- /dev/null +++ b/compose-rust/tests/call_macro.rs @@ -0,0 +1,268 @@ +//! The call macro `#[Composable]` generates next to every composable, through the public +//! surface: functions written by an application and a Host that composes them. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use std::cell::RefCell; +use support::Tree; + +thread_local! { + static LOG: RefCell> = const { RefCell::new(Vec::new()) }; +} + +fn note(line: impl Into) { + LOG.with_borrow_mut(|log| log.push(line.into())); +} + +fn take_log() -> Vec { + LOG.with_borrow_mut(std::mem::take) +} + +/// Composes `content` once and returns the tree it built. +fn compose(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +/// A value that records when it is evaluated, to see the order arguments run in. +fn traced(label: &'static str, value: T) -> T { + note(format!("eval {label}")); + value +} + +#[Composable] +fn Labelled( + text: String, + on_pick: impl Fn(String) + 'static, + modifier: Modifier, + #[default(String::from("none"))] hint: String, + #[default(false)] bold: bool, + #[default(|| {})] content: impl Fn() + 'static, +) { + on_pick(text.clone()); + note(format!( + "Labelled text={text} hint={hint} bold={bold} plain_modifier={}", + modifier == Modifier::new() + )); + content(); +} + +#[Composable] +fn Pair(first: i32, second: i32) { + note(format!("Pair {first} {second}")); +} + +#[test] +fn fr39_function_form_takes_every_argument_and_no_defaults() { + take_log(); + compose(|| { + Labelled( + "a".into(), + |_| {}, + Modifier::new(), + "h".into(), + true, + || note("content"), + ); + }); + assert_eq!( + take_log(), + vec![ + "Labelled text=a hint=h bold=true plain_modifier=true", + "content" + ] + ); +} + +#[test] +fn fr39_call_macro_positional_prefix_then_named_in_any_order() { + take_log(); + compose(|| { + Labelled!("a".into(), |_| {}, bold = true, hint = "h".into()); + Labelled!("b".into(), |_| {}, hint = "x".into(), bold = false); + }); + assert_eq!( + take_log(), + vec![ + "Labelled text=a hint=h bold=true plain_modifier=true", + "Labelled text=b hint=x bold=false plain_modifier=true", + ] + ); +} + +#[test] +fn fr39_call_macro_names_alone_fill_a_signature() { + take_log(); + compose(|| { + Labelled!(on_pick = |_| {}, text = "n".into()); + Pair!(second = 2, first = 1); + }); + assert_eq!( + take_log(), + vec![ + "Labelled text=n hint=none bold=false plain_modifier=true", + "Pair 1 2" + ] + ); +} + +#[test] +fn fr39_call_macro_closure_parameters_infer_from_the_function_bounds() { + take_log(); + compose(|| { + // `text` is a String only because `on_pick: impl Fn(String)` says so. + Labelled!("c".into(), |text| note(format!("picked {}", text.len()))); + }); + assert_eq!(take_log()[0], "picked 1"); +} + +#[test] +fn fr39_call_macro_trailing_block_is_the_content_closure() { + take_log(); + compose(|| { + Labelled!("a".into(), |_| {}, { note("block") }); + Labelled!("b".into(), |_| {}, bold = true, { + note("block after names") + }); + Labelled!("c".into(), |_| {}, content = || note("named content")); + }); + let log = take_log(); + assert!(log.contains(&"block".to_string())); + assert!(log.contains(&"block after names".to_string())); + assert!(log.contains(&"named content".to_string())); +} + +#[test] +fn fr39_call_macro_evaluates_arguments_in_parameter_order() { + take_log(); + compose(|| { + Pair!(second = traced("second", 2), first = traced("first", 1)); + }); + assert_eq!(take_log(), vec!["eval first", "eval second", "Pair 1 2"]); +} + +#[test] +fn fr39_default_modifier_is_modifier_new_and_other_defaults_apply_in_macro_form() { + take_log(); + compose(|| { + Labelled!("a".into(), |_| {}); + }); + assert_eq!( + take_log(), + vec!["Labelled text=a hint=none bold=false plain_modifier=true"] + ); +} + +#[test] +fn fr39_default_modifier_can_be_given_and_then_differs() { + take_log(); + compose(|| { + Labelled!( + "a".into(), + |_| {}, + modifier = Modifier::new().fill_max_width() + ); + }); + assert_eq!( + take_log(), + vec!["Labelled text=a hint=none bold=false plain_modifier=false"] + ); +} + +thread_local! { + static DEFAULTS: RefCell = const { RefCell::new(0) }; +} + +fn counted() -> u32 { + DEFAULTS.with_borrow_mut(|count| { + *count += 1; + *count + }) +} + +#[Composable] +fn Counted(#[default(counted())] value: u32) { + note(format!("value {value}")); +} + +#[test] +fn fr39_default_expressions_run_at_the_call_site_each_time() { + take_log(); + DEFAULTS.with_borrow_mut(|count| *count = 0); + compose(|| { + Counted!(); + Counted!(); + Counted!(value = 9); + }); + assert_eq!(take_log(), vec!["value 1", "value 2", "value 9"]); +} + +#[Composable] +fn Generic( + items: Vec, + #[default(Vec::new())] more: Vec, +) { + note(format!("Generic {items:?} {more:?}")); +} + +#[test] +fn fr39_call_macro_limits_generics_are_inferred_like_the_function_form() { + take_log(); + compose(|| { + Generic(vec![1], vec![2]); + Generic!(vec![1u8]); + Generic!(vec!["a"], more = vec!["b"]); + }); + assert_eq!( + take_log(), + vec![ + "Generic [1] [2]", + "Generic [1] []", + "Generic [\"a\"] [\"b\"]" + ] + ); +} + +#[Composable] +fn Leaf(text: String) { + let clicks = remember(|| mutable_state_of(0)); + Text(format!("{text}:{}", clicks.get())); +} + +#[test] +fn fr39_call_macro_expands_to_the_same_tree_as_the_function_form() { + let function_form = compose(|| { + Column().content(|| { + Leaf("a".into()); + Leaf("b".into()); + }); + }); + let macro_form = compose(|| { + Column().content(|| { + Leaf!("a".into()); + Leaf!("b".into()); + }); + }); + assert_eq!(function_form.dump(), macro_form.dump()); +} + +#[test] +fn fr39_call_macro_expands_mixed_with_the_function_form_keeps_each_calls_state() { + // The slot key is made inside the function, so a call through the macro and a call + // through the function share one position and one identity. Mixing the two forms in one + // parent and recomposing must keep each call's remembered state apart and intact. + let mut host = ComposeHost::with_content(|| { + Column().content(|| { + Leaf("fn".into()); + Leaf!("macro".into()); + }); + }); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + assert_eq!(tree.texts(), vec!["fn:0", "macro:0"]); +} diff --git a/compose-rust/tests/clickable_containers.rs b/compose-rust/tests/clickable_containers.rs index 26c05cc42..6cf8cfd9d 100644 --- a/compose-rust/tests/clickable_containers.rs +++ b/compose-rust/tests/clickable_containers.rs @@ -33,7 +33,7 @@ fn frame(host: &mut ComposeHost, tree: &mut Tree) { tree.apply(&batch); } -#[composable] +#[Composable] fn Clickables() { let count = remember(|| mutable_state_of(0)); Column().content(|| { diff --git a/compose-rust/tests/compose_allocations.rs b/compose-rust/tests/compose_allocations.rs index ac4b1312a..211ec498e 100644 --- a/compose-rust/tests/compose_allocations.rs +++ b/compose-rust/tests/compose_allocations.rs @@ -66,7 +66,7 @@ impl AllocationMeasurement { } } -#[composable] +#[Composable] fn Steady(count: MutableState) { Column().content(|| { Text(format!("{}", count.get())); @@ -77,8 +77,8 @@ fn Steady(count: MutableState) { #[test] fn fr39_a_frame_with_nothing_to_do_allocates_nothing() { let count = mutable_state_of(0); - let state = count.clone(); - let mut host = ComposeHost::with_content(move || Steady(state.clone())); + let state = count; + let mut host = ComposeHost::with_content(move || Steady(state)); host.rebuild().unwrap(); // Warm: the first frames size the scratch buffers the later ones reuse. for frame in 0..4 { diff --git a/compose-rust/tests/compose_animation.rs b/compose-rust/tests/compose_animation.rs index 35b39661b..e03823692 100644 --- a/compose-rust/tests/compose_animation.rs +++ b/compose-rust/tests/compose_animation.rs @@ -22,7 +22,7 @@ thread_local! { static COMPOSED: Cell = const { Cell::new(0) }; } -#[composable] +#[Composable] fn Fading(faded: MutableState) { COMPOSED.with(|composed| composed.set(composed.get() + 1)); let target = if faded.get() { 0.0 } else { 1.0 }; @@ -69,8 +69,8 @@ fn start( #[test] fn fr41_compose_api_emits_the_same_records() { let faded = mutable_state_of(false); - let state = faded.clone(); - let (mut host, mut tree, first) = start(move || Fading(state.clone())); + let state = faded; + let (mut host, mut tree, first) = start(move || Fading(state)); assert!( !first .iter() @@ -124,8 +124,8 @@ fn fr41_compose_api_emits_the_same_records() { #[test] fn fr41_compose_api_animation_does_not_recompose() { let faded = mutable_state_of(false); - let state = faded.clone(); - let (mut host, mut tree, _) = start(move || Fading(state.clone())); + let state = faded; + let (mut host, mut tree, _) = start(move || Fading(state)); faded.set(true); let batch = host .render_frame(16_666_667) @@ -147,7 +147,7 @@ fn fr41_compose_api_animation_does_not_recompose() { ); } -#[composable] +#[Composable] fn Turned() { Box() .modifier(Modifier.size(40.0, 40.0).graphics_layer(GraphicsLayer { @@ -200,7 +200,7 @@ fn fr41_compose_api_graphics_layer_is_a_transform_and_an_alpha() { assert!(modifiers.contains(&Wire::Alpha(0.25)), "{modifiers:?}"); } -#[composable] +#[Composable] fn Moving(moved: MutableState) { let (offset, colour) = if moved.get() { ((24.0, 8.0), Paint::Role(ColorRole::Primary)) @@ -222,8 +222,8 @@ fn Moving(moved: MutableState) { #[test] fn fr41_compose_api_offset_and_colour_are_one_transition_each() { let moved = mutable_state_of(false); - let state = moved.clone(); - let (mut host, _, first) = start(move || Moving(state.clone())); + let state = moved; + let (mut host, _, first) = start(move || Moving(state)); let boxed = node_of(&first, WidgetKind::Box); moved.set(true); let changed = owned(host.render_frame(16_666_667).expect("the frame encodes")); diff --git a/compose-rust/tests/compose_boundary.rs b/compose-rust/tests/compose_boundary.rs index 72867a41b..a6323119e 100644 --- a/compose-rust/tests/compose_boundary.rs +++ b/compose-rust/tests/compose_boundary.rs @@ -13,12 +13,12 @@ use compose_rust::schema::{EventPayload, LoopMode, PROTOCOL_VERSION, PropertyKin use compose_rust::ui::*; use support::Tree; -#[composable] +#[Composable] fn Counter() { let count = remember(|| mutable_state_of(0)); Column().content(|| { Text(format!("{}", count.get())); - let more = count.clone(); + let more = count; Button("More").on_click(move || more.update(|count| *count += 1)); }); } diff --git a/compose-rust/tests/compose_memory.rs b/compose-rust/tests/compose_memory.rs index b073ac00c..9c4c85fe5 100644 --- a/compose-rust/tests/compose_memory.rs +++ b/compose-rust/tests/compose_memory.rs @@ -61,7 +61,7 @@ fn live_bytes() -> isize { LIVE.with(|live| live.get()) } -#[composable] +#[Composable] fn Busy(count: MutableState) { Column().content(|| { for row in 0..40 { @@ -99,7 +99,7 @@ fn nfr_17_live_memory_does_not_grow_with_the_frames_drawn() { ); } -#[composable] +#[Composable] fn Toggled(shown: MutableState) { Column().content(|| { if shown.get() { @@ -146,7 +146,7 @@ fn nfr_17_the_writer_keeps_nothing_for_nodes_that_were_removed() { ); } -#[composable] +#[Composable] fn Chat(count: MutableState, tail: MutableState) { LazyColumn().content(move |list| { let rows = count.get(); diff --git a/compose-rust/tests/compose_runtime.rs b/compose-rust/tests/compose_runtime.rs index 557275c86..d86b53223 100644 --- a/compose-rust/tests/compose_runtime.rs +++ b/compose-rust/tests/compose_runtime.rs @@ -70,23 +70,23 @@ fn click(host: &mut ComposeHost, tree: &mut Tree, label: &str, nth: usize) -> Ve // ----- skipping and scope invalidation ---------------------------------------------------- -#[composable] +#[Composable] fn Reader(count: MutableState) { ran("reader"); Text(format!("{}", count.get())); } -#[composable] +#[Composable] fn Sibling() { ran("sibling"); Text("fixed"); } -#[composable] +#[Composable] fn Parent(count: MutableState) { ran("parent"); Column().content(|| { - Reader(count.clone()); + Reader(count); Sibling(); }); } @@ -95,8 +95,8 @@ fn Parent(count: MutableState) { fn fr39_a_state_change_runs_only_the_scope_that_read_it() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parent(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Parent(state)); assert_eq!( (runs_of("parent"), runs_of("reader"), runs_of("sibling")), (1, 1, 1) @@ -118,13 +118,13 @@ fn fr39_a_state_change_runs_only_the_scope_that_read_it() { assert_eq!(tree.texts(), vec!["1", "fixed"]); } -#[composable] +#[Composable] fn Label(text: String) { ran("label"); Text(text); } -#[composable] +#[Composable] fn Screen(count: MutableState) { ran("screen"); let shown = count.get(); @@ -138,8 +138,8 @@ fn Screen(count: MutableState) { fn fr39_a_composable_whose_parameters_are_unchanged_is_skipped() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Screen(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Screen(state)); count.set(5); let records = frame(&mut host, &mut tree); assert_eq!(runs_of("screen"), 2, "the scope that read the state ran"); @@ -156,8 +156,8 @@ fn fr39_a_composable_whose_parameters_are_unchanged_is_skipped() { fn fr39_an_unchanged_frame_sends_nothing() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parent(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Parent(state)); assert!(frame(&mut host, &mut tree).is_empty()); count.set(0); assert!( @@ -179,7 +179,7 @@ fn bump(name: &str) { note(format!("{name}:{}#{}", slot.0, slot.1.get())); } -#[composable] +#[Composable] fn WithBranch(show: bool) { if show { bump("first"); @@ -191,7 +191,7 @@ fn WithBranch(show: bool) { fn fr39_a_call_site_keeps_its_own_state_when_a_branch_before_it_disappears() { reset(); let show = mutable_state_of(true); - let state = show.clone(); + let state = show; let (mut host, mut tree) = start(move || WithBranch(state.get())); show.set(false); frame(&mut host, &mut tree); @@ -211,7 +211,7 @@ fn fr39_a_call_site_keeps_its_own_state_when_a_branch_before_it_disappears() { ); } -/// The same body without the groups `#[composable]` puts around a branch: the control. A +/// The same body without the groups `#[Composable]` puts around a branch: the control. A /// slot table without groups addresses state by flat position, and this is what that does. fn without_branch_groups(show: bool) { if show { @@ -224,7 +224,7 @@ fn without_branch_groups(show: bool) { fn fr39_without_branch_groups_a_call_site_is_handed_another_ones_state() { reset(); let show = mutable_state_of(true); - let state = show.clone(); + let state = show; let (mut host, mut tree) = start(move || without_branch_groups(state.get())); show.set(false); frame(&mut host, &mut tree); @@ -235,7 +235,7 @@ fn fr39_without_branch_groups_a_call_site_is_handed_another_ones_state() { ); } -#[composable] +#[Composable] fn Counted(rows: usize) { for index in 0..rows { bump(&format!("row{index}")); @@ -246,7 +246,7 @@ fn Counted(rows: usize) { fn fr39_a_shorter_loop_drops_the_iterations_it_lost_and_a_longer_one_starts_them_fresh() { reset(); let rows = mutable_state_of(3_usize); - let state = rows.clone(); + let state = rows; let (mut host, mut tree) = start(move || Counted(state.get())); rows.set(1); frame(&mut host, &mut tree); @@ -266,7 +266,7 @@ fn fr39_a_shorter_loop_drops_the_iterations_it_lost_and_a_longer_one_starts_them ); } -#[composable] +#[Composable] fn Mode(mode: u8) { match mode { 0 => bump("zero"), @@ -280,7 +280,7 @@ fn Mode(mode: u8) { fn fr39_a_match_arm_that_changes_takes_its_state_with_it() { reset(); let mode = mutable_state_of(0_u8); - let state = mode.clone(); + let state = mode; let (mut host, mut tree) = start(move || Mode(state.get())); mode.set(1); frame(&mut host, &mut tree); @@ -299,7 +299,7 @@ fn fr39_a_match_arm_that_changes_takes_its_state_with_it() { ); } -#[composable] +#[Composable] fn Early(stop: bool) { bump("before"); if stop { @@ -308,7 +308,7 @@ fn Early(stop: bool) { bump("tail"); } -#[composable] +#[Composable] fn Looping(limit: usize) { for index in 0..5 { if index == 1 { @@ -321,7 +321,7 @@ fn Looping(limit: usize) { } } -#[composable] +#[Composable] fn Fallible(fail: bool) -> Result<(), String> { bump("start"); if fail { @@ -331,7 +331,7 @@ fn Fallible(fail: bool) -> Result<(), String> { Ok(()) } -#[composable] +#[Composable] fn Panicky(panic: bool) { bump("calm"); if panic { @@ -339,7 +339,7 @@ fn Panicky(panic: bool) { } } -#[composable] +#[Composable] fn ControlFlow(chaos: MutableState) { let on = chaos.get(); Early(on); @@ -353,8 +353,8 @@ fn ControlFlow(chaos: MutableState) { fn fr39_groups_close_on_return_continue_break_question_mark_and_unwind() { reset(); let chaos = mutable_state_of(false); - let state = chaos.clone(); - let (mut host, mut tree) = start(move || ControlFlow(state.clone())); + let state = chaos; + let (mut host, mut tree) = start(move || ControlFlow(state)); chaos.set(true); frame(&mut host, &mut tree); chaos.set(false); @@ -386,9 +386,9 @@ fn fr39_groups_close_on_return_continue_break_question_mark_and_unwind() { fn fr39_a_keyed_item_that_moves_is_moved_and_keeps_what_it_remembered() { reset(); let order = mutable_state_of(vec![1_u32, 2, 3]); - let state = order.clone(); + let state = order; let (mut host, mut tree) = start(move || { - let state = state.clone(); + let state = state; Column().content(move || { for id in state.get() { key(id, || { @@ -419,9 +419,9 @@ fn fr39_a_keyed_item_that_moves_is_moved_and_keeps_what_it_remembered() { fn fr39_writes_from_a_worker_are_one_recomposition_on_the_next_frame() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parent(state.clone())); - let worker = count.clone(); + let state = count; + let (mut host, mut tree) = start(move || Parent(state)); + let worker = count; std::thread::spawn(move || { worker.set(1); worker.set(2); @@ -435,7 +435,7 @@ fn fr39_writes_from_a_worker_are_one_recomposition_on_the_next_frame() { assert_eq!(tree.texts(), vec!["3", "fixed"]); } -#[composable] +#[Composable] fn WritesWhileComposing(target: MutableState) { ran("writer"); target.set(7); @@ -445,10 +445,10 @@ fn WritesWhileComposing(target: MutableState) { fn fr39_a_write_during_composition_waits_for_the_next_frame() { reset(); let target = mutable_state_of(0); - let state = target.clone(); + let state = target; let (mut host, mut tree) = start(move || { - Reader(state.clone()); - WritesWhileComposing(state.clone()); + Reader(state); + WritesWhileComposing(state); }); assert_eq!( tree.texts(), @@ -464,12 +464,12 @@ fn fr39_a_write_during_composition_waits_for_the_next_frame() { ); } -#[composable] +#[Composable] fn ClickCounter() { let count = remember(|| mutable_state_of(0)); Column().content(|| { Text(format!("{}", count.get())); - let more = count.clone(); + let more = count; Button("More").on_click(move || more.update(|count| *count += 1)); }); } @@ -485,9 +485,9 @@ fn fr39_a_write_in_a_handler_is_recomposed_in_the_same_call() { assert_eq!(tree.texts(), vec!["2", "More"]); } -#[composable] +#[Composable] fn Parity(count: MutableState) { - let source = count.clone(); + let source = count; let even = remember(move || derived_state_of(move || source.get() % 2 == 0)); ran("parity"); Text(if even.get() { "even" } else { "odd" }); @@ -497,8 +497,8 @@ fn Parity(count: MutableState) { fn fr39_a_derived_state_runs_its_reader_only_when_its_value_changes() { reset(); let count = mutable_state_of(0); - let state = count.clone(); - let (mut host, mut tree) = start(move || Parity(state.clone())); + let state = count; + let (mut host, mut tree) = start(move || Parity(state)); count.set(2); assert!(frame(&mut host, &mut tree).is_empty()); assert_eq!( @@ -523,7 +523,7 @@ impl Drop for NoteOnDrop { } } -#[composable] +#[Composable] fn Effects(id: u32) { side_effect(|| note("side")); launched_effect(id, move || async move { @@ -537,7 +537,7 @@ fn Effects(id: u32) { }); } -#[composable] +#[Composable] fn EffectsRoot(id: MutableState, present: MutableState) { if present.get() { Effects(id.get()); @@ -549,8 +549,8 @@ fn fr39_effects_run_after_composition_restart_on_a_new_key_and_clean_up_when_the reset(); let id = mutable_state_of(1_u32); let present = mutable_state_of(true); - let (state, shown) = (id.clone(), present.clone()); - let (mut host, mut tree) = start(move || EffectsRoot(state.clone(), shown.clone())); + let (state, shown) = (id, present); + let (mut host, mut tree) = start(move || EffectsRoot(state, shown)); assert_eq!(take_log(), vec!["side", "setup 1", "launched 1"]); id.set(2); @@ -570,10 +570,10 @@ fn fr39_effects_run_after_composition_restart_on_a_new_key_and_clean_up_when_the assert!(take_log().is_empty()); } -#[composable] +#[Composable] fn FrameClock() { let time = remember(|| mutable_state_of(0_u64)); - let writer = time.clone(); + let writer = time; launched_effect((), move || async move { loop { let now = with_frame_nanos(|now| now).await; @@ -594,10 +594,10 @@ fn fr39_a_future_waiting_for_a_frame_gets_that_frames_time() { } } -#[composable] +#[Composable] fn Worked() { let answer = remember(|| mutable_state_of(0_u32)); - let writer = answer.clone(); + let writer = answer; launched_effect((), move || async move { delay(std::time::Duration::from_millis(5)).await; let value = with_worker(|| 21 * 2).await; @@ -626,7 +626,7 @@ fn fr39_work_handed_to_a_worker_resumes_on_the_ui_thread_and_recomposes() { // ----- lists ------------------------------------------------------------------------------ -#[composable] +#[Composable] fn Rows(count: usize) { ran("rows"); LazyColumn().content(move |list| { @@ -689,16 +689,16 @@ fn fr39_a_lazy_list_composes_the_window_it_is_asked_for_and_nothing_above_it() { // ----- the Host as a whole ---------------------------------------------------------------- -#[composable] +#[Composable] fn Deleter() { let deleted = remember(|| mutable_state_of(false)); - let restore = deleted.clone(); - let delete = deleted.clone(); + let restore = deleted; + let delete = deleted; Column().content(|| { Text(if deleted.get() { "gone" } else { "here" }); Button("Delete").on_click(move || { delete.set(true); - let restore = restore.clone(); + let restore = restore; Message::new("Deleted") .with_action("Undo", move |()| restore.set(false)) .show(); @@ -742,7 +742,7 @@ fn fr39_a_message_action_runs_without_dioxus() { assert_eq!(tree.texts(), vec!["here", "Delete"]); } -#[composable] +#[Composable] fn Greeting(name: &'static str) { ran("greeting"); Text(format!("Hello, {name}")); @@ -752,7 +752,7 @@ fn Greeting(name: &'static str) { fn fr39_a_static_string_parameter_is_compared_and_skipped() { reset(); let tick = mutable_state_of(0); - let state = tick.clone(); + let state = tick; let (mut host, mut tree) = start(move || { let _ = state.get(); Greeting("there"); @@ -793,11 +793,11 @@ fn text_field_texts(batch: &[u8], fields: &[u32]) -> Vec<(u32, String)> { fn fr5_a_text_field_starts_with_its_text_and_typing_is_not_sent_back() { reset(); let draft = mutable_state_of(String::new()); - let shown = draft.clone(); + let shown = draft; let mut host = ComposeHost::with_content(move || { // Read, so that typing runs the screen again. let _ = shown.get(); - let typed = shown.clone(); + let typed = shown; TextField() .text("Buy milk") .placeholder("Title") diff --git a/compose-rust/tests/fr53_windows.rs b/compose-rust/tests/fr53_windows.rs index 554b91f79..053e30b16 100644 --- a/compose-rust/tests/fr53_windows.rs +++ b/compose-rust/tests/fr53_windows.rs @@ -54,7 +54,7 @@ fn event(node_id: u32, handler_id: u64, payload: EventPayload<'static>) -> HostE // ----- a single window is what it was ------------------------------------------------- -#[composable] +#[Composable] fn Nothing() {} /// An application launched the way it always was sends the window record it always sent, @@ -121,7 +121,7 @@ fn fr53_window_mode_rides_in_the_spare_bits_of_the_resizable_word() { // ----- windows share one composition --------------------------------------------------- -#[composable] +#[Composable] fn Shared() { let count = remember(|| mutable_state_of(0)); for name in ["a", "b"] { @@ -176,7 +176,7 @@ fn fr53_windows_share_state_in_one_batch() { static PANE_RUNS: [AtomicUsize; 2] = [AtomicUsize::new(0), AtomicUsize::new(0)]; -#[composable] +#[Composable] fn Pane(index: usize) { PANE_RUNS[index].fetch_add(1, Ordering::SeqCst); let size = current_window_size(); @@ -263,7 +263,7 @@ fn fr53_window_events_name_their_window() { static CLOSE_REQUESTS: AtomicUsize = AtomicUsize::new(0); static FOCUS_CHANGES: AtomicUsize = AtomicUsize::new(0); -#[composable] +#[Composable] fn Closable() { let open = remember(|| mutable_state_of(true)); if open.get() { @@ -420,7 +420,7 @@ fn fr53_last_window_closed_policy() { static REQUESTS: Mutex> = Mutex::new(Vec::new()); static REOPENS: AtomicUsize = AtomicUsize::new(0); -#[composable] +#[Composable] fn Listening() { on_window_requested(|request| { REQUESTS @@ -662,8 +662,9 @@ fn fr53_malformed_window_wire_is_a_protocol_error() { let mut batch = encoded(&[Mutation::SetWindow(WindowSettings::new())]); // The flags word of the window record: 12 for the envelope, 16 into the record. + // Bit 1 is the report-size flag, so it is a window's and not a stray bit. let flags_at = 12 + 16; - for stray in [0x0002_u16, 0x0004, 0x0080, 0x0200, 0x2000] { + for stray in [0x0004_u16, 0x0080, 0x0200, 0x2000] { let mut bent = batch.clone(); bent[flags_at..flags_at + 2].copy_from_slice(&(1 | stray).to_le_bytes()); assert!( @@ -678,7 +679,7 @@ fn fr53_malformed_window_wire_is_a_protocol_error() { // ----- misplaced windows ------------------------------------------------------------------- -#[composable] +#[Composable] fn WindowInAColumn() { Column().content(|| { Window(|| {}).content(|| { @@ -764,12 +765,12 @@ fn fr53_wire_numbers_are_the_ones_the_spec_lists() { // ----- nothing outlives a window -------------------------------------------------------------- -#[composable] +#[Composable] fn Roaming() { Text(format!("{:?}", current_window_size().class)); } -#[composable] +#[Composable] fn Opener() { let open = remember(|| mutable_state_of(false)); let toggle = open.clone(); @@ -836,3 +837,108 @@ fn fr53_window_state_is_dropped_when_the_window_closes() { ); } } + +#[Composable] +fn ReportingWindow() { + Window(|| {}).report_size(true).content(|| Nothing()); +} + +/// A window that opts in writes `report_size` as a boolean property on its node. +#[test] +fn fr20_8_window_report_size_writes_the_property() { + let mut host = + ComposeHost::with_theme(ReportingWindow, Theme::unified(DesignSystem::Material3)) + .with_window(several_windows()); + let batch = host.rebuild().unwrap().to_vec(); + let windows = window_ids(&batch); + assert_eq!(windows.len(), 1); + let written = decode_batch(&batch).unwrap().into_iter().any(|mutation| { + matches!( + mutation, + Mutation::SetProp { + node_id, + property: PropertyKind::ReportSize, + value: PropertyValue::Bool(true), + } if node_id == windows[0] + ) + }); + assert!(written, "the window node carries report_size = true"); + // The record itself: the property key 127 and a boolean true, as they are on the wire. + assert_eq!( + raw_set_props(&batch, windows[0]) + .iter() + .filter(|(key, ..)| *key == 127) + .collect::>(), + vec![&(127, VALUE_KIND_BOOL, 1)], + "report_size is written once, as key 127 with boolean value 1" + ); +} + +#[Composable] +fn TogglingWindow() { + let on = remember(|| mutable_state_of(true)); + let toggle = on.clone(); + let window = Window(|| {}); + let window = if on.get() { + window.report_size(true) + } else { + window + }; + window.content(move || { + let toggle = toggle.clone(); + Button("toggle").on_click(move || toggle.update(|on| *on = !*on)); + }); +} + +/// A window that stops saying `report_size` has the property taken away, so the Renderer +/// goes back to the default of reporting class changes only. +#[test] +fn fr20_8_window_report_size_removed_writes_no_property() { + let mut host = ComposeHost::with_theme(TogglingWindow, Theme::unified(DesignSystem::Material3)) + .with_window(several_windows()); + let mut tree = Tree::default(); + let first = host.rebuild().unwrap().to_vec(); + let window = window_ids(&first)[0]; + assert!( + raw_set_props(&first, window).contains(&(127, VALUE_KIND_BOOL, 1)), + "the first batch sets report_size" + ); + tree.apply(&first); + + let (node_id, handler_id) = tree.find("Button", Some("toggle"), 0, PropertyKind::OnClick); + let (batch, _) = host + .dispatch(event(node_id, handler_id, EventPayload::Clicked)) + .expect("the click dispatches"); + let after = raw_set_props(batch, window); + assert!( + after.contains(&(127, VALUE_KIND_NONE, 0)), + "the property is unset on the wire, got {after:?}" + ); + assert!( + !after.contains(&(127, VALUE_KIND_BOOL, 1)), + "no boolean report_size is written again" + ); +} + +const VALUE_KIND_NONE: u16 = 0; +const VALUE_KIND_BOOL: u16 = 2; + +/// The `SetProp` records of one node as `(property key, value kind, value)`, read straight +/// from the encoded bytes: a 12 byte envelope, then records of tag, length, node, key, +/// value kind and a 64 bit value, all little endian. +fn raw_set_props(batch: &[u8], node: u32) -> Vec<(u16, u16, u64)> { + let u16_at = |at: usize| u16::from_le_bytes(batch[at..at + 2].try_into().unwrap()); + let u32_at = |at: usize| u32::from_le_bytes(batch[at..at + 4].try_into().unwrap()); + let end = u32_at(4) as usize; + let mut found = Vec::new(); + let mut at = 12; + while at < end { + let (tag, len) = (u16_at(at), usize::from(u16_at(at + 2))); + if tag == 2 && u32_at(at + 4) == node { + let value = u64::from_le_bytes(batch[at + 12..at + 20].try_into().unwrap()); + found.push((u16_at(at + 8), u16_at(at + 10), value)); + } + at += len; + } + found +} diff --git a/compose-rust/tests/fr67_controls.rs b/compose-rust/tests/fr67_controls.rs new file mode 100644 index 000000000..06cec37fa --- /dev/null +++ b/compose-rust/tests/fr67_controls.rs @@ -0,0 +1,167 @@ +//! Switch, Slider and Checkbox take colours and a size, and send them only when asked. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use support::Tree; + +fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +const INK: Paint = Paint::Literal(Color::rgb(0x1c1b19)); +const SURFACE: Paint = Paint::Literal(Color::rgb(0xffffff)); + +#[Composable] +fn Plain() { + Column().content(|| { + Switch(true); + Slider(0.5); + Checkbox(true); + }); +} + +#[Composable] +fn Styled() { + Column().content(|| { + Switch(true) + .size(36.0, 20.0) + .track_color(INK) + .thumb_color(SURFACE); + Slider(0.5) + .active_track_color(INK) + .track_color(SURFACE) + .thumb_color(INK); + Checkbox(true) + .size(18.0) + .container_color(INK) + .check_color(SURFACE); + }); +} + +fn line<'a>(dump: &'a str, widget: &str) -> &'a str { + dump.lines() + .find(|line| line.trim_start().starts_with(widget)) + .unwrap_or_else(|| panic!("no {widget} in\n{dump}")) +} + +#[test] +fn fr67_controls_that_set_nothing_send_none_of_the_new_properties() { + let dump = start(Plain).dump(); + for name in [ + "TrackColor", + "ThumbColor", + "ActiveTrackColor", + "ContainerColor", + "CheckColor", + "ControlSize", + ] { + assert!(!dump.contains(name), "{name} was sent in\n{dump}"); + } +} + +#[test] +fn fr67_the_switch_sends_its_colours_and_its_size() { + let dump = start(Styled).dump(); + let switch = line(&dump, "Switch"); + for name in ["TrackColor", "ThumbColor", "ControlSize"] { + assert!(switch.contains(name), "{switch}"); + } + for name in ["ActiveTrackColor", "ContainerColor", "CheckColor"] { + assert!(!switch.contains(name), "{switch}"); + } +} + +#[test] +fn fr67_the_slider_sends_the_active_and_remaining_track_and_the_thumb() { + let dump = start(Styled).dump(); + let slider = line(&dump, "Slider"); + for name in ["ActiveTrackColor", "TrackColor", "ThumbColor"] { + assert!(slider.contains(name), "{slider}"); + } + assert!(!slider.contains("ControlSize"), "{slider}"); +} + +#[test] +fn fr67_the_checkbox_sends_its_container_check_and_side() { + let dump = start(Styled).dump(); + let checkbox = line(&dump, "Checkbox"); + for name in ["ContainerColor", "CheckColor", "ControlSize"] { + assert!(checkbox.contains(name), "{checkbox}"); + } + assert!(!checkbox.contains("TrackColor"), "{checkbox}"); +} + +#[Composable] +fn Dropping() { + let styled = remember(|| mutable_state_of(true)); + Column().content(|| { + let flip = styled.clone(); + Row() + .on_click(move || flip.update(|n| *n = !*n)) + .content(|| { + Text("flip"); + }); + // Each widget is finished by its own statement, so they compose in source order. + let switch = Switch(true).thumb_color(SURFACE); + if styled.get() { + switch.size(36.0, 20.0).track_color(INK) + } else { + switch + }; + let checkbox = Checkbox(true).container_color(INK); + if styled.get() { + checkbox.size(18.0).check_color(SURFACE) + } else { + checkbox + }; + }); +} + +#[test] +fn fr67_dropping_a_colour_or_size_sends_a_removal() { + use compose_rust::protocol::{HostEvent, Mutation, PropertyValue, decode_batch}; + use compose_rust::schema::{EventPayload, PropertyKind}; + + let mut host = ComposeHost::with_content(Dropping); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + + let (node_id, handler_id) = tree.handled("Row", PropertyKind::OnClick)[0]; + let (batch, _) = host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the click is accepted"); + let mut batches = vec![batch.to_vec()]; + batches.push(host.render_frame(0).expect("the frame encodes").to_vec()); + + let mut removed = Vec::new(); + for batch in &batches { + for mutation in decode_batch(batch).expect("the batch decodes") { + if let Mutation::SetProp { + property, value, .. + } = mutation + { + if value == PropertyValue::None { + removed.push(format!("{property:?}")); + } + } + } + } + removed.sort(); + // The Switch drops its track colour and size, the Checkbox its check colour and side. + // Colours and sizes that are still set are not removed. + assert_eq!( + removed, + ["CheckColor", "ControlSize", "ControlSize", "TrackColor"], + "{removed:?}" + ); +} diff --git a/compose-rust/tests/fr68_semantics.rs b/compose-rust/tests/fr68_semantics.rs new file mode 100644 index 000000000..65529c144 --- /dev/null +++ b/compose-rust/tests/fr68_semantics.rs @@ -0,0 +1,297 @@ +//! A node can say what it is and what it is called to assistive technology. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use support::Tree; + +fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +#[Composable] +fn Plain() { + Column().content(|| { + Box().modifier(Modifier.padding(4.0)).content(|| { + Text("plain"); + }); + Button("Save").on_click(|| {}); + Switch(true); + }); +} + +#[Composable] +fn Named() { + Column().content(|| { + Box() + .modifier(Modifier.label("Refresh").role(Role::Button)) + .on_click(|| {}) + .content(|| { + Text("↻"); + }); + Button("") + .icon(IconRole::Add) + .accessible_name("Import") + .on_click(|| {}); + Button("Save") + .modifier(Modifier.label("From the modifier")) + .accessible_name("Save the draft") + .on_click(|| {}); + Switch(true).accessible_name("Notifications"); + Checkbox(false).accessible_name("Remember me"); + RadioButton(true).accessible_name("Small"); + Slider(0.5).accessible_name("Volume"); + }); +} + +#[Composable] +fn Stated() { + Column().content(|| { + Box() + .modifier(Modifier.semantics(Role::Tab, "Files", SemanticsState::selected(true))) + .content(|| {}); + Box().modifier(Modifier.checked(false)).content(|| {}); + }); +} + +#[test] +fn fr68_a_node_that_asks_for_no_semantics_sends_none() { + let dump = start(Plain).dump(); + assert!(!dump.contains("AccessibleName"), "{dump}"); + assert!(!dump.contains("Semantics"), "{dump}"); +} + +#[test] +fn fr68_the_modifier_sends_the_name_and_the_role() { + let dump = start(Named).dump(); + assert!(dump.contains("AccessibleName=\"Refresh\""), "{dump}"); + assert!( + dump.contains("Semantics { role: Button, state: 0 }"), + "{dump}" + ); +} + +#[test] +fn fr68_the_modifier_sends_the_state_and_an_unset_role_stays_none() { + let dump = start(Stated).dump(); + // Selected on is 2 in the selected field, which starts two bits up. + assert!(dump.contains("Semantics { role: Tab, state: 8 }"), "{dump}"); + assert!(dump.contains("AccessibleName=\"Files\""), "{dump}"); + // Checked off is 1 in the checked field, with no role given. + assert!( + dump.contains("Semantics { role: None, state: 1 }"), + "{dump}" + ); +} + +#[test] +fn fr68_accessible_name_reaches_every_control_that_has_one() { + let dump = start(Named).dump(); + for (widget, name) in [ + ("Button", "Import"), + ("Switch", "Notifications"), + ("Checkbox", "Remember me"), + ("RadioButton", "Small"), + ("Slider", "Volume"), + ] { + let line = dump + .lines() + .find(|line| line.trim_start().starts_with(widget)) + .unwrap_or_else(|| panic!("no {widget} in\n{dump}")); + assert!( + line.contains(&format!("AccessibleName=\"{name}\"")), + "{widget}: {line}" + ); + } +} + +#[test] +fn fr68_the_widget_name_wins_over_the_modifier_label_and_the_icon() { + let dump = start(Named).dump(); + assert!(dump.contains("AccessibleName=\"Save the draft\""), "{dump}"); + assert!(!dump.contains("From the modifier"), "{dump}"); +} + +#[Composable] +fn Renaming() { + let named = remember(|| mutable_state_of(true)); + Column().content(|| { + let flip = named.clone(); + Row() + .on_click(move || flip.update(|n| *n = !*n)) + .content(|| { + Text("flip"); + }); + let button = Button("").icon(IconRole::Add).on_click(|| {}); + if named.get() { + button.accessible_name("Import") + } else { + button + }; + }); +} + +#[test] +fn fr68_a_widget_that_stops_naming_itself_sends_a_removal() { + use compose_rust::protocol::{HostEvent, Mutation, PropertyValue, decode_batch}; + use compose_rust::schema::{EventPayload, PropertyKind}; + + let mut host = ComposeHost::with_content(Renaming); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + assert!(tree.dump().contains("AccessibleName=\"Import\"")); + + let (node_id, handler_id) = tree.handled("Row", PropertyKind::OnClick)[0]; + let mut batches = Vec::new(); + let (batch, _) = host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the click is accepted"); + batches.push(batch.to_vec()); + batches.push(host.render_frame(0).expect("the frame encodes").to_vec()); + + let mut removed = 0; + for batch in &batches { + for mutation in decode_batch(batch).expect("the batch decodes") { + if let Mutation::SetProp { + property: PropertyKind::AccessibleName, + value, + .. + } = mutation + { + assert_eq!(value, PropertyValue::None); + removed += 1; + } + } + tree.apply(batch); + } + assert_eq!(removed, 1, "one removal of the name was expected"); + assert!(!tree.dump().contains("AccessibleName"), "{}", tree.dump()); +} + +#[Composable] +fn Described() { + let described = remember(|| mutable_state_of(true)); + Column().content(|| { + let flip = described.clone(); + Row() + .on_click(move || flip.update(|d| *d = !*d)) + .content(|| { + Text("flip"); + }); + let modifier = if described.get() { + Modifier.semantics(Role::Heading, "Title", SemanticsState::expanded(true)) + } else { + Modifier + }; + Box().modifier(modifier).content(|| {}); + }); +} + +#[test] +fn fr71_removing_semantics_restores_the_defaults() { + use compose_rust::protocol::{HostEvent, Mutation, PropertyValue, decode_batch}; + use compose_rust::schema::{EventPayload, Modifier as Wire, PropertyKind}; + + let mut host = ComposeHost::with_content(Described); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + assert!( + tree.dump().contains("Semantics { role: Heading"), + "{}", + tree.dump() + ); + + let (node_id, handler_id) = tree.handled("Row", PropertyKind::OnClick)[0]; + let (batch, _) = host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the click is accepted"); + let batches = [ + batch.to_vec(), + host.render_frame(0).expect("the frame encodes").to_vec(), + ]; + + let (mut name_removed, mut modifier_cleared) = (0, 0); + for batch in &batches { + for mutation in decode_batch(batch).expect("the batch decodes") { + match mutation { + Mutation::SetProp { + property: PropertyKind::AccessibleName, + value, + .. + } => { + assert_eq!(value, PropertyValue::None); + name_removed += 1; + } + Mutation::SetModifier { + modifier: Wire::Empty, + .. + } => modifier_cleared += 1, + _ => {} + } + } + tree.apply(batch); + } + assert_eq!(name_removed, 1, "one removal of the name was expected"); + assert!(modifier_cleared >= 1, "the semantics slot was not cleared"); + assert!(!tree.dump().contains("Semantics"), "{}", tree.dump()); + assert!(!tree.dump().contains("AccessibleName"), "{}", tree.dump()); +} + +#[Composable] +fn EveryNewRole() { + Column().content(|| { + for role in [ + Role::TabList, + Role::Tree, + Role::TreeItem, + Role::Menu, + Role::MenuItem, + Role::TextBox, + Role::Heading, + Role::Navigation, + Role::List, + Role::ListItem, + Role::Group, + ] { + Box() + .modifier(Modifier.role(role).disabled(false).click_action(true)) + .content(|| {}); + } + }); +} + +#[test] +fn fr71_every_new_role_round_trips_and_is_exposed() { + let dump = start(EveryNewRole).dump(); + for role in [ + "TabList", + "Tree", + "TreeItem", + "Menu", + "MenuItem", + "TextBox", + "Heading", + "Navigation", + "List", + "ListItem", + "Group", + ] { + assert!( + dump.contains(&format!("Semantics {{ role: {role}, state: ")), + "{role} missing in\n{dump}" + ); + } +} diff --git a/compose-rust/tests/fr72_file_dialog.rs b/compose-rust/tests/fr72_file_dialog.rs new file mode 100644 index 000000000..dc87188e7 --- /dev/null +++ b/compose-rust/tests/fr72_file_dialog.rs @@ -0,0 +1,321 @@ +//! File dialogs from the Host's side: what a request looks like in a batch, how the answer +//! finds the callback that asked, and that the boundary gained nothing for any of it. +//! +//! The Renderer's half, showing the platform's panel, is not here. What is here is +//! everything the Host promises: one request number per ask, one callback call per number, +//! and paths that arrive whole. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::protocol::{ + HostEvent, Mutation, ProtocolError, decode_batch, decode_event, encode_event, +}; +use compose_rust::runtime::*; +use compose_rust::schema::{ + BOUNDARY_SCHEMA, DesignSystem, EventPayload, FileDialogKind, FileDialogStatus, PropertyKind, + Theme, +}; +use compose_rust::ui::*; +use compose_rust::{FileDialog, FileDialogResult, FileDialogSender}; +use std::cell::RefCell; +use std::sync::Mutex; +use support::Tree; + +/// The tests below share the Host's request queue, which is process wide, so they take +/// turns. Each one drains everything it asked for before it lets go. +static SERIAL: Mutex<()> = Mutex::new(()); + +thread_local! { + static RESULTS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +static WORKER_RESULTS: Mutex> = Mutex::new(Vec::new()); + +fn record(label: &'static str) -> impl FnOnce(FileDialogResult) + 'static { + move |result| RESULTS.with_borrow_mut(|results| results.push((label, result))) +} + +fn results() -> Vec<(&'static str, FileDialogResult)> { + RESULTS.with_borrow(Clone::clone) +} + +#[Composable] +fn Askers() { + Button("choose").on_click(|| { + FileDialog::open_files() + .title("Choose") + .filter("Images", &["png", "jpg"]) + .show(record("choose")); + }); + Button("cancel").on_click(|| { + FileDialog::save().name("a.txt").show(record("cancel")); + }); + Button("unavailable").on_click(|| { + FileDialog::open_folder() + .window(0) + .show(record("unavailable")); + }); + Button("worker").on_click(|| { + FileDialogSender::new().show(FileDialog::open_file(), |result| { + WORKER_RESULTS.lock().unwrap().push(result); + }); + }); +} + +fn event(payload: EventPayload<'static>) -> HostEvent<'static> { + HostEvent { + node_id: 0, + handler_id: 0, + payload, + } +} + +fn result_event( + request: u32, + status: FileDialogStatus, + filter: u32, + paths: &'static str, +) -> HostEvent<'static> { + event(EventPayload::FileDialogResult { + request, + status, + filter, + paths, + }) +} + +/// The dialogs a batch asks for, as (request, kind, window). +fn dialogs(batch: &[u8]) -> Vec<(u32, FileDialogKind, u32)> { + decode_batch(batch) + .expect("the batch decodes") + .into_iter() + .filter_map(|mutation| match mutation { + Mutation::ShowFileDialog { + request, + kind, + window, + .. + } => Some((request, kind, window)), + _ => None, + }) + .collect() +} + +struct Session { + host: ComposeHost, + tree: Tree, +} + +impl Session { + fn start() -> Self { + let mut host = ComposeHost::with_theme(Askers, Theme::unified(DesignSystem::Material3)); + let mut tree = Tree::default(); + let first = host.rebuild().expect("the first batch encodes").to_vec(); + tree.apply(&first); + assert!(dialogs(&first).is_empty(), "nothing is asked for at start"); + Self { host, tree } + } + + /// Presses the button and returns the one dialog request the press produced. + fn press(&mut self, label: &str) -> (u32, FileDialogKind, u32) { + let (node_id, handler_id) = self + .tree + .find("Button", Some(label), 0, PropertyKind::OnClick); + let (batch, _) = self + .host + .dispatch(HostEvent { + node_id, + handler_id, + payload: EventPayload::Clicked, + }) + .expect("the press dispatches"); + let mut asked = dialogs(batch); + assert_eq!(asked.len(), 1, "one press, one request: {asked:?}"); + asked.remove(0) + } + + fn answer(&mut self, event: HostEvent<'static>) { + self.host.dispatch(event).expect("the answer dispatches"); + } +} + +/// Selecting, cancelling and being unable to show all end in exactly one call of the +/// callback that asked, and an answer that comes twice or for nobody calls nothing. +#[test] +fn fr72_every_request_gets_exactly_one_result() { + let _turn = SERIAL.lock().unwrap_or_else(|poison| poison.into_inner()); + RESULTS.with_borrow_mut(Vec::clear); + WORKER_RESULTS.lock().unwrap().clear(); + let mut session = Session::start(); + + let (chosen, chosen_kind, chosen_window) = session.press("choose"); + let (cancelled, cancelled_kind, _) = session.press("cancel"); + let (unavailable, unavailable_kind, _) = session.press("unavailable"); + let (worker, worker_kind, _) = session.press("worker"); + assert_eq!(chosen_kind, FileDialogKind::OpenFiles); + assert_eq!( + chosen_window, 0, + "no window named is the implicit root window" + ); + assert_eq!(cancelled_kind, FileDialogKind::Save); + assert_eq!(unavailable_kind, FileDialogKind::OpenFolder); + assert_eq!(worker_kind, FileDialogKind::OpenFile); + let mut numbers = vec![chosen, cancelled, unavailable, worker]; + numbers.sort_unstable(); + numbers.dedup(); + assert_eq!(numbers.len(), 4, "every request has a number of its own"); + assert!(results().is_empty(), "nobody is called before the answer"); + + session.answer(result_event( + chosen, + FileDialogStatus::Chosen, + 1, + "/home/a.png\0/home/b.jpg", + )); + session.answer(result_event( + cancelled, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + session.answer(result_event( + unavailable, + FileDialogStatus::Unavailable, + u32::MAX, + "", + )); + session.answer(result_event( + worker, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + assert_eq!( + results(), + vec![ + ( + "choose", + FileDialogResult::Chosen { + paths: vec!["/home/a.png".to_owned(), "/home/b.jpg".to_owned()], + filter: Some(1), + } + ), + ("cancel", FileDialogResult::Cancelled), + ("unavailable", FileDialogResult::Unavailable), + ] + ); + assert_eq!( + *WORKER_RESULTS.lock().unwrap(), + vec![FileDialogResult::Cancelled], + "a worker's callback is called on the UI thread, once" + ); + + // The same answers again, and one for a number nobody asked for: nothing is called and + // nothing fails. + session.answer(result_event( + chosen, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + session.answer(result_event( + worker, + FileDialogStatus::Cancelled, + u32::MAX, + "", + )); + session.answer(result_event( + u32::MAX, + FileDialogStatus::Unavailable, + u32::MAX, + "", + )); + assert_eq!(results().len(), 3); + assert_eq!(WORKER_RESULTS.lock().unwrap().len(), 1); + + // The answer belongs to the Host: a node or a handler on it is a malformed event. + let on_a_node = HostEvent { + node_id: 5, + handler_id: 0, + payload: EventPayload::FileDialogResult { + request: chosen, + status: FileDialogStatus::Cancelled, + filter: u32::MAX, + paths: "", + }, + }; + assert!(session.host.dispatch(on_a_node).is_err()); +} + +/// A path is whatever the platform gave, whole: Korean text and paths longer than 260 +/// characters arrive unchanged and in order. One that is not UTF-8 never gets as far as the +/// callback, because the event does not decode. +#[test] +fn fr72_paths_arrive_whole_utf8_and_a_non_utf8_path_answers_unavailable() { + let _turn = SERIAL.lock().unwrap_or_else(|poison| poison.into_inner()); + RESULTS.with_borrow_mut(Vec::clear); + let mut session = Session::start(); + let (request, _, _) = session.press("choose"); + + let long: &'static str = Box::leak(format!("/data/{}.bin", "가".repeat(300)).into_boxed_str()); + assert!(long.chars().count() > 260); + let paths: &'static str = Box::leak(format!("/Users/한글/문서 1.txt\0{long}").into_boxed_str()); + + // Over the wire and back, the way the Renderer's event reaches the Host. + let sent = result_event(request, FileDialogStatus::Chosen, 0, paths); + let mut bytes = Vec::new(); + encode_event(&sent, &mut bytes).unwrap(); + assert_eq!(decode_event(&bytes).unwrap(), sent); + + // A path that is not UTF-8 makes the whole event a protocol error, and the request + // stays waiting for the answer the Renderer sends instead. + let mut corrupt = bytes.clone(); + let last = corrupt.len() - 1; + corrupt[last] = 0xFF; + assert_eq!(decode_event(&corrupt), Err(ProtocolError::InvalidUtf8)); + assert!(results().is_empty()); + + session.answer(sent); + assert_eq!( + results(), + vec![( + "choose", + FileDialogResult::Chosen { + paths: vec!["/Users/한글/문서 1.txt".to_owned(), long.to_owned()], + filter: Some(0), + } + )] + ); +} + +/// A dialog adds a record and an event to the protocol and nothing to the boundary: the +/// same five entry points, and no exported symbol in the dialog's module. +#[test] +fn pr2_boundary_surface_unchanged_by_file_dialog() { + let symbols: Vec<&str> = BOUNDARY_SCHEMA + .iter() + .map(|operation| operation.symbol) + .collect(); + assert_eq!( + symbols, + vec![ + "compose_rust_host_init", + "compose_rust_host_dispatch_event", + "compose_rust_host_render_frame", + "compose_rust_host_release_batch", + "compose_rust_host_shutdown", + ] + ); + let exports = include_str!("../src/boundary.rs") + .matches("#[unsafe(no_mangle)]") + .count(); + assert_eq!( + exports, + symbols.len(), + "boundary.rs exports one function per entry point" + ); + let dialog = include_str!("../src/file_dialog.rs"); + assert!(!dialog.contains("no_mangle")); + assert!(!dialog.contains("extern \"C\"")); +} diff --git a/compose-rust/tests/fr74_overlay_tokens.rs b/compose-rust/tests/fr74_overlay_tokens.rs new file mode 100644 index 000000000..2b5e5d510 --- /dev/null +++ b/compose-rust/tests/fr74_overlay_tokens.rs @@ -0,0 +1,70 @@ +//! Overlay theme tokens and node geometry overrides. + +use compose_rust::protocol::{BatchEncoder, Mutation, decode_batch}; +use compose_rust::schema::{DesignSystem, OverlayTokens, PropertyKind, Theme}; + +#[test] +fn fr74_overlay_tokens_command_round_trips() { + let tokens = OverlayTokens::new() + .sheet_corner_radius(24) + .dialog_corner_radius(16) + .popup_corner_radius(12) + .sheet_elevation(8) + .dialog_elevation(6) + .popup_elevation(4) + .sheet_padding(20) + .dialog_padding(16) + .popup_padding(8) + .scrim_argb(0x80000000); + + let mut encoder = BatchEncoder::default(); + encoder.encode(&Mutation::SetOverlayTokens(tokens)).unwrap(); + let bytes = encoder.finish().unwrap(); + + let decoded = decode_batch(bytes).unwrap(); + assert_eq!(decoded.len(), 1); + match &decoded[0] { + Mutation::SetOverlayTokens(actual) => { + assert_eq!(*actual, tokens); + assert_eq!(actual.sheet_corner_radius, 24); + assert_eq!(actual.dialog_corner_radius, 16); + assert_eq!(actual.popup_corner_radius, 12); + assert_eq!(actual.sheet_elevation, 8); + assert_eq!(actual.dialog_elevation, 6); + assert_eq!(actual.popup_elevation, 4); + assert_eq!(actual.sheet_padding, 20); + assert_eq!(actual.dialog_padding, 16); + assert_eq!(actual.popup_padding, 8); + assert_eq!(actual.scrim, 0x80000000); + } + other => panic!("expected SetOverlayTokens, got {other:?}"), + } +} + +#[test] +fn fr74_theme_builder_attaches_overlay_tokens() { + static TOKENS: OverlayTokens = OverlayTokens { + sheet_corner_radius: 24, + dialog_corner_radius: 16, + popup_corner_radius: 12, + sheet_elevation: 8, + popup_elevation: 4, + dialog_elevation: 6, + sheet_padding: 20, + dialog_padding: 16, + popup_padding: 8, + scrim: 0x66000000, + }; + + let theme = Theme::unified(DesignSystem::Cupertino).with_overlay_tokens(&TOKENS); + assert_eq!(theme.overlay, Some(&TOKENS)); +} + +#[test] +fn fr74_property_kind_scrim_has_correct_wire_tag() { + assert_eq!(PropertyKind::Scrim as u16, 128); + assert_eq!( + PropertyKind::from_name("scrim").unwrap(), + PropertyKind::Scrim + ); +} diff --git a/compose-rust/tests/fr75_composer_tokens.rs b/compose-rust/tests/fr75_composer_tokens.rs new file mode 100644 index 000000000..1147195d1 --- /dev/null +++ b/compose-rust/tests/fr75_composer_tokens.rs @@ -0,0 +1,84 @@ +//! TextField typography and control over composer / icon button tokens. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use support::Tree; + +fn start(content: impl Fn() + Send + Sync + 'static) -> Tree { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree +} + +const CUSTOM_COLOR: Paint = Paint::Literal(Color::rgb(0x123456)); + +#[Composable] +fn StyledField() { + Column().content(|| { + TextField() + .text("custom") + .font_size(15.0) + .font_weight(600) + .line_height(22.0) + .letter_spacing(0.5) + .color(CUSTOM_COLOR); + }); +} + +#[Composable] +fn PlainField() { + Column().content(|| { + TextField().text("plain"); + }); +} + +#[test] +fn fr75_text_field_typography_round_trips() { + let tree = start(StyledField); + let dump = tree.dump(); + assert!(dump.contains("TextField"), "missing TextField: {dump}"); + assert!( + dump.contains("FontSize=Float(15.0)"), + "missing FontSize in {dump}" + ); + assert!( + dump.contains("FontWeight=Integer(600)"), + "missing FontWeight in {dump}" + ); + assert!( + dump.contains("LineHeight=Float(22.0)"), + "missing LineHeight in {dump}" + ); + assert!( + dump.contains("LetterSpacing=Float(0.5)"), + "missing LetterSpacing in {dump}" + ); + assert!(dump.contains("Color=Integer("), "missing Color in {dump}"); + assert!( + dump.contains(&format!("Color=Integer({})", CUSTOM_COLOR.to_bits() as i64)), + "missing Color in {dump}" + ); +} + +#[test] +fn fr75_text_field_plain_does_not_send_typography_properties() { + let tree = start(PlainField); + let dump = tree.dump(); + assert!(dump.contains("TextField"), "missing TextField: {dump}"); + for prop in [ + "FontSize", + "FontWeight", + "LineHeight", + "LetterSpacing", + "Color", + ] { + assert!( + !dump.contains(prop), + "{prop} should not be sent for plain field: {dump}" + ); + } +} diff --git a/compose-rust/tests/generated_protocol.rs b/compose-rust/tests/generated_protocol.rs index 9f921958d..c03c3684e 100644 --- a/compose-rust/tests/generated_protocol.rs +++ b/compose-rust/tests/generated_protocol.rs @@ -1,6 +1,6 @@ use compose_rust::codegen::{ LINKED_HOST_FUNCTION, generate_event_vector, generate_kotlin, generate_linux_host_def, - generate_mutation_vector, generate_vector_description, + generate_mutation_vector, generate_transform_3d_vector, generate_vector_description, }; use compose_rust::protocol::{ BatchEncoder, HostEvent, Mutation, PropertyValue, decode_batch, decode_event, encode_event, @@ -18,11 +18,11 @@ fn fr7_generated_kotlin_matches_schema() { assert!(generated.contains("27 -> PropertyKind.Progress")); assert!(generated.contains("else -> throw ProtocolException(\"unknown widget tag $tag\"")); assert!(generated.contains("else -> throw ProtocolException(\"unknown property tag $tag\"")); - assert!(generated.contains("enum class Key { Enter }")); + assert!(generated.contains("enum class Key { Enter, Other }")); assert!(generated.contains( - "data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent" + "data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent" )); - assert!(generated.contains("is HostEvent.KeyDown -> 20")); + assert!(generated.contains("is HostEvent.KeyDown -> 32")); for (field, mask) in [ ("shiftKey", "0x01"), ("ctrlKey", "0x02"), @@ -74,6 +74,11 @@ fn fr7_generated_vectors_match_schema() { generate_mutation_vector().unwrap(), "mutation vector is stale; run `cargo run -p compose-rust --bin codegen`", ); + assert_eq!( + include_bytes!("vectors/transform3d.bin").as_slice(), + generate_transform_3d_vector().unwrap(), + "3D transform vector is stale; run `cargo run -p compose-rust --bin codegen`", + ); assert_eq!( include_bytes!("vectors/events.bin").as_slice(), generate_event_vector().unwrap(), @@ -89,12 +94,12 @@ fn fr7_generated_vectors_match_schema() { #[test] fn fr12_keydown_roundtrips_through_vectors() { let bytes = include_bytes!("vectors/events.bin"); - for (offset, length) in [(0, 16), (16, 30), (46, 28), (74, 16), (90, 35), (125, 20)] { + for (offset, length) in [(0, 16), (16, 30), (46, 28), (74, 16), (90, 35), (125, 37)] { decode_event(&bytes[offset..offset + length]).unwrap(); } - let key_down = &bytes[125..145]; - assert_eq!(&key_down[0..4], &[6, 0, 20, 0]); + let key_down = &bytes[125..162]; + assert_eq!(&key_down[0..4], &[6, 0, 32, 0]); assert_eq!(key_down[18], 0x0f); let expected = HostEvent { @@ -106,6 +111,9 @@ fn fr12_keydown_roundtrips_through_vectors() { ctrl_key: true, alt_key: true, meta_key: true, + repeat: true, + code: 0x28, + text: "Enter", }, }; assert_eq!(decode_event(key_down).unwrap(), expected); @@ -118,7 +126,7 @@ fn fr12_keydown_roundtrips_through_vectors() { #[test] fn fr8_range_requested_roundtrips_through_vectors() { let bytes = include_bytes!("vectors/events.bin"); - let range_requested = &bytes[145..169]; + let range_requested = &bytes[162..186]; assert_eq!(&range_requested[0..4], &[7, 0, 24, 0]); let expected = HostEvent { diff --git a/compose-rust/tests/html_elements.rs b/compose-rust/tests/html_elements.rs index 38d354a3c..54a984d2b 100644 --- a/compose-rust/tests/html_elements.rs +++ b/compose-rust/tests/html_elements.rs @@ -133,11 +133,24 @@ fn every_modifier() -> Vec { origin_x: 0.5, origin_y: 0.25, }, + Modifier::Transform3D { + m: [ + 0.5, -0.25, 1.5, 0.125, -2.0, 3.0, 0.75, -0.5, 4.0, -1.25, 2.5, -0.0625, 12.0, + -6.0, 8.0, 1.0, + ], + origin_x: 0.5, + origin_y: 0.25, + backface_hidden: true, + }, Modifier::MatchParentSize, Modifier::PressFeedback { scale: 0.975, tint_alpha: 0.07, }, + Modifier::Semantics { + role: compose_rust::SemanticsRole::Button, + state: 0b0110, + }, ] } @@ -147,7 +160,8 @@ fn tag_of(modifier: &Modifier) -> u16 { } /// Each new modifier round-trips, and its record is as long as its tag says: 28 bytes for -/// a value of two words, 36 for the shadow's three and 60 for the border's six. +/// a value of two words, 36 for the shadow's three, 60 for the border's six and 92 for the +/// 3D transform's ten. #[test] fn fr42_every_modifier_round_trips_at_the_length_its_tag_fixes() { let modifiers = every_modifier(); @@ -182,6 +196,8 @@ fn fr42_every_modifier_round_trips_at_the_length_its_tag_fixes() { (23, 36), (24, 28), (25, 28), + (26, 44), + (27, 92), ] { assert_eq!( 28 + 8 * usize::from(modifier_extra_words(tag).unwrap()), @@ -198,7 +214,7 @@ fn fr_matchparent_round_trips_with_sparse_tag() { assert_eq!(u16::from_le_bytes([bytes[22], bytes[23]]), 31); assert_eq!(bytes.len(), 40); assert_eq!(decode_batch(&bytes), Ok(vec![record])); - for tag in 27..31 { + for tag in 28..31 { assert_eq!(modifier_extra_words(tag), None); } } diff --git a/compose-rust/tests/measure.rs b/compose-rust/tests/measure.rs index e55721e81..65edba4f4 100644 --- a/compose-rust/tests/measure.rs +++ b/compose-rust/tests/measure.rs @@ -191,7 +191,7 @@ fn pr3_measure_off_the_ui_thread_is_refused() { let mut host = host(); let refused = std::thread::spawn(|| { let host_side = matches!(measurer(), Err(MeasureError::OutsideHostCall)); - let mut requests = vec![0u8; MEASURE_RECORD_LEN]; + let mut requests = [0u8; MEASURE_RECORD_LEN]; requests[0] = 1; let mut results = [MeasureResult::default()]; // SAFETY: both buffers are live for the call. diff --git a/compose-rust/tests/pointer_events.rs b/compose-rust/tests/pointer_events.rs index b681ae0d0..ec6005142 100644 --- a/compose-rust/tests/pointer_events.rs +++ b/compose-rust/tests/pointer_events.rs @@ -31,7 +31,7 @@ thread_local! { static SEEN: RefCell> = const { RefCell::new(Vec::new()) }; } -#[composable] +#[Composable] fn Screen() { Column().content(|| { // Subscribed to presses and moves. A press is consumed, a move is not. diff --git a/compose-rust/tests/renderer_resolution.rs b/compose-rust/tests/renderer_resolution.rs index b9092a44d..066ad98e7 100644 --- a/compose-rust/tests/renderer_resolution.rs +++ b/compose-rust/tests/renderer_resolution.rs @@ -490,6 +490,121 @@ fn nfr14_a_workspace_static_build_comes_before_the_native_image() { assert_eq!(archive.lib_dir, static_dir); } +// ------------------------------------------------------------------------------------- +// Windows: the static kit is the renderer +// ------------------------------------------------------------------------------------- + +const WINDOWS_TARGET: &str = "windows-x64"; +const WINDOWS_OS: &str = "windows"; + +/// What `build-windows.sh` writes into `renderer/build/windows`, and what the release +/// tarball carries at its root: the archive with `native/`, `gcc/` and `skiko/` beside it. +fn windows_kit(root: &Path) -> PathBuf { + std::fs::create_dir_all(root.join("native")).unwrap(); + std::fs::write(root.join(renderer_static_file(WINDOWS_OS)), ARCHIVE_BYTES).unwrap(); + std::fs::write(root.join("native/dxc-windows-native.lib"), b"lib").unwrap(); + root.to_path_buf() +} + +/// Windows ships the Kotlin/Native static kit, so it is a static target, and the +/// GraalVM DLL is no longer a renderer there. +#[test] +fn nfr14_windows_is_a_static_target() { + assert!(STATIC_TARGETS.contains(&WINDOWS_TARGET)); + assert!(PUBLISHED_TARGETS.contains(&WINDOWS_TARGET)); +} + +/// The directory `build-windows.sh` writes resolves as a static renderer, kit root and all. +#[test] +fn nfr14_a_windows_workspace_kit_is_static() { + let temp = TempDir::new("windows-workspace"); + let kit = windows_kit(&temp.path().join("build/windows")); + let renderer = acquire_renderer(&Request { + env_dir: None, + workspace_dirs: &[kit.as_path()], + cache_root: Some(&temp.path().join("cache")), + crate_version: SAMPLE_VERSION, + target: WINDOWS_TARGET, + target_os: WINDOWS_OS, + fetch: None, + }) + .expect("the kit is there"); + assert_eq!(renderer.source, RendererSource::Workspace); + assert_eq!(renderer.kind, RendererKind::Static); + assert_eq!(renderer.lib_dir, kit); +} + +/// A downloaded `compose-rust-renderer-v-windows-x64.tar.gz` is unpacked and linked as +/// the static kit, with its sibling directories intact. +#[test] +fn nfr15_a_windows_tarball_is_unpacked_as_the_static_kit() { + let temp = TempDir::new("windows-download"); + let release = temp.path().join("release"); + windows_kit(&release.join("staging")); + let prepared = pack( + &release.join("staging"), + &release, + Checksum::Correct, + WINDOWS_TARGET, + ); + let served = prepared.clone(); + let fetch = move |url: &str, destination: &Path| -> Result<(), FetchError> { + let source = if url.ends_with(".sha256") { + sibling_checksum(&served) + } else { + served.clone() + }; + std::fs::copy(source, destination) + .map(|_| ()) + .map_err(|error| FetchError::Failed(error.to_string())) + }; + let cache = temp.path().join("cache"); + let renderer = acquire_for( + WINDOWS_TARGET, + WINDOWS_OS, + &temp, + None, + Network::Allowed(&fetch), + &cache, + ) + .expect("the release serves this target"); + assert_eq!(renderer.source, RendererSource::Download); + assert_eq!(renderer.kind, RendererKind::Static); + assert!( + renderer + .lib_dir + .join("native/dxc-windows-native.lib") + .is_file() + ); + assert!( + renderer + .lib_dir + .join(renderer_static_file(WINDOWS_OS)) + .is_file() + ); +} + +/// A leftover GraalVM DLL is not a renderer on Windows: the directory holding only it +/// is refused instead of linked. +#[test] +fn nfr14_a_windows_dll_alone_is_not_a_renderer() { + let temp = TempDir::new("windows-dll"); + let bin = temp.path().join("dist/bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("libcompose_rust_renderer.dll"), b"MZ").unwrap(); + let cache = temp.path().join("cache"); + let error = acquire_for( + WINDOWS_TARGET, + WINDOWS_OS, + &temp, + Some(&temp.path().join("dist")), + Network::Forbidden, + &cache, + ) + .expect_err("a DLL is not the Windows renderer any more"); + assert!(error.contains(renderer_static_file(WINDOWS_OS)), "{error}"); +} + // ------------------------------------------------------------------------------------- // Refusals // ------------------------------------------------------------------------------------- diff --git a/compose-rust/tests/state_handles.rs b/compose-rust/tests/state_handles.rs new file mode 100644 index 000000000..74cbb7f1b --- /dev/null +++ b/compose-rust/tests/state_handles.rs @@ -0,0 +1,348 @@ +//! Copy state handles: an arena index and a generation, owned by the `remember` call that +//! made them, through the public surface and a Host with no Dioxus anywhere. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::runtime::*; +use compose_rust::ui::*; +use std::cell::{Cell, RefCell}; +use std::collections::HashSet; +use support::{Tree, kinds}; + +thread_local! { + static DROPS: Cell = const { Cell::new(0) }; + static HANDLE: Cell>> = const { Cell::new(None) }; + static HANDLES: RefCell)>> = const { RefCell::new(Vec::new()) }; + static SITE_LINE: Cell = const { Cell::new(0) }; + static READER_RUNS: Cell = const { Cell::new(0) }; +} + +/// A value that counts its drops on the thread that composes. +#[derive(Clone, Debug, PartialEq)] +struct Tracked(i32); + +impl Drop for Tracked { + fn drop(&mut self) { + DROPS.with(|drops| drops.set(drops.get() + 1)); + } +} + +fn drops() -> u32 { + DROPS.with(Cell::get) +} + +fn reset() { + DROPS.with(|drops| drops.set(0)); + HANDLE.with(|handle| handle.set(None)); + HANDLES.with_borrow_mut(Vec::clear); + READER_RUNS.with(|runs| runs.set(0)); +} + +fn start(content: impl Fn() + Send + Sync + 'static) -> (ComposeHost, Tree) { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + (host, tree) +} + +fn frame(host: &mut ComposeHost, tree: &mut Tree) -> Vec { + let batch = host.render_frame(0).expect("the frame encodes").to_vec(); + tree.apply(&batch); + kinds(&batch) +} + +#[Composable] +fn Child() { + SITE_LINE.with(|line| line.set(line!() + 1)); + let state = remember(|| { + let state = mutable_state_of(Tracked(0)); + HANDLE.with(|handle| handle.set(Some(state))); + state + }); + Text(format!("child {}", state.with(|tracked| tracked.0))); +} + +#[Composable] +fn Gate(show: MutableState) { + if show.get() { + Child(); + } + Text("after"); +} + +/// Starts a Gate that shows its child and returns the child's handle. +fn gate() -> (MutableState, MutableState, ComposeHost, Tree) { + reset(); + let show = mutable_state_of(true); + let (host, tree) = start(move || Gate(show)); + let handle = HANDLE + .with(Cell::take) + .expect("the child remembered its state"); + (show, handle, host, tree) +} + +/// Removes the child and lets the recomposition apply. +fn remove_child(show: MutableState, host: &mut ComposeHost, tree: &mut Tree) { + show.set(false); + frame(host, tree); +} + +// ----- Copy ------------------------------------------------------------------------------- + +#[test] +fn fr39_state_handle_is_copy_in_the_type_system() { + fn assert_copy() {} + assert_copy::>(); + assert_copy::>(); +} + +#[test] +fn fr39_state_handle_is_copy_into_two_move_closures_without_clone() { + let count = mutable_state_of(0); + let add_one = move || count.update(|value| *value += 1); + let add_ten = move || count.update(|value| *value += 10); + add_one(); + add_ten(); + assert_eq!(count.get(), 11, "both closures changed the same value"); +} + +// ----- release ---------------------------------------------------------------------------- + +#[test] +fn fr39_state_released_value_drops_once_after_the_group_is_removed() { + let (show, handle, mut host, mut tree) = gate(); + assert_eq!(drops(), 0); + assert!(!handle.is_released()); + remove_child(show, &mut host, &mut tree); + assert_eq!( + drops(), + 1, + "the value dropped when the recomposition applied" + ); + assert!(handle.is_released()); + frame(&mut host, &mut tree); + assert_eq!(drops(), 1, "and only once"); +} + +#[test] +fn fr39_state_released_value_survives_a_recomposition_that_keeps_the_group() { + let (show, handle, mut host, mut tree) = gate(); + handle.set(Tracked(7)); + frame(&mut host, &mut tree); + show.set(true); + show.update(|_| {}); + frame(&mut host, &mut tree); + assert!(!handle.is_released()); + assert_eq!(handle.with(|tracked| tracked.0), 7); + // The replaced value of the write above is the only drop so far. + assert_eq!(drops(), 1); +} + +#[Composable] +fn Item(id: u32) { + let state = remember(|| { + let state = mutable_state_of(Tracked(id as i32)); + HANDLES.with_borrow_mut(|handles| handles.push((id, state))); + state + }); + Text(format!("item {} {}", id, state.with(|tracked| tracked.0))); +} + +#[Composable] +fn Items(order: MutableState>) { + for id in order.get() { + key(id, || Item(id)); + } +} + +#[test] +fn fr39_state_released_keyed_item_that_moves_keeps_its_value_and_handle() { + reset(); + let order = mutable_state_of(vec![1_u32, 2, 3]); + let (mut host, mut tree) = start(move || Items(order)); + let before = HANDLES.with_borrow(Clone::clone); + order.set(vec![3, 1, 2]); + frame(&mut host, &mut tree); + assert_eq!(drops(), 0, "a move is not a release"); + for (id, handle) in &before { + assert!(!handle.is_released(), "item {id} kept its state"); + assert_eq!(handle.with(|tracked| tracked.0), *id as i32); + } + assert_eq!( + HANDLES.with_borrow(Vec::len), + 3, + "no item made its state again" + ); +} + +#[test] +fn fr39_state_released_keyed_item_that_leaves_is_released_and_the_others_are_not() { + reset(); + let order = mutable_state_of(vec![1_u32, 2, 3]); + let (mut host, mut tree) = start(move || Items(order)); + let handles = HANDLES.with_borrow(Clone::clone); + order.set(vec![1, 3]); + frame(&mut host, &mut tree); + assert_eq!(drops(), 1); + for (id, handle) in handles { + assert_eq!(handle.is_released(), id == 2, "item {id}"); + } +} + +// ----- use after release ------------------------------------------------------------------ + +#[test] +fn fr39_state_use_after_release_set_is_a_no_op() { + let (show, handle, mut host, mut tree) = gate(); + let other = mutable_state_of(1); + remove_child(show, &mut host, &mut tree); + let texts = tree.texts(); + handle.set(Tracked(5)); + handle.update(|tracked| tracked.0 += 1); + frame(&mut host, &mut tree); + assert_eq!(tree.texts(), texts, "the screen did not change"); + assert_eq!(other.get(), 1, "no other state changed"); + assert!(handle.is_released()); +} + +#[test] +fn fr39_state_use_after_release_warns_once_in_debug() { + let (show, handle, mut host, mut tree) = gate(); + remove_child(show, &mut host, &mut tree); + for round in 0..3 { + handle.set(Tracked(round)); + handle.update(|tracked| tracked.0 += 1); + } + let expected = usize::from(cfg!(debug_assertions)); + assert_eq!(handle.released_warning_count(), expected); +} + +#[test] +fn fr39_state_use_after_release_get_panics_with_the_creation_site() { + let (show, handle, mut host, mut tree) = gate(); + let line = SITE_LINE.with(Cell::get); + remove_child(show, &mut host, &mut tree); + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle.get())) + .expect_err("a released state cannot be read"); + let message = panic + .downcast_ref::() + .cloned() + .or_else(|| panic.downcast_ref::<&str>().map(|text| (*text).to_owned())) + .expect("the panic carries a message"); + assert!(message.contains("Released"), "{message}"); + assert!( + message.contains(&format!("{}:{}", file!(), line)), + "{message} should name {}:{line}", + file!() + ); +} + +#[test] +fn fr39_state_use_after_release_try_returns_released() { + let (show, handle, mut host, mut tree) = gate(); + assert!(handle.try_get().is_ok()); + remove_child(show, &mut host, &mut tree); + assert_eq!(handle.try_get(), Err(StateError::Released)); + assert_eq!(handle.try_set(Tracked(1)), Err(StateError::Released)); + assert_eq!(handle.try_update(|_| {}), Err(StateError::Released)); +} + +#[test] +fn fr39_state_use_after_release_does_not_reach_a_reused_slot() { + let (show, old, mut host, mut tree) = gate(); + remove_child(show, &mut host, &mut tree); + show.set(true); + frame(&mut host, &mut tree); + let fresh = HANDLE.with(Cell::take).expect("the child made a new state"); + assert_ne!(old, fresh, "a new state is not the old handle"); + fresh.set(Tracked(42)); + assert_eq!(old.try_set(Tracked(1)), Err(StateError::Released)); + assert_eq!(old.try_get(), Err(StateError::Released)); + old.set(Tracked(2)); + assert_eq!( + fresh.with(|tracked| tracked.0), + 42, + "the old handle did not write the new value" + ); +} + +// ----- workers ---------------------------------------------------------------------------- + +#[test] +fn fr39_state_worker_handle_is_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); +} + +#[Composable] +fn Reader(count: MutableState) { + READER_RUNS.with(|runs| runs.set(runs.get() + 1)); + Text(format!("{}", count.get())); +} + +#[test] +fn fr39_state_worker_write_lands_on_the_next_frame() { + reset(); + let count = mutable_state_of(0); + let (mut host, mut tree) = start(move || Reader(count)); + std::thread::spawn(move || { + assert_eq!(count.get(), 0, "a worker reads the current value"); + count.set(5); + }) + .join() + .unwrap(); + frame(&mut host, &mut tree); + assert_eq!(tree.texts(), vec!["5"]); +} + +#[test] +fn fr39_state_worker_and_ui_writes_in_one_frame_are_one_recomposition() { + reset(); + let count = mutable_state_of(0); + let (mut host, mut tree) = start(move || Reader(count)); + count.set(1); + std::thread::spawn(move || { + count.set(2); + count.update(|value| *value += 1); + }) + .join() + .unwrap(); + frame(&mut host, &mut tree); + assert_eq!( + READER_RUNS.with(Cell::get), + 2, + "one run for the first batch, one more" + ); + assert_eq!(tree.texts(), vec!["3"]); +} + +#[test] +fn fr39_state_worker_write_after_the_composition_is_gone_is_ignored() { + let (show, handle, mut host, mut tree) = gate(); + remove_child(show, &mut host, &mut tree); + let result = std::thread::spawn(move || { + handle.set(Tracked(9)); + handle.update(|tracked| tracked.0 += 1); + handle.try_set(Tracked(10)) + }) + .join() + .expect("the worker did not panic"); + assert_eq!(result, Err(StateError::Released)); +} + +// ----- identity --------------------------------------------------------------------------- + +#[test] +fn fr39_state_handle_identity_copies_are_equal_and_equal_values_are_not() { + let first = mutable_state_of(1); + let copy = first; + let other = mutable_state_of(1); + assert_eq!(first, copy); + assert_ne!( + first, other, + "the same value in another state is another state" + ); + let set: HashSet<_> = [first, copy, other].into_iter().collect(); + assert_eq!(set.len(), 2); +} diff --git a/compose-rust/tests/transform3d.rs b/compose-rust/tests/transform3d.rs new file mode 100644 index 000000000..b98b1accd --- /dev/null +++ b/compose-rust/tests/transform3d.rs @@ -0,0 +1,124 @@ +//! CSS 3D transforms on the wire. + +use compose_rust::Modifier; +use compose_rust::codegen::TRANSFORM_3D_VECTOR_MATRIX; +use compose_rust::protocol::{BatchEncoder, Mutation, ProtocolError, decode_batch}; + +fn encode(mutations: &[Mutation<'_>]) -> Vec { + let mut encoder = BatchEncoder::default(); + for mutation in mutations { + encoder.encode(mutation).unwrap(); + } + encoder.finish().unwrap().to_vec() +} + +fn transform_3d(m: [f32; 16], origin_x: f32, origin_y: f32, backface_hidden: bool) -> Modifier { + Modifier::Transform3D { + m, + origin_x, + origin_y, + backface_hidden, + } +} + +const IDENTITY: [f32; 16] = [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, +]; + +/// A 3D transform is one 92 byte record: the 28 every modifier has and eight more words. +/// The sixteen matrix values come first in column-major order, two `f32` to a word with +/// the first in the low half, then the origin, then a whole word for the backface flag. +/// The checked-in vector carries the same bytes and the main vector is not touched. +#[test] +fn fr41_3_transform3d_round_trips() { + let modifier = transform_3d(TRANSFORM_3D_VECTOR_MATRIX, 0.5, 0.25, true); + let record = Mutation::SetModifier { + node_id: 3, + index: 0, + modifier, + }; + let bytes = encode(std::slice::from_ref(&record)); + assert_eq!(bytes.len(), 12 + 92); + let body = &bytes[12..]; + assert_eq!(&body[0..4], &[3, 0, 92, 0], "tag 3, length 92"); + assert_eq!( + u16::from_le_bytes([body[10], body[11]]), + 27, + "modifier tag 27" + ); + let floats: Vec = body[12..84] + .chunks(4) + .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]])) + .collect(); + assert_eq!(&floats[..16], &TRANSFORM_3D_VECTOR_MATRIX, "column-major"); + assert_eq!(&floats[16..], &[0.5, 0.25], "origin at offsets 76 and 80"); + assert_eq!( + u64::from_le_bytes(body[84..92].try_into().unwrap()), + 1, + "backface_hidden is a whole word at offset 84" + ); + assert_eq!(decode_batch(&bytes), Ok(vec![record])); + + let vector = include_bytes!("vectors/transform3d.bin"); + assert_eq!(vector.as_slice(), bytes.as_slice(), "the checked-in vector"); + + let visible = encode(&[Mutation::SetModifier { + node_id: 3, + index: 0, + modifier: transform_3d(IDENTITY, 0.5, 0.5, false), + }]); + assert_eq!(&visible[12 + 84..], &[0; 8], "backface visible is zero"); +} + +/// A matrix or an origin that is not a number is a protocol error, and so are a backface +/// flag that is neither 0 nor 1 and a record whose length is not the one the tag fixes. +#[test] +fn fr41_3_non_finite_is_a_protocol_error() { + for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] { + for field in 0..18 { + let mut m = IDENTITY; + let (mut origin_x, mut origin_y) = (0.5, 0.5); + match field { + 16 => origin_x = bad, + 17 => origin_y = bad, + _ => m[field] = bad, + } + let bytes = encode(&[Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: transform_3d(m, origin_x, origin_y, false), + }]); + assert_eq!( + decode_batch(&bytes), + Err(ProtocolError::InvalidModifier(27)), + "{bad} in field {field}" + ); + } + } + + let mut bytes = encode(&[Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: transform_3d(IDENTITY, 0.5, 0.5, false), + }]); + bytes[12 + 84..12 + 92].copy_from_slice(&2_u64.to_le_bytes()); + assert_eq!( + decode_batch(&bytes), + Err(ProtocolError::InvalidModifier(27)), + "a flag of 2" + ); + + let mut bytes = encode(&[Mutation::SetModifier { + node_id: 1, + index: 0, + modifier: transform_3d(IDENTITY, 0.5, 0.5, false), + }]); + // Cut the record to the 44 bytes of the 2D transform and the batch with it. + bytes.truncate(12 + 44); + bytes[4..8].copy_from_slice(&56_u32.to_le_bytes()); + bytes[14..16].copy_from_slice(&44_u16.to_le_bytes()); + assert_eq!( + decode_batch(&bytes), + Err(ProtocolError::InvalidRecordLength) + ); +} diff --git a/compose-rust/tests/transform_animation.rs b/compose-rust/tests/transform_animation.rs index 8a3f21c22..197df689f 100644 --- a/compose-rust/tests/transform_animation.rs +++ b/compose-rust/tests/transform_animation.rs @@ -291,14 +291,14 @@ fn fr41_start_animation_round_trips() { ); let events = include_bytes!("vectors/events.bin"); assert!(matches!( - decode_event(&events[470..510]).unwrap().payload, + decode_event(&events[487..527]).unwrap().payload, EventPayload::AnimationEvent { kind: AnimationEventKind::End, .. } )); assert_eq!( - decode_event(&events[510..530]).unwrap().payload, + decode_event(&events[527..547]).unwrap().payload, EventPayload::ReducedMotionChanged(ReducedMotion::On) ); } diff --git a/compose-rust/tests/ui_button_row.rs b/compose-rust/tests/ui_button_row.rs new file mode 100644 index 000000000..490c1bcf1 --- /dev/null +++ b/compose-rust/tests/ui_button_row.rs @@ -0,0 +1,59 @@ +//! A Button can align and size its label, and can hold children in place of the label. + +mod support; + +use compose_rust::ComposeHost; +use compose_rust::schema::TextAlign; +use compose_rust::ui::*; +use support::Tree; + +fn dump(content: impl Fn() + Send + Sync + 'static) -> String { + let mut host = ComposeHost::with_content(content); + let mut tree = Tree::default(); + tree.apply(host.rebuild().expect("the first batch encodes")); + tree.dump() +} + +#[test] +fn fr66_a_button_that_asks_for_nothing_sends_no_alignment_or_size() { + let tree = dump(|| { + Button("변경 사항"); + }); + assert!(!tree.contains("TextAlign"), "{tree}"); + assert!(!tree.contains("FontSize"), "{tree}"); +} + +#[test] +fn fr66_a_button_writes_the_alignment_and_size_it_was_given() { + let tree = dump(|| { + Button("변경 사항") + .text_align(TextAlign::Start) + .font_size(13.0); + }); + assert!(tree.contains("TextAlign=Integer(1)"), "{tree}"); + assert!(tree.contains("FontSize=Float(13"), "{tree}"); +} + +#[test] +fn fr66_a_button_emits_its_children_under_itself() { + let tree = dump(|| { + Button("").content(|| { + Text("Files"); + Spacer().modifier(Modifier.weight(1.0)); + Text("12"); + }); + }); + let lines: Vec<&str> = tree.lines().collect(); + let button = lines + .iter() + .position(|l| l.trim_start().starts_with("Button")) + .unwrap_or_else(|| panic!("no Button in\n{tree}")); + let indent = |l: &str| l.len() - l.trim_start().len(); + let children: Vec<&&str> = lines[button + 1..] + .iter() + .take_while(|l| indent(l) > indent(lines[button])) + .collect(); + assert_eq!(children.len(), 3, "{tree}"); + assert!(children[0].contains("Text=\"Files\""), "{tree}"); + assert!(children[2].contains("Text=\"12\""), "{tree}"); +} diff --git a/compose-rust/tests/vectors/events.bin b/compose-rust/tests/vectors/events.bin index c9f4e3fc7..8822e654b 100644 Binary files a/compose-rust/tests/vectors/events.bin and b/compose-rust/tests/vectors/events.bin differ diff --git a/compose-rust/tests/vectors/mutations.bin b/compose-rust/tests/vectors/mutations.bin index 4116110c3..c76916cf6 100644 Binary files a/compose-rust/tests/vectors/mutations.bin and b/compose-rust/tests/vectors/mutations.bin differ diff --git a/compose-rust/tests/vectors/transform3d.bin b/compose-rust/tests/vectors/transform3d.bin new file mode 100644 index 000000000..53602a0f7 Binary files /dev/null and b/compose-rust/tests/vectors/transform3d.bin differ diff --git a/compose-rust/tests/vectors/vectors.json b/compose-rust/tests/vectors/vectors.json index 29de9f065..cf0dd8b1c 100644 --- a/compose-rust/tests/vectors/vectors.json +++ b/compose-rust/tests/vectors/vectors.json @@ -1,57 +1,68 @@ { "format": "compose-rust protocol vectors v1", - "schemaHashUnsigned": "554621546583655284", + "schemaHashUnsigned": "2307080319921313708", "byteOrder": "little-endian", "mutations": { "file": "mutations.bin", "description": "One batch covering every record, property value, modifier layout, drawing command, asset, message, notification command, palette entry, text run, split pane property, the HTML and CSS drawing elements, the animations the Renderer plays and code editor record", - "recordCount": 103, + "recordCount": 104, "palette": [ { "role": "Primary", "scheme": "Light", "argb": "ffe8590c" }, { "role": "Primary", "scheme": "Dark", "argb": "ffff8a4c" }, { "role": "SyntaxKeyword", "scheme": "Light", "argb": "80112233" } ], "spanRecordLength": 36, - "strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7", "fn main() {\n let 한 = \"😀\";\n}\n", "mut ", "문서", "보조 창", "ws/1:2", "tab/3"], + "strings": ["안녕", "compose", " token", "필터", "New", "삭제했습니다", "Undo", "session/7", "세션이 끝났습니다", "테스트 214개 통과", "세션", "열기", "", "session/7", "fn main() {\n let 한 = \"😀\";\n}\n", "mut ", "문서", "보조 창", "ws/1:2", "tab/3", "파일 고르기", "/home/한글", "보고서.txt", "이미지\u0000png,jpg\u0000문서\u0000pdf,txt"], "assets": [{ "assetId": 5, "kind": "Png", "bytes": "89504e47" }] }, + "transform3d": { + "file": "transform3d.bin", + "description": "One batch with one 92 byte Transform3D modifier on node 3: the column-major matrix, the origin (0.5, 0.25) and backface-visibility hidden", + "recordCount": 1, + "length": 104, + "matrix": [0.5, -0.25, 1.5, 0.125, -2.0, 3.0, 0.75, -0.5, 4.0, -1.25, 2.5, -0.0625, 12.0, -6.0, 8.0, 1.0], + "originX": 0.5, + "originY": 0.25, + "backfaceHidden": true + }, "events": { "file": "events.bin", - "description": "Thirty-three independently decodable event records concatenated in schema order", + "description": "Thirty-four independently decodable event records concatenated in schema order", "records": [ { "type": "Clicked", "offset": 0, "length": 16, "nodeId": 7, "handlerId": 11 }, { "type": "TextChanged", "offset": 16, "length": 30, "nodeId": 8, "handlerId": 12, "text": "한글" }, { "type": "TextSubmitted", "offset": 46, "length": 28, "nodeId": 8, "handlerId": 13, "text": "done" }, { "type": "FocusLost", "offset": 74, "length": 16, "nodeId": 8, "handlerId": 14 }, { "type": "ProtocolError", "offset": 90, "length": 35, "nodeId": 0, "handlerId": 0, "code": 9, "message": "bad tag" }, - { "type": "KeyDown", "offset": 125, "length": 20, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true }, - { "type": "RangeRequested", "offset": 145, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }, - { "type": "ValueChanged", "offset": 169, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }, - { "type": "WindowSizeChanged", "offset": 193, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }, - { "type": "NotificationActivated", "offset": 237, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }, - { "type": "NotificationPermissionChanged", "offset": 274, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }, - { "type": "CodeChanged", "offset": 294, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }, - { "type": "CodeEditRejected", "offset": 342, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }, - { "type": "CodeHovered", "offset": 386, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }, - { "type": "CodeSaveRequested", "offset": 422, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }, - { "type": "DecorationActivated", "offset": 446, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }, - { "type": "AnimationEvent", "offset": 470, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }, - { "type": "ReducedMotionChanged", "offset": 510, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }, - { "type": "Pointer", "offset": 530, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }, - { "type": "SelectionChanged", "offset": 602, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }, - { "type": "VisibleRangeChanged", "offset": 638, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }, - { "type": "EditorFocusChanged", "offset": 666, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }, - { "type": "FoldChanged", "offset": 690, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }, - { "type": "CompositionChanged", "offset": 714, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }, - { "type": "WindowCloseRequested", "offset": 738, "length": 16, "nodeId": 20, "handlerId": 201 }, - { "type": "WindowFocusChanged", "offset": 754, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }, - { "type": "WindowMoved", "offset": 774, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }, - { "type": "WindowDrag", "offset": 806, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }, - { "type": "WindowDragOver", "offset": 838, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }, - { "type": "WindowUnavailable", "offset": 870, "length": 16, "nodeId": 20, "handlerId": 205 }, - { "type": "WindowClosed", "offset": 886, "length": 16, "nodeId": 20, "handlerId": 206 }, - { "type": "WindowRequested", "offset": 902, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }, - { "type": "ApplicationReopen", "offset": 936, "length": 16, "nodeId": 0, "handlerId": 0 } + { "type": "KeyDown", "offset": 125, "length": 37, "nodeId": 9, "handlerId": 15, "key": "Enter", "shiftKey": true, "ctrlKey": true, "altKey": true, "metaKey": true, "repeat": true, "code": 40, "text": "Enter" }, + { "type": "RangeRequested", "offset": 162, "length": 24, "nodeId": 10, "handlerId": 16, "start": 100, "count": 20 }, + { "type": "ValueChanged", "offset": 186, "length": 24, "nodeId": 11, "handlerId": 17, "value": -19723.5 }, + { "type": "WindowSizeChanged", "offset": 210, "length": 44, "nodeId": 0, "handlerId": 0, "widthDp": 840.0, "heightDp": 600.0, "sizeClass": "Expanded", "heightClass": "Medium", "safeAreaTop": 0.0, "safeAreaLeading": 0.0, "safeAreaTrailing": 0.0 }, + { "type": "NotificationActivated", "offset": 254, "length": 37, "nodeId": 0, "handlerId": 0, "action": 1, "key": "session/7" }, + { "type": "NotificationPermissionChanged", "offset": 291, "length": 20, "nodeId": 0, "handlerId": 0, "state": "Denied" }, + { "type": "CodeChanged", "offset": 311, "length": 48, "nodeId": 12, "handlerId": 18, "version": 1, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 8, "text": "mut " }, + { "type": "CodeEditRejected", "offset": 359, "length": 44, "nodeId": 12, "handlerId": 19, "requestId": 7, "baseVersion": 0, "currentVersion": 2, "startLine": 1, "startColumn": 8, "endLine": 1, "endColumn": 9 }, + { "type": "CodeHovered", "offset": 403, "length": 36, "nodeId": 12, "handlerId": 20, "decoration": 42, "line": 0, "column": 4, "phase": "Rest" }, + { "type": "CodeSaveRequested", "offset": 439, "length": 24, "nodeId": 12, "handlerId": 21, "version": 2 }, + { "type": "DecorationActivated", "offset": 463, "length": 24, "nodeId": 12, "handlerId": 22, "decoration": 41 }, + { "type": "AnimationEvent", "offset": 487, "length": 40, "nodeId": 8, "handlerId": 0, "animationId": 7, "kind": "End", "property": "Transform", "slot": 2, "iteration": 3, "elapsedMs": 3000.0, "timeNanos": 123456789012 }, + { "type": "ReducedMotionChanged", "offset": 527, "length": 20, "nodeId": 0, "handlerId": 0, "state": "On" }, + { "type": "Pointer", "offset": 547, "length": 72, "nodeId": 13, "handlerId": 23, "kind": "Down", "pointerId": 1, "pointerType": "Mouse", "x": 12.5, "y": -3.0, "buttons": 9, "button": 8, "modifiers": 5, "clickCount": 2, "deltaX": 0.0, "deltaY": 0.0, "deltaMode": "Pixel", "timestampNanos": 987654321000 }, + { "type": "SelectionChanged", "offset": 619, "length": 36, "nodeId": 12, "handlerId": 23, "version": 2, "anchorLine": 1, "anchorColumn": 12, "activeLine": 0, "activeColumn": 3 }, + { "type": "VisibleRangeChanged", "offset": 655, "length": 28, "nodeId": 12, "handlerId": 24, "version": 2, "firstLine": 0, "lastLine": 3 }, + { "type": "EditorFocusChanged", "offset": 683, "length": 24, "nodeId": 12, "handlerId": 25, "focused": true }, + { "type": "FoldChanged", "offset": 707, "length": 24, "nodeId": 12, "handlerId": 26, "startLine": 0, "collapsed": true }, + { "type": "CompositionChanged", "offset": 731, "length": 24, "nodeId": 12, "handlerId": 27, "composing": true }, + { "type": "WindowCloseRequested", "offset": 755, "length": 16, "nodeId": 20, "handlerId": 201 }, + { "type": "WindowFocusChanged", "offset": 771, "length": 20, "nodeId": 20, "handlerId": 202, "focused": true }, + { "type": "WindowMoved", "offset": 791, "length": 32, "nodeId": 20, "handlerId": 203, "x": 100.5, "y": -20.0, "width": 800.0, "height": 600.0 }, + { "type": "WindowDrag", "offset": 823, "length": 32, "nodeId": 20, "handlerId": 204, "phase": 2, "screenX": 640.25, "screenY": 360.5, "overWindow": 21 }, + { "type": "WindowDragOver", "offset": 855, "length": 32, "nodeId": 1, "handlerId": 207, "kind": 4, "window": 20, "x": 12.5, "y": 3.0 }, + { "type": "WindowUnavailable", "offset": 887, "length": 16, "nodeId": 20, "handlerId": 205 }, + { "type": "WindowClosed", "offset": 903, "length": 16, "nodeId": 20, "handlerId": 206 }, + { "type": "WindowRequested", "offset": 919, "length": 34, "nodeId": 0, "handlerId": 0, "request": 3, "key": "ws/1:2" }, + { "type": "ApplicationReopen", "offset": 953, "length": 16, "nodeId": 0, "handlerId": 0 }, + { "type": "FileDialogResult", "offset": 969, "length": 73, "nodeId": 0, "handlerId": 0, "request": 7, "status": "Chosen", "filter": 1, "paths": "/Users/한글/문서.txt\u0000/tmp/a b.png" } ] } } diff --git a/compose-rust/tests/windows_manifest.rs b/compose-rust/tests/windows_manifest.rs new file mode 100644 index 000000000..c1e335ade --- /dev/null +++ b/compose-rust/tests/windows_manifest.rs @@ -0,0 +1,64 @@ +//! The manifest every Windows executable built on this crate carries, and the resource +//! file the build script writes it into. +//! +//! The build script is never compiled for test, so the module it includes is included +//! here as well, and the bytes it would hand the linker are checked field by field. + +mod windows_manifest { + include!("../build/windows_manifest.rs"); +} + +use windows_manifest::{APPLICATION_MANIFEST, manifest_resource}; + +fn u16_at(bytes: &[u8], at: usize) -> u16 { + u16::from_le_bytes([bytes[at], bytes[at + 1]]) +} + +fn u32_at(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes([bytes[at], bytes[at + 1], bytes[at + 2], bytes[at + 3]]) +} + +#[test] +fn fr22_6_13_the_manifest_declares_per_monitor_v2_awareness() { + // Both elements: `dpiAwareness` is what Windows 10 1607 and later read, `dpiAware` + // with `/pm` is the fallback for anything older, and the certification kit accepts + // either. + assert!(APPLICATION_MANIFEST.contains( + "PerMonitorV2" + )); + assert!(APPLICATION_MANIFEST.contains( + "true/pm" + )); + assert!(APPLICATION_MANIFEST.contains("level=\"asInvoker\"")); + assert!(APPLICATION_MANIFEST.starts_with("")); +} + +#[test] +fn fr22_6_13_the_resource_file_starts_with_the_empty_entry() { + let res = manifest_resource(b""); + // DataSize 0, HeaderSize 32, type ordinal 0, name ordinal 0, and zeros after. The + // linker recognises a resource file by exactly these 32 bytes. + let mut expected = vec![0u8; 32]; + expected[4] = 0x20; + expected[8..12].copy_from_slice(&[0xFF, 0xFF, 0, 0]); + expected[12..16].copy_from_slice(&[0xFF, 0xFF, 0, 0]); + assert_eq!(&res[..32], &expected[..]); +} + +#[test] +fn fr22_6_13_the_resource_file_holds_the_manifest_as_resource_one() { + let manifest = APPLICATION_MANIFEST.as_bytes(); + let res = manifest_resource(manifest); + let e = 32; + assert_eq!(u32_at(&res, e) as usize, manifest.len(), "DataSize"); + assert_eq!(u32_at(&res, e + 4), 32, "HeaderSize"); + assert_eq!(u16_at(&res, e + 8), 0xFFFF); + assert_eq!(u16_at(&res, e + 10), 24, "RT_MANIFEST"); + assert_eq!(u16_at(&res, e + 12), 0xFFFF); + assert_eq!(u16_at(&res, e + 14), 1, "the executable's manifest id"); + assert_eq!(u16_at(&res, e + 22), 0x0409, "language"); + assert_eq!(&res[e + 32..e + 32 + manifest.len()], manifest); + assert_eq!(res.len() % 4, 0, "entries are padded to four bytes"); + assert!(res.len() - (e + 32 + manifest.len()) < 4); +} diff --git a/compose-rust/tests/zoom.rs b/compose-rust/tests/zoom.rs index 1dfa38c26..37d5ab612 100644 --- a/compose-rust/tests/zoom.rs +++ b/compose-rust/tests/zoom.rs @@ -12,7 +12,7 @@ use compose_rust::ui::*; use compose_rust::{ComposeHost, ZoomHandle, zoom}; use support::Tree; -#[composable] +#[Composable] fn App() { Button("in twice").on_click(|| { let handle = ZoomHandle::default(); diff --git a/docs/guide/en/architecture.html b/docs/guide/en/architecture.html index 1400ad1e0..01ed4781b 100644 --- a/docs/guide/en/architecture.html +++ b/docs/guide/en/architecture.html @@ -80,7 +80,7 @@

Host and Renderer

Host
The Rust side. Owns your code, your domain logic, and the runtime that builds the tree.
Runtime
-
Whatever builds and changes the tree, behind the Runtime trait. For an application written with #[composable] that is compose-rust's own recomposition runtime, the Recomposer.
+
Whatever builds and changes the tree, behind the Runtime trait. For an application written with #[Composable] that is compose-rust's own recomposition runtime, the Recomposer.
Renderer
The ahead-of-time compiled Kotlin and Compose library, including the schema interpreter. On the desktop it draws into a window that the Compose fork provides.
Schema
diff --git a/docs/guide/en/getting-started.html b/docs/guide/en/getting-started.html index 20c564444..d31574abe 100644 --- a/docs/guide/en/getting-started.html +++ b/docs/guide/en/getting-started.html @@ -78,13 +78,13 @@

Adding it to a project

shell
-
cargo add compose-rust@0.0.6
+
cargo add compose-rust@0.0.7
Cargo.toml, the line that command writes
[dependencies]
-compose-rust = "0.0.6"
+compose-rust = "0.0.7"

@@ -144,7 +144,7 @@

Your first application

use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn app() { let name = remember(|| mutable_state_of(String::new())); let typed = name.clone(); @@ -165,7 +165,7 @@

Your first application

- With compose-rust = "0.0.6" this program builds as it is. cargo run + With compose-rust = "0.0.7" this program builds as it is. cargo run downloads the renderer and opens the window.

diff --git a/docs/guide/en/index.html b/docs/guide/en/index.html index c4761848e..615fc0949 100644 --- a/docs/guide/en/index.html +++ b/docs/guide/en/index.html @@ -95,7 +95,7 @@

Compose-shaped UI in Rust,drawn by Compose.

-
#[composable]
+
#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0)).content(|| {
@@ -130,7 +130,7 @@ 

Where it stands today

PartState Boundary, protocol, schema, renderer downloadWorks in 0.0.2 - Authoring API: #[composable], remember, launchPartial: in 0.0.2 as a pre-1.0 API that may change, stable from 1.0.0 (planned for 2026-11-14) + Authoring API: #[Composable], remember, launchPartial: in 0.0.2 as a pre-1.0 API that may change, stable from 1.0.0 (planned for 2026-11-14) macOS, Android, WebWorks end to end Linux, Windows, iOSBuilds and starts diff --git a/docs/guide/en/overview.html b/docs/guide/en/overview.html index b8556a575..cfae5c2d9 100644 --- a/docs/guide/en/overview.html +++ b/docs/guide/en/overview.html @@ -128,7 +128,7 @@

Status

The Runtime traitWorksThe interface that whatever builds the tree implements, plus the types around it: assets, custom drawing, input events, notifications, messages, palettes and window size classes. Drawing, transforms and animationsWorksNew in 0.0.2. HTML/CSS drawing elements: AbsoluteBox and the modifiers RequiredSize, BorderEach, CornerEach, Shadow, Clip and Alpha. A Transform modifier for 2D CSS transforms. Animations the renderer plays itself, through StartAnimation, ControlAnimation, AnimationEvent and ReducedMotionChanged. A layout container's text colour is inherited by the text inside it, and a border's colour can animate. The renderer downloadWorksThe build script fetches the prebuilt renderer for your target, checks it and links it. See Getting started. - The authoring APIPartial#[composable], remember, the states and effects, the Recomposer, launch and one function per widget. New in 0.0.2. It is a pre-1.0 API that may still change, and it is stable from 1.0.0, planned for 2026-11-14 (#23, #64). Animation is in it: Modifier.graphics_layer, Modifier.alpha and the animate_*_as_state functions, which the renderer plays with nothing composed again. 3D transforms are not built yet (#105). + The authoring APIPartial#[Composable], remember, the states and effects, the Recomposer, launch and one function per widget. New in 0.0.2. It is a pre-1.0 API that may still change, and it is stable from 1.0.0, planned for 2026-11-14 (#23, #64). Animation is in it: Modifier.graphics_layer, Modifier.alpha and the animate_*_as_state functions, which the renderer plays with nothing composed again. 3D transforms are not built yet (#105).
@@ -143,7 +143,7 @@

What it looks like

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -159,7 +159,7 @@ 

What it looks like

- #[composable] inserts the groups, so you never write them by hand, and they close + #[Composable] inserts the groups, so you never write them by hand, and they close correctly through branches, early return, ? and unwinding. Names follow Compose. The widget vocabulary is the one in the schema, so nothing is defined twice. Writing UI describes the rest of the API, and eleven diff --git a/docs/guide/en/writing-ui.html b/docs/guide/en/writing-ui.html index 3ca3bfb8a..66548a186 100644 --- a/docs/guide/en/writing-ui.html +++ b/docs/guide/en/writing-ui.html @@ -70,7 +70,7 @@

Composables, state and events

A composable is a function

- Mark a function with #[composable] and call widgets inside it. Two glob imports + Mark a function with #[Composable] and call widgets inside it. Two glob imports bring in everything: compose_rust::runtime for state and effects, and compose_rust::ui for the widgets and Modifier.

@@ -80,7 +80,7 @@

A composable is a function

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -96,7 +96,7 @@ 

A composable is a function

- #[composable] does what the Compose compiler plugin does to a + #[Composable] does what the Compose compiler plugin does to a @Composable function. It gives the call a group, gives every branch of an if and a match a group of its own, and makes the function skippable and restartable. You never write groups by hand. They close correctly through @@ -147,7 +147,7 @@

Your own composables

rust
-
#[composable]
+
#[Composable]
 fn task_row(title: String, done: bool, on_toggle: Rc<dyn Fn(bool)>) {
     Row()
         .modifier(Modifier.fill_max_width())
@@ -469,7 +469,7 @@ 

Animation Works

use compose_rust::ui::*; use compose_rust::{AnimationSpec, animate_float_as_state}; -#[composable] +#[Composable] fn fading(faded: MutableState<bool>) { let target = if faded.get() { 0.0 } else { 1.0 }; Box() diff --git a/docs/guide/ko/architecture.html b/docs/guide/ko/architecture.html index 2b3d11921..a716f6b21 100644 --- a/docs/guide/ko/architecture.html +++ b/docs/guide/ko/architecture.html @@ -80,7 +80,7 @@

Host와 Renderer

Host
Rust 쪽입니다. 사용자의 코드, 도메인 로직, 트리를 만드는 런타임을 갖습니다.
Runtime
-
트리를 만들고 바꾸는 것으로, Runtime 트레이트 뒤에 있습니다. #[composable] 로 쓴 애플리케이션에서는 compose-rust 자신의 recomposition 런타임인 Recomposer 입니다.
+
트리를 만들고 바꾸는 것으로, Runtime 트레이트 뒤에 있습니다. #[Composable] 로 쓴 애플리케이션에서는 compose-rust 자신의 recomposition 런타임인 Recomposer 입니다.
Renderer
미리 컴파일된 Kotlin과 Compose 라이브러리이며, 스키마 인터프리터를 포함합니다. 데스크톱에서는 Compose 포크가 내주는 창에 그립니다.
스키마
diff --git a/docs/guide/ko/getting-started.html b/docs/guide/ko/getting-started.html index 151ebe511..c17b67fbf 100644 --- a/docs/guide/ko/getting-started.html +++ b/docs/guide/ko/getting-started.html @@ -77,13 +77,13 @@

프로젝트에 더하기

shell
-
cargo add compose-rust@0.0.6
+
cargo add compose-rust@0.0.7
Cargo.toml, 그 명령이 쓰는 줄
[dependencies]
-compose-rust = "0.0.6"
+compose-rust = "0.0.7"

@@ -142,7 +142,7 @@

첫 애플리케이션

use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn app() { let name = remember(|| mutable_state_of(String::new())); let typed = name.clone(); @@ -163,7 +163,7 @@

첫 애플리케이션

- compose-rust = "0.0.6" 로는 이 프로그램이 그대로 빌드됩니다. cargo run + compose-rust = "0.0.7" 로는 이 프로그램이 그대로 빌드됩니다. cargo run 이 렌더러를 내려받고 창을 엽니다.

diff --git a/docs/guide/ko/index.html b/docs/guide/ko/index.html index 2bc4cf5a3..c06523b49 100644 --- a/docs/guide/ko/index.html +++ b/docs/guide/ko/index.html @@ -94,7 +94,7 @@

Rust로 쓰는 Compose 모양의 UI,그리는 것은 Comp
-
#[composable]
+
#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0)).content(|| {
@@ -129,7 +129,7 @@ 

현재 상태

부분상태 경계, 프로토콜, 스키마, 렌더러 다운로드0.0.2에 구현 - 작성 API: #[composable], remember, launch부분: 0.0.2에 있는 1.0 전의 API라 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정 + 작성 API: #[Composable], remember, launch부분: 0.0.2에 있는 1.0 전의 API라 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정 macOS, Android, Web처음부터 끝까지 동작 Linux, Windows, iOS빌드되고 시작됨 diff --git a/docs/guide/ko/overview.html b/docs/guide/ko/overview.html index 7224371cb..35d0512d4 100644 --- a/docs/guide/ko/overview.html +++ b/docs/guide/ko/overview.html @@ -127,7 +127,7 @@

현재 상태

Runtime 트레이트구현트리를 만드는 쪽이 구현하는 인터페이스와 그 주변 타입입니다. 에셋, 커스텀 드로잉, 입력 이벤트, 알림, 메시지, 팔레트, 창 크기 클래스가 있습니다. 그리기, 변환, 애니메이션구현0.0.2에서 새로 들어왔습니다. HTML/CSS 그리기 원소인 AbsoluteBox 와 Modifier RequiredSize, BorderEach, CornerEach, Shadow, Clip, Alpha 입니다. 2D CSS 변환을 위한 Transform Modifier가 있습니다. 렌더러가 스스로 재생하는 애니메이션은 StartAnimation, ControlAnimation, AnimationEvent, ReducedMotionChanged 로 다룹니다. 레이아웃 컨테이너의 글자 색은 그 안의 텍스트가 물려받고, 테두리 색도 애니메이션할 수 있습니다. 렌더러 다운로드구현빌드 스크립트가 대상에 맞는 미리 빌드된 렌더러를 받아 검증하고 링크합니다. 시작하기를 보십시오. - 작성 API부분#[composable], remember, 상태와 effect, Recomposer, launch, 위젯마다 하나인 함수입니다. 0.0.2에서 새로 들어왔습니다. 1.0 전의 API라 아직 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정됩니다(#23, #64). 애니메이션도 들어 있습니다. Modifier.graphics_layer, Modifier.alpha, 그리고 렌더러가 재생하는 동안 아무것도 다시 compose하지 않는 animate_*_as_state 함수입니다. 3D 변환은 아직 만들어지지 않았습니다(#105). + 작성 API부분#[Composable], remember, 상태와 effect, Recomposer, launch, 위젯마다 하나인 함수입니다. 0.0.2에서 새로 들어왔습니다. 1.0 전의 API라 아직 바뀔 수 있고, 2026-11-14로 계획된 1.0.0부터 안정됩니다(#23, #64). 애니메이션도 들어 있습니다. Modifier.graphics_layer, Modifier.alpha, 그리고 렌더러가 재생하는 동안 아무것도 다시 compose하지 않는 animate_*_as_state 함수입니다. 3D 변환은 아직 만들어지지 않았습니다(#105).
@@ -142,7 +142,7 @@

어떻게 생겼나

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -158,7 +158,7 @@ 

어떻게 생겼나

- #[composable] 이 그룹을 넣으므로 그룹을 손으로 쓰지 않으며, 분기, 조기 + #[Composable] 이 그룹을 넣으므로 그룹을 손으로 쓰지 않으며, 분기, 조기 return, ?, 되감기를 지나도 그룹이 맞게 닫힙니다. 이름은 Compose를 따릅니다. 위젯 어휘는 스키마에 있는 그것 하나이므로 두 번 정의하지 않습니다. 작성 API의 나머지는 UI 작성하기에 있고, 샘플 열한 개가 diff --git a/docs/guide/ko/writing-ui.html b/docs/guide/ko/writing-ui.html index 9fc253013..2882b5702 100644 --- a/docs/guide/ko/writing-ui.html +++ b/docs/guide/ko/writing-ui.html @@ -70,7 +70,7 @@

컴포저블, 상태, 이벤트

컴포저블은 함수입니다

- 함수에 #[composable] 을 붙이고 그 안에서 위젯을 부릅니다. glob import 두 줄이면 + 함수에 #[Composable] 을 붙이고 그 안에서 위젯을 부릅니다. glob import 두 줄이면 전부 들어옵니다. 상태와 effect는 compose_rust::runtime 에서, 위젯과 Modifier 는 compose_rust::ui 에서 옵니다.

@@ -80,7 +80,7 @@

컴포저블은 함수입니다

use compose_rust::runtime::*;
 use compose_rust::ui::*;
 
-#[composable]
+#[Composable]
 fn counter() {
     let count = remember(|| mutable_state_of(0));
     Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| {
@@ -96,7 +96,7 @@ 

컴포저블은 함수입니다

- #[composable] 은 Compose 컴파일러 플러그인이 @Composable 함수에 하는 + #[Composable] 은 Compose 컴파일러 플러그인이 @Composable 함수에 하는 일을 합니다. 호출에 그룹을 주고, if 와 match 의 가지마다 따로 그룹을 주고, 함수를 건너뛸 수 있고 다시 시작할 수 있게 만듭니다. 그룹을 손으로 쓰는 일은 없습니다. 그룹은 continue, break, return, ?, 되감기를 @@ -144,7 +144,7 @@

직접 만드는 컴포저블

rust
-
#[composable]
+
#[Composable]
 fn task_row(title: String, done: bool, on_toggle: Rc<dyn Fn(bool)>) {
     Row()
         .modifier(Modifier.fill_max_width())
@@ -460,7 +460,7 @@ 

애니메이션 구현

use compose_rust::ui::*; use compose_rust::{AnimationSpec, animate_float_as_state}; -#[composable] +#[Composable] fn fading(faded: MutableState<bool>) { let target = if faded.get() { 0.0 } else { 1.0 }; Box() diff --git a/docs/locales/README_ko.md b/docs/locales/README_ko.md index 49c965725..efd706a7a 100644 --- a/docs/locales/README_ko.md +++ b/docs/locales/README_ko.md @@ -39,7 +39,7 @@ compose-rust는 네 가지를 합친 것입니다. 0.0.2 크레이트에 있는 것: -- **작성 API.** `#[composable]`, `remember`, 상태와 effect, `Recomposer`, `launch`, 그리고 +- **작성 API.** `#[Composable]`, `remember`, 상태와 effect, `Recomposer`, `launch`, 그리고 위젯마다 하나씩인 함수입니다([#23](https://github.com/DarkPyonix/compose-rust/issues/23), [#64](https://github.com/DarkPyonix/compose-rust/issues/64)). 0.0.2에서 새로 들어왔고, 애니메이션도 함께 있습니다. `Modifier.graphics_layer`, `Modifier.alpha`, 그리고 `animate_float_as_state`, `animate_color_as_state`, `animate_offset_as_state` 이며, 그 값은 @@ -70,7 +70,7 @@ compose-rust는 네 가지를 합친 것입니다. use compose_rust::runtime::*; use compose_rust::ui::*; -#[composable] +#[Composable] fn counter() { let count = remember(|| mutable_state_of(0)); Column().modifier(Modifier.padding(16.0).fill_max_width()).content(|| { @@ -86,7 +86,7 @@ fn main() { ``` 위젯은 문장 하나입니다. `Text("Hello")`가 위젯을 기술하고, 그 메서드들이 Compose가 이름 붙은 -인자로 받는 것을 정하며, 컨테이너의 자식은 `content`에 넘긴 클로저입니다. `#[composable]`이 그룹을 +인자로 받는 것을 정하며, 컨테이너의 자식은 `content`에 넘긴 클로저입니다. `#[Composable]`이 그룹을 넣으므로 그룹을 손으로 쓰지 않으며, 분기, 조기 `return`, `?`, 되감기를 지나도 그룹이 맞게 닫힙니다. 이름은 Compose를 따릅니다. 위젯 어휘는 스키마에 있는 그것 하나이고, 두 번 정의하지 않습니다. 나머지는 [가이드](https://darkpyonix.dev/compose-rust/ko/writing-ui.html)에 있습니다. @@ -201,7 +201,7 @@ Material 3, Apple HIG, Fluent, Liquid Glass를 비롯한 디자인 시스템 일 ### 크레이트 쓰기 ```bash -cargo add compose-rust@0.0.6 +cargo add compose-rust@0.0.7 ``` `cargo build`가 대상에 필요한 렌더러를 알아내고, 크레이트의 정확한 버전에 맞는 릴리스 아티팩트를 @@ -224,6 +224,35 @@ FreeType). 어디로 복사해도 실행됩니다. Windows는 정적 렌더러 `default-features = false`로 빌드하면 렌더러 없이 빌드되며, 헤드리스나 문서용 빌드에 씁니다. 그렇게 빌드한 바이너리는 창 없이 조용히 끝나지 않고, 무엇이 빠졌는지 알린 뒤 0이 아닌 값으로 종료합니다. +### macOS 애플리케이션 배포하기 + +`scripts/packager-macos/package-macos`는 `cargo build --release`가 만든 실행 파일을 애플리케이션 +번들로 만듭니다. 이름, 식별자, 버전은 `dx` 번들 설정과 `Cargo.toml`에 이미 적힌 것을 씁니다. +Python 3.11과 Xcode 명령줄 도구가 필요하고, Apple 개발자 계정은 필요하지 않습니다. + +```sh +scripts/packager-macos/package-macos app --manifest-dir . \ + --executable target/release/my-app --out dist +scripts/packager-macos/package-macos dmg --app "dist/My App.app" --out dist/MyApp-1.0.0.dmg +``` + +번들에는 갱신 설정이 없습니다. 직접 배포는 Velopack으로 패키징하고 갱신합니다. + +모든 번들은 임시(ad hoc) 서명됩니다. Apple 실리콘은 서명 없는 코드를 실행하지 않고, 임시 +서명에는 계정이 필요 없습니다. 다만 Apple이 확인한 개발자를 가리키지 않으므로, **내려받은 +사본을 처음 열 때 macOS가 확인을 요구합니다**. + +- **macOS 15(Sequoia) 이상:** 앱을 열고, macOS가 확인할 수 없다고 하면 완료를 누릅니다. + 그다음 시스템 설정 > 개인정보 보호 및 보안에서 보안 항목까지 내려가 앱 옆의 + **그래도 열기**를 누르고(버튼은 약 한 시간 동안 남습니다), 다시 확인한 뒤 암호를 입력합니다. +- **macOS 14 이하:** 앱을 Control-클릭하고 **열기**를 고른 뒤 다시 **열기**를 누릅니다. +- **모든 버전:** `xattr -dr com.apple.quarantine "/Applications/My App.app"`은 다운로드 + 표시를 지우고, 그러면 macOS가 묻지 않고 엽니다. + +처음 실행하기 전에 앱을 응용 프로그램 폴더로 옮기십시오. 디스크 이미지나 다운로드 폴더에서 +실행하면 macOS가 숨겨진 임시 사본을 실행합니다. 디스크 이미지에는 이 절차가 +`If macOS will not open the app.txt`로 들어 있습니다. + ### `develop`의 작성 API 쓰기 0.0.2에는 작성 API와 같은 스키마로 빌드한 렌더러가 들어 있으므로, 0.0.2에 의존하는 프로젝트는 diff --git a/renderer/android/src/shared/Accessibility.kt b/renderer/android/src/shared/Accessibility.kt new file mode 120000 index 000000000..f54ae0784 --- /dev/null +++ b/renderer/android/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/android/src/shared/ControlLook.kt b/renderer/android/src/shared/ControlLook.kt new file mode 120000 index 000000000..45b78b1cf --- /dev/null +++ b/renderer/android/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/android/src/shared/FileDialogHost.kt b/renderer/android/src/shared/FileDialogHost.kt new file mode 120000 index 000000000..32c630324 --- /dev/null +++ b/renderer/android/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/android/src/shared/Transform3DLayers.kt b/renderer/android/src/shared/Transform3DLayers.kt new file mode 120000 index 000000000..1714c8e19 --- /dev/null +++ b/renderer/android/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/desktop/scripts/build-windows.sh b/renderer/desktop/scripts/build-windows.sh index 9e50b5173..3d0b761c1 100755 --- a/renderer/desktop/scripts/build-windows.sh +++ b/renderer/desktop/scripts/build-windows.sh @@ -317,6 +317,17 @@ for symbol in compose_rust_renderer_run compose_rust_renderer_request_frame; do "Check the @CName annotations in staticlib/src/IosEntryPoints.kt." done +# The schema this renderer was generated from, written beside it, so the Host's build script +# can see the two disagree, and so the release can package the kit as it does the others. +schema_hash_decimal="$( + grep -o 'const val SCHEMA_HASH: Long = -\?[0-9]*' \ + "$PROJECT_DIR/desktop/src/protocol/Protocol.gen.kt" | + grep -o -- '-\?[0-9]*$' +)" +[[ -n "$schema_hash_decimal" ]] || die "could not read SCHEMA_HASH from desktop/src/protocol/Protocol.gen.kt" +# printf rather than awk: the hash fills all 64 bits and awk works in doubles. +printf '0x%016x\n' "$schema_hash_decimal" > "$OUT_DIR/schema-hash.txt" + echo echo "$OUT_DIR" du -sh "$OUT_DIR"/* | sed 's|'"$OUT_DIR"'/||' diff --git a/renderer/desktop/src/WindowBackend.kt b/renderer/desktop/src/WindowBackend.kt index 0dbd96e09..45f4943b9 100644 --- a/renderer/desktop/src/WindowBackend.kt +++ b/renderer/desktop/src/WindowBackend.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.ui.platform import dev.darkpyonix.composerust.protocol.Chrome import dev.darkpyonix.composerust.protocol.ColorScheme import dev.darkpyonix.composerust.protocol.TitleBar +import dev.darkpyonix.composerust.runtime.WindowDropTargets // What the operating system layers (macOS, Windows, Linux) implement so that one process can // hold several windows, and the only thing the window manager knows about them. @@ -207,4 +208,11 @@ fun interface DropTargets { companion object { val None = DropTargets { _, _, _ -> null } } + + object Registered : DropTargets { + override fun targetAt(windowId: Int, x: Float, y: Float): DropTarget? = + WindowDropTargets.find(windowId, x, y)?.let { (id, local) -> + DropTarget(id, local.first, local.second) + } + } } diff --git a/renderer/desktop/src/WindowManager.kt b/renderer/desktop/src/WindowManager.kt index 9ee21e2f3..6232556a8 100644 --- a/renderer/desktop/src/WindowManager.kt +++ b/renderer/desktop/src/WindowManager.kt @@ -79,6 +79,8 @@ class WindowManager( /** Starts managing: hooks the Host's batches, listens to the application, opens what exists. */ fun attach() { host.afterBatch = { sync() } + // A file dialog is shown on the platform window of the node it names. + table.fileDialogs.windowResolver = { id -> entries[id]?.platform } backend.setApplicationListener(appListener) sync() } @@ -86,6 +88,7 @@ class WindowManager( /** Stops managing and closes every window. The Host and the table are left as they are. */ fun shutdown() { host.afterBatch = null + table.fileDialogs.windowResolver = { null } backend.setApplicationListener(null) for (entry in entries.values.toList()) closeEntry(entry) waiting.clear() diff --git a/renderer/desktop/src/protocol/Protocol.gen.kt b/renderer/desktop/src/protocol/Protocol.gen.kt index 88e2476c8..93583d781 100644 --- a/renderer/desktop/src/protocol/Protocol.gen.kt +++ b/renderer/desktop/src/protocol/Protocol.gen.kt @@ -8,9 +8,9 @@ import java.nio.charset.StandardCharsets enum class WidgetKind { Column, Row, Box, Text, TextField, Button, Spacer, LazyColumn, ScrollColumn, Image, Icon, Checkbox, RadioButton, Switch, Slider, ProgressIndicator, Divider, Card, Surface, Dialog, Menu, Tabs, TopAppBar, LazyRow, Tooltip, Canvas, DatePicker, TimePicker, Dropdown, Navigation, NavigationItem, Sheet, Scaffold, ScaffoldSlot, LazyGrid, FileDropTarget, ScrollRow, Chip, FloatingAction, Badge, SelectionContainer, SplitPane, CodeEditor, AbsoluteBox, Window, LinearProgressIndicator } -enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, Progress } +enum class PropertyKind { Text, Placeholder, Enabled, Multiline, OnClick, OnValueChange, OnSubmit, OnFocusLost, OnKeyDown, ItemCount, ItemKey, OnRangeRequested, TypeRole, FontSize, FontWeight, LineHeight, LetterSpacing, Color, TextAlign, MaxLines, Overflow, Arrangement, Spacing, SpaceRole, Alignment, Variant, Asset, Checked, Steps, Determinate, Circular, Vertical, Open, OnDismiss, SelectedIndex, Commands, Value, Min, Max, Icon, Slot, Columns, MinColumnWidth, Spans, OnFilesEntered, OnFilesDropped, Section, Count, Collapsible, ButtonKind, Decorations, SyntaxSpans, TabWidth, OnEditRejected, OnHover, OnSave, OnDecorationClick, Folds, GutterDecorations, OnSelectionChange, Font, WordBreak, OverflowWrap, TabSize, AbsoluteSize, SoftWrap, SpanFonts, OnVisibleRangeChange, OnEditorFocusChange, OnFoldChange, OnCompositionChange, OnPointer, PointerKinds, Cursor, UserSelect, IslandZoom, WindowTitle, WindowSize, WindowMinSize, WindowResizable, WindowChrome, WindowTitleBar, WindowIcon, WindowPosition, WindowRestoreKey, WindowZoomLevel, DragFromPointer, OnWindowCloseRequested, OnWindowFocusChanged, OnWindowMoved, OnWindowDrag, OnWindowUnavailable, OnWindowClosed, OnWindowDragOver, TearOffKey, OnFocusGained, OnKeyUp, AccessibleName, TrackColor, ThumbColor, ActiveTrackColor, ContainerColor, CheckColor, ControlSize, ReportSize, Scrim, Progress } -enum class Key { Enter } +enum class Key { Enter, Other } enum class WindowSizeClass { Compact, Medium, Expanded } @@ -28,6 +28,8 @@ enum class TileMode { Clamp, Repeat, Mirror } enum class MaterialRole { Thin, Regular, Thick, Chrome } +enum class SemanticsRole { None, Button, Checkbox, Switch, RadioButton, Tab, Image, DropdownList, TabList, Tree, TreeItem, Menu, MenuItem, TextBox, Heading, Navigation, List, ListItem, Group } + enum class SpaceRole { None, Xs, Sm, Md, Lg, Xl, Xxl } enum class TextAlign { Start, Center, End, Justify } @@ -116,6 +118,10 @@ enum class WindowMode { Single, Windows } enum class LastWindowClosed { Exit, KeepRunning } +enum class FileDialogKind { OpenFile, OpenFiles, OpenFolder, OpenFolders, Save } + +enum class FileDialogStatus { Chosen, Cancelled, Unavailable } + /** * The `TypeRole` property of text with no rung of the ladder at all: CSS text, whose * size, weight and font are its own. Present and zero; an absent property is the @@ -189,6 +195,20 @@ data class Theme( /** One role's colour in one scheme, as the application gave it. */ data class PaletteEntry(val role: ColorRole, val dark: Boolean, val argb: Int) +/** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ +data class OverlayTokens( + val sheetCornerRadius: Int = 0, + val dialogCornerRadius: Int = 0, + val popupCornerRadius: Int = 0, + val sheetElevation: Int = 0, + val popupElevation: Int = 0, + val dialogElevation: Int = 0, + val sheetPadding: Int = 0, + val dialogPadding: Int = 0, + val popupPadding: Int = 0, + val scrim: Long = 0L, +) + /** * What the application asked of its own window. * @@ -243,6 +263,11 @@ data class Window( val mode: WindowMode = WindowMode.Single, /** What happens when the last window goes. Only read in [WindowMode.Windows]. */ val lastWindowClosed: LastWindowClosed = LastWindowClosed.Exit, + /** + * Whether the implicit single window reports every whole-dp change of its size, not + * only a change of size class. Windows made of `Window` nodes use the node property. + */ + val reportSize: Boolean = false, ) sealed interface PropertyValue { @@ -1021,9 +1046,11 @@ sealed interface Modifier { data class Clip(val enabled: Boolean) : Modifier data class Alpha(val value: kotlin.Float) : Modifier data class Transform(val a: kotlin.Float, val b: kotlin.Float, val c: kotlin.Float, val d: kotlin.Float, val e: kotlin.Float, val f: kotlin.Float, val originX: kotlin.Float, val originY: kotlin.Float) : Modifier + data class Transform3D(val m0: kotlin.Float, val m1: kotlin.Float, val m2: kotlin.Float, val m3: kotlin.Float, val m4: kotlin.Float, val m5: kotlin.Float, val m6: kotlin.Float, val m7: kotlin.Float, val m8: kotlin.Float, val m9: kotlin.Float, val m10: kotlin.Float, val m11: kotlin.Float, val m12: kotlin.Float, val m13: kotlin.Float, val m14: kotlin.Float, val m15: kotlin.Float, val originX: kotlin.Float, val originY: kotlin.Float, val backfaceHidden: Boolean) : Modifier data object MatchParentSize : Modifier data class WindowDrag(val enabled: Boolean) : Modifier data class PressFeedback(val scale: kotlin.Float, val tintAlpha: kotlin.Float) : Modifier + data class Semantics(val role: dev.darkpyonix.composerust.protocol.SemanticsRole, val state: Int) : Modifier } /** How the segment from one keyframe to the next is timed. */ @@ -1246,6 +1273,29 @@ sealed interface Mutation { /** Brings a window forward and makes it the key window. Happens once. */ data class FocusWindow(val nodeId: Int) : Mutation + + /** Moves input focus to [nodeId], or clears focus when [nodeId] is 0. */ + data class RequestFocus(val nodeId: Int) : Mutation + + /** + * Asks the platform for a file dialog on the window [window] (0 is the implicit root + * window). Answered exactly once, later, by a `FileDialogResult` event carrying + * [request]. [filters] is one string: a label and its comma-joined extensions as a pair, + * pairs joined by NUL. + */ + data class ShowFileDialog( + val request: Int, + val window: Int, + val kind: FileDialogKind, + val defaultFilter: Int, + val title: String, + val directory: String, + val name: String, + val filters: String, + ) : Mutation + + /** Sets application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + data class SetOverlayTokens(val overlay: OverlayTokens) : Mutation } sealed interface HostEvent { @@ -1257,7 +1307,7 @@ sealed interface HostEvent { data class TextSubmitted(override val nodeId: Int, override val handlerId: Long, val text: String) : HostEvent data class FocusLost(override val nodeId: Int, override val handlerId: Long) : HostEvent data class ProtocolError(override val nodeId: Int, override val handlerId: Long, val code: Int, val message: String) : HostEvent - data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean) : HostEvent + data class KeyDown(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent data class RangeRequested(override val nodeId: Int, override val handlerId: Long, val start: Int, val count: Int) : HostEvent data class ValueChanged(override val nodeId: Int, override val handlerId: Long, val value: Double) : HostEvent data class WindowSizeChanged(override val nodeId: Int, override val handlerId: Long, val widthDp: kotlin.Float, val heightDp: kotlin.Float, val sizeClass: WindowSizeClass, val heightClass: WindowHeightClass, val safeAreaTop: kotlin.Float, val safeAreaLeading: kotlin.Float, val safeAreaTrailing: kotlin.Float) : HostEvent @@ -1292,13 +1342,16 @@ sealed interface HostEvent { data class WindowClosed(override val nodeId: Int, override val handlerId: Long) : HostEvent data class WindowRequested(override val nodeId: Int, override val handlerId: Long, val request: Int, val key: String) : HostEvent data class ApplicationReopen(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class FocusGained(override val nodeId: Int, override val handlerId: Long) : HostEvent + data class KeyUp(override val nodeId: Int, override val handlerId: Long, val key: Key, val shiftKey: Boolean, val ctrlKey: Boolean, val altKey: Boolean, val metaKey: Boolean, val repeat: Boolean, val code: Int, val text: String) : HostEvent + data class FileDialogResult(override val nodeId: Int, override val handlerId: Long, val request: Int, val status: FileDialogStatus, val filter: Int, val paths: String) : HostEvent } class ProtocolException(message: String, val offset: Int) : IllegalArgumentException("$message at byte offset $offset") object Protocol { - const val SCHEMA_HASH: Long = 554621546583655284L + const val SCHEMA_HASH: Long = 2307080319921313708L const val PROTOCOL_VERSION: Int = 1 private const val TAG_ENVELOPE = 0 @@ -1326,6 +1379,9 @@ object Protocol { private const val TAG_SET_FOLDED = 22 private const val TAG_SET_WINDOW_THEME = 23 private const val TAG_FOCUS_WINDOW = 24 + private const val TAG_REQUEST_FOCUS = 25 + private const val TAG_SHOW_FILE_DIALOG = 26 + private const val TAG_SET_OVERLAY_TOKENS = 27 private const val ENVELOPE_LENGTH = 12 private const val ANIMATION_HEADER_LENGTH = 52 private const val KEYFRAME_HEAD_LENGTH = 24 @@ -1475,16 +1531,51 @@ object Protocol { requireRecordLength(length, 8, offset) Mutation.FocusWindow(readU32(batch, base, available, offset + 4).toInt()) } + TAG_REQUEST_FOCUS -> { + requireRecordLength(length, 8, offset) + Mutation.RequestFocus(readU32(batch, base, available, offset + 4).toInt()) + } + TAG_SHOW_FILE_DIALOG -> { + requireRecordLength(length, 48, offset) + Mutation.ShowFileDialog( + readU32(batch, base, available, offset + 4).toInt(), + readU32(batch, base, available, offset + 8).toInt(), + fileDialogKind(readU16(batch, base, available, offset + 12), offset + 12), + readU16(batch, base, available, offset + 14), + readString(batch, base, available, offset + 16), + readString(batch, base, available, offset + 24), + readString(batch, base, available, offset + 32), + readString(batch, base, available, offset + 40), + ) + } + TAG_SET_OVERLAY_TOKENS -> { + requireRecordLength(length, 28, offset) + Mutation.SetOverlayTokens( + OverlayTokens( + sheetCornerRadius = readU16(batch, base, available, offset + 4), + dialogCornerRadius = readU16(batch, base, available, offset + 6), + popupCornerRadius = readU16(batch, base, available, offset + 8), + sheetElevation = readU16(batch, base, available, offset + 10), + popupElevation = readU16(batch, base, available, offset + 12), + dialogElevation = readU16(batch, base, available, offset + 14), + sheetPadding = readU16(batch, base, available, offset + 16), + dialogPadding = readU16(batch, base, available, offset + 18), + popupPadding = readU16(batch, base, available, offset + 20), + scrim = readU32(batch, base, available, offset + 24), + ), + ) + } TAG_SET_WINDOW -> { requireRecordLength(length, 32, offset) - // Bit 0 is `resizable`; the mode and the last window policy are the - // nibbles above the second byte. Anything else standing is a record - // the sides disagree on. + // Bit 0 is `resizable`, bit 1 asks for every whole-dp size change; the + // mode and the last window policy are the nibbles above the second + // byte. Anything else standing is a record the sides disagree on. val flags = readU16(batch, base, available, offset + 16) - if ((flags and 0x00FE) != 0) { + if ((flags and 0x00FC) != 0) { throw ProtocolException("invalid window flags $flags", offset + 16) } val resizable = flags and 1 + val reportSize = (flags and 0x0002) != 0 // A byte saying whether there is a zoom level, then the level as a // signed byte. val zoom = readU16(batch, base, available, offset + 18) @@ -1511,6 +1602,7 @@ object Protocol { zoomLevel, windowMode((flags shr 8) and 0xF, offset + 16), lastWindowClosed(flags shr 12, offset + 16), + reportSize, ), ) } @@ -1713,7 +1805,7 @@ object Protocol { is HostEvent.TextSubmitted -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.FocusLost -> null is HostEvent.ProtocolError -> event.message.toByteArray(StandardCharsets.UTF_8) - is HostEvent.KeyDown -> null + is HostEvent.KeyDown -> event.text.toByteArray(StandardCharsets.UTF_8) is HostEvent.RangeRequested -> null is HostEvent.ValueChanged -> null is HostEvent.WindowSizeChanged -> null @@ -1748,6 +1840,9 @@ object Protocol { is HostEvent.WindowClosed -> null is HostEvent.WindowRequested -> event.key.toByteArray(StandardCharsets.UTF_8) is HostEvent.ApplicationReopen -> null + is HostEvent.FocusGained -> null + is HostEvent.KeyUp -> event.text.toByteArray(StandardCharsets.UTF_8) + is HostEvent.FileDialogResult -> event.paths.toByteArray(StandardCharsets.UTF_8) } val recordLength = when (event) { is HostEvent.Clicked -> 16 @@ -1755,7 +1850,7 @@ object Protocol { is HostEvent.TextSubmitted -> 24 is HostEvent.FocusLost -> 16 is HostEvent.ProtocolError -> 28 - is HostEvent.KeyDown -> 20 + is HostEvent.KeyDown -> 32 is HostEvent.RangeRequested -> 24 is HostEvent.ValueChanged -> 24 is HostEvent.WindowSizeChanged -> 44 @@ -1790,6 +1885,9 @@ object Protocol { is HostEvent.WindowClosed -> 16 is HostEvent.WindowRequested -> 28 is HostEvent.ApplicationReopen -> 16 + is HostEvent.FocusGained -> 16 + is HostEvent.KeyUp -> 32 + is HostEvent.FileDialogResult -> 36 } val totalLength = recordLength.toLong() + (text?.size ?: 0) if (totalLength > Int.MAX_VALUE || totalLength > out.remaining().toLong()) { @@ -1836,6 +1934,9 @@ object Protocol { is HostEvent.WindowClosed -> 45 is HostEvent.WindowRequested -> 46 is HostEvent.ApplicationReopen -> 47 + is HostEvent.FocusGained -> 48 + is HostEvent.KeyUp -> 49 + is HostEvent.FileDialogResult -> 50 } out.putShort(tag.toShort()) out.putShort(recordLength.toShort()) @@ -1858,7 +1959,9 @@ object Protocol { if (event.altKey) modifiers = modifiers or 0x04 if (event.metaKey) modifiers = modifiers or 0x08 out.put(modifiers.toByte()) - out.put(0.toByte()) + out.put((if (event.repeat) 1 else 0).toByte()) + out.putInt(event.code) + writeStringReference(out, recordLength, text!!) } is HostEvent.RangeRequested -> { out.putInt(event.start) @@ -2003,6 +2106,25 @@ object Protocol { writeStringReference(out, recordLength, text!!) } is HostEvent.ApplicationReopen -> Unit + is HostEvent.FocusGained -> Unit + is HostEvent.KeyUp -> { + out.putShort(keyTag(event.key).toShort()) + var modifiers = 0 + if (event.shiftKey) modifiers = modifiers or 0x01 + if (event.ctrlKey) modifiers = modifiers or 0x02 + if (event.altKey) modifiers = modifiers or 0x04 + if (event.metaKey) modifiers = modifiers or 0x08 + out.put(modifiers.toByte()) + out.put((if (event.repeat) 1 else 0).toByte()) + out.putInt(event.code) + writeStringReference(out, recordLength, text!!) + } + is HostEvent.FileDialogResult -> { + out.putInt(event.request) + out.putInt(fileDialogStatusTag(event.status)) + out.putInt(event.filter) + writeStringReference(out, recordLength, text!!) + } } if (text != null) out.put(text) return out.position() - start @@ -2186,12 +2308,24 @@ object Protocol { 166 -> PropertyKind.OnWindowClosed 167 -> PropertyKind.OnWindowDragOver 168 -> PropertyKind.TearOffKey + 117 -> PropertyKind.OnFocusGained + 118 -> PropertyKind.OnKeyUp + 119 -> PropertyKind.AccessibleName + 121 -> PropertyKind.TrackColor + 122 -> PropertyKind.ThumbColor + 123 -> PropertyKind.ActiveTrackColor + 124 -> PropertyKind.ContainerColor + 125 -> PropertyKind.CheckColor + 126 -> PropertyKind.ControlSize + 127 -> PropertyKind.ReportSize + 128 -> PropertyKind.Scrim 27 -> PropertyKind.Progress else -> throw ProtocolException("unknown property tag $tag", offset) } private fun keyTag(key: Key): Int = when (key) { Key.Enter -> 1 + Key.Other -> 2 } private fun windowSizeClassTag(sizeClass: WindowSizeClass): Int = when (sizeClass) { @@ -2228,6 +2362,12 @@ object Protocol { NotificationPermission.Unsupported -> 4 } + private fun fileDialogStatusTag(status: FileDialogStatus): Int = when (status) { + FileDialogStatus.Chosen -> 1 + FileDialogStatus.Cancelled -> 2 + FileDialogStatus.Unavailable -> 3 + } + private fun animationEventKindTag(value: AnimationEventKind): Int = when (value) { AnimationEventKind.Ready -> 1 AnimationEventKind.Active -> 2 @@ -2417,6 +2557,29 @@ object Protocol { else -> throw ProtocolException("unknown MaterialRole tag $tag", offset) } + private fun semanticsRole(tag: Int, offset: Int): SemanticsRole = when (tag) { + 1 -> SemanticsRole.None + 2 -> SemanticsRole.Button + 3 -> SemanticsRole.Checkbox + 4 -> SemanticsRole.Switch + 5 -> SemanticsRole.RadioButton + 6 -> SemanticsRole.Tab + 7 -> SemanticsRole.Image + 8 -> SemanticsRole.DropdownList + 9 -> SemanticsRole.TabList + 10 -> SemanticsRole.Tree + 11 -> SemanticsRole.TreeItem + 12 -> SemanticsRole.Menu + 13 -> SemanticsRole.MenuItem + 14 -> SemanticsRole.TextBox + 15 -> SemanticsRole.Heading + 16 -> SemanticsRole.Navigation + 17 -> SemanticsRole.List + 18 -> SemanticsRole.ListItem + 19 -> SemanticsRole.Group + else -> throw ProtocolException("unknown SemanticsRole tag $tag", offset) + } + private fun spaceRole(tag: Int, offset: Int): SpaceRole = when (tag) { 1 -> SpaceRole.None 2 -> SpaceRole.Xs @@ -2808,6 +2971,22 @@ object Protocol { else -> throw ProtocolException("unknown LastWindowClosed tag $tag", offset) } + private fun fileDialogKind(tag: Int, offset: Int): FileDialogKind = when (tag) { + 1 -> FileDialogKind.OpenFile + 2 -> FileDialogKind.OpenFiles + 3 -> FileDialogKind.OpenFolder + 4 -> FileDialogKind.OpenFolders + 5 -> FileDialogKind.Save + else -> throw ProtocolException("unknown FileDialogKind tag $tag", offset) + } + + private fun fileDialogStatus(tag: Int, offset: Int): FileDialogStatus = when (tag) { + 1 -> FileDialogStatus.Chosen + 2 -> FileDialogStatus.Cancelled + 3 -> FileDialogStatus.Unavailable + else -> throw ProtocolException("unknown FileDialogStatus tag $tag", offset) + } + private fun paint(bits: Long, offset: Int): Paint { val value = bits.toInt() return when (val kind = (bits ushr 32).toInt()) { @@ -2861,9 +3040,11 @@ object Protocol { 24 -> 0 25 -> 0 26 -> 2 + 27 -> 8 31 -> 0 32 -> 0 33 -> 0 + 34 -> 0 else -> throw ProtocolException("unknown modifier tag $tag", offset) } @@ -2898,9 +3079,11 @@ object Protocol { 24 -> Modifier.Clip(flag(first, offset)) 25 -> Modifier.Alpha(kotlin.Float.fromBits(first.toInt())) 26 -> Modifier.Transform(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset)) + 27 -> Modifier.Transform3D(finite(kotlin.Float.fromBits(first.toInt()), offset), finite(kotlin.Float.fromBits((first ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(second.toInt()), offset), finite(kotlin.Float.fromBits((second ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 16).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 16) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 24).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 24) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 32).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 32) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 40).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 40) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 48).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 48) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 56).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 56) ushr 32).toInt()), offset), finite(kotlin.Float.fromBits(readU64(batch, base, available, at + 64).toInt()), offset), finite(kotlin.Float.fromBits((readU64(batch, base, available, at + 64) ushr 32).toInt()), offset), flag(readU64(batch, base, available, at + 72), offset)) 31 -> Modifier.MatchParentSize 32 -> Modifier.WindowDrag(flag(first, offset)) 33 -> Modifier.PressFeedback(kotlin.Float.fromBits(first.toInt()), kotlin.Float.fromBits((first ushr 32).toInt())) + 34 -> Modifier.Semantics(semanticsRole(first.toInt(), offset), second.toInt()) else -> throw ProtocolException("unknown modifier tag $tag", offset) } } diff --git a/renderer/desktop/src/renderer/Accessibility.kt b/renderer/desktop/src/renderer/Accessibility.kt new file mode 100644 index 000000000..d9b4abdca --- /dev/null +++ b/renderer/desktop/src/renderer/Accessibility.kt @@ -0,0 +1,119 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.disabled +import androidx.compose.ui.semantics.focused +import androidx.compose.ui.semantics.heading +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.semantics.toggleableState +import androidx.compose.ui.state.ToggleableState +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.ui.node.Node + +/** The two bits of one tri-state field in the `Semantics` state word. */ +private const val FIELD_MASK = 0b11 +private const val CHECKED_SHIFT = 0 +private const val SELECTED_SHIFT = 2 +private const val EXPANDED_SHIFT = 4 +private const val DISABLED_SHIFT = 6 +private const val FOCUSED_SHIFT = 8 +private const val HIDDEN_BIT = 1 shl 10 +private const val CLICK_ACTION_BIT = 1 shl 11 + +/** A field of the state word: unset, off or on. */ +private fun Int.field(shift: Int): Boolean? = when ((this shr shift) and FIELD_MASK) { + 1 -> false + 2 -> true + else -> null +} + +private fun SemanticsRole.toRole(): Role? = when (this) { + SemanticsRole.None -> null + SemanticsRole.Button -> Role.Button + SemanticsRole.Checkbox -> Role.Checkbox + SemanticsRole.Switch -> Role.Switch + SemanticsRole.RadioButton -> Role.RadioButton + SemanticsRole.Tab -> Role.Tab + SemanticsRole.Image -> Role.Image + SemanticsRole.DropdownList -> Role.DropdownList + else -> null +} + +/** The word a screen reader gets for a role Compose's `Role` cannot express. */ +private fun SemanticsRole.word(): String? = when (this) { + SemanticsRole.TabList -> "tablist" + SemanticsRole.Tree -> "tree" + SemanticsRole.TreeItem -> "treeitem" + SemanticsRole.Menu -> "menu" + SemanticsRole.MenuItem -> "menuitem" + SemanticsRole.TextBox -> "textbox" + SemanticsRole.Navigation -> "navigation" + SemanticsRole.List -> "list" + SemanticsRole.ListItem -> "listitem" + SemanticsRole.Group -> "group" + else -> null +} + +/** + * What the Host said this node is to assistive technology: the accessible name, and the + * role and state of a `Semantics` modifier. + * + * It is applied here, outside the widget, because every widget receives this modifier as its + * own, so the one place covers a container, a control and a node the Host only labelled. A + * node that said nothing gets the same modifier back, so nothing changes for it. + */ +internal fun Modifier.accessibility( + node: Node, + dispatcher: EventDispatcher? = null, + clickHandlerId: Long? = null, +): Modifier { + val name = node.text(PropertyKind.AccessibleName).takeIf { it.isNotEmpty() } + val declared = node.modifiers.lastOrNull { it is ProtocolModifier.Semantics } as? ProtocolModifier.Semantics + if (name == null && declared == null) return this + val state = declared?.state ?: 0 + val declaredRole = declared?.role + val role = declaredRole?.toRole() + val checked = state.field(CHECKED_SHIFT) + val selected = state.field(SELECTED_SHIFT) + val expanded = state.field(EXPANDED_SHIFT) + val isDisabled = state.field(DISABLED_SHIFT) + val focused = state.field(FOCUSED_SHIFT) + val hidden = state and HIDDEN_BIT != 0 + val isHeading = declaredRole == SemanticsRole.Heading + val description = listOfNotNull( + declaredRole?.word(), + expanded?.let { if (it) "expanded" else "collapsed" }, + ).joinToString(", ").takeIf { it.isNotEmpty() } + // A disabled node is not pressed, whichever way it was disabled: by this modifier or by + // the node's own enabled property that the ordinary click path honours. + val pressable = isDisabled != true && node.flag(PropertyKind.Enabled, default = true) + val handler = clickHandlerId.takeIf { pressable && state and CLICK_ACTION_BIT != 0 } + return semantics { + if (name != null) contentDescription = name + if (role != null) this.role = role + if (isHeading) heading() + if (description != null) stateDescription = description + if (checked != null) toggleableState = ToggleableState(checked) + if (selected != null) this.selected = selected + if (isDisabled == true) disabled() + if (focused != null) this.focused = focused + if (hidden) hideFromAccessibility() + if (handler != null && dispatcher != null) { + onClick(label = null) { + dispatcher.dispatch(HostEvent.Clicked(node.id, handler)) + true + } + } + } +} diff --git a/renderer/desktop/src/renderer/CodeEditorModel.kt b/renderer/desktop/src/renderer/CodeEditorModel.kt index 3ee193413..c5c6824de 100644 --- a/renderer/desktop/src/renderer/CodeEditorModel.kt +++ b/renderer/desktop/src/renderer/CodeEditorModel.kt @@ -63,6 +63,23 @@ class LiveGutterMark( val glyph: Int, ) +/** + * What a change-marker bar says about its line. Declared in the order the bars win a line: + * a removal outranks a modification, which outranks an addition. + */ +enum class ChangeMark { Removed, Modified, Added } + +/** The change a mark stands for, or null when it is an ordinary mark beside the line number. */ +fun LiveGutterMark.changeMark(): ChangeMark? { + if (kind != GutterDecorationKind.Bar) return null + return when (color) { + ColorRole.DiffAdded -> ChangeMark.Added + ColorRole.DiffModified -> ChangeMark.Modified + ColorRole.DiffRemoved -> ChangeMark.Removed + else -> null + } +} + /** A range the Host asked to have brought into view. A new request is a new object. */ class RevealRequest(val start: CodePosition, val end: CodePosition, val align: RevealAlign) @@ -186,6 +203,12 @@ class CodeEditorModel( private val foldList = ArrayList() private val gutterList = ArrayList() + /** Changes whenever the gutter marks are replaced, moved or dropped, for the flags below. */ + private var gutterStamp = 0 + private var flagsStamp = -1 + private var changeBarsPresent = false + private var ordinaryMarksPresent = false + /** Changes whenever a fold is added, moved, removed or flipped, for the cache below. */ private var foldStamp = 0 private var hiddenStamp = -1 @@ -246,6 +269,7 @@ class CodeEditorModel( redoStack.clear() foldList.clear() gutterList.clear() + gutterStamp += 1 foldStamp += 1 selection = CodeSelection.at(CodePosition.Zero) contentRevision += 1 @@ -567,6 +591,7 @@ class CodeEditorModel( /** Replaces every mark beside the line numbers with the Host's new list. */ fun setGutterDecorations(records: List) { gutterList.clear() + gutterStamp += 1 for (record in records) { if (record.kind == GutterDecorationKind.Glyph && !isScalarValue(record.glyph)) { output += EditorOutput.Error("a gutter glyph is ${record.glyph}, which is not a Unicode scalar value") @@ -717,6 +742,82 @@ class CodeEditorModel( /** The marks beside a line, in the order the Host sent them. */ fun gutterMarksAt(line: Int): List = gutterList.filter { it.line == line } + /** The marks of a line that sit in the mark column: every one that is not a change bar. */ + fun ordinaryMarksAt(line: Int): List = + gutterList.filter { it.line == line && it.changeMark() == null } + + /** + * The index in [gutterMarks] of the first mark at or after [from] that sits on [line] in + * the mark column, or -1. Drawing walks the marks with this instead of building a list + * per line, so a frame allocates nothing for them. + */ + fun nextOrdinaryMarkIndex(line: Int, from: Int): Int { + if (!ordinaryMarksPresent()) return -1 + for (i in from until gutterList.size) { + val mark = gutterList[i] + if (mark.line == line && mark.changeMark() == null) return i + } + return -1 + } + + private fun refreshMarkFlags() { + if (flagsStamp == gutterStamp) return + var bars = false + var ordinary = false + for (i in 0 until gutterList.size) { + if (gutterList[i].changeMark() != null) bars = true else ordinary = true + } + changeBarsPresent = bars + ordinaryMarksPresent = ordinary + flagsStamp = gutterStamp + } + + private fun ordinaryMarksPresent(): Boolean { + refreshMarkFlags() + return ordinaryMarksPresent + } + + /** Whether any mark is a change bar, which is what makes the gutter reserve room for them. */ + val hasChangeBars: Boolean + get() { + refreshMarkFlags() + return changeBarsPresent + } + + /** Whether any mark belongs in the mark column. */ + val hasOrdinaryMarks: Boolean get() = ordinaryMarksPresent() + + /** + * The one change bar a line shows. Several can sit on a line; the others stay in the + * model and keep following edits, but only the strongest is drawn. + */ + fun changeBarAt(line: Int): LiveGutterMark? { + if (!hasChangeBars) return null + var best: LiveGutterMark? = null + var bestRank = Int.MAX_VALUE + for (i in 0 until gutterList.size) { + val mark = gutterList[i] + if (mark.line != line) continue + val rank = mark.changeMark()?.ordinal ?: continue + if (rank < bestRank) { + best = mark + bestRank = rank + } + } + return best + } + + /** The lines that show a change bar, by the change they show, in line order. */ + fun changeLines(): Map> { + val shown = HashMap() + for (mark in gutterList) { + val change = mark.changeMark() ?: continue + val old = shown[mark.line] + if (old == null || change.ordinal < old.ordinal) shown[mark.line] = change + } + return shown.entries.groupBy({ it.value }, { it.key }).mapValues { it.value.sorted() } + } + /** * A selection the Host asked for, written against [baseVersion]. Both ends are moved * along with what the reader typed since. An end that is not a place in the document @@ -941,6 +1042,7 @@ class CodeEditorModel( moved = true } if (gutterList.isNotEmpty()) { + gutterStamp += 1 val iterator = gutterList.iterator() while (iterator.hasNext()) { val mark = iterator.next() diff --git a/renderer/desktop/src/renderer/CodeEditorSurface.kt b/renderer/desktop/src/renderer/CodeEditorSurface.kt index 6d87e935d..37d69bd9e 100644 --- a/renderer/desktop/src/renderer/CodeEditorSurface.kt +++ b/renderer/desktop/src/renderer/CodeEditorSurface.kt @@ -64,6 +64,8 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLayoutResult @@ -168,8 +170,58 @@ class CodeEditorLook( val gutterMark: (ColorRole?) -> Color = { lens }, /** The ink of the marker that folds or unfolds a stretch. */ val foldMarker: Color = lineNumber, + /** The width of a change-marker bar, which does not scale with the row. */ + val changeBarWidth: Dp = 4.dp, + /** The space between a change-marker bar and what comes after it. */ + val changeBarGap: Dp = 12.dp, + /** The word a screen reader hears for a changed line. */ + val changeMarkWord: (ChangeMark) -> String = { localizedChangeMarkWord(it) }, ) +/** The word for a changed line in [language], in the same set of languages as the sidebar's. */ +internal fun localizedChangeMarkWord(change: ChangeMark, language: String = androidx.compose.ui.text.intl.Locale.current.language): String = + when (language) { + "ko" -> when (change) { + ChangeMark.Added -> "추가됨" + ChangeMark.Modified -> "수정됨" + ChangeMark.Removed -> "삭제됨" + } + else -> when (change) { + ChangeMark.Added -> "added" + ChangeMark.Modified -> "modified" + ChangeMark.Removed -> "removed" + } + } + +/** The room the gutter keeps for change bars and the gap behind them: none when no line has one. */ +internal fun Density.changeColumnPx(model: CodeEditorModel, look: CodeEditorLook): Int = + if (model.hasChangeBars) (look.changeBarWidth + look.changeBarGap).roundToPx() else 0 + +/** + * What a screen reader hears about the changed lines, or null when there are none. The + * editor is one node, so the lines are listed under the word for their change. + */ +internal fun changeDescription(model: CodeEditorModel, look: CodeEditorLook): String? { + val lines = model.changeLines() + if (lines.isEmpty()) return null + return ChangeMark.entries.filter { it in lines }.joinToString("; ") { change -> + val numbers = lines.getValue(change) + val shown = numbers.take(CHANGE_LINES_SPOKEN).joinToString(", ") { (it + 1).toString() } + val rest = if (numbers.size > CHANGE_LINES_SPOKEN) ", +${numbers.size - CHANGE_LINES_SPOKEN}" else "" + "${look.changeMarkWord(change)}: $shown$rest" + } +} + +private const val CHANGE_LINES_SPOKEN = 50 + +/** Gives the editor's node the description of its changed lines, when it has any. */ +@Composable +internal fun Modifier.changeSemantics(model: CodeEditorModel, look: CodeEditorLook): Modifier { + val revision = model.overlayRevision + val text = remember(model, revision, look) { changeDescription(model, look) } ?: return this + return semantics { stateDescription = text } +} + /** What a surface tells the editor that holds it. All of them are called on the UI thread. */ class CodeEditorCallbacks( /** The model has something queued for the Host. */ @@ -194,16 +246,17 @@ fun CodeEditorSurface( callbacks: CodeEditorCallbacks, modifier: Modifier = Modifier, path: CodeEditorPath = defaultCodeEditorPath, + focusRequest: Long = 0L, ) { when (path) { - CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false) - CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true) + CodeEditorPath.Windowed -> FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) + CodeEditorPath.Whole -> FieldSurface(model, look, callbacks, modifier, whole = true, focusRequest = focusRequest) CodeEditorPath.Drawn -> { val drawn = drawnCodeEditorSurface if (drawn != null) { drawn(model, look, callbacks, modifier) } else { - FieldSurface(model, look, callbacks, modifier, whole = false) + FieldSurface(model, look, callbacks, modifier, whole = false, focusRequest = focusRequest) } } } @@ -784,6 +837,7 @@ private fun FieldSurface( callbacks: CodeEditorCallbacks, modifier: Modifier, whole: Boolean, + focusRequest: Long = 0L, ) { val density = LocalDensity.current val rowPx = remember(look.textStyle, density) { rowHeightPx(look.textStyle, density) } @@ -806,6 +860,10 @@ private fun FieldSurface( // reader itself reads the field's layout state, so what draws from it still follows. val layoutHolder = remember { arrayOfNulls<() -> TextLayoutResult?>(1) } val focus = remember { FocusRequester() } + val overlayFocusStack = dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack.current + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focus.requestFocus() + } // Read here so the row map and the gutter follow every change of text and overlay. val contentRevision = model.contentRevision @@ -813,13 +871,13 @@ private fun FieldSurface( val rows = remember(contentRevision, overlayRevision) { RowMap.of(model) } val lineCount = rows.lineCount val digits = lineCount.toString().length - val hasMarks = model.gutterMarks.isNotEmpty() + val hasMarks = model.hasOrdinaryMarks val hasFolds = model.folds.isNotEmpty() val markColumnPx = if (hasMarks) rowPx else 0 val foldColumnPx = if (hasFolds) rowPx else 0 val gutterWidthPx = with(density) { digits * digitWidth + (look.gutterPadding * 2).roundToPx() + look.gutterDividerWidth.roundToPx() - } + markColumnPx + foldColumnPx + } + markColumnPx + foldColumnPx + density.changeColumnPx(model, look) // Refilling the window recomposes this, so the field moves to where the new lines are. window.revision @@ -865,6 +923,7 @@ private fun FieldSurface( Box( modifier .boundedHeight(DEFAULT_EDITOR_HEIGHT) + .changeSemantics(model, look) .background(look.container) .clipToBounds() .onSizeChanged { size -> @@ -951,6 +1010,7 @@ private fun FieldSurface( } } .onFocusChanged { focusState -> + overlayFocusStack.noteFocused(focus, focusState.hasFocus) model.focusChanged(focusState.hasFocus) callbacks.onOutput() } @@ -1095,7 +1155,7 @@ internal fun pressGutter( if (foldColumn > 0 && point.x >= gutterWidth - foldColumn) { model.toggleFold(line) } else if (markColumn > 0 && point.x < markColumn) { - model.gutterMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) + model.ordinaryMarksAt(line).lastOrNull { it.id != 0L }?.let(model::activateGutter) } } @@ -1326,15 +1386,16 @@ private fun DrawScope.drawGutter( val last = min(rows.lineCount - 1, rows.lineAtRow((scroll + viewportHeight) / rowPx + 1)) val padding = look.gutterPadding.toPx() val divider = look.gutterDividerWidth.toPx() + val changeColumn = changeColumnPx(window.model, look) for (line in first..last) { if (rows.isHidden(line)) continue val label = (line + 1).toString() val color = if (line == caretLine) look.currentLineNumber else look.lineNumber val measured = measurer.measure(label, style.copy(color = color)) - val x = width - divider - foldColumn - padding - measured.size.width + val x = width - divider - foldColumn - changeColumn - padding - measured.size.width val top = rows.textRow(line) * rowPx.toFloat() drawText(measured, topLeft = Offset(x, top)) - drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, measurer, style, look) + drawGutterAdornments(window.model, line, top, rowPx, markColumn, width - divider - foldColumn, foldColumn, changeColumn, measurer, style, look) } } @@ -1347,13 +1408,24 @@ internal fun DrawScope.drawGutterAdornments( markColumn: Int, foldLeft: Float, foldColumn: Int, + changeColumn: Int, measurer: TextMeasurer, style: TextStyle, look: CodeEditorLook, ) { val cell = rowPx.toFloat() + // The change bar sits right of the numbers, the full row high, so that the bars of + // neighbouring lines meet. + val bar = model.changeBarAt(line) + if (bar != null) { + val left = foldLeft - changeColumn + drawRect(look.gutterMark(bar.color), Offset(left, top), Size(look.changeBarWidth.toPx(), cell)) + } if (markColumn > 0) { - for (mark in model.gutterMarksAt(line)) { + var index = model.nextOrdinaryMarkIndex(line, 0) + while (index >= 0) { + val mark = model.gutterMarks[index] + index = model.nextOrdinaryMarkIndex(line, index + 1) val color = look.gutterMark(mark.color) when (mark.kind) { GutterDecorationKind.Dot -> drawCircle(color, cell * 0.2f, Offset(markColumn / 2f, top + cell / 2f)) diff --git a/renderer/desktop/src/renderer/ComposeRustHost.kt b/renderer/desktop/src/renderer/ComposeRustHost.kt index 33496f8af..681dcea32 100644 --- a/renderer/desktop/src/renderer/ComposeRustHost.kt +++ b/renderer/desktop/src/renderer/ComposeRustHost.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.snapshots.Snapshot @@ -30,6 +31,7 @@ import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.Modifier import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.runtime.mutableStateOf @@ -39,6 +41,7 @@ import dev.darkpyonix.composerust.foundation.systemChrome import androidx.compose.ui.graphics.Color import dev.darkpyonix.composerust.protocol.ColorRole import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.FrameRequests @@ -76,7 +79,17 @@ import androidx.compose.runtime.getValue import androidx.compose.foundation.isSystemInDarkTheme import dev.darkpyonix.composerust.design.HostPlatform -internal class WindowDragExclusion { var downKey: Pair? = null } +/** + * The press a `WindowDrag(false)` node saw, as pointer id and uptime. Two fields rather than + * a pair, so marking a press allocates nothing. + */ +internal class WindowDragExclusion { + var downId: Long = -1L + var downUptime: Long = -1L + fun mark(id: Long, uptime: Long) { downId = id; downUptime = uptime } + fun clear() { downId = -1L; downUptime = -1L } + fun isMarked(id: Long, uptime: Long): Boolean = downId == id && downUptime == uptime +} internal val LocalWindowDragExclusion = staticCompositionLocalOf { null } /** @@ -132,6 +145,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // After the first batch, so the first thing the Host hears is a press that started // the application, if one did, and then what the platform says about permission. table.notifications.attach(this) + table.fileDialogs.attach(this) } /** Dispatches synchronously and returns the Host's consumption result. */ @@ -165,6 +179,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher // answer from the platform asks for a frame for exactly this, because the frame is // where the UI thread, and so the Host, can be reached from any other thread. table.notifications.drain() + table.fileDialogs.drain() applyTransaction { apply -> connection.renderFrame(frameTimeNanos, apply) } } @@ -178,6 +193,7 @@ class ComposeRustHost(private val connection: HostConnection) : EventDispatcher fun shutdown() { table.notifications.shutdown() + table.fileDialogs.shutdown() connection.shutdown() } @@ -379,6 +395,11 @@ private fun ComposeRustTree( // however many windows there are. val windowNodeId = LocalWindowNodeId.current val pageRoots = if (windowNodeId == 0) host.roots else host.table.node(windowNodeId)?.children?.toList().orEmpty() + val focusManager = LocalFocusManager.current + val clearFocusRevision = host.table.focusClearRevision + LaunchedEffect(clearFocusRevision) { + if (clearFocusRevision > 0L) focusManager.clearFocus() + } LaunchedEffect(host, frames, windowNodeId, servesFrames) { if (windowNodeId != 0 || !servesFrames) return@LaunchedEffect // Counted from the Host's start, not from the moment this loop began listening. The @@ -396,6 +417,8 @@ private fun ComposeRustTree( // A notification centre answers on a thread of its own, and the way from any thread to // this one is a frame request. The centre asks through the same source this loop serves. SideEffect { host.table.notifications.wake = frames::request } + // A file dialog's answer arrives the same way, from whichever thread the platform uses. + SideEffect { host.table.fileDialogs.wake = frames::request } // Whether the window is the one in use decides whether a notification that asked to be // shown only when it is not is shown at all, and coming back to the window is when a // permission changed in the system's settings is looked for. @@ -433,11 +456,17 @@ private fun ComposeRustTree( // and the design system is resolved against it, because a system is allowed to // answer differently in a desktop window than on a phone. var sizeClass by remember(host) { mutableStateOf(WindowSizeClass.Compact) } + // The height class is state too, so that a window which only crosses a height boundary + // (a phone-width window that is tall) recomposes and reaches the reporter below. Read + // only through the effect, the size is not observed, and a window whose width class did + // not change would never be reported. + var heightClass by remember(host) { mutableStateOf(WindowHeightClass.Compact) } var measuredSize by remember(host, windowNodeId) { mutableStateOf(null) } val measured = Modifier.onSizeChanged { size -> with(density) { val widthDp = size.width.toDp().value sizeClass = windowSizeClassOf(widthDp) + heightClass = windowHeightClassOf(size.height.toDp().value) measuredSize = size } } @@ -455,6 +484,7 @@ private fun ComposeRustTree( chainOf = { asset -> (host.table.assets.asset(asset) as? Asset.Font)?.chain }, brushOf = { asset -> (host.table.assets.asset(asset) as? Asset.Brush)?.brush }, windowBackdrop = asked && platformBacksWindowWithMaterial(), + overlay = host.table.overlayTokens, ) // The animations the Host handed over are played here, on this side's frame clock, and // the Host is not called while they play unless one asked for events. The loop waits @@ -549,9 +579,25 @@ private fun ComposeRustTree( captionStyle.side == CaptionSide.Start WindowSafeArea(caption.height.value, if (atStart) width.value else 0f, if (atStart) 0f else width.value) } + // Read through a derived state so a recomposition does not repeat the map lookup (which + // boxes the node id) and the read only runs again when the property itself changes. + // The implicit root window (id 0) opts in through its SetWindow settings and any other + // window through its node property; the table answers for both. + val reportEveryDp = remember(host, windowNodeId) { + derivedStateOf { host.table.reportSizeFor(windowNodeId) } + }.value + // A window that asked for every whole-dp change reads the size here, in the composition, + // so a new size recomposes this and reaches the effect. Any other window keeps reading it + // only inside the effect, as before, so a resize inside a size class costs it no + // recomposition. + val observedSize = if (reportEveryDp) measuredSize else null + // Subscribes this scope to the height class: reading the class here is what makes a + // height-only class change recompose, and so run the effect below with the new size. + @Suppress("UNUSED_VARIABLE") + val observedHeightClass = heightClass SideEffect { - measuredSize?.let { size -> - with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea) } + (observedSize ?: measuredSize)?.let { size -> + with(density) { reporter.report(size.width.toDp().value, size.height.toDp().value, host, safeArea, reportEveryDp) } } } // How much room the buttons take and at which end. The platform's own are at the @@ -575,12 +621,13 @@ private fun ComposeRustTree( // wherever focus is. Heard on the way back up from the focused node, so a text field // that wants the key keeps it. val sidebarShortcuts = remember(host) { dev.darkpyonix.composerust.foundation.SidebarShortcuts() } + val overlayFocusStack = remember(host, windowNodeId) { dev.darkpyonix.composerust.foundation.OverlayFocusStack() } val dragWindow = LocalWindowDrag.current val dragExclusion = remember { WindowDragExclusion() } val dragStrip = if (caption.fullSize && caption.height > 0.dp) Modifier.pointerInput(caption.height, dragWindow) { awaitEachGesture { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed && dragExclusion.downKey != (down.id.value to down.uptimeMillis) && down.position.y < caption.height.toPx()) { + if (!down.isConsumed && !dragExclusion.isMarked(down.id.value, down.uptimeMillis) && down.position.y < caption.height.toPx()) { while (true) { val change = awaitPointerEvent(PointerEventPass.Final).changes.firstOrNull { it.id == down.id } ?: break if (change.isConsumed || !change.pressed) break @@ -595,6 +642,7 @@ private fun ComposeRustTree( } else Modifier CompositionLocalProvider( dev.darkpyonix.composerust.foundation.LocalSidebarShortcuts provides sidebarShortcuts, + dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack provides overlayFocusStack, LocalWindowDragExclusion provides dragExclusion, LocalDesignTheme provides theme, LocalReduceTransparency provides reduceTransparency, @@ -615,7 +663,9 @@ private fun ComposeRustTree( modifier .then(measured) .then(dragStrip) - .onKeyEvent { event -> sidebarShortcuts.handle(event, platform) } + .onKeyEvent { event -> + overlayFocusStack.handleEscape(event) || sidebarShortcuts.handle(event, platform) + } .background(host.table.windowFill(pageRoots, theme)), ) { Box(Modifier.padding(top = pageTop, bottom = pageBottom)) { diff --git a/renderer/desktop/src/renderer/ControlLook.kt b/renderer/desktop/src/renderer/ControlLook.kt new file mode 100644 index 000000000..302318c7f --- /dev/null +++ b/renderer/desktop/src/renderer/ControlLook.kt @@ -0,0 +1,142 @@ +package dev.darkpyonix.composerust.design + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.paintProp +import dev.darkpyonix.composerust.ui.resolvedShape + +/** + * The colours and size the application asked a Switch, Slider or Checkbox to draw with. + * + * Every slot is null until the node sent it, and a slot that is null keeps what the design + * system draws. A look with nothing in it changes nothing, which is how a control that + * sends none of these properties stays exactly as it was. + */ +class ControlLook( + /** The track of a Switch, or the part of a Slider's track that is not filled. */ + val track: Color? = null, + /** The travelling thumb of a Switch or a Slider. */ + val thumb: Color? = null, + /** The filled part of a Slider's track. */ + val activeTrack: Color? = null, + /** The box of a checked Checkbox. */ + val container: Color? = null, + /** The check mark. */ + val check: Color? = null, + /** A Switch's width, or a Checkbox's side. */ + val width: Dp? = null, + /** A Switch's height. */ + val height: Dp? = null, + /** The corner shape the node asked for, or null where the design system's applies. */ + val shape: Shape? = null, +) { + /** Whether a Switch or a Checkbox has to be drawn from this look rather than by default. */ + fun appliesTo(role: ToggleRole): Boolean = when (role) { + ToggleRole.Switch -> track != null || thumb != null || width != null || height != null + ToggleRole.Checkbox -> container != null || check != null || width != null + ToggleRole.RadioButton -> false + } + + companion object { + val None = ControlLook() + } +} + +/** + * The look of [node], rebuilt only when something it reads has changed. + * + * A recomposition that changes none of the six properties, the corner modifier or the theme + * hands back the look it made last time, and a node that sends none of them gets the shared + * [ControlLook.None], so the steady state allocates nothing. A property that is removed + * makes its slot null, which differs from the value it held, so the look returns to the + * defaults. + */ +@Composable +internal fun rememberControlLook(node: Node, theme: ResolvedTheme): ControlLook { + val cache = remember { ControlLookCache() } + return cache.read(node, theme) +} + +private class ControlLookCache { + private var primed = false + private var theme: ResolvedTheme? = null + private var track: PropertyValue? = null + private var thumb: PropertyValue? = null + private var activeTrack: PropertyValue? = null + private var container: PropertyValue? = null + private var check: PropertyValue? = null + private var size: PropertyValue? = null + private var corner: dev.darkpyonix.composerust.protocol.Modifier? = null + private var named = false + private var look: ControlLook = ControlLook.None + + fun read(node: Node, current: ResolvedTheme): ControlLook { + val props = node.props + val newTrack = props[PropertyKind.TrackColor] + val newThumb = props[PropertyKind.ThumbColor] + val newActive = props[PropertyKind.ActiveTrackColor] + val newContainer = props[PropertyKind.ContainerColor] + val newCheck = props[PropertyKind.CheckColor] + val newSize = props[PropertyKind.ControlSize] + var newNamed = false + var newCorner: dev.darkpyonix.composerust.protocol.Modifier? = null + val modifiers = node.modifiers + for (index in modifiers.indices) { + val value = modifiers[index] + val isNamed = value is dev.darkpyonix.composerust.protocol.Modifier.Shape || + value is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + if (isNamed) newNamed = true + if (isNamed || value is dev.darkpyonix.composerust.protocol.Modifier.CornerEach) newCorner = value + } + if (primed && theme === current && track == newTrack && thumb == newThumb && + activeTrack == newActive && container == newContainer && check == newCheck && + size == newSize && named == newNamed && corner == newCorner + ) { + return look + } + primed = true + theme = current + track = newTrack; thumb = newThumb; activeTrack = newActive + container = newContainer; check = newCheck; size = newSize + named = newNamed; corner = newCorner + val empty = newTrack == null && newThumb == null && newActive == null && + newContainer == null && newCheck == null && newSize == null && !newNamed + look = if (empty) ControlLook.None else node.controlLook(current) + return look + } +} + +/** Reads the look a node asked for. Colours go through the theme like every other paint. */ +internal fun Node.controlLook(theme: ResolvedTheme): ControlLook { + fun colour(kind: PropertyKind): Color? = paintProp(kind)?.let { theme.color(it) } + val size = bytes(PropertyKind.ControlSize)?.takeIf { it.size >= 8 } + val width = size?.let { littleEndianFloat(it, 0) }?.takeIf { it > 0f }?.dp + val height = size?.let { littleEndianFloat(it, 4) }?.takeIf { it > 0f }?.dp + val named = modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.Shape || + it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole + } + return ControlLook( + track = colour(PropertyKind.TrackColor), + thumb = colour(PropertyKind.ThumbColor), + activeTrack = colour(PropertyKind.ActiveTrackColor), + container = colour(PropertyKind.ContainerColor), + check = colour(PropertyKind.CheckColor), + width = width, + height = height, + shape = if (named) modifiers.resolvedShape(theme) else null, + ) +} + +private fun littleEndianFloat(bytes: ByteArray, at: Int): Float { + var bits = 0 + for (index in 3 downTo 0) bits = (bits shl 8) or (bytes[at + index].toInt() and 0xff) + return Float.fromBits(bits) +} diff --git a/renderer/desktop/src/renderer/ControlWidgets.kt b/renderer/desktop/src/renderer/ControlWidgets.kt index b0d554ea9..896c05ea3 100644 --- a/renderer/desktop/src/renderer/ControlWidgets.kt +++ b/renderer/desktop/src/renderer/ControlWidgets.kt @@ -82,6 +82,7 @@ interface ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -97,6 +98,9 @@ interface ControlWidgets { * [named] colours the part that has been filled in and the thumb with it, where the * application said what those should look like. It is null in the ordinary case and * the design system answers instead, which is what an adaptive application wants. + * + * [look] holds the colours the application named for the two parts of the track and + * the thumb, each of which wins over [named] and over the design system where it is set. */ @Composable fun Slider( @@ -108,6 +112,7 @@ interface ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook = ControlLook.None, ) /** @@ -152,6 +157,7 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).toggle(role) val motion = theme.rules.motion @@ -163,7 +169,11 @@ internal object DrawnControlWidgets : ControlWidgets { // animates without a frame of it crossing the boundary. val progress by animateFloatAsState(if (checked) 1f else 0f, spec) val container by animateColorAsState( - targetValue = if (checked) style.containerChecked else style.container, + targetValue = when { + role == ToggleRole.Switch && look.track != null -> look.track + checked -> look.container ?: style.containerChecked + else -> style.container + }, animationSpec = tween(spec.durationMillis, easing = motion.easing), ) val alpha = if (enabled) 1f else style.disabledAlpha @@ -208,14 +218,19 @@ internal object DrawnControlWidgets : ControlWidgets { } when (role) { - ToggleRole.Checkbox -> Box( - clickable - .size(style.size) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { drawTick(style.mark.copy(alpha = alpha), progress) }, - ) + ToggleRole.Checkbox -> { + val shape = look.shape ?: style.shape + // A box the application filled has no outline of its own to show. + val outline = if (checked && look.container != null) 0.dp else style.borderWidth + Box( + clickable + .size(look.width ?: style.size) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { drawTick((look.check ?: style.mark).copy(alpha = alpha), progress) }, + ) + } ToggleRole.RadioButton -> Box( clickable @@ -231,27 +246,41 @@ internal object DrawnControlWidgets : ControlWidgets { }, ) - ToggleRole.Switch -> Box( - clickable - .size(width = style.trackWidth, height = style.trackHeight) - .clip(style.shape) - .background(container.copy(alpha = container.alpha * alpha), style.shape) - .controlBorder(style.borderWidth, style.border.copy(alpha = alpha), style.shape) - .drawBehind { - val diameter = style.thumbSize.toPx() - val margin = (size.height - diameter) / 2f - val travel = size.width - diameter - margin * 2f - val thumb = if (checked) style.mark else style.markUnchecked - drawCircle( - color = thumb.copy(alpha = alpha), - radius = diameter / 2f, - center = Offset( - x = margin + diameter / 2f + travel * progress, - y = size.height / 2f, - ), + ToggleRole.Switch -> { + val shape = look.shape ?: style.shape + val outline = if (look.track != null) 0.dp else style.borderWidth + Box( + clickable + .size( + width = look.width ?: style.trackWidth, + height = look.height ?: style.trackHeight, ) - }, - ) + .clip(shape) + .background(container.copy(alpha = container.alpha * alpha), shape) + .controlBorder(outline, style.border.copy(alpha = alpha), shape) + .drawBehind { + // A track of the application's own height has a thumb that + // scales with it, the same 4 to 5 proportion as a 16 dp thumb on + // a 20 dp track. + val diameter = if (look.height != null) { + size.height * SWITCH_THUMB_RATIO + } else { + style.thumbSize.toPx() + } + val margin = (size.height - diameter) / 2f + val travel = size.width - diameter - margin * 2f + val thumb = look.thumb ?: if (checked) style.mark else style.markUnchecked + drawCircle( + color = thumb.copy(alpha = alpha), + radius = diameter / 2f, + center = Offset( + x = margin + diameter / 2f + travel * progress, + y = size.height / 2f, + ), + ) + }, + ) + } } } @@ -265,10 +294,12 @@ internal object DrawnControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val style = theme.rules.controls(theme).slider - val active = named ?: style.activeTrack - val knob = named ?: style.thumb + val active = look.activeTrack ?: named ?: style.activeTrack + val knob = look.thumb ?: named ?: style.thumb + val rest = look.track ?: style.track val min = range.start val max = range.endInclusive val span = (max - min).takeIf { it > 0f } @@ -312,7 +343,7 @@ internal object DrawnControlWidgets : ControlWidgets { val usable = size.width - thumb val radius = CornerRadius(trackHeight / 2f, trackHeight / 2f) drawRoundRect( - color = style.track, + color = rest, topLeft = Offset(left, trackTop), size = Size(usable, trackHeight), cornerRadius = radius, @@ -502,6 +533,9 @@ private fun valueAt(x: Float, width: Float, thumb: Float, min: Float, max: Float return min + (max - min) * ((x - thumb / 2f) / usable).coerceIn(0f, 1f) } +/** A switch thumb's diameter as a share of the track's height, when the height is the application's. */ +private const val SWITCH_THUMB_RATIO = 0.8f + /** Narrow enough to sit in a row, wide enough that the track is worth dragging. */ private val SLIDER_MIN_WIDTH = 120.dp diff --git a/renderer/desktop/src/renderer/DesignSystem.kt b/renderer/desktop/src/renderer/DesignSystem.kt index 682b9586f..f603e80fb 100644 --- a/renderer/desktop/src/renderer/DesignSystem.kt +++ b/renderer/desktop/src/renderer/DesignSystem.kt @@ -22,6 +22,7 @@ import dev.darkpyonix.composerust.protocol.DesignTokens import androidx.compose.animation.core.FiniteAnimationSpec import dev.darkpyonix.composerust.protocol.MaterialRole import dev.darkpyonix.composerust.protocol.MotionRole +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.protocol.SpaceRole @@ -180,6 +181,11 @@ class ResolvedTheme( * given is enough to find the rest of the stack: see [chainFor]. */ val fontChains: List = emptyList(), + + /** + * Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. + */ + val overlay: OverlayTokens? = null, ) { /** * The stack [family] is the first family of, or null where it is not one. @@ -1638,6 +1644,8 @@ fun resolveTheme( * family lacks in the families after it. */ chainOf: (Int) -> FontChain? = { null }, + /** Application-level overlay theme tokens for Dialog, Menu, Dropdown and Sheet. */ + overlay: OverlayTokens? = null, ): ResolvedTheme { val system = when { theme == null -> adaptiveSystem(platform, DesignSystem.Material3) @@ -1684,6 +1692,7 @@ fun resolveTheme( platform, application, chains, + overlay, ) } diff --git a/renderer/desktop/src/renderer/DrawnCodeEditor.kt b/renderer/desktop/src/renderer/DrawnCodeEditor.kt index 167343b16..b9dc70f49 100644 --- a/renderer/desktop/src/renderer/DrawnCodeEditor.kt +++ b/renderer/desktop/src/renderer/DrawnCodeEditor.kt @@ -305,11 +305,11 @@ private fun DrawnSurface( val extent = rows.totalRows * rowPx - viewportHeight SideEffect { vertical.max = extent } val digitWidth = remember(look.textStyle) { measurer.measure("0", look.textStyle).size.width } - val markColumn = if (model.gutterMarks.isNotEmpty()) rowPx else 0 + val markColumn = if (model.hasOrdinaryMarks) rowPx else 0 val foldColumn = if (model.folds.isNotEmpty()) rowPx else 0 val gutterWidth = with(density) { rows.lineCount.toString().length * digitWidth + (look.gutterPadding * 2).toPx() + look.gutterDividerWidth.toPx() - } + markColumn + foldColumn + } + markColumn + foldColumn + density.changeColumnPx(model, look) state.textLeft = gutterWidth + with(density) { look.textInset.toPx() } // What the reader's edits queued is sent from here as well, on the composition thread. @@ -342,6 +342,7 @@ private fun DrawnSurface( Box( modifier .boundedHeight(DEFAULT_EDITOR_HEIGHT) + .changeSemantics(model, look) .background(look.container) .clipToBounds() .onSizeChanged { viewportHeight = it.height }, @@ -451,6 +452,7 @@ private fun DrawScope.drawLines( drawRect(look.gutterDivider, Offset(gutterWidth - width, scroll.toFloat()), Size(width, viewportHeight.toFloat())) } val lenses = model.lensesByLine().toMap() + val changeColumn = changeColumnPx(model, look) for (line in first..last) { if (rows.isHidden(line)) continue val view = state.view(line) @@ -488,7 +490,7 @@ private fun DrawScope.drawLines( (line + 1).toString(), state.style.copy(color = if (line == selection.caret.line) look.currentLineNumber else look.lineNumber), ) - val padding = look.gutterPadding.toPx() + look.gutterDividerWidth.toPx() + foldColumn + val padding = look.gutterPadding.toPx() + look.gutterDividerWidth.toPx() + foldColumn + changeColumn drawText(number, topLeft = Offset(gutterWidth - padding - number.size.width, top + (rowPx - number.size.height) / 2)) drawGutterAdornments( model, @@ -498,6 +500,7 @@ private fun DrawScope.drawLines( markColumn, gutterWidth - look.gutterDividerWidth.toPx() - foldColumn, foldColumn, + changeColumn, state.measurer!!, state.style, look, diff --git a/renderer/desktop/src/renderer/FakeHostConnection.kt b/renderer/desktop/src/renderer/FakeHostConnection.kt index 0991e8bbe..f1b1675db 100644 --- a/renderer/desktop/src/renderer/FakeHostConnection.kt +++ b/renderer/desktop/src/renderer/FakeHostConnection.kt @@ -70,6 +70,9 @@ class FakeHostConnection( // The same for the notification permission: the Renderer reporting what the platform // said, sent once at start, and nothing a fixture's reply is about. if (event is HostEvent.NotificationPermissionChanged) return 0 + // The end of a file dialog is the Renderer reporting too, not something a fixture's + // reply to a click is about. + if (event is HostEvent.FileDialogResult) return 0 // And the motion setting, for the same reason. if (event is HostEvent.ReducedMotionChanged) return 0 // And the zoom, reported at start and on every change. diff --git a/renderer/desktop/src/renderer/FileDialogHost.kt b/renderer/desktop/src/renderer/FileDialogHost.kt new file mode 100644 index 000000000..4e1466bf8 --- /dev/null +++ b/renderer/desktop/src/renderer/FileDialogHost.kt @@ -0,0 +1,430 @@ +package dev.darkpyonix.composerust.ui.platform + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.runtime.EventDispatcher +import kotlinx.coroutines.channels.Channel + +/** The limits a request and its answer are held to. */ +object FileDialogLimits { + /** Filters kept from one request; the rest are dropped. */ + const val MAX_FILTERS = 16 + + /** Extensions kept from one filter; the rest are dropped. */ + const val MAX_EXTENSIONS = 32 + + /** Paths one answer may carry. More is not delivered as a shorter list: it is unavailable. */ + const val MAX_PATHS = 10_000 + + /** + * Bytes of paths one answer may carry, separators included. + * + * Half of what the largest event buffer the desktop connection will grow to, so the + * record around the paths always fits. A platform with a smaller buffer sets a smaller + * limit on its [FileDialogCenter]. + */ + const val MAX_PAYLOAD_BYTES = 32 * 1024 * 1024 +} + +/** One filter of a dialog: what the user sees, and the extensions it lets through. */ +data class FileDialogFilter( + val label: String, + /** Lower case, without a leading dot or `*.`. Empty means every file. */ + val extensions: List, +) + +/** + * One dialog, as a platform is asked to show it, after the request was checked and + * normalised. + * + * [windowNode] is the `Window` node the dialog belongs to, or 0 for the implicit root + * window. [name] is a bare file name, and only a save dialog has one. [filters] is empty for + * the folder kinds. [defaultFilter] is a position inside [filters], or 0. + */ +data class FileDialogSpec( + val request: Int, + val windowNode: Int, + val kind: FileDialogKind, + val title: String, + val directory: String, + val name: String, + val filters: List, + val defaultFilter: Int, + /** + * For each entry of [filters], its position in the list the Host sent. A filter dropped + * on the way makes this differ from the identity; the result's `filter` is mapped back + * through it. Empty means the identity. + */ + val sentPositions: List = emptyList(), +) + +/** + * One path a dialog returned. A platform that hands over text passes [Text]; one whose paths + * are bytes (a Linux file system does not promise UTF-8) passes [Bytes]. One that is not UTF-8 + * makes the whole choice unavailable. + */ +sealed interface FileDialogPath { + class Text(val value: String) : FileDialogPath + class Bytes(val value: ByteArray) : FileDialogPath +} + +/** How a platform's dialog ended. */ +sealed interface FileDialogOutcome { + /** + * The user chose. [filter] is the position of the filter that was selected, or null when + * the dialog had none. + */ + class Chosen(val paths: List, val filter: Int? = null) : FileDialogOutcome { + companion object { + fun ofText(paths: List, filter: Int? = null): Chosen = + Chosen(paths.map { FileDialogPath.Text(it) }, filter) + } + } + + /** The user closed the dialog. */ + data object Cancelled : FileDialogOutcome + + /** The dialog could not be shown. */ + data object Unavailable : FileDialogOutcome +} + +/** + * One platform's file dialogs. + * + * [show] is called on the UI thread and must return without waiting for the user. The + * outcome goes to [onResult], from whichever thread the platform answers on, once. Calling + * it again is harmless: only the first counts. + */ +interface FileDialogPlatform { + /** + * Shows [request] on [window], the handle for the window it belongs to. The handle is + * whatever [FileDialogCenter.windowResolver] gave for the request's window node, and is + * null for the implicit root window or when nothing resolves one. + */ + fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) + + /** + * The window a dialog was shown on is going away. The platform closes that window's + * dialog if it has one open. The request ends with a cancel whether or not it does. + */ + fun cancelWindow(window: Any?) {} +} + +/** + * A platform with no file dialog yet, or a run that cannot show one. Every request is + * answered at once with [FileDialogOutcome.Unavailable]; that is not a protocol error. + */ +object UnsupportedFileDialogs : FileDialogPlatform { + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) = + onResult(FileDialogOutcome.Unavailable) +} + +/** + * The file dialogs this process uses. + * + * Installed by each platform's entry point before the Host starts, the way the notification + * centre is. A run nobody installed anything for answers every request as unavailable. + */ +object FileDialogs { + var platform: FileDialogPlatform = UnsupportedFileDialogs +} + +/** + * What the interpreter does with file dialog requests, and the one route the answers take + * back to the Host. + * + * It owns the rules that do not depend on a platform: a request number waits for one answer + * only, requests on one window are shown one after the other in the order they came, a + * window that goes away ends its dialog with a cancel, and an answer is delivered on the UI + * thread inside a frame, whole or not at all. The platform underneath only shows and reports. + */ +class FileDialogCenter( + private val platform: FileDialogPlatform = FileDialogs.platform, + private val maxPayloadBytes: Int = FileDialogLimits.MAX_PAYLOAD_BYTES, +) { + /** What `request` made of the request, for the table to report. */ + enum class Accepted { Accepted, Duplicate, UnknownWindow } + + private class Entry(val spec: FileDialogSpec) { + /** The platform was asked, or the request is already answered and only waits to be delivered. */ + var started = false + + /** An answer is on its way to being delivered, so nothing else starts on this window yet. */ + var finishing = false + var handle: Any? = null + } + + private class Completion(val request: Int, val outcome: FileDialogOutcome) + + /** + * Answers from the platform, from any thread. A channel because it is the one queue every + * target can share across threads without a lock of its own, and unlimited because what + * goes in is a handful of answers. + */ + private val inbox = Channel(Channel.UNLIMITED) + + /** Requests that have not been delivered an answer yet, in the order they came. */ + private val pending = LinkedHashMap() + + private var dispatcher: EventDispatcher? = null + private var draining = false + + /** How an answer asks for the frame that delivers it. */ + var wake: () -> Unit = {} + + /** + * Gives the platform's handle for a `Window` node, read when the dialog is shown. Node 0 + * has none. The window manager of a multi-window application installs this. + */ + var windowResolver: (Int) -> Any? = { null } + + /** How many requests are waiting for an answer. */ + internal val pendingCount: Int get() = pending.size + + /** + * Takes the request, normalised, unless its number is already waiting for an answer. + * + * [windowKnown] says whether the window it names is the implicit root or an existing + * `Window` node. A request on an unknown window is still answered, as unavailable. + */ + fun request(mutation: Mutation.ShowFileDialog, windowKnown: Boolean): Accepted { + if (pending.containsKey(mutation.request)) return Accepted.Duplicate + val entry = Entry(normalize(mutation)) + pending[mutation.request] = entry + if (!windowKnown) { + entry.started = true + entry.finishing = true + inbox.trySend(Completion(mutation.request, FileDialogOutcome.Unavailable)) + wake() + return Accepted.UnknownWindow + } + wake() + return Accepted.Accepted + } + + /** + * The `Window` node [windowNode] was removed. Its dialog is closed and every request on it + * ends with one cancel. + */ + fun windowRemoved(windowNode: Int) { + if (windowNode == 0) return + var any = false + for (entry in pending.values.toList()) { + if (entry.spec.windowNode != windowNode || entry.finishing) continue + any = true + entry.finishing = true + if (entry.started) platform.cancelWindow(entry.handle) + inbox.trySend(Completion(entry.spec.request, FileDialogOutcome.Cancelled)) + } + if (any) wake() + } + + /** Connects the center to the Host, once its first batch has been applied. */ + fun attach(host: EventDispatcher) { + dispatcher = host + drain() + } + + /** The process is ending. */ + fun shutdown() { + dispatcher = null + pending.clear() + while (inbox.tryReceive().getOrNull() != null) Unit + } + + /** + * Starts what may start and hands every answer to the Host, on the UI thread. + * + * Called where the Host can be entered: after its start, and at the top of every frame + * an answer asked for. A request is started here and not where it arrives, because the + * batch that carried it may also have created the window it belongs to, and the platform + * window exists only once the batch is through. + */ + fun drain() { + val host = dispatcher ?: return + if (draining) return + draining = true + try { + var progress = true + while (progress) { + progress = false + while (true) { + val done = inbox.tryReceive().getOrNull() ?: break + // Only the first answer to a request counts; a second finds nothing waiting. + val entry = pending.remove(done.request) ?: continue + progress = true + host.dispatch(resultEvent(entry.spec, done.outcome)) + } + for (entry in pending.values.toList()) { + if (entry.started || entry.finishing) continue + if (pending.values.any { it !== entry && it.spec.windowNode == entry.spec.windowNode && (it.started || it.finishing) }) continue + start(entry) + progress = true + } + } + } finally { + draining = false + } + } + + private fun start(entry: Entry) { + entry.started = true + val request = entry.spec.request + val handle = windowResolver(entry.spec.windowNode) + entry.handle = handle + try { + platform.show(handle, entry.spec) { outcome -> + inbox.trySend(Completion(request, outcome)) + wake() + } + } catch (failure: Exception) { + // A platform that throws has no dialog to show, which is the same answer. + inbox.trySend(Completion(request, FileDialogOutcome.Unavailable)) + } + } + + private fun resultEvent(spec: FileDialogSpec, outcome: FileDialogOutcome): HostEvent { + val none = -1 + return when (val checked = judge(outcome)) { + is Checked.Paths -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = FileDialogStatus.Chosen, + // The platform answers with a position in the cleaned list; the Host is told the + // position in the list it sent. + filter = checked.filter + ?.takeIf { chosen -> chosen in spec.filters.indices } + ?.let { chosen -> spec.sentPositions.getOrElse(chosen) { chosen } } + ?: none, + paths = checked.joined, + ) + is Checked.Ended -> HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = spec.request, + status = checked.status, + filter = none, + paths = "", + ) + } + } + + private sealed interface Checked { + class Paths(val joined: String, val filter: Int?) : Checked + class Ended(val status: FileDialogStatus) : Checked + } + + /** + * What the Host is told about an outcome. + * + * A path that is not well formed text makes the whole choice unavailable: the other paths + * are not sent either, because the application could not tell that the list is shorter + * than what the user picked, and a cancel would be false. A choice with too many paths, or too many bytes of them, is + * unavailable too, never a shorter list: the application would not know it was short. + */ + private fun judge(outcome: FileDialogOutcome): Checked = when (outcome) { + FileDialogOutcome.Cancelled -> Checked.Ended(FileDialogStatus.Cancelled) + FileDialogOutcome.Unavailable -> Checked.Ended(FileDialogStatus.Unavailable) + is FileDialogOutcome.Chosen -> when { + outcome.paths.isEmpty() -> Checked.Ended(FileDialogStatus.Cancelled) + outcome.paths.size > FileDialogLimits.MAX_PATHS -> Checked.Ended(FileDialogStatus.Unavailable) + else -> { + val checked = outcome.paths.map { wellFormedPath(it) } + val texts = checked.filterNotNull() + when { + texts.size != checked.size -> Checked.Ended(FileDialogStatus.Unavailable) + texts.sumOf { it.encodeToByteArray().size.toLong() + 1L } > maxPayloadBytes.toLong() -> + Checked.Ended(FileDialogStatus.Unavailable) + else -> Checked.Paths(texts.joinToString("\u0000"), outcome.filter) + } + } + } + } + + companion object { + /** + * Checks a request and puts it in the form a platform can rely on: filters and + * extensions within their limits, extensions lower case without a leading dot or + * `*.`, a default filter that exists, a bare file name, and nothing in the fields a + * kind does not use. + */ + fun normalize(mutation: Mutation.ShowFileDialog): FileDialogSpec { + val folders = mutation.kind == FileDialogKind.OpenFolder || mutation.kind == FileDialogKind.OpenFolders + val parsed = if (folders) emptyList() else parseFilters(mutation.filters) + val filters = parsed.map { it.second } + val name = if (mutation.kind == FileDialogKind.Save) { + mutation.name.substringAfterLast('/').substringAfterLast('\\') + } else { + "" + } + return FileDialogSpec( + request = mutation.request, + windowNode = mutation.window, + kind = mutation.kind, + title = mutation.title, + directory = mutation.directory, + name = name, + filters = filters, + // The default is a position in the list the Host sent, so it follows its filter + // when an earlier one was dropped. + defaultFilter = parsed.indexOfFirst { it.first == mutation.defaultFilter }.takeIf { it >= 0 } ?: 0, + sentPositions = parsed.map { it.first }, + ) + } + + private fun parseFilters(encoded: String): List> { + if (encoded.isEmpty()) return emptyList() + // Label, extensions, label, extensions. A label with nothing after it lets every + // file through, like one with no extensions. + val parts = encoded.split('\u0000') + val filters = ArrayList>() + var index = 0 + var position = 0 + while (index < parts.size && position < FileDialogLimits.MAX_FILTERS) { + val named = (parts.getOrNull(index + 1) ?: "").split(',').filter { it.isNotBlank() } + val extensions = named + .map { it.trim().removePrefix("*.").removePrefix(".").lowercase() } + .filter { it.isNotEmpty() } + .take(FileDialogLimits.MAX_EXTENSIONS) + // A filter that names extensions and keeps none of them (a lone ".") would + // otherwise mean every file, the opposite of what it asked for. + if (named.isEmpty() || extensions.isNotEmpty()) { + filters += position to FileDialogFilter(parts[index], extensions) + } + position++ + index += 2 + } + return filters + } + + /** The path as text, or null for one that is empty, holds a NUL, or is not valid Unicode text. */ + private fun wellFormedPath(path: FileDialogPath): String? { + val text = when (path) { + is FileDialogPath.Text -> path.value + is FileDialogPath.Bytes -> try { + path.value.decodeToString(throwOnInvalidSequence = true) + } catch (failure: Exception) { + return null + } + } + if (text.isEmpty()) return null + var index = 0 + while (index < text.length) { + val unit = text[index] + when { + unit == '\u0000' -> return null + unit.isHighSurrogate() -> { + if (index + 1 >= text.length || !text[index + 1].isLowSurrogate()) return null + index++ + } + unit.isLowSurrogate() -> return null + } + index++ + } + return text + } + } +} diff --git a/renderer/desktop/src/renderer/HostButton.kt b/renderer/desktop/src/renderer/HostButton.kt index 63b5ea196..430e97285 100644 --- a/renderer/desktop/src/renderer/HostButton.kt +++ b/renderer/desktop/src/renderer/HostButton.kt @@ -13,7 +13,9 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -21,10 +23,15 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorProducer import androidx.compose.ui.layout.layout import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected @@ -42,10 +49,14 @@ import dev.darkpyonix.composerust.protocol.TextAlign import dev.darkpyonix.composerust.design.compositeOver import dev.darkpyonix.composerust.design.ensureContrast import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ButtonStyle import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.ui.node.Children +import dev.darkpyonix.composerust.ui.node.LocalInheritedTextColor import dev.darkpyonix.composerust.ui.node.Node +import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.pressFeedback import dev.darkpyonix.composerust.ui.groupAlpha import dev.darkpyonix.composerust.ui.intProp @@ -66,6 +77,7 @@ import dev.darkpyonix.composerust.ui.variant internal fun HostButton( node: Node, modifier: Modifier, + table: NodeTable, dispatcher: EventDispatcher, theme: ResolvedTheme, ) { @@ -118,22 +130,50 @@ internal fun HostButton( // label instead of staying opaque behind it. The label is the exception: it is drawn // again outside the fade, in a colour that keeps its contrast with the faded fill. val available = if (enabled) Modifier else Modifier.groupAlpha { style.disabledAlpha } + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(node.id) { FocusRequester() } + val ownShape = node.modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + val shape = if (ownShape) node.modifiers.resolvedShape(theme) else style.shape + val hasExplicitSize = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Width || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + val setsOwnHeight = node.modifiers.any { + it is ProtocolModifier.Size || + it is ProtocolModifier.Height || + it is ProtocolModifier.RequiredSize + } + // Read before the decoration, because how much room the button needs depends on it. + val wordless = node.text(PropertyKind.Text).isEmpty() && + node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null + val labelPadH = if (wordless && hasExplicitSize) 0.dp else if (wordless) style.verticalPadding else style.horizontalPadding + val labelPadV = if (wordless && hasExplicitSize) 0.dp else style.verticalPadding + val clickable = if (handlerId == null) { available.then(modifier) } else { // The Host result is not used here: a click that reached a Button is never offered // to anything below it. Indication is null because the design system owns the // feedback, and a ripple is only one system's answer. - available.then(modifier).pressFeedback( - interactions, enabled, theme, style.shape, - node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, - node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, - ).clickable( - interactionSource = interactions, - indication = null, - enabled = enabled, - role = Role.Button, - ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } + available.then(modifier) + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } + .pressFeedback( + interactions, enabled, theme, shape, + node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: style.content, + node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.PressFeedback } as? dev.darkpyonix.composerust.protocol.Modifier.PressFeedback, + ).clickable( + interactionSource = interactions, + indication = null, + enabled = enabled, + role = Role.Button, + ) { dispatcher.dispatch(HostEvent.Clicked(node.id, handlerId)) } } // A Host that named its own Background has already had it applied to this node's @@ -145,30 +185,29 @@ internal fun HostButton( // pill, and asking for a variant instead hands the question to whichever design // system is active, which answers with its own accent. val ownFill = node.setsOwnBackground() - // Read before the decoration, because how much room the button needs depends on it. - val wordless = node.text(PropertyKind.Text).isEmpty() && - node.role(PropertyKind.Icon, IconRole.entries.toTypedArray()) != null val decorated = theme.rules - .elevation(clickable, elevation, style.shape, theme) - .clip(style.shape) - .then(if (ownFill) Modifier else Modifier.background(container, style.shape)) + .elevation(clickable, elevation, shape, theme) + .clip(shape) + .then(if (ownFill) Modifier else Modifier.background(container, shape)) .then( if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, borderColor, style.shape) + Modifier.border(style.borderWidth, borderColor, shape) } else { Modifier }, ) .topHighlight(style.topHighlight) - .defaultMinSize(minHeight = style.minHeight) + .let { + if (setsOwnHeight || (wordless && hasExplicitSize)) it else it.defaultMinSize(minHeight = style.minHeight) + } // A button with a glyph and no word gets the same room on both axes. The // horizontal padding is there to keep a word off the button's ends, and a button // with no word has nothing to keep off them: using it anyway makes every icon // control half as wide again as it needs to be, which two of them in a floating // group and one at the head of a composer both showed. .labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ) @@ -202,13 +241,28 @@ internal fun HostButton( // the glyph means, so it is what the name is made of, and assistive technology never // meets an unnamed control. val named = decorated - .let { if (icon != null && text.isEmpty()) it.semantics { contentDescription = icon.name } else it } + .let { + // The Host's own name for the button is read instead of the icon's meaning. + if (icon != null && text.isEmpty() && node.text(PropertyKind.AccessibleName).isEmpty()) { + it.semantics { contentDescription = icon.name } + } else { + it + } + } .let { if (node.property(PropertyKind.Checked) != null) it.semantics { selected = checked } else it } // The row gap between the glyph and the word is the design system's smallest step, so a // dense language sets them closer than a roomy one does. @Composable fun ButtonLabel() { - if (icon == null) { + if (node.children.isNotEmpty()) { + // Children replace the label. They are laid out as a Row centred vertically, so + // a weighted Spacer pushes whatever follows it to the button's inner edge. + CompositionLocalProvider(LocalInheritedTextColor provides ColorProducer { tint }) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Children(node, table, dispatcher) + } + } + } else if (icon == null) { ThemedText(text = text, style = label, maxLines = lines, softWrap = lines > 1, overflow = ellipsis) } else { Row( @@ -222,7 +276,10 @@ internal fun HostButton( } val alignment = if (alignStart) Alignment.CenterStart else Alignment.Center - if (enabled) { + // A button with children is faded as one group, children included: its content is the + // Host's to colour, so there is no label tint to keep readable, and composing the + // children twice would draw and expose each of them twice. + if (enabled || node.children.isNotEmpty()) { Box(named, contentAlignment = alignment) { ButtonLabel() } } else { Box { @@ -233,8 +290,8 @@ internal fun HostButton( } Box( Modifier.matchParentSize().labelPadding( - horizontal = if (wordless) style.verticalPadding else style.horizontalPadding, - vertical = style.verticalPadding, + horizontal = labelPadH, + vertical = labelPadV, alignStart = alignStart, ), contentAlignment = alignment, diff --git a/renderer/desktop/src/renderer/HostCodeEditor.kt b/renderer/desktop/src/renderer/HostCodeEditor.kt index 5f5399d21..38beff3dc 100644 --- a/renderer/desktop/src/renderer/HostCodeEditor.kt +++ b/renderer/desktop/src/renderer/HostCodeEditor.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import dev.darkpyonix.composerust.design.HostPlatform import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.foundation.code.CodeEditorCallbacks @@ -73,7 +74,8 @@ internal fun HostCodeEditor( }, ) } - CodeEditorSurface(model, look, callbacks, modifier) + val focusRequest = node.focusRequestRevision + CodeEditorSurface(model, look, callbacks, modifier, focusRequest = focusRequest) } /** What the design system says an editor looks like here, resolved to plain values. */ @@ -107,6 +109,9 @@ internal fun codeEditorLook(node: Node, theme: ResolvedTheme, tabWidth: Int?): C // A mark takes the role the Host named, and otherwise the lens's quiet ink. gutterMark = { role -> if (role == null) style.lens else theme.color(role) }, foldMarker = style.lineNumber, + // The same for every design system: the width and gap of a change-marker bar. + changeBarWidth = 4.dp, + changeBarGap = 12.dp, ) } diff --git a/renderer/desktop/src/renderer/HostContainer.kt b/renderer/desktop/src/renderer/HostContainer.kt index 9914be33c..bafe57099 100644 --- a/renderer/desktop/src/renderer/HostContainer.kt +++ b/renderer/desktop/src/renderer/HostContainer.kt @@ -7,6 +7,7 @@ import dev.darkpyonix.composerust.protocol.TypeRole import dev.darkpyonix.composerust.ui.textStyle import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -45,12 +46,20 @@ import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.weightOf import dev.darkpyonix.composerust.design.liftsOffThePage +/** True when the Host set a shape modifier of its own, which replaces the default shape. */ +internal fun Node.setsOwnShape(): Boolean = + modifiers.any { + it is ProtocolModifier.Shape || + it is ProtocolModifier.ShapeRole || + it is ProtocolModifier.CornerEach + } + /** * The corner this container is drawn with: the one the Host asked for if it asked, and the * design system's corner for this kind of container otherwise. */ internal fun Node.containerShape(style: ContainerStyle, theme: ResolvedTheme): Shape = - if (modifiers.any { it is ProtocolModifier.Shape || it is ProtocolModifier.ShapeRole || it is ProtocolModifier.CornerEach }) { + if (setsOwnShape()) { modifiers.resolvedShape(theme) } else { style.shape @@ -98,12 +107,34 @@ internal fun Modifier.containerDecoration( theme: ResolvedTheme, /** True for a menu, a dialog or a tooltip: a sheet over the page rather than in it. */ overlay: Boolean = false, + role: ContainerRole? = null, ): Modifier { - val shape = node.containerShape(style, theme) - val raised = if (node.setsOwnElevation()) { + val shape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + ContainerRole.Menu -> theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { RoundedCornerShape(it.dp) } + else -> null + } ?: style.shape + } else { + style.shape + } + val effectiveElevation = if (node.setsOwnElevation()) { + 0.dp + } else if (overlay) { + when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogElevation?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } + else -> null + } ?: style.elevation + } else { + style.elevation + } + val raised = if (node.setsOwnElevation() || effectiveElevation.value <= 0f) { this } else { - theme.rules.elevation(this, style.elevation, shape, theme) + theme.rules.elevation(this, effectiveElevation, shape, theme) } val material = style.material // A Host that named its own Background has already had it applied, so painting the @@ -139,6 +170,17 @@ internal fun Modifier.containerDecoration( .then( if (node.setsOwnPadding()) { Modifier + } else if (overlay) { + val customPadding = when (role) { + ContainerRole.Dialog -> theme.overlay?.dialogPadding?.takeIf { it > 0 }?.let { it.dp } + ContainerRole.Menu -> theme.overlay?.popupPadding?.takeIf { it > 0 }?.let { it.dp } + else -> null + } + if (customPadding != null) { + Modifier.padding(customPadding) + } else { + Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) + } } else { Modifier.padding(horizontal = style.horizontalPadding, vertical = style.verticalPadding) }, diff --git a/renderer/desktop/src/renderer/HostDropdown.kt b/renderer/desktop/src/renderer/HostDropdown.kt index 52b3f5267..dc1fcdd9f 100644 --- a/renderer/desktop/src/renderer/HostDropdown.kt +++ b/renderer/desktop/src/renderer/HostDropdown.kt @@ -196,6 +196,29 @@ private fun OpeningList( ) } if (open) { + val popupShape = if (node.setsOwnShape()) { + node.containerShape(style, theme) + } else { + theme.overlay?.popupCornerRadius?.takeIf { it > 0 }?.let { androidx.compose.foundation.shape.RoundedCornerShape(it.dp) } ?: style.shape + } + val popupElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.popupElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val popupRaised = if (node.setsOwnElevation() || popupElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, popupElevation, popupShape, theme) + } + val explicitPopupPad = theme.overlay?.popupPadding + val popupPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitPopupPad != null && explicitPopupPad > 0) { + Modifier.padding(explicitPopupPad.dp) + } else { + Modifier.padding(vertical = theme.space(SpaceRole.Xs)) + } Popup( popupPositionProvider = BelowAnchorPositionProvider, onDismissRequest = { open = false }, @@ -205,10 +228,11 @@ private fun OpeningList( Column( Modifier .testTag(dropdownPopupTestTag(node.id)) - .clip(style.shape) - .background(style.container, style.shape) - .border(style.borderWidth, style.borderColor, style.shape) - .padding(vertical = theme.space(SpaceRole.Xs)), + .then(popupRaised) + .clip(popupShape) + .background(style.container, popupShape) + .border(style.borderWidth, style.borderColor, popupShape) + .then(popupPadding), ) { options.forEachIndexed { index, childId -> key(childId) { diff --git a/renderer/desktop/src/renderer/HostOverlay.kt b/renderer/desktop/src/renderer/HostOverlay.kt index d32738685..834c02465 100644 --- a/renderer/desktop/src/renderer/HostOverlay.kt +++ b/renderer/desktop/src/renderer/HostOverlay.kt @@ -1,6 +1,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsHoveredAsState @@ -8,8 +9,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.staticCompositionLocalOf 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 @@ -17,6 +21,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEvent +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -37,10 +50,106 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.design.LocalGlassBackdrop import androidx.compose.runtime.CompositionLocalProvider +/** + * The open modal overlays of a window, in the order they were opened, and the control that + * held focus before each one opened. + * + * Escape asks to close only the topmost open overlay, and only after whatever holds focus + * inside it has declined the key. When the overlay closes, focus returns to the control that + * had it before the overlay opened. + */ +class OverlayFocusStack { + private class Entry(val nodeId: Int, var onEscape: () -> Unit, val opener: FocusRequester?) + + private val entries = mutableListOf() + private var currentFocused: FocusRequester? = null + + internal fun noteFocused(requester: FocusRequester, focused: Boolean) { + if (focused) { + currentFocused = requester + } else if (currentFocused === requester) { + currentFocused = null + } + } + + internal fun push(nodeId: Int, onEscape: () -> Unit): FocusRequester? { + entries.removeAll { it.nodeId == nodeId } + val opener = currentFocused + entries += Entry(nodeId, onEscape, opener) + return opener + } + + internal fun update(nodeId: Int, onEscape: () -> Unit) { + entries.firstOrNull { it.nodeId == nodeId }?.onEscape = onEscape + } + + internal fun pop(nodeId: Int) { + val index = entries.indexOfFirst { it.nodeId == nodeId } + if (index < 0) return + val removed = entries.removeAt(index) + val target = if (entries.isEmpty()) removed.opener else null + if (target != null) { + try { + target.requestFocus() + } catch (_: IllegalStateException) { + // The opener left the tree while the overlay was open. + } + } + } + + internal fun handleEscape(event: KeyEvent, fromNodeId: Int? = null): Boolean { + if (event.type != KeyEventType.KeyDown || event.key != Key.Escape) return false + val top = entries.lastOrNull() ?: return false + if (fromNodeId != null && top.nodeId != fromNodeId) return false + top.onEscape() + return true + } +} + +val LocalOverlayFocusStack = staticCompositionLocalOf { OverlayFocusStack() } + +/** + * Keeps [node]'s presence in the window's overlay stack and moves focus into [surfaceRequester] + * when the overlay opens, returning focus to the opener when it leaves. + */ +@Composable +internal fun ModalOverlayFocus( + nodeId: Int, + surfaceRequester: FocusRequester, + onEscape: () -> Unit, +) { + val stack = LocalOverlayFocusStack.current + SideEffect { stack.update(nodeId, onEscape) } + DisposableEffect(stack, nodeId) { + stack.push(nodeId, onEscape) + onDispose { stack.pop(nodeId) } + } + LaunchedEffect(nodeId) { + try { + surfaceRequester.requestFocus() + } catch (_: IllegalStateException) { + } + } +} + +/** Modifier that places an overlay's surface in the focus tree and catches an unconsumed Escape. */ +@Composable +internal fun Modifier.modalOverlaySurface( + nodeId: Int, + surfaceRequester: FocusRequester, +): Modifier { + val stack = LocalOverlayFocusStack.current + return this + .onKeyEvent { event -> stack.handleEscape(event, fromNodeId = nodeId) } + .focusRequester(surfaceRequester) + .focusable() +} + /** Whether an overlay is showing, and the way to change it without asking the Host. */ internal class OverlayOpen(val value: Boolean, val set: (Boolean) -> Unit) @@ -72,6 +181,9 @@ internal fun dismiss(node: Node, dispatcher: EventDispatcher) { * The scrim, the placement and the corner come from the design system, so the same records * are a Material dialog, a HIG alert or a Fluent content dialog. */ +/** Test tag of a dialog's surface inside its popup. */ +fun dialogTestTag(nodeId: Int): String = "${nodeTestTag(nodeId)}-dialog" + @Composable internal fun HostDialog( node: Node, @@ -83,19 +195,29 @@ internal fun HostDialog( val open = rememberOverlayOpen(node) if (!open.value) return val style = theme.rules.container(ContainerRole.Dialog, theme) + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = { open.set(false) dismiss(node, dispatcher) }, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( - Modifier.fillMaxSize().background(style.scrim), + Modifier.fillMaxSize().background(scrim).testTag(dialogTestTag(node.id)), contentAlignment = Alignment.Center, ) { - Column(modifier.containerDecoration(node, style, theme, overlay = true)) { + Column( + modifier + .modalOverlaySurface(node.id, surfaceRequester) + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Dialog), + ) { node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } @@ -146,7 +268,7 @@ internal fun HostMenu( Column( Modifier .testTag(menuPopupTestTag(node.id)) - .containerDecoration(node, style, theme, overlay = true), + .containerDecoration(node, style, theme, overlay = true, role = ContainerRole.Menu), ) { entries.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } diff --git a/renderer/desktop/src/renderer/HostRichText.kt b/renderer/desktop/src/renderer/HostRichText.kt index 3393b22e4..4bd48f00e 100644 --- a/renderer/desktop/src/renderer/HostRichText.kt +++ b/renderer/desktop/src/renderer/HostRichText.kt @@ -6,6 +6,12 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LayoutModifier +import androidx.compose.ui.layout.Measurable +import androidx.compose.ui.layout.MeasureScope +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFontFamilyResolver import androidx.compose.ui.text.LinkAnnotation @@ -14,11 +20,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.MultiParagraphIntrinsics import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Paint import dev.darkpyonix.composerust.protocol.SpanRecords import dev.darkpyonix.composerust.protocol.TypeRole +import dev.darkpyonix.composerust.protocol.WordBreak +import dev.darkpyonix.composerust.protocol.OverflowWrap import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.ui.node.AssetCache import dev.darkpyonix.composerust.ui.node.Node @@ -126,12 +135,13 @@ internal fun HostRichText( ) { val input = node.textInput() val density = textDensity(input, LocalDensity.current) + val fontResolver = LocalFontFamilyResolver.current val resolved = resolveText( input, theme, assets, density, - LocalFontFamilyResolver.current, + fontResolver, link = { run -> // A link is a press on a range, and a press is the event the boundary already // has. No new event tag for a thing that is already a click. @@ -143,6 +153,10 @@ internal fun HostRichText( ) }, ) + val wordFloor = if (input.softWrap && (input.wordBreak == null || input.wordBreak == WordBreak.Normal) && input.overflowWrap != OverflowWrap.Anywhere) { + val words = resolveText(input, theme, assets, density, fontResolver, intrinsicWordBreak = WordBreak.KeepAll) + kotlin.math.ceil(MultiParagraphIntrinsics(words.text, words.style, words.placeholders, density, fontResolver).minIntrinsicWidth).toInt() + } else 0 val runs = node.runsDecodeProblem() ?: resolved.runsProblem val fonts = node.fontListProblem() ?: resolved.problems.firstOrNull() val problem = when { @@ -156,7 +170,7 @@ internal fun HostRichText( CompositionLocalProvider(LocalDensity provides density) { BasicText( text = resolved.text, - modifier = modifier, + modifier = modifier.then(WordFloor(wordFloor)), style = resolved.style, overflow = resolved.overflow, softWrap = resolved.softWrap, @@ -167,6 +181,16 @@ internal fun HostRichText( } } +private class WordFloor(private val width: Int) : LayoutModifier { + override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints) = + measurable.measure(constraints).let { placeable -> + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurable: IntrinsicMeasurable, height: Int): Int = + maxOf(width, measurable.minIntrinsicWidth(height)) +} + /** * Says a set of runs is wrong, and goes on drawing the string without them. * diff --git a/renderer/desktop/src/renderer/HostSheet.kt b/renderer/desktop/src/renderer/HostSheet.kt index 2bdd6d721..74cd7dce3 100644 --- a/renderer/desktop/src/renderer/HostSheet.kt +++ b/renderer/desktop/src/renderer/HostSheet.kt @@ -24,6 +24,7 @@ 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.focus.FocusRequester import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag @@ -33,6 +34,7 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import dev.darkpyonix.composerust.design.ResolvedTheme import dev.darkpyonix.composerust.design.SheetEdge +import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.ShapeRole import dev.darkpyonix.composerust.runtime.EventDispatcher import dev.darkpyonix.composerust.runtime.LocalWindowSizeClass @@ -40,6 +42,7 @@ import dev.darkpyonix.composerust.ui.node.Node import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.node.RenderNode import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp import kotlin.math.roundToInt /** How far a sheet has to be pulled towards its edge before letting go closes it. */ @@ -80,17 +83,79 @@ internal fun HostSheet( open.set(false) dismiss(node, dispatcher) } + val surfaceRequester = remember(node.id) { FocusRequester() } + val onEscape = { dismiss(node, dispatcher) } + + val scrim = node.paintProp(PropertyKind.Scrim)?.let { theme.color(it) } + ?: theme.overlay?.scrim?.takeIf { it != 0L }?.let { androidx.compose.ui.graphics.Color(it.toULong()) } + ?: style.scrim + + val shape = if (node.setsOwnShape()) { + node.containerShape( + dev.darkpyonix.composerust.design.ContainerStyle( + container = style.container, + content = androidx.compose.ui.graphics.Color.Unspecified, + shape = style.shape, + elevation = style.elevation, + borderWidth = style.borderWidth, + borderColor = style.borderColor, + horizontalPadding = 0.dp, + verticalPadding = 0.dp, + separator = null, + scrim = style.scrim, + typeRole = dev.darkpyonix.composerust.protocol.TypeRole.Body, + ), + theme, + ) + } else { + theme.overlay?.sheetCornerRadius?.takeIf { it > 0 }?.let { + if (bottom) { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, it.dp, 0.dp, 0.dp) + } else { + androidx.compose.foundation.shape.RoundedCornerShape(it.dp, 0.dp, 0.dp, it.dp) + } + } ?: style.shape + } + + val sheetElevation = if (node.setsOwnElevation()) { + 0.dp + } else { + theme.overlay?.sheetElevation?.takeIf { it > 0 }?.let { it.dp } ?: style.elevation + } + val raised = if (node.setsOwnElevation() || sheetElevation.value <= 0f) { + Modifier + } else { + theme.rules.elevation(Modifier, sheetElevation, shape, theme) + } + + val containerBg = if (node.setsOwnBackground()) Modifier else Modifier.background(style.container) + + val outline = if (style.borderWidth.value > 0f) { + Modifier.border(style.borderWidth, style.borderColor, shape) + } else { + Modifier + } + + val explicitSheetPad = theme.overlay?.sheetPadding + val sheetPadding = if (node.setsOwnPadding()) { + Modifier + } else if (explicitSheetPad != null && explicitSheetPad > 0) { + Modifier.padding(explicitSheetPad.dp) + } else { + Modifier.padding(style.padding) + } Popup( alignment = Alignment.Center, - properties = PopupProperties(focusable = true), + properties = PopupProperties(focusable = true, dismissOnBackPress = false), onDismissRequest = close, ) { + ModalOverlayFocus(node.id, surfaceRequester, onEscape) Box( Modifier .testTag(sheetScrimTestTag(node.id)) .fillMaxSize() - .background(style.scrim) + .background(scrim) // Pressing what the sheet covers is the other way of asking to close it, // and it gets the same answer: one dismissal. A raw tap detector rather // than `clickable`, because a scrim is not a button and saying it is one @@ -103,17 +168,13 @@ internal fun HostSheet( } else { Modifier.fillMaxHeight().fillMaxWidth(style.widthFraction) } - val outline = if (style.borderWidth.value > 0f) { - Modifier.border(style.borderWidth, style.borderColor, style.shape) - } else { - Modifier - } // Two boxes rather than one: the outer is the sheet itself, which is what is // dragged and what the design system sized, and the inner carries whatever // Modifiers the Host put on the node, which belong inside its surface. Box( Modifier .testTag(sheetTestTag(node.id)) + .modalOverlaySurface(node.id, surfaceRequester) .then(sized) .offset { if (bottom) { @@ -122,8 +183,9 @@ internal fun HostSheet( IntOffset(travel.roundToInt(), 0) } } - .clip(style.shape) - .background(style.container) + .then(raised) + .clip(shape) + .then(containerBg) .then(outline) .draggable( orientation = if (bottom) Orientation.Vertical else Orientation.Horizontal, @@ -138,7 +200,7 @@ internal fun HostSheet( // swallowed here rather than reaching the scrim behind. .pointerInput(Unit) { detectTapGestures { } }, ) { - Column(modifier.fillMaxWidth().padding(style.padding)) { + Column(modifier.fillMaxWidth().then(sheetPadding)) { style.handle?.let { handle -> Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Box( diff --git a/renderer/desktop/src/renderer/HostSlider.kt b/renderer/desktop/src/renderer/HostSlider.kt index 5f886f69e..bdb3ffb95 100644 --- a/renderer/desktop/src/renderer/HostSlider.kt +++ b/renderer/desktop/src/renderer/HostSlider.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.runtime.EventDispatcher @@ -71,6 +72,7 @@ internal fun HostSlider( onChange = report, modifier = modifier, theme = theme, + look = rememberControlLook(node, theme), ) } diff --git a/renderer/desktop/src/renderer/HostTextField.kt b/renderer/desktop/src/renderer/HostTextField.kt index 0408e2a40..a7ad44015 100644 --- a/renderer/desktop/src/renderer/HostTextField.kt +++ b/renderer/desktop/src/renderer/HostTextField.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.input.key.Key @@ -60,6 +62,10 @@ import dev.darkpyonix.composerust.ui.textStyle import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.ui.node.HostText import dev.darkpyonix.composerust.design.LocalGlassDepth +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.ui.unit.Dp +import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier /** * The uncontrolled TextField. @@ -88,15 +94,22 @@ internal fun HostTextField( } var appliedHostRevision by remember(nodeId) { mutableStateOf(0L) } var focused by remember(nodeId) { mutableStateOf(false) } + var reportedComposing by remember(nodeId) { mutableStateOf(false) } + val focusRequester = remember(nodeId) { FocusRequester() } + val overlayFocusStack = LocalOverlayFocusStack.current val multiline = node.flag(PropertyKind.Multiline, default = false) val enabled = node.flag(PropertyKind.Enabled, default = true) val changeHandler = node.handler(PropertyKind.OnValueChange) val submitHandler = node.handler(PropertyKind.OnSubmit) val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + val compositionHandler = node.handler(PropertyKind.OnCompositionChange) val placeholder = node.text(PropertyKind.Placeholder) val hostText = node.hostText + val focusRequest = node.focusRequestRevision val composing = value.composition != null // The field takes its type role and its colours from the design system, the same way a // Text does. @@ -130,6 +143,24 @@ internal fun HostTextField( } } + LaunchedEffect(focusRequest) { + if (focusRequest > 0L) focusRequester.requestFocus() + } + + val applyValueChange: (TextFieldValue) -> Unit = { next -> + val nowComposing = next.composition != null + value = next + if (nowComposing != reportedComposing) { + reportedComposing = nowComposing + if (compositionHandler != null) { + dispatcher.dispatch( + HostEvent.CompositionChanged(nodeId, compositionHandler, nowComposing), + ) + } + } + } + hostTextFieldValueHook?.invoke(applyValueChange) + // Change notification, at most one a frame. // // An edit is told to the Host on the next frame, carrying the latest text, so keys that @@ -166,8 +197,14 @@ internal fun HostTextField( } } + val heldKeys = remember { HeldKeys() } val inputModifier = modifier + .focusRequester(focusRequester) .onFocusChanged { state -> + overlayFocusStack.noteFocused(focusRequester, state.isFocused) + if (!focused && state.isFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } if (focused && !state.isFocused && focusLostHandler != null) { dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) } @@ -181,22 +218,31 @@ internal fun HostTextField( // guard that keeps Enter away from Rust during a Korean composition. .onPreviewKeyEvent { event -> val composingNow = value.composition != null - val consumedByHandler = keyDownHandler != null && - dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher) - if (consumedByHandler) { + if (composingNow && event.type == KeyEventType.KeyDown && event.key == Key.Escape) { + applyValueChange(value.copy(composition = null)) true - } else if (!isDispatchableKeyDown(event, composingNow)) { - false - } else if (!shouldSubmitOnEnter( - composing = composingNow, - multiline = multiline, - shiftPressed = event.isShiftPressed, - hasSubmitHandler = submitHandler != null, - ) - ) { - false } else { - dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + // The key-down handler sees every event first, key-ups included, so the held + // keys it tracks can tell a repeat from a first press. + val consumedByHandler = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composingNow, dispatcher, heldKeys) + if (consumedByHandler) { + true + } else if (keyUpHandler != null && isDispatchableKeyUp(event, composingNow)) { + dispatchKeyUp(nodeId, keyUpHandler, event, composingNow, dispatcher) + } else if (!isEnterKeyDown(event, composingNow)) { + false + } else if (!shouldSubmitOnEnter( + composing = composingNow, + multiline = multiline, + shiftPressed = event.isShiftPressed, + hasSubmitHandler = submitHandler != null, + ) + ) { + false + } else { + dispatcher.dispatch(HostEvent.TextSubmitted(nodeId, submitHandler!!, value.text)) + } } } @@ -230,6 +276,12 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.Height || it is dev.darkpyonix.composerust.protocol.Modifier.Size } + val setsOwnHeight = node.modifiers.any { + it is dev.darkpyonix.composerust.protocol.Modifier.FillMaxHeight || + it is dev.darkpyonix.composerust.protocol.Modifier.Height || + it is dev.darkpyonix.composerust.protocol.Modifier.Size || + it is dev.darkpyonix.composerust.protocol.Modifier.RequiredSize + } // What the node named for itself replaces what the design system would have drawn // The system's frame is the default for a field that said nothing, and a field @@ -245,16 +297,11 @@ internal fun HostTextField( it is dev.darkpyonix.composerust.protocol.Modifier.ShapeRole || it is dev.darkpyonix.composerust.protocol.Modifier.CornerEach } - val ownRoom = node.modifiers.any { - it is dev.darkpyonix.composerust.protocol.Modifier.Padding || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingEach || - it is dev.darkpyonix.composerust.protocol.Modifier.PaddingRole - } val frameShape = if (ownShape) node.modifiers.resolvedShape(theme) else frame.shape BasicTextField( value = value, - onValueChange = { value = it }, + onValueChange = applyValueChange, modifier = inputModifier, enabled = enabled, singleLine = !multiline, @@ -270,19 +317,14 @@ internal fun HostTextField( // could only put the caret in by hitting the words. .fillMaxWidth() .let { if (fillsHeight) it.fillMaxHeight() else it } - .defaultMinSize(minHeight = frame.minHeight) + .let { if (setsOwnHeight) it else it.defaultMinSize(minHeight = frame.minHeight) } .clip(frameShape) .let { if (ownFill) it else it.background(if (focused) frame.containerFocused else frame.container) } .let { if (ownLine) it else it.fieldBorder(frame.copy(shape = frameShape), focused) } .let { if (ownLine || ownFill) it else it.fieldUnderline(frame.underline, focused) } - .let { - if (ownRoom) it else it.padding( - horizontal = frame.horizontalPadding, - vertical = frame.verticalPadding, - ) - }, + .padding(node.contentPadding(theme, frame.horizontalPadding, frame.verticalPadding)), contentAlignment = if (multiline) Alignment.TopStart else Alignment.CenterStart, ) { if (value.text.isEmpty() && placeholder.isNotEmpty()) { @@ -310,6 +352,12 @@ internal fun HostTextField( */ private const val PLACEHOLDER_ALPHA = 0.55f +/** + * Test hook that receives the value-change callback of the mounted [HostTextField] so a test + * can drive an IME composition transition directly through `TextFieldValue.composition`. + */ +internal var hostTextFieldValueHook: (((TextFieldValue) -> Unit) -> Unit)? = null + /** The box around a field, or nothing where the system draws none in this state. */ private fun Modifier.fieldBorder(style: FieldStyle, focused: Boolean): Modifier { val width = if (focused) style.borderWidthFocused else style.borderWidth @@ -388,3 +436,19 @@ private class FontChainTransformation(private val chain: FontChain) : VisualTran override fun hashCode(): Int = chain.hashCode() } + +internal fun Node.contentPadding( + theme: ResolvedTheme, + defaultHorizontal: Dp, + defaultVertical: Dp, +): PaddingValues { + val padEach = modifiers.filterIsInstance().lastOrNull() + if (padEach != null) { + return PaddingValues(padEach.start.dp, padEach.top.dp, padEach.end.dp, padEach.bottom.dp) + } + val pad = modifiers.filterIsInstance().lastOrNull() + if (pad != null) return PaddingValues(pad.value.dp) + val padRole = modifiers.filterIsInstance().lastOrNull() + if (padRole != null) return PaddingValues(theme.space(padRole.role)) + return PaddingValues(horizontal = defaultHorizontal, vertical = defaultVertical) +} diff --git a/renderer/desktop/src/renderer/HostToggle.kt b/renderer/desktop/src/renderer/HostToggle.kt index 42355f360..c3ae14060 100644 --- a/renderer/desktop/src/renderer/HostToggle.kt +++ b/renderer/desktop/src/renderer/HostToggle.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.foundation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import dev.darkpyonix.composerust.design.ResolvedTheme +import dev.darkpyonix.composerust.design.rememberControlLook import dev.darkpyonix.composerust.design.ToggleRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind @@ -47,5 +48,6 @@ internal fun HostToggle( }, modifier = modifier, theme = theme, + look = rememberControlLook(node, theme), ) } diff --git a/renderer/desktop/src/renderer/KeyEvents.kt b/renderer/desktop/src/renderer/KeyEvents.kt index b5d64e2df..a77aa4072 100644 --- a/renderer/desktop/src/renderer/KeyEvents.kt +++ b/renderer/desktop/src/renderer/KeyEvents.kt @@ -1,6 +1,8 @@ package dev.darkpyonix.composerust.foundation +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isAltPressed @@ -10,6 +12,7 @@ import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.key.utf16CodePoint import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Key as ProtocolKey import androidx.compose.ui.input.key.KeyEvent as ComposeKeyEvent @@ -26,21 +29,133 @@ import dev.darkpyonix.composerust.runtime.EventDispatcher * a Host handler clear the field and the syllable being composed would vanish. */ -/** The protocol key for a Compose key, or null when the schema cannot name it. */ -internal fun protocolKey(key: Key): ProtocolKey? = when (key) { +/** + * The protocol key for a Compose key. The schema names Enter; every other key is `Other`, + * and the event's `code` and `text` say which one it was. + */ +internal fun protocolKey(key: Key): ProtocolKey = when (key) { Key.Enter, Key.NumPadEnter -> ProtocolKey.Enter - // The schema's `Key` enum has only `Enter`, so no other key can cross the boundary. - // Every additional key needs a schema variant, which changes the schema hash and so - // requires both sides to be rebuilt together. - else -> null + else -> ProtocolKey.Other +} + +/** What a physical key is called on the wire: its USB HID usage and its DOM `key` string. */ +internal class KeyName(val code: Int, val text: String) + +private val namedKeys: Map = buildMap { + // USB HID usage IDs of the keyboard page (0x07); the DOM `key` value is the second word. + fun name(key: Key, code: Int, text: String) { + put(key, KeyName(code, text)) + } + val letters = listOf( + Key.A, Key.B, Key.C, Key.D, Key.E, Key.F, Key.G, Key.H, Key.I, Key.J, Key.K, Key.L, Key.M, + Key.N, Key.O, Key.P, Key.Q, Key.R, Key.S, Key.T, Key.U, Key.V, Key.W, Key.X, Key.Y, Key.Z, + ) + letters.forEachIndexed { index, key -> name(key, 0x04 + index, ('a' + index).toString()) } + val digits = listOf( + Key.One, Key.Two, Key.Three, Key.Four, Key.Five, Key.Six, Key.Seven, Key.Eight, Key.Nine, + Key.Zero, + ) + digits.forEachIndexed { index, key -> name(key, 0x1E + index, ((index + 1) % 10).toString()) } + val functions = listOf( + Key.F1, Key.F2, Key.F3, Key.F4, Key.F5, Key.F6, Key.F7, Key.F8, Key.F9, Key.F10, Key.F11, + Key.F12, + ) + functions.forEachIndexed { index, key -> name(key, 0x3A + index, "F${index + 1}") } + name(Key.Enter, 0x28, "Enter") + name(Key.Escape, 0x29, "Escape") + name(Key.Backspace, 0x2A, "Backspace") + name(Key.Tab, 0x2B, "Tab") + name(Key.Spacebar, 0x2C, " ") + name(Key.Minus, 0x2D, "-") + name(Key.Equals, 0x2E, "=") + name(Key.LeftBracket, 0x2F, "[") + name(Key.RightBracket, 0x30, "]") + name(Key.Backslash, 0x31, "\\") + name(Key.Semicolon, 0x33, ";") + name(Key.Apostrophe, 0x34, "'") + name(Key.Grave, 0x35, "`") + name(Key.Comma, 0x36, ",") + name(Key.Period, 0x37, ".") + name(Key.Slash, 0x38, "/") + name(Key.CapsLock, 0x39, "CapsLock") + name(Key.Insert, 0x49, "Insert") + name(Key.MoveHome, 0x4A, "Home") + name(Key.PageUp, 0x4B, "PageUp") + name(Key.Delete, 0x4C, "Delete") + name(Key.MoveEnd, 0x4D, "End") + name(Key.PageDown, 0x4E, "PageDown") + name(Key.DirectionRight, 0x4F, "ArrowRight") + name(Key.DirectionLeft, 0x50, "ArrowLeft") + name(Key.DirectionDown, 0x51, "ArrowDown") + name(Key.DirectionUp, 0x52, "ArrowUp") + name(Key.NumPadEnter, 0x58, "Enter") + name(Key.CtrlLeft, 0xE0, "Control") + name(Key.ShiftLeft, 0xE1, "Shift") + name(Key.AltLeft, 0xE2, "Alt") + name(Key.MetaLeft, 0xE3, "Meta") + name(Key.CtrlRight, 0xE4, "Control") + name(Key.ShiftRight, 0xE5, "Shift") + name(Key.AltRight, 0xE6, "Alt") + name(Key.MetaRight, 0xE7, "Meta") +} + +/** + * The HID usage and DOM `key` string of a key press. A letter is upper case while Shift is + * held, as the DOM reports it. A key the table does not know keeps a zero usage and the + * character it typed, or `"Unidentified"` when it typed none. + */ +internal fun keyName(key: Key, shift: Boolean, codePoint: Int = 0): KeyName { + val known = namedKeys[key] + if (known != null) { + val isLetter = known.text.length == 1 && known.text[0] in 'a'..'z' + return if (isLetter && shift) KeyName(known.code, known.text.uppercase()) else known + } + val typed = if (codePoint > 0x1F && codePoint != 0x7F) { + codePointToString(codePoint) + } else { + "Unidentified" + } + return KeyName(0, typed) +} + +private fun codePointToString(codePoint: Int): String = + if (codePoint < 0x10000) { + codePoint.toChar().toString() + } else { + val offset = codePoint - 0x10000 + charArrayOf((0xD800 + (offset shr 10)).toChar(), (0xDC00 + (offset and 0x3FF)).toChar()) + .concatToString() + } + +/** + * Which keys are held down at one place that listens for keys. + * + * A key-down for a key that has not been released is the platform repeating it. Compose's + * key event carries no repeat flag, so the renderer tells repeats from first presses by + * watching the releases. + */ +internal class HeldKeys { + private val held = HashSet() + + /** Notes one event and says whether it is a key-down repeating a held key. */ + fun observe(type: KeyEventType, key: Key): Boolean = when (type) { + KeyEventType.KeyDown -> !held.add(key.keyCode) + KeyEventType.KeyUp -> { + held.remove(key.keyCode) + false + } + else -> false + } + + fun clear() = held.clear() } /** * Whether a key press may be offered to the Host at all. * - * Key-down only (a Host handler is `on_key_down`), a key the schema can name, and **never - * while an IME composition is in progress**. Kept free of Compose types so the rule that - * protects a Korean composition can be asserted directly, the way [shouldSubmitOnEnter] is. + * Key-down only (a Host handler is `on_key_down`) and **never while an IME composition is + * in progress**. Kept free of Compose types so the rule that protects a Korean composition + * can be asserted directly, the way [shouldSubmitOnEnter] is. */ internal fun shouldDispatchKeyDown( composing: Boolean, @@ -56,11 +171,16 @@ internal fun isDispatchableKeyDown(event: ComposeKeyEvent, composing: Boolean): key = protocolKey(event.key), ) +/** Whether the press is an Enter key-down outside a composition, the one that can submit. */ +internal fun isEnterKeyDown(event: ComposeKeyEvent, composing: Boolean): Boolean = + isDispatchableKeyDown(event, composing) && protocolKey(event.key) == ProtocolKey.Enter + /** * Sends one key press to the Host and returns its consumption result. * * Returns false without calling the Host whenever [isDispatchableKeyDown] says no, so a - * composition keystroke never reaches Rust. + * composition keystroke never reaches Rust. [held] sees every event, key-ups included, so + * a repeated key-down is marked as one. */ internal fun dispatchKeyDown( nodeId: Int, @@ -68,18 +188,73 @@ internal fun dispatchKeyDown( event: ComposeKeyEvent, composing: Boolean, dispatcher: EventDispatcher, + held: HeldKeys = HeldKeys(), ): Boolean { + val repeat = held.observe(event.type, event.key) if (!isDispatchableKeyDown(event, composing)) return false - val key = protocolKey(event.key) ?: return false + val name = keyName(event.key, event.isShiftPressed, event.utf16CodePoint) return dispatcher.dispatch( HostEvent.KeyDown( nodeId = nodeId, handlerId = handlerId, - key = key, + key = protocolKey(event.key), shiftKey = event.isShiftPressed, ctrlKey = event.isCtrlPressed, altKey = event.isAltPressed, metaKey = event.isMetaPressed, + repeat = repeat, + code = name.code, + text = name.text, + ), + ) +} + +/** + * Whether a key release may be offered to the Host at all. + * + * Key-up only, and never while an IME composition is in progress. + */ +internal fun shouldDispatchKeyUp( + composing: Boolean, + isKeyUp: Boolean, + key: ProtocolKey?, +): Boolean = !composing && isKeyUp && key != null + +/** [shouldDispatchKeyUp] applied to a Compose key event. */ +internal fun isDispatchableKeyUp(event: ComposeKeyEvent, composing: Boolean): Boolean = + shouldDispatchKeyUp( + composing = composing, + isKeyUp = event.type == KeyEventType.KeyUp, + key = protocolKey(event.key), + ) + +/** + * Sends one key release to the Host and returns its consumption result. + * + * Returns false without calling the Host whenever [isDispatchableKeyUp] says no, so a + * composition keystroke never reaches Rust. + */ +internal fun dispatchKeyUp( + nodeId: Int, + handlerId: Long, + event: ComposeKeyEvent, + composing: Boolean, + dispatcher: EventDispatcher, +): Boolean { + if (!isDispatchableKeyUp(event, composing)) return false + val name = keyName(event.key, event.isShiftPressed, event.utf16CodePoint) + return dispatcher.dispatch( + HostEvent.KeyUp( + nodeId = nodeId, + handlerId = handlerId, + key = protocolKey(event.key), + shiftKey = event.isShiftPressed, + ctrlKey = event.isCtrlPressed, + altKey = event.isAltPressed, + metaKey = event.isMetaPressed, + repeat = false, + code = name.code, + text = name.text, ), ) } @@ -93,8 +268,32 @@ internal fun dispatchKeyDown( */ internal fun Modifier.hostKeyEvents( nodeId: Int, - handlerId: Long, + keyDownHandler: Long?, + keyUpHandler: Long?, dispatcher: EventDispatcher, -): Modifier = onKeyEvent { event -> - dispatchKeyDown(nodeId, handlerId, event, composing = false, dispatcher = dispatcher) +): Modifier = composed { + val held = remember { HeldKeys() } + onKeyEvent { event -> + // The key-down dispatch sees every event, key-ups included, so it can tell a repeat + // from a first press; it sends nothing for an event that is not a key-down. + val consumed = keyDownHandler != null && + dispatchKeyDown(nodeId, keyDownHandler, event, composing = false, dispatcher = dispatcher, held = held) + when { + consumed -> true + keyUpHandler != null && isDispatchableKeyUp(event, composing = false) -> + dispatchKeyUp(nodeId, keyUpHandler, event, composing = false, dispatcher = dispatcher) + else -> false + } + } } + +internal fun Modifier.hostKeyEvents( + nodeId: Int, + handlerId: Long, + dispatcher: EventDispatcher, +): Modifier = hostKeyEvents( + nodeId = nodeId, + keyDownHandler = handlerId, + keyUpHandler = null, + dispatcher = dispatcher, +) diff --git a/renderer/desktop/src/renderer/Material3ControlWidgets.kt b/renderer/desktop/src/renderer/Material3ControlWidgets.kt index 1c1dbfecf..e642cc06b 100644 --- a/renderer/desktop/src/renderer/Material3ControlWidgets.kt +++ b/renderer/desktop/src/renderer/Material3ControlWidgets.kt @@ -45,7 +45,15 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: ((Boolean) -> Unit)?, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { + // The library's Switch and Checkbox have fixed metrics, so a size or a colour the + // application named for them is drawn by the shared control drawing instead, which + // also owns the transition and the toggle semantics. + if (look.appliesTo(role)) { + DrawnControlWidgets.Toggle(role, checked, enabled, onChange, modifier, theme, look) + return + } val primary = theme.color(ColorRole.Primary) val onPrimary = theme.color(ColorRole.OnPrimary) val outline = theme.color(ColorRole.Outline) @@ -101,6 +109,7 @@ internal object Material3ControlWidgets : ControlWidgets { onChange: (Float) -> Unit, modifier: Modifier, theme: ResolvedTheme, + look: ControlLook, ) { val primary = named ?: theme.color(ColorRole.Primary) Slider( @@ -111,9 +120,9 @@ internal object Material3ControlWidgets : ControlWidgets { valueRange = range, steps = steps, colors = SliderDefaults.colors( - thumbColor = primary, - activeTrackColor = primary, - inactiveTrackColor = theme.color(ColorRole.SurfaceVariant), + thumbColor = look.thumb ?: primary, + activeTrackColor = look.activeTrack ?: primary, + inactiveTrackColor = look.track ?: theme.color(ColorRole.SurfaceVariant), ), ) } diff --git a/renderer/desktop/src/renderer/ModifierChain.kt b/renderer/desktop/src/renderer/ModifierChain.kt index 48f256014..7c32bc334 100644 --- a/renderer/desktop/src/renderer/ModifierChain.kt +++ b/renderer/desktop/src/renderer/ModifierChain.kt @@ -3,6 +3,7 @@ package dev.darkpyonix.composerust.ui import androidx.compose.animation.animateContentSize import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.PointerEventPass import dev.darkpyonix.composerust.runtime.LocalWindowDragExclusion @@ -53,6 +54,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithCache import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import dev.darkpyonix.composerust.foundation.LocalOverlayFocusStack import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.RoundRect @@ -106,6 +111,7 @@ internal fun List.toComposeModifier( containerShape: Shape? = null, pressColor: Color = theme.color(ColorRole.OnSurface), allowPress: Boolean = true, + allowPadding: Boolean = true, ): Modifier { // The last Shape or ShapeRole in the list is what clips, what the border // follows and what the background fills, whatever their order in the chain. @@ -164,20 +170,31 @@ internal fun List.toComposeModifier( awaitEachGesture { if (value.enabled) { val down = awaitFirstDown(requireUnconsumed = false, pass = PointerEventPass.Final) - if (!down.isConsumed) { + // A no-drag node under the press has marked it already (children see the + // Main pass first), so the window stays where it is. + val excludedHere = exclusion?.isMarked(down.id.value, down.uptimeMillis) == true + if (!down.isConsumed && !excludedHere) { down.consume() drag() } } else { // Mark this down for the strip without consuming it, so ancestors can click. - val excluded = awaitFirstDown(requireUnconsumed = false); exclusion?.downKey = excluded.id.value to excluded.uptimeMillis + val excluded = awaitFirstDown(requireUnconsumed = false) + exclusion?.mark(excluded.id.value, excluded.uptimeMillis) + // Forget the press once it ends, so a later press that happens to + // share an id and a timestamp is not taken for this one. + waitForUpOrCancellation() + exclusion?.clear() } } } } is ProtocolModifier.PressFeedback -> chain + // Applied with the node's accessible name in one place, `accessibility`. + is ProtocolModifier.Semantics -> chain is ProtocolModifier.Empty -> chain - is ProtocolModifier.Padding -> attachPressableIfNeeded(chain).padding(value.value.dp) + is ProtocolModifier.Padding -> + if (allowPadding) attachPressableIfNeeded(chain).padding(value.value.dp) else chain is ProtocolModifier.MatchParentSize -> chain is ProtocolModifier.FillMaxWidth -> chain.fillMaxWidth() is ProtocolModifier.FillMaxHeight -> chain.fillMaxHeight() @@ -198,9 +215,17 @@ internal fun List.toComposeModifier( } is ProtocolModifier.PaddingEach -> - attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(value.start.dp, value.top.dp, value.end.dp, value.bottom.dp) + } else { + chain + } is ProtocolModifier.PaddingRole -> - attachPressableIfNeeded(chain).padding(theme.space(value.role)) + if (allowPadding) { + attachPressableIfNeeded(chain).padding(theme.space(value.role)) + } else { + chain + } is ProtocolModifier.Shape -> chain.clip(shape) is ProtocolModifier.ShapeRole -> chain.clip(shape) is ProtocolModifier.Border -> theme.brush(value.paint)?.let { brush -> @@ -295,6 +320,18 @@ internal fun List.toComposeModifier( } } + // CSS 3D `transform`: one matrix, drawn through the layers that reproduce it + // exactly, so the layout is untouched and a pointer is tested against the shape + // that is drawn. The plan is worked out here, once for each value of the record. + is ProtocolModifier.Transform3D -> { + val plan = Transform3DPlan() + plan.solve( + floatArrayOf(value.m0, value.m1, value.m2, value.m3, value.m4, value.m5, value.m6, value.m7, value.m8, value.m9, value.m10, value.m11, value.m12, value.m13, value.m14, value.m15), + value.backfaceHidden, + ) + chain.perspectiveTransform(plan, value.originX, value.originY) + } + // The node and everything in it are drawn into one layer first, and the layer is // faded once. Fading each child separately would show where they overlap, which // CSS `opacity` does not. @@ -786,7 +823,11 @@ internal fun Modifier.pressable( } val ink = theme.color(ColorRole.OnSurface) val ring = theme.color(ColorRole.Primary) + val overlayFocusStack = LocalOverlayFocusStack.current + val focusRequester = remember(nodeId) { FocusRequester() } this + .focusRequester(focusRequester) + .onFocusChanged { state -> overlayFocusStack.noteFocused(focusRequester, state.isFocused) } .pressFeedback(source, enabled, theme, shape, pressColor, feedback) .hoverable(source, enabled) .clickable( diff --git a/renderer/desktop/src/renderer/NodeTable.kt b/renderer/desktop/src/renderer/NodeTable.kt index 22296a8a8..e74429f50 100644 --- a/renderer/desktop/src/renderer/NodeTable.kt +++ b/renderer/desktop/src/renderer/NodeTable.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.setValue import dev.darkpyonix.composerust.foundation.ChromeInsets import dev.darkpyonix.composerust.foundation.MessageQueue import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.Theme @@ -46,6 +47,7 @@ class Node internal constructor(val id: Int, val widget: WidgetKind) { internal val children = mutableStateListOf() internal var parentId: Int = NodeTable.ROOT_ID internal var hostText by mutableStateOf(null) + internal var focusRequestRevision by mutableStateOf(0L) /** * A code editor's document and everything over it. Created with the node and kept for @@ -92,6 +94,8 @@ data class TableError(val code: Int, val message: String) { const val MISPLACED_WINDOW = 13 /** A node moved from the content of one `Window` into another, or out of its window. */ const val MOVE_BETWEEN_WINDOWS = 14 + /** A file dialog request whose number is still waiting for its answer. The command is dropped. */ + const val DUPLICATE_FILE_DIALOG = 15 } } @@ -165,12 +169,29 @@ class NodeTable { val notifications: dev.darkpyonix.composerust.ui.platform.NotificationCenter = dev.darkpyonix.composerust.ui.platform.NotificationCenter() + /** + * The file dialogs the Host has asked for, and their answers on the way back. + * + * Not cleared with the tree, for the reason the notifications are not: a request the Host + * is waiting on is still owed its one answer after a resync. + */ + val fileDialogs: dev.darkpyonix.composerust.ui.platform.FileDialogCenter = + dev.darkpyonix.composerust.ui.platform.FileDialogCenter() + private var revision = 0L + /** Increments on each `RequestFocus(0)` so the root composition clears active focus. */ + var focusClearRevision by mutableStateOf(0L) + private set + /** Null until the Host sends its first `SetTheme` record. */ var theme: Theme? by mutableStateOf(null) private set + /** Null until the Host sends an overlay tokens record. */ + var overlayTokens: OverlayTokens? by mutableStateOf(null) + private set + /** Null until the Host sends its first `SetWindow` record. */ var window: Window? by mutableStateOf(null) private set @@ -195,6 +216,16 @@ class NodeTable { return level?.toInt()?.coerceIn(-8, 8) } + /** + * Whether the window [windowId] asked for every whole-dp change of its size. Node 0 is + * the single window of `SetWindow`, which asks through its settings; a `Window` node + * asks through its `report_size` property. + */ + fun reportSizeFor(windowId: Int): Boolean { + if (windowId == ROOT_ID) return window?.reportSize == true + return node(windowId)?.flag(PropertyKind.ReportSize, false) == true + } + /** * The `Window` node the node [nodeId] is drawn in, itself included, or null for a node * that sits in none (every node of a single window application). @@ -253,6 +284,9 @@ class NodeTable { * start over and anything kept from before would collide with them. */ fun clear() { + // The windows go with the nodes, and a dialog open on one of them ends with a cancel. + nodes.filterValues { it.widget == WidgetKind.Window }.keys.toList() + .forEach(fileDialogs::windowRemoved) animations.clear() nodes.clear() codeNodes.clear() @@ -267,6 +301,7 @@ class NodeTable { window = null materials = 0 revision = 0 + focusClearRevision = 0 } fun apply(mutation: Mutation) { @@ -290,6 +325,7 @@ class NodeTable { fail(TableError.INVALID_PALETTE, "theme palette: $problem") } } + is Mutation.SetOverlayTokens -> overlayTokens = mutation.overlay // Read before there is a window to apply it to. The platform layer asks for // this out of the first batch and builds the window from it, so by the time // the batch is applied the window already looks the way it says. Keeping it @@ -301,6 +337,7 @@ class NodeTable { // the batch. is Mutation.SetWindowTheme -> setWindowTheme(mutation) is Mutation.FocusWindow -> focusWindow(mutation) + is Mutation.RequestFocus -> requestFocus(mutation) // The bytes are read here, once, and never again: what a frame carries is the // id. A kind this Renderer cannot read, or a release of something that was // never registered, is reported and the rest of the batch still applies. @@ -325,6 +362,10 @@ class NodeTable { is Mutation.WithdrawNotification -> notifications.withdraw(mutation.key) is Mutation.RequestNotificationPermission -> notifications.requestPermission() + // Not a node either. A request on a window that is not there is answered as + // unavailable, so the Host's one answer per request holds, and is reported too. + is Mutation.ShowFileDialog -> showFileDialog(mutation) + // Played on this side's frame clock until it ends. Whether the node has // somewhere to play it is asked once the whole batch is in. is Mutation.StartAnimation -> animations.start(mutation.animation) @@ -387,6 +428,36 @@ class NodeTable { focusRequests += mutation.nodeId } + private fun showFileDialog(mutation: Mutation.ShowFileDialog) { + val windowKnown = mutation.window == ROOT_ID || + nodes[mutation.window]?.widget == WidgetKind.Window + when (fileDialogs.request(mutation, windowKnown)) { + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Accepted -> Unit + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.Duplicate -> fail( + TableError.DUPLICATE_FILE_DIALOG, + "ShowFileDialog request ${mutation.request} is already waiting for its answer", + ) + dev.darkpyonix.composerust.ui.platform.FileDialogCenter.Accepted.UnknownWindow -> fail( + TableError.UNKNOWN_NODE, + "ShowFileDialog for unknown window ${mutation.window}", + ) + } + } + + private fun requestFocus(mutation: Mutation.RequestFocus) { + if (mutation.nodeId == ROOT_ID) { + revision += 1 + focusClearRevision = revision + return + } + val node = nodes[mutation.nodeId] ?: return fail( + TableError.UNKNOWN_NODE, + "RequestFocus for unknown node ${mutation.nodeId}", + ) + revision += 1 + node.focusRequestRevision = revision + } + /** The code editor a command names, or null after reporting that it names none. */ private fun codeNode(nodeId: Int, command: String): Pair? { val node = nodes[nodeId] ?: run { @@ -608,7 +679,10 @@ class NodeTable { private fun removeSubtree(nodeId: Int) { val node = nodes.remove(nodeId) ?: return codeNodes.remove(nodeId) - if (node.widget == WidgetKind.Window) windowThemes.remove(nodeId) + if (node.widget == WidgetKind.Window) { + windowThemes.remove(nodeId) + fileDialogs.windowRemoved(nodeId) + } animations.removeNode(nodeId) materials -= node.modifiers.count { it is ProtocolModifier.Material } node.children.toList().forEach(::removeSubtree) @@ -694,12 +768,30 @@ class NodeTable { internal fun supportsProperty(widget: WidgetKind, property: PropertyKind): Boolean = when (property) { + // The name assistive technology reads can be given to any node. + PropertyKind.AccessibleName -> true + + // Colours and size of the selection controls. + PropertyKind.TrackColor, + PropertyKind.ThumbColor, + -> widget == WidgetKind.Switch || widget == WidgetKind.Slider + + PropertyKind.ActiveTrackColor -> widget == WidgetKind.Slider + + PropertyKind.ContainerColor, + PropertyKind.CheckColor, + -> widget == WidgetKind.Checkbox + + PropertyKind.ControlSize -> widget == WidgetKind.Switch || widget == WidgetKind.Checkbox + // Event properties carry handler ids and are valid on any widget. PropertyKind.OnClick, PropertyKind.OnValueChange, PropertyKind.OnSubmit, PropertyKind.OnFocusLost, + PropertyKind.OnFocusGained, PropertyKind.OnKeyDown, + PropertyKind.OnKeyUp, PropertyKind.OnRangeRequested, PropertyKind.OnDismiss, -> true @@ -888,6 +980,11 @@ class NodeTable { widget == WidgetKind.Menu || // A sheet is an overlay on the same terms as a dialog. widget == WidgetKind.Sheet + + // A custom scrim colour for modal and popup overlays. + PropertyKind.Scrim -> widget == WidgetKind.Dialog || + widget == WidgetKind.Menu || + widget == WidgetKind.Sheet // A Dropdown's selection is a position in its own children, the same thing // a tab strip's selection is. PropertyKind.SelectedIndex -> @@ -977,9 +1074,11 @@ class NodeTable { PropertyKind.OnVisibleRangeChange, PropertyKind.OnEditorFocusChange, PropertyKind.OnFoldChange, - PropertyKind.OnCompositionChange, -> widget == WidgetKind.CodeEditor + PropertyKind.OnCompositionChange -> + widget == WidgetKind.CodeEditor || widget == WidgetKind.TextField + // Files over a node and files let go on it. Only the widget that exists // to receive them, because a handler is attached whether or not a screen // supplied one: on any container these would cost every container in the @@ -999,6 +1098,7 @@ class NodeTable { PropertyKind.WindowSize, PropertyKind.WindowMinSize, PropertyKind.WindowResizable, + PropertyKind.ReportSize, PropertyKind.WindowChrome, PropertyKind.WindowTitleBar, PropertyKind.WindowIcon, diff --git a/renderer/desktop/src/renderer/RenderNode.kt b/renderer/desktop/src/renderer/RenderNode.kt index a4893d68e..78d2dc932 100644 --- a/renderer/desktop/src/renderer/RenderNode.kt +++ b/renderer/desktop/src/renderer/RenderNode.kt @@ -1,5 +1,7 @@ package dev.darkpyonix.composerust.ui.node +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column @@ -12,21 +14,35 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.focusable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorProducer import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.IntrinsicMeasurable +import androidx.compose.ui.layout.IntrinsicMeasureScope +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable @@ -36,6 +52,8 @@ import androidx.compose.ui.layout.MeasureScope import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight +import androidx.compose.ui.unit.constrainWidth import androidx.compose.ui.unit.dp import kotlinx.coroutines.flow.distinctUntilChanged import kotlin.math.roundToInt @@ -43,6 +61,8 @@ import dev.darkpyonix.composerust.protocol.ColorRole import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.runtime.LocalWindowNodeId +import dev.darkpyonix.composerust.runtime.WindowDropTargets import dev.darkpyonix.composerust.runtime.LocalZoom import dev.darkpyonix.composerust.runtime.islandDensity import dev.darkpyonix.composerust.protocol.TypeRole @@ -50,6 +70,7 @@ import dev.darkpyonix.composerust.protocol.WidgetKind import dev.darkpyonix.composerust.design.ContainerRole import androidx.compose.ui.graphics.Shape import dev.darkpyonix.composerust.foundation.containerShape +import dev.darkpyonix.composerust.ui.accessibility import dev.darkpyonix.composerust.ui.pressable import dev.darkpyonix.composerust.ui.resolvedShape import dev.darkpyonix.composerust.design.ToggleRole @@ -175,7 +196,28 @@ fun RenderNode( } else { null } - val chain = profiled + // The key handlers go ahead of the node's own modifiers. A modifier that makes the node + // focusable (a click, say) is a focus target, and a key event reaches only the handlers + // outside it on the way down. Placed after it, a handler would see keys from focused + // descendants only and never the keys pressed while the node itself has focus. + // The TextField wires its own key handling, because it has an editor to intercept and a + // composition to protect; everything else routes keys here. + val keyDownHandler = node.handler(PropertyKind.OnKeyDown) + val keyUpHandler = node.handler(PropertyKind.OnKeyUp) + val keyed = if ((keyDownHandler == null && keyUpHandler == null) || + node.widget == WidgetKind.TextField + ) { + profiled + } else { + // Plain containers and text are not focus targets on their own, so a node that + // listens for keys is made one, ahead of its modifiers like the handlers. Without it + // Tab never reaches the node and a DOM element with tabindex and onkeydown is dead. + // Widgets that are controls bring their own focus handling and are left alone. + val focusTarget = node.widget in KeyFocusTargets + profiled.hostKeyEvents(nodeId, keyDownHandler, keyUpHandler, dispatcher) + .let { if (focusTarget) it.focusable() else it } + } + val chain = keyed .then( node.modifiers.toComposeModifier( nodeId, @@ -187,35 +229,115 @@ fun RenderNode( containerShape = if (clickHandler != null) node.pressedShape(theme) else null, pressColor = node.paintProp(PropertyKind.Color)?.let { theme.color(it) } ?: theme.color(ColorRole.OnSurface), allowPress = node.widget != WidgetKind.TextField, + allowPadding = node.widget != WidgetKind.TextField, ), ) .testTag(nodeTestTag(nodeId)) - // The TextField wires its own key handling, because it has an editor to intercept and a - // composition to protect; everything else routes keys here. - val keyDownHandler = node.handler(PropertyKind.OnKeyDown) - val pressable = if (keyDownHandler == null || node.widget == WidgetKind.TextField) { + .accessibility( + node, + dispatcher, + clickHandler + ?: (node.modifiers.lastOrNull { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + as? dev.darkpyonix.composerust.protocol.Modifier.Clickable)?.handlerId + ?: node.handler(PropertyKind.OnClick), + ) + // Focus-gained and focus-lost for non-TextField nodes: `hasFocus` covers both the node + // itself and any focusable descendant inside it. + val focusGainedHandler = node.handler(PropertyKind.OnFocusGained) + val focusLostHandler = node.handler(PropertyKind.OnFocusLost) + var hasFocusWithin by remember(nodeId) { mutableStateOf(false) } + val withFocusEvents = if ((focusGainedHandler == null && focusLostHandler == null) || + node.widget == WidgetKind.TextField + ) { chain } else { - chain.hostKeyEvents(nodeId, keyDownHandler, dispatcher) + chain.onFocusChanged { state -> + val nowFocused = state.hasFocus + if (!hasFocusWithin && nowFocused && focusGainedHandler != null) { + dispatcher.dispatch(HostEvent.FocusGained(nodeId, focusGainedHandler)) + } + if (hasFocusWithin && !nowFocused && focusLostHandler != null) { + dispatcher.dispatch(HostEvent.FocusLost(nodeId, focusLostHandler)) + } + hasFocusWithin = nowFocused + } } - val modifier = platformFileDrop(pressable, node, dispatcher) + val pressable = withFocusEvents + val focusRequest = node.focusRequestRevision + val focusRequester = if (focusRequest > 0L && + node.widget != WidgetKind.TextField && + node.widget != WidgetKind.CodeEditor + ) { + remember(nodeId) { FocusRequester() } + } else { + null + } + if (focusRequester != null) { + LaunchedEffect(focusRequest) { + focusRequester.requestFocus() + } + } + val focusableModifier = if (focusRequester == null) { + pressable + } else { + val withRequester = pressable.focusRequester(focusRequester) + if (clickHandler == null && + node.widget in CLICKABLE_CONTAINERS && + node.modifiers.none { it is dev.darkpyonix.composerust.protocol.Modifier.Clickable } + ) { + withRequester.focusable() + } else { + withRequester + } + } + val windowId = LocalWindowNodeId.current + val subscribed = windowId != 0 && node.handler(PropertyKind.OnWindowDragOver) != null + val density = LocalDensity.current.density + if (subscribed) { + DisposableEffect(windowId, nodeId) { + onDispose { WindowDropTargets.remove(windowId, nodeId) } + } + } + var depth = 0 + var ancestor = node.parentId + while (ancestor != NodeTable.ROOT_ID && depth <= 1024) { + depth++ + ancestor = table.node(ancestor)?.parentId ?: NodeTable.ROOT_ID + } + val dropModifier = if (subscribed) focusableModifier.onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + WindowDropTargets.update(windowId, nodeId, bounds.left, bounds.top, bounds.right, bounds.bottom, density, depth) + } else focusableModifier + val modifier = platformFileDrop(dropModifier, node, dispatcher) // A layout made of glass tells what is inside it so. A field in a glass bar is part of // the bar in the systems that draw glass, and it can only take that shape if it knows // what it is sitting on. val glass = node.isMadeOfGlass(theme) ProvideInheritedColor(nodeId, node, table, theme) { when (node.widget) { - WidgetKind.Column -> Column( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.width(IntrinsicSize.Min) else modifier, - verticalArrangement = node.verticalArrangement(theme), - horizontalAlignment = node.horizontalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } - - WidgetKind.Row -> Row( - modifier = if (node.usesIntrinsicCrossAxis(table)) modifier.height(IntrinsicSize.Min) else modifier, - horizontalArrangement = node.horizontalArrangement(theme), - verticalAlignment = node.verticalAlignment(), - ) { OnGlass(glass) { Children(node, table, dispatcher) } } + WidgetKind.Column, WidgetKind.Row -> { + val horizontal = node.widget == WidgetKind.Row + val sizedModifier = if (node.usesIntrinsicCrossAxis(table)) { + if (horizontal) modifier.height(IntrinsicSize.Min) else modifier.width(IntrinsicSize.Min) + } else modifier + val weights = node.children.map { table.node(it)?.modifiers?.weightOf() } + val intrinsicSafe = node.children.map { child -> !table.hasScrollingDescendant(child) } + if (weights.any { it != null }) { + WeightedStack( + node, table, dispatcher, sizedModifier, horizontal, weights, intrinsicSafe, + node.horizontalArrangement(theme), node.verticalArrangement(theme), + node.horizontalAlignment(), node.verticalAlignment(), glass, + ) + } else if (horizontal) { + Row(sizedModifier, node.horizontalArrangement(theme), node.verticalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } else { + Column(sizedModifier, node.verticalArrangement(theme), node.horizontalAlignment()) { + OnGlass(glass) { Children(node, table, dispatcher) } + } + } + } // A place files may be dropped is laid out as a plain box. Being this widget is // the whole of the difference: the drop target is set up for it and for nothing @@ -244,7 +366,7 @@ fun RenderNode( // The controls are drawn through `Unselectable`, which leaves them out of a // selectable region around them: a label is pressed, not copied. Outside a region // it adds nothing. - WidgetKind.Button -> Unselectable { HostButton(node, modifier, dispatcher, theme) } + WidgetKind.Button -> Unselectable { HostButton(node, modifier, table, dispatcher, theme) } WidgetKind.TextField -> Unselectable { HostTextField(node, modifier, dispatcher, table.assets) } WidgetKind.LazyColumn -> HostLazyColumn(node, modifier, table, dispatcher) @@ -728,6 +850,160 @@ internal fun OnGlass(glass: Boolean, content: @Composable () -> Unit) { CompositionLocalProvider(LocalGlassDepth provides LocalGlassDepth.current + 1, content = content) } +/** Measures weighted children at least as large as their main-axis minimum intrinsic size. */ +@Composable +private fun WeightedStack( + node: Node, + table: NodeTable, + dispatcher: EventDispatcher, + modifier: Modifier, + horizontal: Boolean, + weights: List, + intrinsicSafe: List, + horizontalArrangement: Arrangement.Horizontal, + verticalArrangement: Arrangement.Vertical, + horizontalAlignment: Alignment.Horizontal, + verticalAlignment: Alignment.Vertical, + glass: Boolean, +) { + val policy = remember(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) { + WeightedStackPolicy(horizontal, weights, intrinsicSafe, horizontalArrangement, verticalArrangement, horizontalAlignment, verticalAlignment) + } + Layout( + content = { + OnGlass(glass) { + node.children.forEach { childId -> key(childId) { RenderNode(childId, table, dispatcher) } } + } + }, + modifier = modifier.clipToBounds(), + measurePolicy = policy, + ) +} + +private class WeightedStackPolicy( + private val horizontal: Boolean, + private val weights: List, + private val intrinsicSafe: List, + private val horizontalArrangement: Arrangement.Horizontal, + private val verticalArrangement: Arrangement.Vertical, + private val horizontalAlignment: Alignment.Horizontal, + private val verticalAlignment: Alignment.Vertical, +) : MeasurePolicy { + private fun spacing(scope: androidx.compose.ui.unit.Density): Int = with(scope) { + (if (horizontal) horizontalArrangement.spacing else verticalArrangement.spacing).roundToPx() + } + + override fun MeasureScope.measure(measurables: List, constraints: Constraints): MeasureResult { + val mainMax = if (horizontal) constraints.maxWidth else constraints.maxHeight + val mainMin = if (horizontal) constraints.minWidth else constraints.minHeight + val crossMax = if (horizontal) constraints.maxHeight else constraints.maxWidth + val gap = spacing(this) + val places = arrayOfNulls(measurables.size) + var fixed = 0 + val gaps = gap * (measurables.size - 1).coerceAtLeast(0) + fun childConstraints(min: Int, max: Int) = if (horizontal) { + Constraints(minWidth = min, maxWidth = max, maxHeight = crossMax) + } else { + Constraints(minHeight = min, maxHeight = max, maxWidth = crossMax) + } + measurables.forEachIndexed { index, measurable -> + if (weights[index] == null) { + val available = if (mainMax == Constraints.Infinity) mainMax else (mainMax - fixed - gaps).coerceAtLeast(0) + val place = measurable.measure(childConstraints(0, available)) + places[index] = place + fixed += if (horizontal) place.width else place.height + } + } + val target = if (mainMax == Constraints.Infinity) mainMin else mainMax + val remaining = (target - fixed - gaps).coerceAtLeast(0) + val totalWeight = weights.filterNotNull().sum() + var distributed = 0 + var remainingWeight = totalWeight + measurables.forEachIndexed { index, measurable -> + val weight = weights[index] ?: return@forEachIndexed + val share = if (remainingWeight == weight) remaining - distributed else (remaining * weight / totalWeight).roundToInt().coerceAtMost((remaining - distributed).coerceAtLeast(0)) + distributed += share + remainingWeight -= weight + val intrinsic = intrinsic(index) { if (horizontal) measurable.minIntrinsicWidth(crossMax) else measurable.minIntrinsicHeight(crossMax) } + val size = maxOf(share, intrinsic).coerceAtMost(Constraints.Infinity - 1) + places[index] = measurable.measure(childConstraints(size, size)) + } + val children = places.map { it!! } + val sizes = children.map { if (horizontal) it.width else it.height }.toIntArray() + val contentMain = (sizes.fold(0L) { sum, size -> sum + size } + gaps).coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + val main = if (horizontal) constraints.constrainWidth(contentMain) else constraints.constrainHeight(contentMain) + val contentCross = children.maxOfOrNull { if (horizontal) it.height else it.width } ?: 0 + val cross = if (horizontal) constraints.constrainHeight(contentCross) else constraints.constrainWidth(contentCross) + val positions = IntArray(children.size) + if (horizontal) with(horizontalArrangement) { arrange(main, sizes, layoutDirection, positions) } + else with(verticalArrangement) { arrange(main, sizes, positions) } + return layout(if (horizontal) main else cross, if (horizontal) cross else main) { + children.forEachIndexed { index, place -> + if (horizontal) place.place(positions[index], verticalAlignment.align(place.height, cross)) + else place.place(horizontalAlignment.align(place.width, cross, layoutDirection), positions[index]) + } + } + } + + private fun IntrinsicMeasureScope.mainIntrinsic(children: List, cross: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val sizes = children.mapIndexed { index, child -> intrinsic(index) { + if (horizontal) { + if (min) child.minIntrinsicWidth(cross) else child.maxIntrinsicWidth(cross) + } else { + if (min) child.minIntrinsicHeight(cross) else child.maxIntrinsicHeight(cross) + } + } } + val unit = sizes.indices.filter { weights[it] != null }.maxOfOrNull { sizes[it] / weights[it]!! } ?: 0f + val weighted = (unit * weights.filterNotNull().sum()).roundToInt() + return (sizes.indices.filter { weights[it] == null }.sumOf { sizes[it].toLong() } + weighted + gap) + .coerceAtMost(Int.MAX_VALUE.toLong()).toInt() + } + + private fun IntrinsicMeasureScope.crossIntrinsic(children: List, main: Int, min: Boolean): Int { + val gap = spacing(this) * (children.size - 1).coerceAtLeast(0) + val fixed = children.indices.filter { weights[it] == null }.map { index -> + val child = children[index] + intrinsic(index) { if (horizontal) child.maxIntrinsicWidth(Constraints.Infinity) else child.maxIntrinsicHeight(Constraints.Infinity) } + }.fold(0L) { sum, value -> sum + value } + val remaining = if (main == Constraints.Infinity) Constraints.Infinity else (main.toLong() - fixed - gap).coerceAtLeast(0).toInt() + val total = weights.filterNotNull().sum() + return children.indices.maxOfOrNull { index -> + val child = children[index] + val weight = weights[index] + val available = if (weight == null || remaining == Constraints.Infinity) Constraints.Infinity + else (remaining * weight / total).roundToInt() + val minimum = intrinsic(index) { if (horizontal) child.minIntrinsicWidth(Constraints.Infinity) else child.minIntrinsicHeight(Constraints.Infinity) } + val size = if (weight == null) available else maxOf(available, minimum) + intrinsic(index) { if (horizontal) { + if (min) child.minIntrinsicHeight(size) else child.maxIntrinsicHeight(size) + } else { + if (min) child.minIntrinsicWidth(size) else child.maxIntrinsicWidth(size) + } } + } ?: 0 + } + + override fun IntrinsicMeasureScope.minIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, true) else crossIntrinsic(measurables, height, true) + override fun IntrinsicMeasureScope.maxIntrinsicWidth(measurables: List, height: Int): Int = + if (horizontal) mainIntrinsic(measurables, height, false) else crossIntrinsic(measurables, height, false) + override fun IntrinsicMeasureScope.minIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, true) else mainIntrinsic(measurables, width, true) + override fun IntrinsicMeasureScope.maxIntrinsicHeight(measurables: List, width: Int): Int = + if (horizontal) crossIntrinsic(measurables, width, false) else mainIntrinsic(measurables, width, false) + + private inline fun intrinsic(index: Int, query: () -> Int): Int { + if (!intrinsicSafe[index]) return 0 + return try { query() } catch (_: IllegalStateException) { 0 } + } +} + +private fun NodeTable.hasScrollingDescendant(id: Int): Boolean { + val node = node(id) ?: return false + if (node.widget in setOf(WidgetKind.LazyColumn, WidgetKind.LazyRow, WidgetKind.LazyGrid, WidgetKind.ScrollColumn, WidgetKind.ScrollRow)) return true + return node.children.any { hasScrollingDescendant(it) } +} + /** * Children of a Column: weight is applied here because `Modifier.weight` exists only inside * `ColumnScope`. Every layout that stacks its children vertically draws them through this, @@ -753,7 +1029,7 @@ private fun ColumnScope.WeightedChild(childId: Int, table: NodeTable, dispatcher } @Composable -private fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { +internal fun RowScope.Children(node: Node, table: NodeTable, dispatcher: EventDispatcher) { node.children.forEach { childId -> key(childId) { WeightedChild(childId, table, dispatcher) } } } @@ -829,3 +1105,6 @@ object LayoutProfile { fun snapshot(): List> = totals.entries.map { Triple(it.key, it.value.nanos / 1e6, it.value.count) }.sortedByDescending { it.second } } + +/** The containers that become focus targets when they listen for keys. */ +private val KeyFocusTargets = setOf(WidgetKind.Box, WidgetKind.Column, WidgetKind.Row, WidgetKind.FileDropTarget) diff --git a/renderer/desktop/src/renderer/TextResolution.kt b/renderer/desktop/src/renderer/TextResolution.kt index c284b2b53..70bf067b0 100644 --- a/renderer/desktop/src/renderer/TextResolution.kt +++ b/renderer/desktop/src/renderer/TextResolution.kt @@ -143,6 +143,7 @@ internal fun resolveText( * resolving a colour through the design system is the dearest thing done here. */ forMeasure: Boolean = false, + intrinsicWordBreak: WordBreak? = null, ): ResolvedText { val problems = mutableListOf() // Sizes are sp. Text in CSS pixels is not given smaller sp to undo the font scale: @@ -203,7 +204,7 @@ internal fun resolveText( val badRuns = if (input.runs.isEmpty()) null else runsProblem(input.runs, input.text.encodeToByteArray().size) val runs = if (badRuns == null) input.runs else emptyList() - val mode = when (input.wordBreak) { + val mode = when (intrinsicWordBreak ?: input.wordBreak) { WordBreak.KeepAll -> BreakMode.KeepAll WordBreak.BreakAll -> BreakMode.BreakAll WordBreak.Normal, null -> BreakMode.Normal @@ -408,24 +409,7 @@ private fun annotate( val runChain = input.runFonts[index] ?.takeIf { input.noRole && run.role == null } ?.let { resolveFontChain(it, assets, problems) } - val family = runChain?.primary - val style = SpanStyle( - color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, - // Behind this run's letters only, line by line where it wraps. The line's - // own background is the node's, and this is the part of it that changed. - background = runBackground(run, theme), - fontWeight = if (run.bold) FontWeight.Bold else null, - fontStyle = if (run.italic) FontStyle.Italic else null, - fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, - fontFamily = family, - textDecoration = when { - run.underline && run.strikethrough -> - TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) - run.underline -> TextDecoration.Underline - run.strikethrough -> TextDecoration.LineThrough - else -> null - }, - ) + val style = spanStyle(run, runChain?.primary, theme, sized) addStyle(style, from, to) runChain?.spans(broken.text.substring(from, to))?.forEach { span -> addStyle(span.item, from + span.start, from + span.end) @@ -435,6 +419,36 @@ private fun annotate( } } +/** + * The Compose style of one span record. Measuring and drawing both build their spans here, + * so what a span changes about its letters is decided in one place and the two cannot + * drift apart. + */ +private fun spanStyle( + run: TextRun, + family: FontFamily?, + theme: ResolvedTheme, + sized: (Float) -> TextUnit, +): SpanStyle { + return SpanStyle( + color = run.color?.let(theme::color) ?: androidx.compose.ui.graphics.Color.Unspecified, + // Behind this run's letters only, line by line where it wraps. The line's + // own background is the node's, and this is the part of it that changed. + background = runBackground(run, theme), + fontWeight = if (run.bold) FontWeight.Bold else null, + fontStyle = if (run.italic) FontStyle.Italic else null, + fontSize = run.role?.let { sized(theme.type(it).size) } ?: TextUnit.Unspecified, + fontFamily = family, + textDecoration = when { + run.underline && run.strikethrough -> + TextDecoration.combine(listOf(TextDecoration.Underline, TextDecoration.LineThrough)) + run.underline -> TextDecoration.Underline + run.strikethrough -> TextDecoration.LineThrough + else -> null + }, + ) +} + private fun tabId(index: Int): String = "compose-rust-tab-$index" /** diff --git a/renderer/desktop/src/renderer/Transform3DLayers.kt b/renderer/desktop/src/renderer/Transform3DLayers.kt new file mode 100644 index 000000000..73b5d00bb --- /dev/null +++ b/renderer/desktop/src/renderer/Transform3DLayers.kt @@ -0,0 +1,304 @@ +package dev.darkpyonix.composerust.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.asin +import kotlin.math.atan2 +import kotlin.math.max +import kotlin.math.sqrt + +/** + * What this file is bound to. + * + * Everything below relies on how a Compose layer turns its properties into a matrix, and + * that is not part of Compose's documented surface. It was read from the Compose release + * named here, in `prepareTransformationMatrix` of `ui-graphics`: a layer moves its origin + * to zero, applies the rotation about Z, then Y, then X, then the scale, then the + * perspective that exists only while X or Y is rotated, and moves the origin back and adds + * the translation. The perspective term is `-1 / (cameraDistance * 72)`. + * + * A test compares [COMPOSE_VERSION] with the version the renderer module depends on, so + * moving to another Compose release fails that test until this file has been read against + * the new release. + */ +internal object Compose3DBinding { + const val COMPOSE_VERSION = "1.11.1-ext.2" + + /** Pixels of camera depth for one unit of `cameraDistance`. */ + const val PIXELS_PER_CAMERA_UNIT = 72f +} + +/** + * How a 4x4 CSS matrix is drawn with Compose layers, and nothing approximate about it. + * + * A node is flat: its content lies in the plane z = 0 and what CSS draws is the image of + * that plane. So only nine entries of the matrix matter, those of the homography + * + * ``` + * x' = (m0 x + m4 y + m12) / w y' = (m1 x + m5 y + m13) / w w = m3 x + m7 y + m15 + * ``` + * + * and every such homography is drawn exactly by the layers Compose has. After dividing by + * `m15`, the translation `t` is `(m12, m13)`, which leaves a homography `K` with a 2x2 + * linear part `F` and a perspective row `(g, h)`. + * + * - **No perspective** (`g = h = 0`): an affine map, drawn by the two layers of + * [affineTransform]. + * - **One layer** ([Kind.Single]): the one `graphicsLayer` whose rotationX, rotationY, + * rotationZ, scale, translation and cameraDistance produce `K`. A layer's own matrix is + * `S Q P` with `Q` a rotation, so two rows of `Q` are the two rows of `F` with the + * perspective row scaled in, and the three unknown scales and depth follow from those + * two rows being orthonormal. The depth falls out of `g h D^2 = -(F row 0 . F row 1)` + * when both `g` and `h` are present. With only one of them the depth is free, and it is + * taken to be the one that leaves that axis unscaled, which is the camera distance of + * the CSS `perspective()` that produced the matrix. + * - **Stacked** ([Kind.Stacked]): where no single layer matches (a skew together with + * perspective is the usual case), a layer with the same perspective row and the + * rotations that give it, followed by the affine layers of the remaining 2x2 map. Layers + * compose exactly, so the picture is the same one. + * + * Every layer is a `graphicsLayer`, so the layout is untouched and Compose maps a pointer + * through each layer's inverse, which is why input follows the drawn shape. + * + * Values are in dp. [Kind.Hidden] is a node that is not drawn and takes no input: a matrix + * with no inverse, a `w` that is not positive at the origin, or a back face with + * backface-visibility hidden. + */ +internal class Transform3DPlan { + enum class Kind { Hidden, Affine, Single, Stacked } + + var kind = Kind.Hidden + private set + + /** The perspective layer of [Kind.Single] and [Kind.Stacked], angles in degrees. */ + var rotationX = 0f + private set + var rotationY = 0f + private set + var rotationZ = 0f + private set + var scaleX = 1f + private set + var scaleY = 1f + private set + + /** Camera distance in dp. */ + var depth = 0f + private set + + /** The perspective layer's own translation, dp. Nonzero only for [Kind.Single]. */ + var translationX = 0f + private set + var translationY = 0f + private set + + /** `a, b, c, d, e, f` of the affine layers of [Kind.Affine] and [Kind.Stacked]. */ + val affine = FloatArray(6) + var affineOneLayer = true + private set + + /** Whether the node faces away from the viewer: the map reverses orientation. */ + var facingAway = false + private set + + /** + * Works out how [m], sixteen values in the column-major order of CSS `matrix3d`, is + * drawn. Allocates only the intermediate arithmetic, none of it kept. + */ + fun solve(m: FloatArray, backfaceHidden: Boolean) { + kind = Kind.Hidden + facingAway = false + affineOneLayer = true + val w0 = m[15].toDouble() + if (!(w0 > 0.0)) return + val inverse = 1.0 / w0 + val tx = m[12] * inverse + val ty = m[13] * inverse + val g = m[3] * inverse + val h = m[7] * inverse + val a00 = m[0] * inverse - tx * g + val a01 = m[1] * inverse - ty * g + val a10 = m[4] * inverse - tx * h + val a11 = m[5] * inverse - ty * h + val determinant = a00 * a11 - a01 * a10 + if (determinant == 0.0 || !determinant.isFinite()) return + facingAway = determinant < 0.0 + if (backfaceHidden && facingAway) return + + if (g == 0.0 && h == 0.0) { + affine[0] = a00.toFloat() + affine[1] = a01.toFloat() + affine[2] = a10.toFloat() + affine[3] = a11.toFloat() + affine[4] = tx.toFloat() + affine[5] = ty.toFloat() + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = if (affine.all { it.isFinite() }) Kind.Affine else Kind.Hidden + return + } + if (solveSingleLayer(a00, a01, a10, a11, g, h, tx, ty)) return + solveStacked(a00, a01, a10, a11, g, h, tx, ty) + } + + private fun solveSingleLayer( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ): Boolean { + val depthSquared = when { + g != 0.0 && h != 0.0 -> -(a00 * a10 + a01 * a11) / (g * h) + g != 0.0 -> unscaledDepthSquared(a00 * a00 + a01 * a01, g) + else -> unscaledDepthSquared(a10 * a10 + a11 * a11, h) + } + if (!(depthSquared > 0.0) || !depthSquared.isFinite()) return false + val distance = sqrt(depthSquared) + val row0x = a00 + val row0y = a01 + val row0z = -g * distance + val row1x = a10 + val row1y = a11 + val row1z = -h * distance + val length0 = sqrt(row0x * row0x + row0y * row0y + row0z * row0z) + val length1 = sqrt(row1x * row1x + row1y * row1y + row1z * row1z) + if (!(length0 > 1e-12) || !(length1 > 1e-12)) return false + val q00 = row0x / length0 + val q01 = row0y / length0 + val q02 = row0z / length0 + val q10 = row1x / length1 + val q11 = row1y / length1 + val q12 = row1z / length1 + // The two rows have to be at right angles, or no rotation has them. + if (abs(q00 * q10 + q01 * q11 + q02 * q12) > ORTHOGONAL_TOLERANCE) return false + // The third row of the rotation is the cross product of the first two. + val q20 = q01 * q12 - q02 * q11 + val q21 = q02 * q10 - q00 * q12 + val q22 = q00 * q11 - q01 * q10 + val sinY = q20.coerceIn(-1.0, 1.0) + val cosY = sqrt(1.0 - sinY * sinY) + val x: Double + val z: Double + if (cosY > GIMBAL_LIMIT) { + x = atan2(-q21, q22) + z = atan2(-q10, q00) + } else { + // Looking along the Y axis, where X and Z turn the same way: all of it is Z. + x = 0.0 + z = atan2(q01, q11) + } + val results = floatArrayOf( + degrees(x), degrees(asin(sinY)), degrees(z), + length0.toFloat(), length1.toFloat(), distance.toFloat(), + tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return false + rotationX = results[0] + rotationY = results[1] + rotationZ = results[2] + scaleX = results[3] + scaleY = results[4] + depth = results[5] + translationX = results[6] + translationY = results[7] + kind = Kind.Single + return true + } + + /** + * The depth at which the one perspective axis leaves its own scale at one, which is the + * camera of the CSS `perspective()` that gave the matrix. A row already longer than + * one cannot have that, and takes the depth that turns it by a half right angle. + */ + private fun unscaledDepthSquared(rowLengthSquared: Double, perspective: Double): Double = + if (rowLengthSquared < 1.0) { + (1.0 - rowLengthSquared) / (perspective * perspective) + } else { + rowLengthSquared / (perspective * perspective) + } + + private fun solveStacked( + a00: Double, a01: Double, a10: Double, a11: Double, + g: Double, h: Double, tx: Double, ty: Double, + ) { + // A layer with this perspective row and no scale: sin(rotX) = -h D, and + // sin(rotY) cos(rotX) = g D. D keeps both below one half, so the cosines are fine. + val distance = 0.5 / max(abs(g), abs(h)) + val sinX = -h * distance + val cosX = sqrt(1.0 - sinX * sinX) + val sinY = g * distance / cosX + val cosY = sqrt(1.0 - sinY * sinY) + // That layer's own 2x2 map is [[cosY, sinY sinX], [0, cosX]], with determinant + // cosY cosX. What is left for the affine layers is its inverse times the target. + val determinant = cosY * cosX + val n00 = (cosX * a00 - sinY * sinX * a10) / determinant + val n01 = (cosX * a01 - sinY * sinX * a11) / determinant + val n10 = cosY * a10 / determinant + val n11 = cosY * a11 / determinant + val results = floatArrayOf( + degrees(asin(sinX)), degrees(asin(sinY)), distance.toFloat(), + n00.toFloat(), n01.toFloat(), n10.toFloat(), n11.toFloat(), tx.toFloat(), ty.toFloat(), + ) + if (!results.all { it.isFinite() }) return + rotationX = results[0] + rotationY = results[1] + rotationZ = 0f + scaleX = 1f + scaleY = 1f + depth = results[2] + translationX = 0f + translationY = 0f + for (index in 0 until 6) affine[index] = results[3 + index] + affineOneLayer = AffineParts.isOneLayer(affine[0], affine[1], affine[2], affine[3]) + kind = Kind.Stacked + } + + private fun degrees(radians: Double): Float = (radians * 180.0 / PI).toFloat() + + private companion object { + const val ORTHOGONAL_TOLERANCE = 1e-4 + + /** Below this cosine of the Y angle the X and Z angles cannot be told apart. */ + const val GIMBAL_LIMIT = 1e-6 + } +} + +/** + * Draws this node and its content through [plan], turning about the fraction of the node's + * size [originX], [originY]. Which layers that takes, and why they are exact, is on + * [Transform3DPlan]. + */ +internal fun Modifier.perspectiveTransform(plan: Transform3DPlan, originX: Float, originY: Float): Modifier { + val origin = TransformOrigin(originX, originY) + return when (plan.kind) { + Transform3DPlan.Kind.Hidden -> graphicsLayer { + // A layer that scales to nothing has no inverse, so nothing is drawn and no + // pointer is ever inside it. + transformOrigin = origin + alpha = 0f + scaleX = 0f + scaleY = 0f + } + Transform3DPlan.Kind.Affine -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + } + Transform3DPlan.Kind.Single -> graphicsLayer { perspectiveLayer(plan, origin) } + Transform3DPlan.Kind.Stacked -> affineTransform(originX, originY, plan.affineOneLayer) { matrix -> + plan.affine.copyInto(matrix) + }.graphicsLayer { perspectiveLayer(plan, origin) } + } +} + +private fun GraphicsLayerScope.perspectiveLayer(plan: Transform3DPlan, origin: TransformOrigin) { + transformOrigin = origin + translationX = plan.translationX.dp.toPx() + translationY = plan.translationY.dp.toPx() + rotationX = plan.rotationX + rotationY = plan.rotationY + rotationZ = plan.rotationZ + scaleX = plan.scaleX + scaleY = plan.scaleY + cameraDistance = plan.depth.dp.toPx() / Compose3DBinding.PIXELS_PER_CAMERA_UNIT +} diff --git a/renderer/desktop/src/renderer/WindowSize.kt b/renderer/desktop/src/renderer/WindowSize.kt index b0da631ac..bee09da52 100644 --- a/renderer/desktop/src/renderer/WindowSize.kt +++ b/renderer/desktop/src/renderer/WindowSize.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass +import kotlin.math.floor /** * The width in dp at which each size class begins. @@ -79,6 +80,8 @@ class WindowSizeReporter(private val nodeId: Int = 0) { private var lastClass: WindowSizeClass = WindowSizeClass.Compact private var lastSafeArea = WindowSafeArea() private var lastHeightClass: WindowHeightClass = WindowHeightClass.Compact + private var lastWidthDp = 0 + private var lastHeightDp = 0 /** * Reports one measurement. Returns whether an event was sent. @@ -86,11 +89,29 @@ class WindowSizeReporter(private val nodeId: Int = 0) { * Nothing is allocated when neither class has changed, which is the common case. The * two classes ride in one record, so a window that gets wider and shorter in the same * drag costs one event rather than two. + * + * A window that asked for [everyDpChange] also reports when its width or height, each + * rounded to the nearest whole dp (a half rounds up), differs from the rounded values of + * the last event sent. The rounded integers are compared, never the raw floats, so jitter + * under one dp is ignored. The event still carries the measured, unrounded size. */ - fun report(widthDp: Float, heightDp: Float, dispatcher: EventDispatcher, safeArea: WindowSafeArea = WindowSafeArea()): Boolean { + fun report( + widthDp: Float, + heightDp: Float, + dispatcher: EventDispatcher, + safeArea: WindowSafeArea = WindowSafeArea(), + everyDpChange: Boolean = false, + ): Boolean { val sizeClass = windowSizeClassOf(widthDp) val heightClass = windowHeightClassOf(heightDp) - if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) return false + if (sizeClass == lastClass && heightClass == lastHeightClass && safeArea == lastSafeArea) { + if (!everyDpChange) return false + if (floor(widthDp + 0.5f).toInt() == lastWidthDp && floor(heightDp + 0.5f).toInt() == lastHeightDp) return false + } + if (everyDpChange) { + lastWidthDp = floor(widthDp + 0.5f).toInt() + lastHeightDp = floor(heightDp + 0.5f).toInt() + } lastClass = sizeClass lastHeightClass = heightClass lastSafeArea = safeArea @@ -117,3 +138,23 @@ class WindowSizeReporter(private val nodeId: Int = 0) { * zoom - carry this as their node id, and the theme and zoom level are looked up under it. */ val LocalWindowNodeId = staticCompositionLocalOf { 0 } + +/** Bounds are in window pixels, matching the Linux backend's screen coordinates. */ +internal object WindowDropTargets { + private data class Target(val left: Float, val top: Float, val right: Float, val bottom: Float, val density: Float, val depth: Int) + private val targets = LinkedHashMap, Target>() + + fun update(windowId: Int, nodeId: Int, left: Float, top: Float, right: Float, bottom: Float, density: Float, depth: Int) { + targets[windowId to nodeId] = Target(left, top, right, bottom, density, depth) + } + + fun remove(windowId: Int, nodeId: Int) { targets.remove(windowId to nodeId) } + + fun find(windowId: Int, x: Float, y: Float): Pair>? { + val target = targets.entries.toList().asReversed().filter { (key, rect) -> + key.first == windowId && x >= rect.left && x < rect.right && y >= rect.top && y < rect.bottom + }.maxByOrNull { it.value.depth } ?: return null + val rect = target.value + return target.key.second to ((x - rect.left) / rect.density to (y - rect.top) / rect.density) + } +} diff --git a/renderer/desktop/test/AccessibilityModifierTest.kt b/renderer/desktop/test/AccessibilityModifierTest.kt new file mode 100644 index 000000000..5d931fd05 --- /dev/null +++ b/renderer/desktop/test/AccessibilityModifierTest.kt @@ -0,0 +1,451 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsNode +import androidx.compose.ui.semantics.SemanticsActions +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.state.ToggleableState +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performSemanticsAction +import androidx.compose.ui.test.SemanticsMatcher +import androidx.compose.ui.test.runComposeUiTest +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.IconRole +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.SemanticsRole +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val TREE_ROOT = 1 +private const val TAG_PREFIX = "compose-node-" +private const val SOME_HANDLER = 77L + +private fun name(text: String) = PropertyValue.Text(text) + +/** What a screen reader would read for a node: its description, then its text. */ +private fun SemanticsNode.spoken(): String { + val description = config.getOrNull(SemanticsProperties.ContentDescription).orEmpty() + val text = config.getOrNull(SemanticsProperties.Text).orEmpty().map { it.text } + return (description + text).joinToString(" ").trim() +} + +private fun SemanticsNode.tag(): String? = config.getOrNull(SemanticsProperties.TestTag) + +/** Everything that can take focus or be operated, which is what must be named. */ +private val operable = SemanticsMatcher("can be operated") { + it.tag()?.startsWith(TAG_PREFIX) == true && ( + it.config.contains(SemanticsActions.OnClick) || + it.config.contains(SemanticsProperties.ToggleableState) || + it.config.contains(SemanticsProperties.Selected) || + it.config.contains(SemanticsProperties.ProgressBarRangeInfo) + ) +} + +@OptIn(ExperimentalTestApi::class) +private fun ComposeUiTest.show(mutations: List) { + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) } + waitForIdle() +} + +/** + * Widgets the every-operable-node-has-a-name check does not cover. + * Sliders are left out until the design system's Slider exposes a range semantics node: thisisthepy/compose-multiplatform-core-extended#180. + */ +private val LEFT_OUT_OF_THE_CHECK = setOf(WidgetKind.Slider) + +/** A column holding one of every kind of control, each given a name unless it names itself. */ +private fun everyOperable(named: Boolean) = buildList { + add(Mutation.Create(TREE_ROOT, WidgetKind.Column)) + var next = TREE_ROOT + 1 + fun child(widget: WidgetKind, setup: (Int) -> List): Int { + val id = next++ + add(Mutation.Create(id, widget)) + addAll(setup(id)) + add(Mutation.Insert(TREE_ROOT, id, id - TREE_ROOT - 1)) + return id + } + fun label(id: Int, text: String) = + if (named) listOf(Mutation.SetProp(id, PropertyKind.AccessibleName, name(text))) else emptyList() + + // A button with a word, and one with only a glyph, which is named by its meaning. + child(WidgetKind.Button) { + listOf( + Mutation.SetProp(it, PropertyKind.Text, name("Save")), + Mutation.SetProp(it, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + } + child(WidgetKind.Button) { + listOf( + Mutation.SetProp(it, PropertyKind.Text, name("")), + Mutation.SetProp(it, PropertyKind.Icon, PropertyValue.Integer(IconRole.Add.ordinal + 1L)), + Mutation.SetProp(it, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "Import") + } + // A pressable container with no text of its own. + child(WidgetKind.Box) { + listOf( + Mutation.SetProp(it, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "Refresh") + } + for (kind in listOf(WidgetKind.Switch, WidgetKind.Checkbox, WidgetKind.RadioButton)) { + child(kind) { + listOf( + Mutation.SetProp(it, PropertyKind.Checked, PropertyValue.Bool(true)), + Mutation.SetProp(it, PropertyKind.OnValueChange, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "$kind option") + } + } + if (WidgetKind.Slider !in LEFT_OUT_OF_THE_CHECK) child(WidgetKind.Slider) { + listOf( + Mutation.SetProp(it, PropertyKind.Value, PropertyValue.Float(0.5f)), + Mutation.SetProp(it, PropertyKind.Min, PropertyValue.Float(0f)), + Mutation.SetProp(it, PropertyKind.Max, PropertyValue.Float(1f)), + Mutation.SetProp(it, PropertyKind.OnValueChange, PropertyValue.Integer(SOME_HANDLER)), + ) + label(it, "Volume") + } +} + +/** The nodes that can be operated and that a screen reader would find without a name. */ +@OptIn(ExperimentalTestApi::class) +private fun ComposeUiTest.unnamed(): List = + onAllNodes(operable).fetchSemanticsNodes().filter { it.spoken().isEmpty() }.map { it.tag().orEmpty() } + +@OptIn(ExperimentalTestApi::class) +class AccessibilityModifierTest { + @Test + fun fr68_every_operable_node_has_a_name() { + runComposeUiTest { + show(everyOperable(named = true)) + val operableCount = onAllNodes(operable).fetchSemanticsNodes().size + assertTrue(operableCount >= 6, "only $operableCount operable nodes were found, so the check proves nothing") + val missing = unnamed() + assertTrue(missing.isEmpty(), "these nodes can be operated and have no name: $missing") + } + } + + @Test + fun fr68_the_check_finds_a_control_that_has_no_name() { + runComposeUiTest { + show(everyOperable(named = false)) + val missing = unnamed() + // The pressable container and the three toggles say nothing by themselves. Sliders + // are in LEFT_OUT_OF_THE_CHECK. + assertTrue(missing.size >= 4, "an unnamed control went unnoticed: $missing") + assertTrue(missing.all { it.startsWith(TAG_PREFIX) }, "$missing") + } + } + + @Test + fun fr68_a_box_with_a_label_and_a_role_is_announced_as_both() { + val box = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetProp(box, PropertyKind.AccessibleName, name("Refresh")), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Button, 0)), + ), + ) + val node = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode() + assertEquals(listOf("Refresh"), node.config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(Role.Button, node.config.getOrNull(SemanticsProperties.Role)) + } + } + + @Test + fun fr68_a_node_that_asks_for_nothing_gets_no_semantics_of_its_own() { + val box = 1 + runComposeUiTest { + show(listOf(Mutation.Create(box, WidgetKind.Box))) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertEquals(null, config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(null, config.getOrNull(SemanticsProperties.Role)) + } + } + + @Test + fun fr68_a_box_with_a_label_and_a_role_is_one_semantics_node() { + val box = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetProp(box, PropertyKind.AccessibleName, name("Refresh")), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Button, 0)), + ), + ) + val nodes = onAllNodes(SemanticsMatcher("carries the label or the role") { + it.tag() == nodeTestTag(box) || + it.config.getOrNull(SemanticsProperties.ContentDescription) == listOf("Refresh") || + it.config.getOrNull(SemanticsProperties.Role) == Role.Button + }, useUnmergedTree = true).fetchSemanticsNodes() + assertEquals(1, nodes.size, "the label and the role must sit on one node: $nodes") + val config = nodes.single().config + assertEquals(listOf("Refresh"), config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(Role.Button, config.getOrNull(SemanticsProperties.Role)) + } + } + + @Test + fun fr68_the_state_of_a_modifier_reaches_the_semantics() { + val box = 1 + runComposeUiTest { + // Checked on is 2 in the low field, selected off is 1 in the field above it. + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Tab, 2 or (1 shl 2))), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertEquals(Role.Tab, config.getOrNull(SemanticsProperties.Role)) + assertEquals(ToggleableState.On, config.getOrNull(SemanticsProperties.ToggleableState)) + assertEquals(false, config.getOrNull(SemanticsProperties.Selected)) + } + } + + @Test + fun fr68_the_accessible_name_beats_the_name_of_the_icon() { + val button = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(button, WidgetKind.Button), + Mutation.SetProp(button, PropertyKind.Text, name("")), + Mutation.SetProp(button, PropertyKind.Icon, PropertyValue.Integer(IconRole.Search.ordinal + 1L)), + Mutation.SetProp(button, PropertyKind.AccessibleName, name("Import")), + Mutation.SetProp(button, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ), + ) + val node = onNodeWithTag(nodeTestTag(button)).fetchSemanticsNode() + assertEquals(listOf("Import"), node.config.getOrNull(SemanticsProperties.ContentDescription)) + } + } + + @Test + fun fr68_a_named_switch_says_its_name_and_that_it_is_on() { + val switch = 1 + runComposeUiTest { + show( + listOf( + Mutation.Create(switch, WidgetKind.Switch), + Mutation.SetProp(switch, PropertyKind.Checked, PropertyValue.Bool(true)), + Mutation.SetProp(switch, PropertyKind.AccessibleName, name("Notifications")), + Mutation.SetProp(switch, PropertyKind.OnValueChange, PropertyValue.Integer(SOME_HANDLER)), + ), + ) + val config = onNodeWithTag(nodeTestTag(switch)).fetchSemanticsNode().config + assertEquals(listOf("Notifications"), config.getOrNull(SemanticsProperties.ContentDescription)) + assertEquals(ToggleableState.On, config.getOrNull(SemanticsProperties.ToggleableState)) + } + } + + @Test + fun fr68_removing_the_accessible_name_returns_the_default_name() { + val button = 1 + fun iconButton(vararg extra: Mutation) = listOf( + Mutation.Create(button, WidgetKind.Button), + Mutation.SetProp(button, PropertyKind.Text, name("")), + Mutation.SetProp(button, PropertyKind.Icon, PropertyValue.Integer(IconRole.Search.ordinal + 1L)), + Mutation.SetProp(button, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + ) + extra + fun ComposeUiTest.description() = onNodeWithTag(nodeTestTag(button)).fetchSemanticsNode() + .config.getOrNull(SemanticsProperties.ContentDescription) + + // The name of the same button when it never had one of its own. + var defaultName: List? = null + runComposeUiTest { + show(iconButton()) + defaultName = description() + } + + runComposeUiTest { + val frames = FrameRequestSource() + val connection = FakeHostConnection( + iconButton(Mutation.SetProp(button, PropertyKind.AccessibleName, name("가져오기"))), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + assertEquals(listOf("가져오기"), description()) + + connection.scheduleFrame(listOf(Mutation.SetProp(button, PropertyKind.AccessibleName, PropertyValue.None))) + frames.request() + waitForIdle() + assertEquals(defaultName, description(), "the name after the removal is not the default one") + assertTrue(description() != listOf("가져오기"), "the removed name is still spoken") + } + } + + private val newRoles = mapOf( + SemanticsRole.TabList to "tablist", + SemanticsRole.Tree to "tree", + SemanticsRole.TreeItem to "treeitem", + SemanticsRole.Menu to "menu", + SemanticsRole.MenuItem to "menuitem", + SemanticsRole.TextBox to "textbox", + SemanticsRole.Navigation to "navigation", + SemanticsRole.List to "list", + SemanticsRole.ListItem to "listitem", + SemanticsRole.Group to "group", + ) + + @Test + fun fr71_every_new_role_round_trips_and_is_exposed() { + val box = 1 + for ((role, word) in newRoles) { + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(role, 0)), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertEquals(word, config.getOrNull(SemanticsProperties.StateDescription), "$role") + } + } + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.Heading, 0)), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertTrue(config.contains(SemanticsProperties.Heading), "a heading must be marked as one") + } + } + + @Test + fun fr71_state_bits_reach_the_accessibility_node() { + val box = 1 + fun ComposeUiTest.configOf(state: Int, role: SemanticsRole = SemanticsRole.None): androidx.compose.ui.semantics.SemanticsConfiguration { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(role, state)), + ), + ) + return onNodeWithTag(nodeTestTag(box), useUnmergedTree = true).fetchSemanticsNode().config + } + runComposeUiTest { + // Expanded on, disabled on, focused off. + val config = configOf((2 shl 4) or (2 shl 6) or (1 shl 8), SemanticsRole.TreeItem) + assertEquals("treeitem, expanded", config.getOrNull(SemanticsProperties.StateDescription)) + assertTrue(config.contains(SemanticsProperties.Disabled)) + assertEquals(false, config.getOrNull(SemanticsProperties.Focused)) + assertTrue(!config.contains(SemanticsProperties.HideFromAccessibility)) + } + runComposeUiTest { + val config = configOf((1 shl 4) or (2 shl 8)) + assertEquals("collapsed", config.getOrNull(SemanticsProperties.StateDescription)) + assertEquals(true, config.getOrNull(SemanticsProperties.Focused)) + assertTrue(!config.contains(SemanticsProperties.Disabled)) + } + runComposeUiTest { + // Enabled is explicit and sets nothing; hidden leaves the node out of the tree. + val config = configOf((1 shl 6) or (1 shl 10), SemanticsRole.Group) + assertTrue(!config.contains(SemanticsProperties.Disabled)) + assertTrue(config.contains(SemanticsProperties.HideFromAccessibility)) + } + } + + @Test + fun fr71_click_action_calls_the_nodes_click_handler() { + val box = 1 + val clickAction = 1 shl 11 + runComposeUiTest { + val connection = FakeHostConnection( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetProp(box, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.MenuItem, clickAction)), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + onNodeWithTag(nodeTestTag(box)).performSemanticsAction(SemanticsActions.OnClick) + waitForIdle() + val clicks = connection.events.filterIsInstance() + assertEquals(listOf(HostEvent.Clicked(box, SOME_HANDLER)), clicks) + } + runComposeUiTest { + show( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Semantics(SemanticsRole.MenuItem, clickAction)), + ), + ) + val config = onNodeWithTag(nodeTestTag(box)).fetchSemanticsNode().config + assertTrue(!config.contains(SemanticsActions.OnClick), "a node without a handler has no click action") + } + } + + /** + * Text is not a pressable container, so only the accessibility modifier can register a + * click action on it. Performs the action when the node has one and returns the clicks. + */ + private fun clicksFromAccessibilityOn(extra: List, state: Int): List { + val text = 1 + var clicks = emptyList() + runComposeUiTest { + val connection = FakeHostConnection( + listOf( + Mutation.Create(text, WidgetKind.Text), + Mutation.SetProp(text, PropertyKind.OnClick, PropertyValue.Integer(SOME_HANDLER)), + Mutation.SetModifier(text, 0, ProtocolModifier.Semantics(SemanticsRole.MenuItem, state)), + ) + extra, + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + val config = onNodeWithTag(nodeTestTag(text)).fetchSemanticsNode().config + if (config.contains(SemanticsActions.OnClick)) { + onNodeWithTag(nodeTestTag(text)).performSemanticsAction(SemanticsActions.OnClick) + waitForIdle() + } + clicks = connection.events.filterIsInstance() + } + return clicks + } + + @Test + fun fr71_click_action_does_not_fire_on_a_disabled_node() { + val clickAction = 1 shl 11 + val disabled = 2 shl 6 + val enabledClicks = clicksFromAccessibilityOn(emptyList(), clickAction) + assertEquals(listOf(HostEvent.Clicked(1, SOME_HANDLER)), enabledClicks, "control: the enabled node clicks once") + val clicks = clicksFromAccessibilityOn(emptyList(), clickAction or disabled) + assertEquals(emptyList(), clicks, "a node disabled by its semantics state is not clicked") + } + + @Test + fun fr71_click_action_does_not_fire_on_an_enabled_false_node() { + val clickAction = 1 shl 11 + val enabledClicks = clicksFromAccessibilityOn(emptyList(), clickAction) + assertEquals(listOf(HostEvent.Clicked(1, SOME_HANDLER)), enabledClicks, "control: the enabled node clicks once") + val clicks = clicksFromAccessibilityOn( + listOf(Mutation.SetProp(1, PropertyKind.Enabled, PropertyValue.Bool(false))), + clickAction, + ) + assertEquals(emptyList(), clicks, "a node whose enabled property is false is not clicked") + } +} diff --git a/renderer/desktop/test/ButtonRowTest.kt b/renderer/desktop/test/ButtonRowTest.kt new file mode 100644 index 000000000..8153ee8d1 --- /dev/null +++ b/renderer/desktop/test/ButtonRowTest.kt @@ -0,0 +1,182 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.DpRect +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.ButtonVariant +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.TextAlign +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A Button that is a row: a left-aligned label, its own label size, and children in place + * of the label. + */ +@OptIn(ExperimentalTestApi::class) +class ButtonRowTest { + private val root = 1 + private val button = 2 + private val label = 3 + private val spacer = 4 + private val count = 5 + private val handler = 77L + + private fun theme() = Mutation.SetTheme( + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false), + ) + + private fun buttonNode(width: Float?) = buildList { + add(Mutation.Create(button, WidgetKind.Button)) + if (width != null) add(Mutation.SetModifier(button, 0, ProtocolModifier.Width(width))) + add(Mutation.SetProp(button, PropertyKind.Variant, PropertyValue.Integer(ButtonVariant.Text.ordinal + 1L))) + add(Mutation.SetProp(button, PropertyKind.OnClick, PropertyValue.Integer(handler))) + } + + private fun text(id: Int, value: String) = listOf( + Mutation.Create(id, WidgetKind.Text), + Mutation.SetProp(id, PropertyKind.Text, PropertyValue.Text(value)), + Mutation.Insert(button, id, if (id == label) 0 else 2), + ) + + private fun childrenButton() = listOf(theme(), Mutation.Create(root, WidgetKind.Box)) + + buttonNode(300f) + + text(label, "Files") + + listOf( + Mutation.Create(spacer, WidgetKind.Spacer), + Mutation.SetModifier(spacer, 0, ProtocolModifier.Weight(1f)), + Mutation.Insert(button, spacer, 1), + ) + + text(count, "12") + + listOf(Mutation.Insert(root, button, 0)) + + @Test + fun fr66_children_lay_out_as_a_row_with_the_count_at_the_right_edge() { + runComposeUiTest { + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(childrenButton()))) } + waitForIdle() + val outer = onNodeWithTag(nodeTestTag(button)).getBoundsInRoot() + val first = onNodeWithTag(nodeTestTag(label), useUnmergedTree = true).getBoundsInRoot() + val last = onNodeWithTag(nodeTestTag(count), useUnmergedTree = true).getBoundsInRoot() + assertTrue(first.left - outer.left in 0.dp..32.dp, "label starts at the inner padding: ${first.left - outer.left}") + assertTrue(outer.right - last.right in 0.dp..32.dp, "count is at the inner right edge: ${outer.right - last.right}") + val labelMid = (first.top + first.bottom) / 2 + val countMid = (last.top + last.bottom) / 2 + assertTrue((labelMid - countMid).value in -1f..1f, "children share a vertical centre") + } + } + + @Test + fun fr66_a_disabled_button_with_children_draws_and_exposes_them_once() { + runComposeUiTest { + val connection = FakeHostConnection( + childrenButton() + Mutation.SetProp(button, PropertyKind.Enabled, PropertyValue.Bool(false)), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + for (child in listOf(label, count)) { + assertEquals( + 1, + onAllNodesWithTag(nodeTestTag(child), useUnmergedTree = true).fetchSemanticsNodes().size, + "child $child appears once in the unmerged tree", + ) + } + onNodeWithTag(nodeTestTag(button)).performClick() + waitForIdle() + assertTrue( + connection.events.filterIsInstance().isEmpty(), + "a disabled button delivers no click", + ) + } + } + + @Test + fun fr66_a_click_on_the_whole_button_fires_once() { + runComposeUiTest { + val connection = FakeHostConnection(childrenButton()) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + onNodeWithTag(nodeTestTag(button)).performClick() + waitForIdle() + assertEquals( + listOf(HostEvent.Clicked(button, handler)), + connection.events.filterIsInstance(), + ) + } + } + + private fun widthOf(fontSize: Float?, align: TextAlign?): Float { + var width = 0f + runComposeUiTest { + val mutations = buildList { + add(theme()) + add(Mutation.Create(root, WidgetKind.Box)) + addAll(buttonNode(null)) + add(Mutation.SetProp(button, PropertyKind.Text, PropertyValue.Text("Changes"))) + if (fontSize != null) add(Mutation.SetProp(button, PropertyKind.FontSize, PropertyValue.Float(fontSize))) + if (align != null) add(Mutation.SetProp(button, PropertyKind.TextAlign, PropertyValue.Integer(align.ordinal + 1L))) + add(Mutation.Insert(root, button, 0)) + } + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) } + waitForIdle() + val bounds = onNodeWithTag(nodeTestTag(button)).getBoundsInRoot() + width = bounds.right.value - bounds.left.value + } + return width + } + + @Test + fun fr66_font_size_sizes_the_label_and_its_absence_changes_nothing() { + assertTrue(widthOf(30f, TextAlign.Start) > widthOf(null, TextAlign.Start), "a larger FontSize widens the label") + assertEquals(widthOf(null, null), widthOf(null, TextAlign.Start), "alignment alone does not resize an unconstrained button") + } + + private fun labelBounds(width: Float, fontSize: Float?, align: TextAlign?): Pair { + lateinit var result: Pair + runComposeUiTest { + val mutations = buildList { + add(theme()) + add(Mutation.Create(root, WidgetKind.Box)) + addAll(buttonNode(width)) + add(Mutation.SetProp(button, PropertyKind.Text, PropertyValue.Text("Changes"))) + if (fontSize != null) add(Mutation.SetProp(button, PropertyKind.FontSize, PropertyValue.Float(fontSize))) + if (align != null) add(Mutation.SetProp(button, PropertyKind.TextAlign, PropertyValue.Integer(align.ordinal + 1L))) + add(Mutation.Insert(root, button, 0)) + } + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) } + waitForIdle() + result = onNodeWithTag(nodeTestTag(button)).getBoundsInRoot() to + onNodeWithText("Changes", useUnmergedTree = true).getBoundsInRoot() + } + return result + } + + @Test + fun fr66_start_alignment_puts_the_label_at_the_inner_padding_and_font_size_sets_its_height() { + val (outer, start) = labelBounds(300f, 13f, TextAlign.Start) + val (_, centred) = labelBounds(300f, 13f, null) + assertTrue(start.left - outer.left in 0.dp..32.dp, "a Start label sits at the inner padding: ${start.left - outer.left}") + assertTrue(centred.left - outer.left > 60.dp, "an unaligned label stays centred: ${centred.left - outer.left}") + val (_, small) = labelBounds(300f, 13f, TextAlign.Start) + val (_, large) = labelBounds(300f, 26f, TextAlign.Start) + assertTrue(large.bottom - large.top > small.bottom - small.top, "FontSize sets the label size") + } +} diff --git a/renderer/desktop/test/CodeEditorChangeBarTest.kt b/renderer/desktop/test/CodeEditorChangeBarTest.kt new file mode 100644 index 000000000..2094001c5 --- /dev/null +++ b/renderer/desktop/test/CodeEditorChangeBarTest.kt @@ -0,0 +1,246 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Canvas +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.drawscope.CanvasDrawScope +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.foundation.code.ChangeMark +import dev.darkpyonix.composerust.foundation.code.CodeEditorLook +import dev.darkpyonix.composerust.foundation.code.CodeEditorModel +import dev.darkpyonix.composerust.foundation.code.CodePosition +import dev.darkpyonix.composerust.foundation.code.EditOrigin +import dev.darkpyonix.composerust.foundation.code.changeColumnPx +import dev.darkpyonix.composerust.foundation.code.changeMark +import dev.darkpyonix.composerust.foundation.code.drawGutterAdornments +import dev.darkpyonix.composerust.protocol.ColorRole +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.ui.node.NodeTable +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertNull + +private const val EDITOR = 5 +private const val ROW = 20 +private const val MARK_COLUMN = 20 +private const val FOLD_LEFT = 150f +private const val ADDED = 39 +private const val REMOVED = 40 +private const val MODIFIED = 41 +private const val DOT = 1 +private const val BAR = 2 + +private val addedInk = Color(0f, 1f, 0f) +private val removedInk = Color(1f, 0f, 0f) +private val modifiedInk = Color(0f, 0f, 1f) +private val plainInk = Color(1f, 0f, 1f) + +private fun look(): CodeEditorLook = CodeEditorLook( + textStyle = TextStyle(), + container = Color.White, + gutter = Color.White, + lineNumber = Color.Black, + currentLineNumber = Color.Black, + currentLine = Color.Transparent, + currentLineBorder = Color.Transparent, + gutterDivider = Color.Transparent, + gutterDividerWidth = 1.dp, + gutterPadding = 8.dp, + textInset = 8.dp, + selection = Color.Gray, + cursor = Color.Black, + underlineWidth = 1.dp, + underline = { Color.Red to dev.darkpyonix.composerust.foundation.code.UnderlineShape.Straight }, + paint = { Color.Black }, + ghostTextAlpha = 0.5f, + lens = Color.Gray, + tabWidth = 4, + hoverDelayMillis = 300L, + commandIsMeta = false, + gutterMark = { role -> + when (role) { + ColorRole.DiffAdded -> addedInk + ColorRole.DiffRemoved -> removedInk + ColorRole.DiffModified -> modifiedInk + else -> plainInk + } + }, +) + +/** Gutter marks as (line, kind, colour code) records, applied through the interpreter. */ +private fun modelWith(text: String, vararg marks: Triple): CodeEditorModel { + val table = NodeTable() + table.apply(Mutation.Create(EDITOR, WidgetKind.CodeEditor)) + table.apply(Mutation.SetProp(EDITOR, PropertyKind.Text, PropertyValue.Text(text))) + val buffer = ByteBuffer.allocate(marks.size * 28).order(ByteOrder.LITTLE_ENDIAN) + for ((line, kind, color) in marks) { + buffer.putInt(0).putInt(line).putShort(kind.toShort()).putShort(color.toShort()).putLong(0L).putInt(0).putInt(0) + } + table.apply(Mutation.SetProp(EDITOR, PropertyKind.GutterDecorations, PropertyValue.Bytes(buffer.array()))) + return table.node(EDITOR)!!.code!! +} + +private val lines = (0 until 6).joinToString("\n") { "l$it" } + +@OptIn(ExperimentalTestApi::class) +class CodeEditorChangeBarTest { + /** Draws the gutter adornments of the first [rows] lines into a bitmap, one row per line. */ + private fun render(model: CodeEditorModel, rows: Int = 6): androidx.compose.ui.graphics.PixelMap { + var measurer: TextMeasurer? = null + val image = ImageBitmap(200, ROW * rows) + runComposeUiTest { + setContent { measurer = rememberTextMeasurer() } + waitForIdle() + } + val look = look() + val scope = CanvasDrawScope() + scope.draw(Density(1f), LayoutDirection.Ltr, Canvas(image), Size(200f, ROW * rows.toFloat())) { + for (line in 0 until rows) { + drawGutterAdornments( + model, line, line * ROW.toFloat(), ROW, + if (model.hasOrdinaryMarks) MARK_COLUMN else 0, + FOLD_LEFT, 0, with(Density(1f)) { changeColumnPx(model, look) }, measurer!!, TextStyle(), look, + ) + } + } + return image.toPixelMap() + } + + /** The bar starts at the fold column's left edge less 4dp and 12dp, and is 4 pixels wide. */ + private val barLeft = (FOLD_LEFT - 16).toInt() + + /** Criterion 16.1: four dp wide, right of the numbers, the full row, and neighbours meet. */ + @Test + fun fr38_change_bar_is_four_dp_wide_right_of_the_numbers() { + val model = modelWith(lines, Triple(1, BAR, ADDED), Triple(2, BAR, ADDED)) + val pixels = render(model) + for (y in ROW until 3 * ROW) { + assertEquals(addedInk, pixels[barLeft, y], "left edge at $y") + assertEquals(addedInk, pixels[barLeft + 3, y], "right edge at $y") + assertNotEquals(addedInk, pixels[barLeft - 1, y]) + assertNotEquals(addedInk, pixels[barLeft + 4, y]) + } + // Nothing above the first changed line or below the last. + assertNotEquals(addedInk, pixels[barLeft, ROW - 1]) + assertNotEquals(addedInk, pixels[barLeft, 3 * ROW]) + } + + /** Criterion 16.3: one bar per line, the strongest of the changes on it. */ + @Test + fun fr38_change_bars_priority_removed_modified_added() { + val model = modelWith( + lines, + Triple(1, BAR, ADDED), Triple(1, BAR, MODIFIED), Triple(1, BAR, REMOVED), + Triple(2, BAR, ADDED), Triple(2, BAR, MODIFIED), + ) + assertEquals(ChangeMark.Removed, model.changeBarAt(1)!!.changeMark()) + assertEquals(ChangeMark.Modified, model.changeBarAt(2)!!.changeMark()) + // The ones that lost stay in the model. + assertEquals(5, model.gutterMarks.size) + val pixels = render(model) + assertEquals(removedInk, pixels[barLeft + 1, ROW + 5]) + assertEquals(modifiedInk, pixels[barLeft + 1, 2 * ROW + 5]) + } + + /** Criterion 16.4: the bar goes where its line goes and leaves with it. */ + @Test + fun fr38_change_bar_follows_edits() { + val model = modelWith(lines, Triple(3, BAR, MODIFIED), Triple(4, BAR, ADDED)) + model.edit(CodePosition(0, 0), CodePosition(0, 0), "x\ny\n", EditOrigin.Reader) + assertEquals(ChangeMark.Modified, model.changeBarAt(5)!!.changeMark()) + assertNull(model.changeBarAt(3)) + // Deleting the changed line takes its bar, and the other one closes up. + model.edit(CodePosition(5, 0), CodePosition(6, 0), "", EditOrigin.Reader) + assertEquals(ChangeMark.Added, model.changeBarAt(5)!!.changeMark()) + assertEquals(mapOf(ChangeMark.Added to listOf(5)), model.changeLines()) + } + + /** Criterion 16.2: a dot and a plain bar stay in the mark column beside the change bars. */ + @Test + fun fr38_other_gutter_marks_are_unchanged_next_to_change_bars() { + val model = modelWith( + lines, + Triple(0, DOT, 0), Triple(1, BAR, 0), Triple(1, BAR, ADDED), Triple(2, BAR, MODIFIED), + ) + val pixels = render(model) + // The dot is centred in the mark column; the plain bar is centred in it, 0.2 of a row wide. + assertEquals(plainInk, pixels[MARK_COLUMN / 2, ROW / 2]) + assertEquals(plainInk, pixels[MARK_COLUMN / 2, ROW + 5]) + assertEquals(addedInk, pixels[barLeft + 1, ROW + 5]) + assertEquals(modifiedInk, pixels[barLeft + 1, 2 * ROW + 5]) + // A model without change bars reserves no room for them. + val plain = modelWith(lines, Triple(0, DOT, 0)) + assertEquals(false, plain.hasChangeBars) + assertEquals(true, model.hasChangeBars) + } + + private val threads = java.lang.management.ManagementFactory.getThreadMXBean() as com.sun.management.ThreadMXBean + + /** + * Bytes this thread allocates per call of the gutter drawing for one line, after a + * warm-up, averaged over many calls. + */ + private fun bytesPerLine(model: CodeEditorModel): Long { + var measurer: TextMeasurer? = null + runComposeUiTest { + setContent { measurer = rememberTextMeasurer() } + waitForIdle() + } + val look = look() + val density = Density(1f) + val style = TextStyle() + val image = ImageBitmap(200, ROW * 6) + var bytes = 0L + CanvasDrawScope().draw(density, LayoutDirection.Ltr, Canvas(image), Size(200f, ROW * 6f)) { + val markColumn = if (model.hasOrdinaryMarks) MARK_COLUMN else 0 + fun pass() { + val changeColumn = changeColumnPx(model, look) + for (line in 0 until 6) { + drawGutterAdornments(model, line, line * ROW.toFloat(), ROW, markColumn, FOLD_LEFT, 0, changeColumn, measurer!!, style, look) + } + } + repeat(2_000) { pass() } + val before = threads.currentThreadAllocatedBytes + repeat(10_000) { pass() } + bytes = (threads.currentThreadAllocatedBytes - before) / (10_000L * 6) + } + return bytes + } + + /** + * Drawing the gutter of a line allocates nothing the editor keeps up. Mark lookups walk + * the mark list by index, so the only bytes left are the temporaries of the drawing + * calls themselves (offsets and sizes the JIT may not elide). The ceiling is 256 bytes + * per line, which a per-line list or iterator would exceed several times over. + */ + @Test + fun fr38_gutter_draw_does_not_allocate_without_change_bars() { + val model = modelWith(lines, Triple(1, DOT, 0), Triple(3, BAR, 0)) + assertEquals(false, model.hasChangeBars) + val bytes = bytesPerLine(model) + kotlin.test.assertTrue(bytes < 256, "allocated $bytes bytes per line") + } + + @Test + fun fr38_gutter_draw_does_not_allocate_with_change_bars() { + val model = modelWith(lines, Triple(1, BAR, ADDED), Triple(2, BAR, MODIFIED), Triple(2, BAR, REMOVED)) + assertEquals(true, model.hasChangeBars) + val bytes = bytesPerLine(model) + kotlin.test.assertTrue(bytes < 256, "allocated $bytes bytes per line") + } +} diff --git a/renderer/desktop/test/CodeEditorTest.kt b/renderer/desktop/test/CodeEditorTest.kt index 3da710109..7093113d1 100644 --- a/renderer/desktop/test/CodeEditorTest.kt +++ b/renderer/desktop/test/CodeEditorTest.kt @@ -389,4 +389,29 @@ class CodeEditorTest { assertTrue(shown.contains("let line_50000 = 50000;"), "the field starts at ${shown.take(40)}") assertFalse(shown.startsWith("let line_0 = 0;")) } + + /** Criterion 16.5: a changed line is named in the editor's description, in words rather than colour. */ + @Test + fun fr38_change_bar_adds_a_word_to_the_accessibility_description() = runComposeUiTest { + hostPlatformOverride = HostPlatform.Windows + val buffer = ByteBuffer.allocate(2 * 28).order(ByteOrder.LITTLE_ENDIAN) + for ((line, color) in listOf(1 to 39, 3 to 41)) { + buffer.putInt(0).putInt(line).putShort(2).putShort(color.toShort()).putLong(0L).putInt(0).putInt(0) + } + val connection = FakeHostConnection( + editor( + DesignSystem.Material3, + "a\nb\nc\nd\n", + Mutation.SetProp(EDITOR, PropertyKind.GutterDecorations, PropertyValue.Bytes(buffer.array())), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + val described = onNodeWithTag(nodeTestTag(EDITOR), useUnmergedTree = true) + .fetchSemanticsNode() + .config[SemanticsProperties.StateDescription] + assertTrue(described.contains("2") && described.contains("4"), described) + assertTrue(described.contains("added") || described.contains("추가됨"), described) + assertTrue(described.contains("modified") || described.contains("수정됨"), described) + } } diff --git a/renderer/desktop/test/ControlColoursTest.kt b/renderer/desktop/test/ControlColoursTest.kt new file mode 100644 index 000000000..768e3b2ae --- /dev/null +++ b/renderer/desktop/test/ControlColoursTest.kt @@ -0,0 +1,278 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.graphics.toAwtImage +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertHeightIsEqualTo +import androidx.compose.ui.test.assertWidthIsEqualTo +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.height +import androidx.compose.ui.unit.width +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.Paint +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.ShapeRole +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +/** + * A Switch, Slider and Checkbox draw with the colours and the size the Host named, and + * keep the design system's look for every slot it did not name. + */ +@OptIn(ExperimentalTestApi::class) +class ControlColoursTest { + private val root = 1 + private val control = 2 + + private val ink = 0xFF1C1B19.toInt() + private val white = 0xFFFFFFFF.toInt() + private val red = 0xFFFF0000.toInt() + private val green = 0xFF00FF00.toInt() + + private fun literal(argb: Int) = PropertyValue.Integer((2L shl 32) or (argb.toLong() and 0xffffffffL)) + + private fun size(width: Float, height: Float) = PropertyValue.Bytes( + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putFloat(width).putFloat(height).array(), + ) + + /** One control on a white 240x80 page, with the properties the test names. */ + private fun render( + system: DesignSystem, + widget: WidgetKind, + properties: List>, + extra: List = emptyList(), + check: androidx.compose.ui.test.ComposeUiTest.(java.awt.image.BufferedImage) -> Unit, + ) { + runComposeUiTest { + val mutations = buildList { + add(Mutation.SetTheme(Theme(system, system, ColorScheme.Light, false))) + add(Mutation.Create(root, WidgetKind.Box)) + add(Mutation.SetModifier(root, 0, ProtocolModifier.Size(240f, 80f))) + add(Mutation.SetModifier(root, 1, ProtocolModifier.Background(Paint.Literal(white)))) + add(Mutation.Create(control, widget)) + properties.forEach { (kind, value) -> add(Mutation.SetProp(control, kind, value)) } + addAll(extra) + add(Mutation.Insert(root, control, 0)) + } + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(mutations))) + } + waitForIdle() + check(onNodeWithTag(nodeTestTag(root)).captureToImage().toAwtImage()) + } + } + + @Test + fun fr67_a_switch_with_a_size_and_colours_measures_that_size_and_draws_them() { + DesignSystem.entries.forEach { system -> + render( + system, + WidgetKind.Switch, + listOf( + PropertyKind.Checked to PropertyValue.Bool(true), + PropertyKind.ControlSize to size(36f, 20f), + PropertyKind.TrackColor to literal(ink), + PropertyKind.ThumbColor to literal(red), + ), + ) { image -> + onNodeWithTag(nodeTestTag(control)).assertWidthIsEqualTo(36.dp) + onNodeWithTag(nodeTestTag(control)).assertHeightIsEqualTo(20.dp) + // The track, left of the thumb, and the thumb, 16 dp across at the right end. + assertEquals(ink, image.getRGB(5, 10), "$system: the checked track") + assertEquals(red, image.getRGB(26, 10), "$system: the thumb") + // Outside the 36x20 box is the page. + assertEquals(white, image.getRGB(40, 10), "$system: beyond the track") + } + } + } + + @Test + fun fr67_a_slider_draws_the_filled_and_the_remaining_track_in_the_colours_named() { + listOf(DesignSystem.Material3, DesignSystem.Fluent).forEach { system -> + render( + system, + WidgetKind.Slider, + listOf( + PropertyKind.Value to PropertyValue.Float(0.5f), + PropertyKind.Min to PropertyValue.Float(0f), + PropertyKind.Max to PropertyValue.Float(1f), + PropertyKind.ActiveTrackColor to literal(ink), + PropertyKind.TrackColor to literal(green), + PropertyKind.ThumbColor to literal(red), + ), + extra = listOf(Mutation.SetModifier(control, 0, ProtocolModifier.Size(200f, 48f))), + ) { image -> + assertEquals(ink, image.getRGB(30, 24), "$system: the filled part") + assertEquals(green, image.getRGB(170, 24), "$system: the remaining part") + } + } + } + + @Test + fun fr67_a_checked_checkbox_is_a_filled_square_with_a_check_in_the_colour_named() { + DesignSystem.entries.forEach { system -> + render( + system, + WidgetKind.Checkbox, + listOf( + PropertyKind.Checked to PropertyValue.Bool(true), + PropertyKind.ControlSize to size(18f, 18f), + PropertyKind.ContainerColor to literal(ink), + PropertyKind.CheckColor to literal(white), + ), + extra = listOf(Mutation.SetModifier(control, 0, ProtocolModifier.ShapeRole(ShapeRole.None))), + ) { image -> + onNodeWithTag(nodeTestTag(control)).assertWidthIsEqualTo(18.dp) + onNodeWithTag(nodeTestTag(control)).assertHeightIsEqualTo(18.dp) + // The corner of a square filled box is the container colour. + assertEquals(ink, image.getRGB(1, 1), "$system: the box") + assertEquals(ink, image.getRGB(16, 2), "$system: the box") + // The check passes through the middle of the box in white. + var whitePixels = 0 + for (y in 2 until 16) for (x in 2 until 16) if (image.getRGB(x, y) == white) whitePixels++ + check(whitePixels > 8) { "$system: no white check inside the box ($whitePixels)" } + } + } + } + + /** The measured size of the control and the page around it, after the Host sent `afterwards`, if it did. */ + private class Snapshot(val width: Float, val height: Float, val pixels: List) + + private fun snapshot( + system: DesignSystem, + widget: WidgetKind, + properties: List>, + afterwards: List = emptyList(), + extra: List = emptyList(), + ): Snapshot { + lateinit var result: Snapshot + runComposeUiTest { + val frames = FrameRequestSource() + val mutations = buildList { + add(Mutation.SetTheme(Theme(system, system, ColorScheme.Light, false))) + add(Mutation.Create(root, WidgetKind.Box)) + add(Mutation.SetModifier(root, 0, ProtocolModifier.Size(240f, 80f))) + add(Mutation.SetModifier(root, 1, ProtocolModifier.Background(Paint.Literal(white)))) + add(Mutation.Create(control, widget)) + properties.forEach { (kind, value) -> add(Mutation.SetProp(control, kind, value)) } + addAll(extra) + add(Mutation.Insert(root, control, 0)) + } + val connection = FakeHostConnection(mutations) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + if (afterwards.isNotEmpty()) { + connection.scheduleFrame(afterwards) + frames.request() + waitForIdle() + } + val bounds = onNodeWithTag(nodeTestTag(control)).getUnclippedBoundsInRoot() + val image = onNodeWithTag(nodeTestTag(root)).captureToImage().toAwtImage() + val pixels = buildList { for (y in 0 until image.height) for (x in 0 until image.width) add(image.getRGB(x, y)) } + result = Snapshot(bounds.width.value, bounds.height.value, pixels) + } + return result + } + + @Test + fun fr67_removing_the_colours_and_size_returns_the_defaults() { + val named = listOf( + PropertyKind.ControlSize to size(36f, 20f), + PropertyKind.TrackColor to literal(ink), + PropertyKind.ThumbColor to literal(red), + ) + DesignSystem.entries.forEach { system -> + val checked = PropertyKind.Checked to PropertyValue.Bool(true) + val reference = snapshot(system, WidgetKind.Switch, listOf(checked)) + val styled = snapshot(system, WidgetKind.Switch, listOf(checked) + named) + assertEquals(36f, styled.width, "$system: the size was applied") + assertNotEquals(reference.pixels, styled.pixels, "$system: the colours were applied") + val removed = snapshot( + system, + WidgetKind.Switch, + listOf(checked) + named, + afterwards = named.map { (kind, _) -> Mutation.SetProp(control, kind, PropertyValue.None) }, + ) + assertEquals(reference.width, removed.width, "$system: the width after the removal") + assertEquals(reference.height, removed.height, "$system: the height after the removal") + assertEquals(reference.pixels, removed.pixels, "$system: the pixels after the removal") + } + } + + @Test + fun fr67_removing_the_checkbox_colours_and_size_returns_the_defaults() { + val named = listOf( + PropertyKind.ControlSize to size(30f, 30f), + PropertyKind.ContainerColor to literal(ink), + PropertyKind.CheckColor to literal(red), + ) + DesignSystem.entries.forEach { system -> + val checked = PropertyKind.Checked to PropertyValue.Bool(true) + val reference = snapshot(system, WidgetKind.Checkbox, listOf(checked)) + val styled = snapshot(system, WidgetKind.Checkbox, listOf(checked) + named) + assertEquals(30f, styled.width, "$system: the size was applied") + assertNotEquals(reference.pixels, styled.pixels, "$system: the colours were applied") + val removed = snapshot( + system, + WidgetKind.Checkbox, + listOf(checked) + named, + afterwards = named.map { (kind, _) -> Mutation.SetProp(control, kind, PropertyValue.None) }, + ) + assertEquals(reference.width, removed.width, "$system: the width after the removal") + assertEquals(reference.height, removed.height, "$system: the height after the removal") + assertEquals(reference.pixels, removed.pixels, "$system: the pixels after the removal") + } + } + + @Test + fun fr67_control_look_is_not_recomputed_for_unchanged_properties() { + val system = DesignSystem.entries.first() + val theme = dev.darkpyonix.composerust.design.ResolvedTheme( + system = system, + tokens = dev.darkpyonix.composerust.protocol.DesignTokens.of(system), + rules = dev.darkpyonix.composerust.design.rulesFor(system), + dark = false, + ) + val node = dev.darkpyonix.composerust.ui.node.Node(control, WidgetKind.Switch) + node.props[PropertyKind.TrackColor] = literal(ink) + val looks = mutableListOf() + runComposeUiTest { + val tick = androidx.compose.runtime.mutableIntStateOf(0) + setContent { + tick.intValue + looks += dev.darkpyonix.composerust.design.rememberControlLook(node, theme) + } + waitForIdle() + tick.intValue = 1 + waitForIdle() + node.props[PropertyKind.TrackColor] = literal(red) + waitForIdle() + } + check(looks.size >= 2) { "the content was composed ${looks.size} times" } + assertEquals(true, looks[0] === looks[1], "an unchanged node gets the look it had") + assertEquals(false, looks.last() === looks[0], "a changed colour makes a new look") + } +} diff --git a/renderer/desktop/test/FileDialogTest.kt b/renderer/desktop/test/FileDialogTest.kt new file mode 100644 index 000000000..918ab73ca --- /dev/null +++ b/renderer/desktop/test/FileDialogTest.kt @@ -0,0 +1,396 @@ +package dev.darkpyonix.composerust.test + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.EventDispatcher +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.TableError +import dev.darkpyonix.composerust.ui.platform.FileDialogCenter +import dev.darkpyonix.composerust.ui.platform.FileDialogOutcome +import dev.darkpyonix.composerust.ui.platform.FileDialogPath +import dev.darkpyonix.composerust.ui.platform.FileDialogPlatform +import dev.darkpyonix.composerust.ui.platform.FileDialogSpec +import dev.darkpyonix.composerust.ui.platform.FileDialogs +import dev.darkpyonix.composerust.ui.platform.UnsupportedFileDialogs +import kotlin.concurrent.thread +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** A platform that remembers what it was asked and answers when the test says so. */ +private class FakeFileDialogs : FileDialogPlatform { + class Shown(val window: Any?, val spec: FileDialogSpec, val onResult: (FileDialogOutcome) -> Unit) + + val shown = mutableListOf() + val cancelledWindows = mutableListOf() + + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) { + shown += Shown(window, request, onResult) + } + + override fun cancelWindow(window: Any?) { + cancelledWindows += window + } + + fun forRequest(request: Int): Shown = shown.single { it.spec.request == request } +} + +private fun dialog( + request: Int, + window: Int = 0, + kind: FileDialogKind = FileDialogKind.OpenFiles, + defaultFilter: Int = 0, + name: String = "", + filters: String = "", +) = Mutation.ShowFileDialog( + request = request, + window = window, + kind = kind, + defaultFilter = defaultFilter, + title = "파일 고르기", + directory = "/home/한글", + name = name, + filters = filters, +) + +/** + * The interpreter's half of file dialogs: what it checks, what it normalises, and the one + * way an answer reaches the Host. Whether a real platform panel opens is a manual check per + * platform; everything on this side of it is checked here against a platform that only + * remembers. + */ +class FileDialogTest { + @AfterTest + fun restore() { + FileDialogs.platform = UnsupportedFileDialogs + } + + private fun hostWith(platform: FileDialogPlatform): Pair { + FileDialogs.platform = platform + val connection = FakeHostConnection() + val host = ComposeRustHost(connection) + host.start() + return host to connection + } + + /** Delivers what the platform reported, the way the frame an answer asks for does. */ + private fun ComposeRustHost.frame() = renderFrame(0) + + /** + * Asks for a dialog on a window of its own, so that requests made together are all shown + * at once and the test does not depend on the order a window shows them in. + */ + private fun ComposeRustHost.ask( + request: Int, + kind: FileDialogKind = FileDialogKind.OpenFiles, + defaultFilter: Int = 0, + name: String = "", + filters: String = "", + ) { + table.apply(Mutation.Create(100 + request, WidgetKind.Window)) + table.apply(dialog(request, 100 + request, kind, defaultFilter, name, filters)) + } + + private fun FakeHostConnection.results() = events.filterIsInstance() + + private fun FakeHostConnection.errorCodes() = + events.filterIsInstance().map { it.code } + + @Test + fun fr72_unknown_window_reports_and_answers_unavailable() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + + host.table.apply(dialog(1, window = 42)) + // A node that exists but is not a window is as unknown as one that does not exist. + host.table.apply(Mutation.Create(7, WidgetKind.Box)) + host.table.apply(dialog(2, window = 7)) + host.frame() + + assertTrue(platform.shown.isEmpty(), "no platform dialog is shown for a window that is not there") + assertEquals( + listOf(1 to FileDialogStatus.Unavailable, 2 to FileDialogStatus.Unavailable), + connection.results().map { it.request to it.status }, + ) + assertTrue(connection.results().all { it.paths.isEmpty() && it.filter == -1 }) + assertEquals(listOf(TableError.UNKNOWN_NODE, TableError.UNKNOWN_NODE), connection.errorCodes()) + } + + @Test + fun fr72_duplicate_pending_request_is_a_protocol_error() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + + host.table.apply(dialog(5)) + host.table.apply(dialog(5, kind = FileDialogKind.Save)) + host.frame() + + assertEquals(1, platform.shown.size, "the second command is dropped") + assertEquals(FileDialogKind.OpenFiles, platform.shown.single().spec.kind) + assertEquals(listOf(TableError.DUPLICATE_FILE_DIALOG), connection.errorCodes()) + assertTrue(connection.results().isEmpty(), "the first request is still waiting") + + platform.forRequest(5).onResult(FileDialogOutcome.Chosen.ofText(listOf("/a/b.txt"))) + host.frame() + assertEquals( + listOf(5 to FileDialogStatus.Chosen), + connection.results().map { it.request to it.status }, + "the first request keeps its single answer", + ) + assertEquals(1, connection.errorCodes().size, "and the duplicate was reported once") + + // The number is free again once it was answered. + host.table.apply(dialog(5)) + host.frame() + assertEquals(2, platform.shown.size) + assertEquals(1, connection.errorCodes().size) + } + + @Test + fun fr72_filters_over_the_limit_are_trimmed_not_rejected() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + + val encoded = (0 until 20).joinToString("\u0000") { filter -> + val extensions = (0 until 40).joinToString(",") { extension -> + when (extension % 3) { + 0 -> "*.EXT$extension" + 1 -> ".Ext$extension" + else -> "EXT$extension" + } + } + "F$filter\u0000$extensions" + } + host.ask(1, defaultFilter = 99, filters = encoded) + host.ask(2, defaultFilter = 3, filters = encoded) + host.ask(3, kind = FileDialogKind.OpenFolder, filters = encoded) + host.ask(4, kind = FileDialogKind.Save, name = "dir/sub\\report.txt", filters = "Text\u0000TXT") + host.ask(5, kind = FileDialogKind.OpenFile, name = "ignored.txt") + host.ask(6, defaultFilter = 2, filters = "Dots\u0000.,*.\u0000All\u0000\u0000Txt\u0000txt") + host.frame() + + assertTrue(connection.errorCodes().isEmpty(), "trimming is not an error") + assertEquals(6, platform.shown.size, "every request is shown") + val first = platform.forRequest(1).spec + assertEquals(16, first.filters.size) + assertEquals("F0", first.filters.first().label) + assertEquals("F15", first.filters.last().label) + assertTrue(first.filters.all { it.extensions.size == 32 }) + assertEquals(listOf("ext0", "ext1", "ext2"), first.filters.first().extensions.take(3)) + assertEquals("ext31", first.filters.first().extensions.last()) + assertEquals(0, first.defaultFilter, "a default filter out of range is the first") + val dots = platform.forRequest(6).spec + assertEquals(listOf("All", "Txt"), dots.filters.map { it.label }, "a filter of only dots is dropped, an empty one is kept") + assertEquals(1, dots.defaultFilter, "the default follows its filter after an earlier one was dropped") + assertEquals(3, platform.forRequest(2).spec.defaultFilter) + assertTrue(platform.forRequest(3).spec.filters.isEmpty(), "a folder dialog has no filters") + assertEquals("report.txt", platform.forRequest(4).spec.name) + assertEquals(listOf("txt"), platform.forRequest(4).spec.filters.single().extensions) + assertEquals("", platform.forRequest(5).spec.name, "an open dialog has no file name") + } + + @Test + fun fr72_result_filter_is_the_position_in_the_list_the_host_sent() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + // The filter at position 0 keeps no extension and is dropped, so "All" and "Txt" + // are positions 1 and 2 in what the Host sent and 0 and 1 in what the platform shows. + host.ask(1, filters = "Dots\u0000.\u0000All\u0000\u0000Txt\u0000txt") + host.ask(2, filters = "Dots\u0000.\u0000All\u0000\u0000Txt\u0000txt") + host.frame() + platform.forRequest(1).onResult(FileDialogOutcome.Chosen.ofText(listOf("/a.txt"), filter = 1)) + host.frame() + platform.forRequest(2).onResult(FileDialogOutcome.Chosen.ofText(listOf("/b"), filter = 0)) + host.frame() + + val results = connection.results().associateBy { it.request } + assertEquals(2, results.getValue(1).filter, "Txt was sent at position 2") + assertEquals(1, results.getValue(2).filter, "All was sent at position 1") + } + + @Test + fun fr72_paths_arrive_whole_utf8_and_a_non_utf8_path_answers_unavailable() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + val long = "/" + "a".repeat(300) + val korean = "/home/한글/문서 1.txt" + + host.ask(1, filters = "Docs\u0000txt") + host.ask(2) + host.ask(3) + host.ask(4) + host.frame() + platform.forRequest(1).onResult( + FileDialogOutcome.Chosen( + listOf( + FileDialogPath.Text(korean), + FileDialogPath.Text(long), + FileDialogPath.Bytes("/tmp/\u00e9".encodeToByteArray()), + ), + filter = 0, + ), + ) + platform.forRequest(2).onResult( + FileDialogOutcome.Chosen(listOf(FileDialogPath.Bytes(byteArrayOf(0xC3.toByte())))), + ) + platform.forRequest(3).onResult(FileDialogOutcome.Chosen(emptyList())) + platform.forRequest(4).onResult(FileDialogOutcome.Cancelled) + host.frame() + + val results = connection.results().associateBy { it.request } + assertEquals(FileDialogStatus.Chosen, results.getValue(1).status) + assertEquals(listOf(korean, long, "/tmp/\u00e9"), results.getValue(1).paths.split('\u0000')) + assertEquals(0, results.getValue(1).filter) + // One path is not text, so the whole choice is unavailable and the others are not sent. + assertEquals(FileDialogStatus.Unavailable, results.getValue(2).status) + assertEquals("", results.getValue(2).paths) + // Nothing was chosen at all, which is what a cancel is. + assertEquals(FileDialogStatus.Cancelled, results.getValue(3).status) + assertEquals(FileDialogStatus.Cancelled, results.getValue(4).status) + } + + @Test + fun fr72_more_than_ten_thousand_paths_answers_unavailable() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.ask(1) + host.ask(2) + host.frame() + + platform.forRequest(1).onResult( + FileDialogOutcome.Chosen.ofText((0..10_000).map { "/files/$it" }), + ) + platform.forRequest(2).onResult( + FileDialogOutcome.Chosen.ofText((1..10_000).map { "/files/$it" }), + ) + host.frame() + + val results = connection.results().associateBy { it.request } + assertEquals(FileDialogStatus.Unavailable, results.getValue(1).status, "10001 paths") + assertEquals("", results.getValue(1).paths, "never a shorter list under Chosen") + assertEquals(FileDialogStatus.Chosen, results.getValue(2).status, "10000 paths still fit") + assertEquals(10_000, results.getValue(2).paths.split('\u0000').size) + + // Too many bytes of paths is unavailable for the same reason. + val answers = mutableListOf() + val fake = FakeFileDialogs() + val limited = FileDialogCenter(fake, maxPayloadBytes = 64) + limited.attach(EventDispatcher { event -> answers += event; true }) + limited.request(dialog(9), windowKnown = true) + limited.drain() + fake.forRequest(9).onResult(FileDialogOutcome.Chosen.ofText(listOf("/" + "x".repeat(40), "/" + "y".repeat(40)))) + limited.drain() + val bytes = answers.filterIsInstance().single() + assertEquals(FileDialogStatus.Unavailable, bytes.status) + assertEquals("", bytes.paths) + } + + @Test + fun fr72_closing_the_owner_window_cancels_the_dialog() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.table.fileDialogs.windowResolver = { node -> "window-$node" } + host.table.apply(Mutation.Create(5, WidgetKind.Window)) + host.table.apply(dialog(1, window = 5)) + host.table.apply(dialog(2, window = 5)) + host.frame() + assertEquals(listOf("window-5"), platform.shown.map { it.window }) + assertEquals(1, platform.shown.size, "the second request on the window waits for the first") + + host.table.apply(Mutation.Remove(5)) + host.frame() + + assertEquals(listOf("window-5"), platform.cancelledWindows, "the open dialog is told to close") + assertEquals( + listOf(1 to FileDialogStatus.Cancelled, 2 to FileDialogStatus.Cancelled), + connection.results().map { it.request to it.status }, + "each request ends with one cancel", + ) + assertEquals(1, platform.shown.size, "the waiting request was never shown") + + // The platform answering late changes nothing: the request was already answered. + platform.forRequest(1).onResult(FileDialogOutcome.Chosen.ofText(listOf("/late"))) + host.frame() + assertEquals(2, connection.results().size) + assertTrue(connection.errorCodes().isEmpty()) + } + + @Test + fun fr72_unsupported_platform_answers_unavailable() { + val (host, connection) = hostWith(UnsupportedFileDialogs) + host.table.apply(dialog(1)) + host.table.apply(dialog(2, kind = FileDialogKind.Save)) + host.frame() + host.frame() + + assertEquals( + listOf(1 to FileDialogStatus.Unavailable, 2 to FileDialogStatus.Unavailable), + connection.results().map { it.request to it.status }, + ) + assertTrue(connection.errorCodes().isEmpty(), "an unsupported platform is not a protocol error") + } + + @Test + fun fr72_every_request_gets_exactly_one_result() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.table.apply(dialog(1)) + host.table.apply(Mutation.Create(5, WidgetKind.Window)) + host.table.apply(dialog(2, window = 5)) + host.table.apply(dialog(3, window = 6)) + host.table.apply(dialog(4, window = 5)) + host.frame() + + // Chosen, then answered again and differently: only the first counts. + platform.forRequest(1).onResult(FileDialogOutcome.Chosen.ofText(listOf("/a"))) + platform.forRequest(1).onResult(FileDialogOutcome.Cancelled) + // Cancelled, from a thread of the platform's own. + val worker = thread { platform.forRequest(2).onResult(FileDialogOutcome.Cancelled) } + worker.join() + host.frame() + // Request 4 was waiting behind request 2 and is shown now. + host.frame() + platform.forRequest(4).onResult(FileDialogOutcome.Unavailable) + host.frame() + host.frame() + + val byRequest = connection.results().groupBy { it.request } + assertEquals(setOf(1, 2, 3, 4), byRequest.keys) + assertTrue(byRequest.values.all { it.size == 1 }, "one answer each: ${connection.results()}") + assertEquals(FileDialogStatus.Chosen, byRequest.getValue(1).single().status) + assertEquals(FileDialogStatus.Cancelled, byRequest.getValue(2).single().status) + assertEquals(FileDialogStatus.Unavailable, byRequest.getValue(3).single().status) + assertEquals(FileDialogStatus.Unavailable, byRequest.getValue(4).single().status) + assertTrue(connection.results().all { it.nodeId == 0 && it.handlerId == 0L }) + } + + @Test + fun fr72_requests_on_one_window_are_shown_in_order() { + val platform = FakeFileDialogs() + val (host, connection) = hostWith(platform) + host.table.apply(Mutation.Create(5, WidgetKind.Window)) + host.table.apply(dialog(1)) + host.table.apply(dialog(2)) + host.table.apply(dialog(3, window = 5)) + host.frame() + + assertEquals( + listOf(1, 3), + platform.shown.map { it.spec.request }, + "the second request on the root window waits, the other window does not", + ) + + platform.forRequest(1).onResult(FileDialogOutcome.Cancelled) + host.frame() + assertEquals(listOf(1, 3, 2), platform.shown.map { it.spec.request }) + platform.forRequest(2).onResult(FileDialogOutcome.Cancelled) + platform.forRequest(3).onResult(FileDialogOutcome.Cancelled) + host.frame() + assertEquals(listOf(1, 2, 3), connection.results().map { it.request }) + } +} diff --git a/renderer/desktop/test/HostComposerTokensTest.kt b/renderer/desktop/test/HostComposerTokensTest.kt new file mode 100644 index 000000000..208bcf040 --- /dev/null +++ b/renderer/desktop/test/HostComposerTokensTest.kt @@ -0,0 +1,88 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.IconRole +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * TextField typography and control over composer and icon button tokens. + */ +@OptIn(ExperimentalTestApi::class) +class HostComposerTokensTest { + private val root = 1 + private val control = 2 + + @Test + fun fr75_text_field_honors_explicit_height_and_content_padding() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false)), + Mutation.Create(root, WidgetKind.Box), + Mutation.Create(control, WidgetKind.TextField), + Mutation.SetProp(control, PropertyKind.Text, PropertyValue.Text("hello")), + Mutation.SetModifier(control, 0, ProtocolModifier.Height(36f)), + Mutation.SetModifier(control, 1, ProtocolModifier.PaddingEach(12f, 6f, 12f, 6f)), + Mutation.Insert(root, control, 0), + ) + + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) + } + waitForIdle() + + val bounds = onNodeWithTag(nodeTestTag(control)).getBoundsInRoot() + val height = bounds.bottom - bounds.top + assertTrue( + abs(height.value - 36f) <= 1.5f, + "field with explicit height 36dp measured $height instead of adhering to height", + ) + } + + @Test + fun fr75_icon_button_honors_explicit_size() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false)), + Mutation.Create(root, WidgetKind.Box), + Mutation.Create(control, WidgetKind.Button), + Mutation.SetProp(control, PropertyKind.Text, PropertyValue.Text("")), + Mutation.SetProp(control, PropertyKind.Icon, PropertyValue.Integer(IconRole.Add.ordinal + 1L)), + Mutation.SetModifier(control, 0, ProtocolModifier.Size(32f, 32f)), + Mutation.SetModifier(control, 1, ProtocolModifier.CornerEach(8f, 8f, 8f, 8f)), + Mutation.Insert(root, control, 0), + ) + + setContent { + ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) + } + waitForIdle() + + val bounds = onNodeWithTag(nodeTestTag(control)).getBoundsInRoot() + val width = bounds.right - bounds.left + val height = bounds.bottom - bounds.top + assertTrue( + abs(width.value - 32f) <= 1.5f, + "wordless button with explicit size 32dp measured width $width", + ) + assertTrue( + abs(height.value - 32f) <= 1.5f, + "wordless button with explicit size 32dp measured height $height", + ) + } +} diff --git a/renderer/desktop/test/HostOverlayTokensTest.kt b/renderer/desktop/test/HostOverlayTokensTest.kt new file mode 100644 index 000000000..49dfd4df8 --- /dev/null +++ b/renderer/desktop/test/HostOverlayTokensTest.kt @@ -0,0 +1,149 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.foundation.dialogTestTag +import dev.darkpyonix.composerust.foundation.sheetTestTag +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.OverlayTokens +import dev.darkpyonix.composerust.protocol.Paint +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import dev.darkpyonix.composerust.ui.paintProp +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +@OptIn(ExperimentalTestApi::class) +class HostOverlayTokensTest { + private val frames = FrameRequestSource() + + @Test + fun fr74_sheet_honors_overlay_tokens_and_own_modifiers() = runComposeUiTest { + val sheetId = 1 + val textId = 2 + val tokens = OverlayTokens( + sheetCornerRadius = 24, + sheetElevation = 8, + sheetPadding = 20, + scrim = 0x80000000L, + ) + val connection = FakeHostConnection( + listOf( + Mutation.SetTheme( + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, adaptive = false), + ), + Mutation.SetOverlayTokens(tokens), + Mutation.Create(sheetId, WidgetKind.Sheet), + Mutation.SetProp(sheetId, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.Create(textId, WidgetKind.Text), + Mutation.SetProp(textId, PropertyKind.Text, PropertyValue.Text("Settings")), + Mutation.Insert(sheetId, textId, 0), + ), + ) + lateinit var host: ComposeRustHost + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + host = rememberComposeRustHost(connection) + ComposeRustContent( + host, + Modifier.requiredSize(500.dp, 800.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, host.table.overlayTokens) + onNodeWithTag(nodeTestTag(textId)).assertTextEquals("Settings") + + // Overriding scrim on the node directly + val scrimBits = (2L shl 32) or (0x40112233L and 0xFFFFFFFFL) + connection.scheduleFrame( + listOf( + Mutation.SetProp(sheetId, PropertyKind.Scrim, PropertyValue.Integer(scrimBits)), + ), + ) + frames.request() + waitForIdle() + + val node = host.table.node(sheetId) + assertNotNull(node) + assertEquals(Paint.Literal(0x40112233), node.paintProp(PropertyKind.Scrim)) + } + + @Test + fun fr74_dialog_and_menu_honor_scrim_and_tokens() = runComposeUiTest { + val root = 10 + val dialogId = 11 + val dialogText = 12 + val tokens = OverlayTokens( + dialogCornerRadius = 16, + popupCornerRadius = 12, + dialogElevation = 6, + popupElevation = 4, + dialogPadding = 16, + popupPadding = 8, + scrim = 0x66000000L, + ) + val scrimBits = (2L shl 32) or (0x80000000L and 0xFFFFFFFFL) + val connection = FakeHostConnection( + listOf( + Mutation.SetTheme( + Theme(DesignSystem.Cupertino, DesignSystem.Cupertino, ColorScheme.Light, adaptive = false), + ), + Mutation.SetOverlayTokens(tokens), + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(dialogId, WidgetKind.Dialog), + Mutation.SetProp(dialogId, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(dialogId, PropertyKind.Scrim, PropertyValue.Integer(scrimBits)), + Mutation.Create(dialogText, WidgetKind.Text), + Mutation.SetProp(dialogText, PropertyKind.Text, PropertyValue.Text("Confirm")), + Mutation.Insert(dialogId, dialogText, 0), + Mutation.Insert(root, dialogId, 0), + ), + ) + lateinit var host: ComposeRustHost + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + host = rememberComposeRustHost(connection) + ComposeRustContent( + host, + Modifier.requiredSize(800.dp, 600.dp), + ) + } + } + waitForIdle() + + assertEquals(tokens, host.table.overlayTokens) + onNodeWithTag(nodeTestTag(dialogText)).assertTextEquals("Confirm") + onNodeWithTag(dialogTestTag(dialogId)).assertExists() + val dialogNode = host.table.node(dialogId) + assertNotNull(dialogNode) + assertEquals(Paint.Literal(0x80000000.toInt()), dialogNode.paintProp(PropertyKind.Scrim)) + } +} diff --git a/renderer/desktop/test/KeyEventTest.kt b/renderer/desktop/test/KeyEventTest.kt index 261694644..baf514f6a 100644 --- a/renderer/desktop/test/KeyEventTest.kt +++ b/renderer/desktop/test/KeyEventTest.kt @@ -1,7 +1,9 @@ package dev.darkpyonix.composerust.test import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performKeyInput @@ -14,12 +16,16 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Key as ProtocolKey +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier import dev.darkpyonix.composerust.protocol.Mutation import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.foundation.HeldKeys +import dev.darkpyonix.composerust.foundation.keyName import dev.darkpyonix.composerust.foundation.protocolKey import dev.darkpyonix.composerust.foundation.shouldDispatchKeyDown +import dev.darkpyonix.composerust.foundation.shouldDispatchKeyUp import dev.darkpyonix.composerust.runtime.ComposeRustContent import dev.darkpyonix.composerust.runtime.rememberComposeRustHost import dev.darkpyonix.composerust.tooling.FakeHostConnection @@ -63,6 +69,9 @@ class KeyEventTest { ctrlKey = false, altKey = false, metaKey = false, + repeat = false, + code = 0x28, + text = "Enter", ), ), connection.nodeEvents, @@ -88,7 +97,7 @@ class KeyEventTest { ) } - /** Key-up is not a key-down, and an unnameable key cannot cross the schema. */ + /** Key-up is not a key-down, and a key the enum does not name travels as `Other`. */ @Test fun fr12_only_schema_key_downs_cross_the_boundary() { assertFalse( @@ -97,7 +106,7 @@ class KeyEventTest { assertFalse(shouldDispatchKeyDown(composing = false, isKeyDown = true, key = null)) assertEquals(ProtocolKey.Enter, protocolKey(Key.Enter)) assertEquals(ProtocolKey.Enter, protocolKey(Key.NumPadEnter)) - assertEquals(null, protocolKey(Key.A)) + assertEquals(ProtocolKey.Other, protocolKey(Key.A)) } /** A Host that does not consume leaves the key to the editor. */ @@ -140,4 +149,262 @@ class KeyEventTest { connection.nodeEvents, ) } + + /** + * A focused `TextField` sends `FocusGained` once when focus arrives and `FocusLost` + * when focus moves to another control. + */ + @Test + fun fr45_focus_gained_and_lost_pair() = runComposeUiTest { + val otherField = 2 + val gainedHandler = 51L + val lostHandler = 52L + val connection = FakeHostConnection( + listOf( + Mutation.Create(10, WidgetKind.Column), + Mutation.Create(FIELD, WidgetKind.TextField), + Mutation.SetProp(FIELD, PropertyKind.OnFocusGained, PropertyValue.Integer(gainedHandler)), + Mutation.SetProp(FIELD, PropertyKind.OnFocusLost, PropertyValue.Integer(lostHandler)), + Mutation.Create(otherField, WidgetKind.TextField), + Mutation.Insert(10, FIELD, 0), + Mutation.Insert(10, otherField, 1), + Mutation.Insert(0, 10, 0), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + waitForIdle() + assertEquals( + listOf(HostEvent.FocusGained(FIELD, gainedHandler)), + connection.nodeEvents, + ) + + onNodeWithTag(nodeTestTag(otherField)).requestFocus() + waitForIdle() + assertEquals( + listOf( + HostEvent.FocusGained(FIELD, gainedHandler), + HostEvent.FocusLost(FIELD, lostHandler), + ), + connection.nodeEvents, + ) + } + + /** + * Pressing and releasing a key sends `KeyDown` followed by `KeyUp` with the same key + * and modifiers, and never while an IME composition is in progress. + */ + @Test + fun fr45_key_up_follows_key_down() = runComposeUiTest { + val keyUpHandler = 43L + assertFalse( + shouldDispatchKeyUp(composing = true, isKeyUp = true, key = ProtocolKey.Enter), + "Key-up during a composition must not be offered to the Host", + ) + assertTrue( + shouldDispatchKeyUp(composing = false, isKeyUp = true, key = ProtocolKey.Enter), + ) + + val connection = FakeHostConnection( + keyField( + Mutation.SetProp(FIELD, PropertyKind.OnKeyUp, PropertyValue.Integer(keyUpHandler)), + ), + ) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { pressKey(Key.Enter) } + waitForIdle() + + assertEquals( + listOf( + HostEvent.KeyDown( + nodeId = FIELD, + handlerId = KEY_HANDLER, + key = ProtocolKey.Enter, + shiftKey = false, + ctrlKey = false, + altKey = false, + metaKey = false, + repeat = false, + code = 0x28, + text = "Enter", + ), + HostEvent.KeyUp( + nodeId = FIELD, + handlerId = keyUpHandler, + key = ProtocolKey.Enter, + shiftKey = false, + ctrlKey = false, + altKey = false, + metaKey = false, + repeat = false, + code = 0x28, + text = "Enter", + ), + ), + connection.nodeEvents, + ) + } + + /** + * A node with neither focus nor key handlers registered sends nothing across the boundary + * when focused, blurred, or typed into. + */ + @Test + fun fr45_unregistered_nodes_get_no_events() = runComposeUiTest { + val otherField = 2 + val connection = FakeHostConnection( + listOf( + Mutation.Create(10, WidgetKind.Column), + Mutation.Create(FIELD, WidgetKind.TextField), + Mutation.SetProp(FIELD, PropertyKind.Multiline, PropertyValue.Bool(true)), + Mutation.Create(otherField, WidgetKind.TextField), + Mutation.Insert(10, FIELD, 0), + Mutation.Insert(10, otherField, 1), + Mutation.Insert(0, 10, 0), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { pressKey(Key.Enter) } + onNodeWithTag(nodeTestTag(otherField)).requestFocus() + waitForIdle() + + assertEquals(emptyList(), connection.nodeEvents) + } + + /** Arrows, Escape, Tab and letters each arrive with their HID usage and DOM key string. */ + @Test + fun fr12_every_key_reaches_the_host_with_code_and_text() = runComposeUiTest { + val connection = FakeHostConnection(keyField()) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + val pressed = listOf( + Key.DirectionDown to (0x51 to "ArrowDown"), + Key.DirectionUp to (0x52 to "ArrowUp"), + Key.Escape to (0x29 to "Escape"), + Key.Tab to (0x2B to "Tab"), + Key.A to (0x04 to "a"), + ) + for ((key, _) in pressed) { + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { pressKey(key) } + } + waitForIdle() + + val events = connection.nodeEvents.filterIsInstance() + assertEquals(pressed.map { it.second }, events.map { it.code to it.text }) + assertTrue(events.all { !it.repeat }) + assertEquals(ProtocolKey.Other, events.first().key) + assertEquals("A", keyName(Key.A, shift = true).text) + } + + /** Only the first down of a held key is a first press; the rest are repeats. */ + @Test + fun fr12_held_key_marks_repeat() = runComposeUiTest { + val held = HeldKeys() + assertFalse(held.observe(KeyEventType.KeyDown, Key.DirectionDown)) + assertTrue(held.observe(KeyEventType.KeyDown, Key.DirectionDown)) + assertTrue(held.observe(KeyEventType.KeyDown, Key.DirectionDown)) + assertFalse(held.observe(KeyEventType.KeyUp, Key.DirectionDown)) + assertFalse(held.observe(KeyEventType.KeyDown, Key.DirectionDown), "a new press after release") + + val connection = FakeHostConnection(keyField()) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { + keyDown(Key.DirectionDown) + advanceEventTime(1_000) + keyUp(Key.DirectionDown) + } + waitForIdle() + + val events = connection.nodeEvents.filterIsInstance() + assertEquals(false, events.first().repeat) + assertTrue(events.drop(1).isNotEmpty() && events.drop(1).all { it.repeat }, "held key produced no repeats: ${events.map { it.repeat }}") + } + + /** No key of any kind is offered to the Host while a composition is in progress. */ + @Test + fun fr12_no_key_reaches_the_host_during_composition() { + for (key in listOf(Key.Enter, Key.DirectionDown, Key.Escape, Key.Tab, Key.A)) { + assertFalse( + shouldDispatchKeyDown(composing = true, isKeyDown = true, key = protocolKey(key)), + "$key reached the Host during a composition", + ) + assertTrue( + shouldDispatchKeyDown(composing = false, isKeyDown = true, key = protocolKey(key)), + ) + } + } + + /** A key the Host consumes is not handled by the editor, whatever key it is. */ + @Test + fun fr12_consumed_key_is_not_handled_by_the_renderer() = runComposeUiTest { + val connection = FakeHostConnection(keyField()) + connection.respondWith { HostResponse(result = 1) } + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + onNodeWithTag(nodeTestTag(FIELD)).requestFocus() + onNodeWithTag(nodeTestTag(FIELD)).performKeyInput { + pressKey(Key.A) + pressKey(Key.Enter) + } + waitForIdle() + + assertEquals(2, connection.nodeEvents.filterIsInstance().size) + onNodeWithTag(nodeTestTag(FIELD)).assertTextEquals("") + } + + /** + * A focusable box with `OnKeyDown` receives the keys pressed while the box itself has + * focus, not only keys that bubble up from a focused descendant (issue 101). The box has + * no children, so the only way a key can reach the handler is through the box's own + * focus target. + */ + @Test + fun fr12_a_focused_box_receives_its_own_keys() = runComposeUiTest { + val box = 2 + val connection = FakeHostConnection( + listOf( + Mutation.Create(box, WidgetKind.Box), + Mutation.SetModifier(box, 0, ProtocolModifier.Size(40f, 40f)), + Mutation.SetModifier(box, 1, ProtocolModifier.Clickable(77L)), + Mutation.SetProp(box, PropertyKind.OnKeyDown, PropertyValue.Integer(KEY_HANDLER)), + ), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + + // A box with a key handler is made focusable but may expose no RequestFocus action, + // so it is focused the way a keyboard user does it. + onNodeWithTag(nodeTestTag(box)).performKeyInput { pressKey(Key.Tab) } + waitForIdle() + onNodeWithTag(nodeTestTag(box)).assertIsFocused() + onNodeWithTag(nodeTestTag(box)).performKeyInput { pressKey(Key.Enter) } + waitForIdle() + + assertEquals( + listOf( + HostEvent.KeyDown( + nodeId = box, + handlerId = KEY_HANDLER, + key = ProtocolKey.Enter, + shiftKey = false, + ctrlKey = false, + altKey = false, + metaKey = false, + repeat = false, + code = 0x28, + text = "Enter", + ), + ), + connection.nodeEvents.filterIsInstance().filter { it.key == ProtocolKey.Enter }, + ) + } } diff --git a/renderer/desktop/test/MeasureTest.kt b/renderer/desktop/test/MeasureTest.kt index 212ad49ee..4e7b6bbad 100644 --- a/renderer/desktop/test/MeasureTest.kt +++ b/renderer/desktop/test/MeasureTest.kt @@ -156,6 +156,24 @@ class MeasureTest { ) { text, width -> text(text, width = width, spans = spanRecord(6, 4, flags = 1) + spanRecord(15, 4, role = 9)) }, + Case( + "mixed role spans", + "Heading words then body words then a small caption that wraps", + 110f, + listOf( + PropertyKind.Spans to PropertyValue.Bytes( + spanRecord(0, 7, role = 3) + spanRecord(19, 4, flags = 1) + spanRecord(37, 7, role = 9) + + spanRecord(45, 7, role = 3, flags = 1), + ), + ), + ) { text, width -> + text( + text, + width = width, + spans = spanRecord(0, 7, role = 3) + spanRecord(19, 4, flags = 1) + spanRecord(37, 7, role = 9) + + spanRecord(45, 7, role = 3, flags = 1), + ) + }, Case( "truncated", "One line, then another line, then a third that is cut off", @@ -190,6 +208,22 @@ class MeasureTest { } } + @Test + fun pr2_span_fonts_are_ignored_where_the_text_has_a_role() = runComposeUiTest { + startHost(FakeHostConnection(emptyList())) + val text = "Words set in a role keep the theme font" + val spans = spanRecord(0, 5) + spanRecord(10, 4, flags = 1) + val mono = mapOf(0 to listOf(FontRef.Generic(GenericFamily.Monospace)), 1 to listOf(FontRef.Generic(GenericFamily.Monospace))) + val (status, results) = measure( + MeasureRequests() + .text(text, role = 5, width = 400f, spans = spans) + .text(text, role = 5, width = 400f, spans = spans, spanFonts = mono), + ) + assertEquals(MeasureRecords.CALL_OK, status) + assertEquals(results[0].width, results[1].width) + assertEquals(results[0].height, results[1].height) + } + @Test fun pr2_intrinsic_widths_match_compose() = runComposeUiTest { lateinit var drawDensity: Density diff --git a/renderer/desktop/test/NativeClipboardTest.kt b/renderer/desktop/test/NativeClipboardTest.kt index 87ee065f8..5543ae806 100644 --- a/renderer/desktop/test/NativeClipboardTest.kt +++ b/renderer/desktop/test/NativeClipboardTest.kt @@ -24,6 +24,8 @@ import dev.darkpyonix.composerust.ui.platform.NativeMenuEntry import dev.darkpyonix.composerust.ui.platform.PasteboardClipboard import dev.darkpyonix.composerust.ui.platform.WindowClipboardImpl import java.awt.datatransfer.DataFlavor +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -40,6 +42,20 @@ import kotlin.test.assertTrue */ class NativeClipboardTest { + private var savedLocale: java.util.Locale? = null + + /** The menu labels come from the default locale; a Korean machine would label them 잘라내기. */ + @BeforeTest + fun pinEnglishLocale() { + savedLocale = java.util.Locale.getDefault() + java.util.Locale.setDefault(java.util.Locale.ENGLISH) + } + + @AfterTest + fun restoreLocale() { + savedLocale?.let { java.util.Locale.setDefault(it) } + } + private var board = "" private val clipboard = WindowClipboardImpl(PasteboardClipboard({ board }, { board = it })) diff --git a/renderer/desktop/test/NativeContextMenuTest.kt b/renderer/desktop/test/NativeContextMenuTest.kt index 052324c90..f094fc372 100644 --- a/renderer/desktop/test/NativeContextMenuTest.kt +++ b/renderer/desktop/test/NativeContextMenuTest.kt @@ -43,6 +43,8 @@ import org.thisisthepy.compose.window.WindowEvent import dev.darkpyonix.composerust.ui.platform.receive import org.jetbrains.skia.Surface import kotlinx.coroutines.Dispatchers +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -59,6 +61,20 @@ private const val FIELD = "field" */ class NativeContextMenuTest { + private var savedLocale: java.util.Locale? = null + + /** The menu labels come from the default locale; a Korean machine would label them 잘라내기. */ + @BeforeTest + fun pinEnglishLocale() { + savedLocale = java.util.Locale.getDefault() + java.util.Locale.setDefault(java.util.Locale.ENGLISH) + } + + @AfterTest + fun restoreLocale() { + savedLocale?.let { java.util.Locale.setDefault(it) } + } + /** Every menu the native representation was asked for, as the entries it was given. */ private val nativeRequests = mutableListOf>() diff --git a/renderer/desktop/test/PickerTest.kt b/renderer/desktop/test/PickerTest.kt index 44e17f623..af675f50b 100644 --- a/renderer/desktop/test/PickerTest.kt +++ b/renderer/desktop/test/PickerTest.kt @@ -209,9 +209,13 @@ class PickerTest { PropertyKind.OnValueChange, PropertyKind.OnSubmit, PropertyKind.OnFocusLost, + PropertyKind.OnFocusGained, PropertyKind.OnKeyDown, + PropertyKind.OnKeyUp, PropertyKind.OnRangeRequested, PropertyKind.OnDismiss, + // The name assistive technology reads can be given to any node. + PropertyKind.AccessibleName, ) listOf(WidgetKind.DatePicker, WidgetKind.TimePicker).forEach { widget -> PropertyKind.entries.forEach { property -> diff --git a/renderer/desktop/test/ProtocolVectorsTest.kt b/renderer/desktop/test/ProtocolVectorsTest.kt index fc9f53428..1418fa117 100644 --- a/renderer/desktop/test/ProtocolVectorsTest.kt +++ b/renderer/desktop/test/ProtocolVectorsTest.kt @@ -10,7 +10,10 @@ import java.nio.ByteOrder import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.protocol.FileDialogStatus import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.ProtocolException import dev.darkpyonix.composerust.protocol.SyntaxSpanRecord import dev.darkpyonix.composerust.protocol.Severity import dev.darkpyonix.composerust.protocol.HoverPhase @@ -98,11 +101,13 @@ class ProtocolVectorsTest { is Mutation.AppendText -> mutation.nodeId !in created // The theme applies to the tree, not to a node, so it is never a bad record. is Mutation.SetTheme -> false + is Mutation.SetOverlayTokens -> false // Nor does the window: it is about the frame around the tree. is Mutation.SetWindow -> false // A window's theme and bringing one forward name a node the vector created. is Mutation.SetWindowTheme -> mutation.nodeId !in created is Mutation.FocusWindow -> mutation.nodeId !in created + is Mutation.RequestFocus -> mutation.nodeId !in created && mutation.nodeId != 0 is Mutation.Create -> false is Mutation.RegisterAsset -> true is Mutation.ReleaseAsset -> true @@ -112,6 +117,8 @@ class ProtocolVectorsTest { is Mutation.PostNotification -> false is Mutation.WithdrawNotification -> false is Mutation.RequestNotificationPermission -> false + // The vector's dialog is on the implicit root window, which always exists. + is Mutation.ShowFileDialog -> false is Mutation.EditCode -> mutation.nodeId !in created is Mutation.SetSelection -> mutation.nodeId !in created is Mutation.RevealRange -> mutation.nodeId !in created @@ -191,7 +198,7 @@ class ProtocolVectorsTest { activated, ) assertEquals(37, length) - assertEquals(bytes.copyOfRange(237, 274).toList(), activated.array().copyOf(length).toList()) + assertEquals(bytes.copyOfRange(254, 291).toList(), activated.array().copyOf(length).toList()) val changed = ByteBuffer.allocate(32) val changedLength = Protocol.encodeEvent( @@ -203,7 +210,73 @@ class ProtocolVectorsTest { changed, ) assertEquals(20, changedLength) - assertEquals(bytes.copyOfRange(274, 294).toList(), changed.array().copyOf(changedLength).toList()) + assertEquals(bytes.copyOfRange(291, 311).toList(), changed.array().copyOf(changedLength).toList()) + } + + /** + * The file dialog request and its result, read from and encoded to the bytes the Host + * wrote: four string references, a kind and a default filter, and three words and a + * string reference. + */ + @Test + fun fr72_the_file_dialog_records_in_the_vector_round_trip() { + assertEquals( + listOf( + Mutation.ShowFileDialog( + request = 7, + window = 0, + kind = FileDialogKind.OpenFiles, + defaultFilter = 1, + title = "파일 고르기", + directory = "/home/한글", + name = "보고서.txt", + filters = "이미지\u0000png,jpg\u0000문서\u0000pdf,txt", + ), + ), + decodeVector("mutations.bin").filterIsInstance(), + ) + + val bytes = vectorFile("events.bin").readBytes() + val out = ByteBuffer.allocate(128) + val length = Protocol.encodeEvent( + HostEvent.FileDialogResult( + nodeId = 0, + handlerId = 0, + request = 7, + status = FileDialogStatus.Chosen, + filter = 1, + paths = "/Users/한글/문서.txt\u0000/tmp/a b.png", + ), + out, + ) + assertEquals(73, length) + assertEquals(bytes.copyOfRange(969, 969 + 73).toList(), out.array().copyOf(length).toList()) + } + + /** A kind the Renderer does not know is a decode error, which the host reports instead of crashing. */ + @Test + fun fr72_an_unknown_file_dialog_kind_is_a_protocol_error() { + val bytes = vectorFile("mutations.bin").readBytes() + val view = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + val recordsLength = view.getInt(4) + var offset = 12 + var patched = false + while (offset < recordsLength) { + val tag = view.getShort(offset).toInt() + val length = view.getShort(offset + 2).toInt() + if (tag == 26) { + for (unknown in listOf(0, 6, 9)) { + view.putShort(offset + 12, unknown.toShort()) + val failure = runCatching { + Protocol.decode(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)) { } + }.exceptionOrNull() + assertTrue(failure is ProtocolException, "kind $unknown decoded instead of being refused: $failure") + } + patched = true + } + offset += length + } + assertTrue(patched, "the vector carries a file dialog request") } @Test @@ -400,14 +473,14 @@ class ProtocolVectorsTest { out, ) assertEquals(40, length) - assertEquals(bytes.copyOfRange(470, 510).toList(), out.array().copyOf(length).toList()) + assertEquals(bytes.copyOfRange(487, 527).toList(), out.array().copyOf(length).toList()) val motion = ByteBuffer.allocate(32) val motionLength = Protocol.encodeEvent( HostEvent.ReducedMotionChanged(0, 0, dev.darkpyonix.composerust.protocol.ReducedMotion.On), motion, ) assertEquals(20, motionLength) - assertEquals(bytes.copyOfRange(510, 530).toList(), motion.array().copyOf(motionLength).toList()) + assertEquals(bytes.copyOfRange(527, 547).toList(), motion.array().copyOf(motionLength).toList()) } /** The pointer event encodes to the 72 bytes the Host decodes. */ @@ -436,7 +509,7 @@ class ProtocolVectorsTest { out, ) assertEquals(72, length) - assertEquals(bytes.copyOfRange(530, 602).toList(), out.array().copyOf(length).toList()) + assertEquals(bytes.copyOfRange(547, 619).toList(), out.array().copyOf(length).toList()) } /** The theme record carries the palette behind it, and both sides read the same entries. */ @@ -553,16 +626,16 @@ class ProtocolVectorsTest { return buffer.array().copyOf(length) } val expected = listOf( - 294 to HostEvent.CodeChanged(12, 18L, 1, 1, 8, 1, 8, "mut "), - 342 to HostEvent.CodeEditRejected(12, 19L, 7, 0, 2, 1, 8, 1, 9), - 386 to HostEvent.CodeHovered(12, 20L, 42L, 0, 4, HoverPhase.Rest), - 422 to HostEvent.CodeSaveRequested(12, 21L, 2), - 446 to HostEvent.DecorationActivated(12, 22L, 41L), - 602 to HostEvent.SelectionChanged(12, 23L, 2, 1, 12, 0, 3), - 638 to HostEvent.VisibleRangeChanged(12, 24L, 2, 0, 3), - 666 to HostEvent.EditorFocusChanged(12, 25L, true), - 690 to HostEvent.FoldChanged(12, 26L, 0, true), - 714 to HostEvent.CompositionChanged(12, 27L, true), + 311 to HostEvent.CodeChanged(12, 18L, 1, 1, 8, 1, 8, "mut "), + 359 to HostEvent.CodeEditRejected(12, 19L, 7, 0, 2, 1, 8, 1, 9), + 403 to HostEvent.CodeHovered(12, 20L, 42L, 0, 4, HoverPhase.Rest), + 439 to HostEvent.CodeSaveRequested(12, 21L, 2), + 463 to HostEvent.DecorationActivated(12, 22L, 41L), + 619 to HostEvent.SelectionChanged(12, 23L, 2, 1, 12, 0, 3), + 655 to HostEvent.VisibleRangeChanged(12, 24L, 2, 0, 3), + 683 to HostEvent.EditorFocusChanged(12, 25L, true), + 707 to HostEvent.FoldChanged(12, 26L, 0, true), + 731 to HostEvent.CompositionChanged(12, 27L, true), ) for ((offset, event) in expected) { val wire = encoded(event) @@ -571,7 +644,7 @@ class ProtocolVectorsTest { "$event at $offset", ) } - assertEquals(952, bytes.size) + assertEquals(1042, bytes.size) } /** diff --git a/renderer/desktop/test/RowContentSizeTest.kt b/renderer/desktop/test/RowContentSizeTest.kt new file mode 100644 index 000000000..e0a3f3251 --- /dev/null +++ b/renderer/desktop/test/RowContentSizeTest.kt @@ -0,0 +1,132 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runComposeUiTest +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@OptIn(ExperimentalTestApi::class) +class RowContentSizeTest { + @Test + fun fr70_unweighted_containers_wrap_their_content() = runComposeUiTest { + val batch = buildList { + add(Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false))) + add(Mutation.Create(1, WidgetKind.Column)) + listOf(WidgetKind.Box, WidgetKind.Row, WidgetKind.Column).forEachIndexed { index, kind -> + val row = 10 + index * 10 + add(Mutation.Create(row, WidgetKind.Row)) + add(Mutation.SetModifier(row, 0, ProtocolModifier.Width(300f))) + add(Mutation.Create(row + 1, WidgetKind.Column)) + add(Mutation.SetModifier(row + 1, 0, ProtocolModifier.Weight(1f))) + add(Mutation.Create(row + 2, kind)) + add(Mutation.Create(row + 3, WidgetKind.Text)) + add(Mutation.SetProp(row + 3, PropertyKind.Text, PropertyValue.Text("Switch"))) + add(Mutation.Insert(row + 2, row + 3, 0)) + add(Mutation.Insert(row, row + 1, 0)) + add(Mutation.Insert(row, row + 2, 1)) + add(Mutation.Insert(1, row, index)) + } + } + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + for (row in listOf(10, 20, 30)) { + val container = onNodeWithTag(nodeTestTag(row + 2)).fetchSemanticsNode().size.width + val text = onNodeWithTag(nodeTestTag(row + 3)).fetchSemanticsNode().size.width + val width = onNodeWithTag(nodeTestTag(row)).fetchSemanticsNode().size.width + assertEquals(width, onNodeWithTag(nodeTestTag(row + 1)).fetchSemanticsNode().size.width + container) + assertEquals(text, container, "an unweighted container wraps its content") + assertTrue(container < width) + } + } + + @Test + fun fr70_weighted_text_keeps_room_for_the_longest_word() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + Mutation.Create(1, WidgetKind.Column), + Mutation.Create(2, WidgetKind.Row), + Mutation.SetModifier(2, 0, ProtocolModifier.Width(300f)), + Mutation.Create(3, WidgetKind.Column), + Mutation.SetModifier(3, 0, ProtocolModifier.Weight(1f)), + Mutation.Create(4, WidgetKind.Text), + Mutation.SetProp(4, PropertyKind.Text, PropertyValue.Text("가나다라마바사 다른 문장")), + Mutation.Create(5, WidgetKind.Box), + Mutation.SetModifier(5, 0, ProtocolModifier.Width(290f)), + Mutation.Create(6, WidgetKind.Text), + Mutation.SetProp(6, PropertyKind.Text, PropertyValue.Text("가나다라마바사")), + Mutation.Insert(3, 4, 0), + Mutation.Insert(2, 3, 0), + Mutation.Insert(2, 5, 1), + Mutation.Insert(1, 2, 0), + Mutation.Insert(1, 6, 1), + ) + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + val columnWidth = onNodeWithTag(nodeTestTag(3)).fetchSemanticsNode().size.width + val wordWidth = onNodeWithTag(nodeTestTag(6)).fetchSemanticsNode().size.width + assertTrue(columnWidth >= wordWidth, "weighted text has room for its longest word") + } + @Test + fun fr70_weighted_column_child_keeps_minimum_intrinsic_height() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + Mutation.Create(1, WidgetKind.Column), + Mutation.SetModifier(1, 0, ProtocolModifier.Height(300f)), + Mutation.Create(2, WidgetKind.Box), + Mutation.SetModifier(2, 0, ProtocolModifier.Weight(1f)), + Mutation.Create(3, WidgetKind.Box), + Mutation.SetModifier(3, 0, ProtocolModifier.Height(80f)), + Mutation.Create(4, WidgetKind.Box), + Mutation.SetModifier(4, 0, ProtocolModifier.Height(290f)), + Mutation.Insert(2, 3, 0), + Mutation.Insert(1, 2, 0), + Mutation.Insert(1, 4, 1), + ) + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + val weighted = onNodeWithTag(nodeTestTag(2)).fetchSemanticsNode().size.height + val content = onNodeWithTag(nodeTestTag(3)).fetchSemanticsNode().size.height + assertEquals(content, weighted, "weighted child retains its intrinsic height") + } + + @Test + fun fr70_weighted_children_share_remaining_space() = runComposeUiTest { + val batch = listOf( + Mutation.SetTheme(Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + Mutation.Create(1, WidgetKind.Row), + Mutation.SetModifier(1, 0, ProtocolModifier.Width(300f)), + Mutation.Create(2, WidgetKind.Box), + Mutation.SetModifier(2, 0, ProtocolModifier.Weight(1f)), + Mutation.Create(3, WidgetKind.Box), + Mutation.SetModifier(3, 0, ProtocolModifier.Weight(2f)), + Mutation.Create(4, WidgetKind.Box), + Mutation.SetModifier(4, 0, ProtocolModifier.Width(60f)), + Mutation.Insert(1, 2, 0), + Mutation.Insert(1, 3, 1), + Mutation.Insert(1, 4, 2), + ) + setContent { ComposeRustContent(rememberComposeRustHost(FakeHostConnection(batch))) } + waitForIdle() + val first = onNodeWithTag(nodeTestTag(2)).fetchSemanticsNode().size.width + val second = onNodeWithTag(nodeTestTag(3)).fetchSemanticsNode().size.width + val fixed = onNodeWithTag(nodeTestTag(4)).fetchSemanticsNode().size.width + val row = onNodeWithTag(nodeTestTag(1)).fetchSemanticsNode().size.width + assertEquals(row - fixed, first + second) + assertTrue(kotlin.math.abs(second - 2 * first) <= 2) + } + +} diff --git a/renderer/desktop/test/SheetTest.kt b/renderer/desktop/test/SheetTest.kt index 287b97a14..3235fe029 100644 --- a/renderer/desktop/test/SheetTest.kt +++ b/renderer/desktop/test/SheetTest.kt @@ -6,12 +6,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsFocused import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.getBoundsInRoot +import androidx.compose.ui.test.isRoot +import androidx.compose.ui.test.onLast import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performKeyInput import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.pressKey +import androidx.compose.ui.test.requestFocus import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.test.swipeDown import androidx.compose.ui.unit.Density @@ -173,4 +180,311 @@ class SheetTest { ) assertNear(covered.right, sheet.right, "its trailing edge") } + + /** + * Pressing Escape on an open Dialog dispatches OnDismiss once, and leaves the Dialog + * open when the Host keeps Open true. + */ + @Test + fun fr21_escape_dismisses_an_open_dialog_once() = runComposeUiTest { + val dialog = 1 + val label = 2 + val handler = 61L + val connection = FakeHostConnection( + listOf( + Mutation.Create(dialog, WidgetKind.Dialog), + Mutation.SetProp(dialog, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(dialog, PropertyKind.OnDismiss, PropertyValue.Integer(handler)), + Mutation.Create(label, WidgetKind.Text), + Mutation.SetProp(label, PropertyKind.Text, PropertyValue.Text("unsaved changes")), + Mutation.Insert(dialog, label, 0), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(label)).assertTextEquals("unsaved changes") + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + val dismissals = connection.events.filterIsInstance() + assertEquals(listOf(HostEvent.Clicked(dialog, handler)), dismissals) + // The Host did not send Open=false, so the Dialog stays open. + onNodeWithTag(nodeTestTag(label)).assertTextEquals("unsaved changes") + } + + /** + * Pressing Escape on an open Sheet dispatches OnDismiss once in both narrow and wide + * windows. + */ + @Test + fun fr21_escape_dismisses_an_open_sheet_once() = runComposeUiTest { + for (windowWidth in listOf(500.dp, 1100.dp)) { + val connection = FakeHostConnection(sheetBatch(open = true)) + setContent { + CompositionLocalProvider( + LocalFrameRequests provides frames, + LocalDensity provides Density(1f), + ) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(windowWidth, 800.dp), + ) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(CONTENT)).assertTextEquals("filter by") + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + val dismissals = connection.events.filterIsInstance() + assertEquals( + listOf(HostEvent.Clicked(SHEET, DISMISS_HANDLER)), + dismissals, + "width=$windowWidth", + ) + onNodeWithTag(nodeTestTag(CONTENT)).assertTextEquals("filter by") + } + } + + /** + * An overlay with no focusable controls inside it still receives Escape immediately + * after opening, even when a control on the page held focus before. + */ + @Test + fun fr21_escape_reaches_an_overlay_with_no_focusable_content() = runComposeUiTest { + val root = 10 + val opener = 11 + val sheet = 12 + val text = 13 + val connection = FakeHostConnection( + listOf( + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(opener, WidgetKind.Button), + Mutation.SetProp(opener, PropertyKind.Text, PropertyValue.Text("Settings")), + Mutation.SetProp(opener, PropertyKind.OnClick, PropertyValue.Integer(70L)), + Mutation.Insert(root, opener, 0), + Mutation.Create(sheet, WidgetKind.Sheet), + Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(false)), + Mutation.SetProp(sheet, PropertyKind.OnDismiss, PropertyValue.Integer(DISMISS_HANDLER)), + Mutation.Create(text, WidgetKind.Text), + Mutation.SetProp(text, PropertyKind.Text, PropertyValue.Text("Read-only info")), + Mutation.Insert(sheet, text, 0), + Mutation.Insert(root, sheet, 1), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(opener)).requestFocus() + waitForIdle() + + connection.scheduleFrame( + listOf(Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(true))), + ) + frames.request() + waitForIdle() + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + val dismissals = connection.events.filterIsInstance() + assertEquals(listOf(HostEvent.Clicked(sheet, DISMISS_HANDLER)), dismissals) + } + + /** + * When a focused TextField inside an overlay has an active IME composition, Escape + * cancels the composition and does not dispatch OnDismiss. + */ + @Test + fun fr21_escape_in_a_composing_text_field_does_not_dismiss() = runComposeUiTest { + val field = 20 + val compositionHandler = 81L + var editorUpdate: ((androidx.compose.ui.text.input.TextFieldValue) -> Unit)? = null + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = { update -> editorUpdate = update } + try { + val connection = FakeHostConnection( + listOf( + Mutation.Create(SHEET, WidgetKind.Sheet), + Mutation.SetProp(SHEET, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(SHEET, PropertyKind.OnDismiss, PropertyValue.Integer(DISMISS_HANDLER)), + Mutation.Create(field, WidgetKind.TextField), + Mutation.SetProp( + field, + PropertyKind.OnCompositionChange, + PropertyValue.Integer(compositionHandler), + ), + Mutation.Insert(SHEET, field, 0), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + onNodeWithTag(nodeTestTag(field)).requestFocus() + waitForIdle() + + val update = requireNotNull(editorUpdate) { "TextField did not install value hook" } + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "한", + selection = androidx.compose.ui.text.TextRange(1), + composition = androidx.compose.ui.text.TextRange(0, 1), + ), + ) + } + waitForIdle() + + // First Escape cancels the active IME composition without dismissing the sheet. + onNodeWithTag(nodeTestTag(field)).performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + assertEquals( + emptyList(), + connection.events.filterIsInstance(), + "Escape during IME composition must not dismiss the overlay", + ) + assertEquals( + listOf( + HostEvent.CompositionChanged(field, compositionHandler, true), + HostEvent.CompositionChanged(field, compositionHandler, false), + ), + connection.events.filterIsInstance(), + ) + + // Once composition is gone, a subsequent Escape dismisses the sheet. + onNodeWithTag(nodeTestTag(field)).performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + assertEquals( + listOf(HostEvent.Clicked(SHEET, DISMISS_HANDLER)), + connection.events.filterIsInstance(), + ) + } finally { + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = null + } + } + + /** + * When a Dialog is open on top of a Sheet, Escape dispatches OnDismiss only for the + * topmost overlay (the Dialog), leaving the Sheet's handler untouched. + */ + @Test + fun fr21_escape_goes_to_the_topmost_overlay() = runComposeUiTest { + val root = 10 + val sheet = 11 + val sheetText = 12 + val dialog = 13 + val dialogText = 14 + val sheetDismiss = 91L + val dialogDismiss = 92L + val connection = FakeHostConnection( + listOf( + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(sheet, WidgetKind.Sheet), + Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(true)), + Mutation.SetProp(sheet, PropertyKind.OnDismiss, PropertyValue.Integer(sheetDismiss)), + Mutation.Create(sheetText, WidgetKind.Text), + Mutation.SetProp(sheetText, PropertyKind.Text, PropertyValue.Text("Project settings")), + Mutation.Insert(sheet, sheetText, 0), + Mutation.Insert(root, sheet, 0), + Mutation.Create(dialog, WidgetKind.Dialog), + Mutation.SetProp(dialog, PropertyKind.Open, PropertyValue.Bool(false)), + Mutation.SetProp(dialog, PropertyKind.OnDismiss, PropertyValue.Integer(dialogDismiss)), + Mutation.Create(dialogText, WidgetKind.Text), + Mutation.SetProp(dialogText, PropertyKind.Text, PropertyValue.Text("Discard changes?")), + Mutation.Insert(dialog, dialogText, 0), + Mutation.Insert(root, dialog, 1), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + + connection.scheduleFrame( + listOf(Mutation.SetProp(dialog, PropertyKind.Open, PropertyValue.Bool(true))), + ) + frames.request() + waitForIdle() + + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + + assertEquals( + listOf(HostEvent.Clicked(dialog, dialogDismiss)), + connection.events.filterIsInstance(), + ) + } + + /** + * When an overlay closes, keyboard focus returns to the control that held focus before + * the overlay opened. + */ + @Test + fun fr21_escape_returns_focus_to_the_opener() = runComposeUiTest { + val root = 10 + val opener = 11 + val sheet = 12 + val sheetText = 13 + val connection = FakeHostConnection( + listOf( + Mutation.Create(root, WidgetKind.Column), + Mutation.Create(opener, WidgetKind.TextField), + Mutation.Insert(root, opener, 0), + Mutation.Create(sheet, WidgetKind.Sheet), + Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(false)), + Mutation.SetProp(sheet, PropertyKind.OnDismiss, PropertyValue.Integer(DISMISS_HANDLER)), + Mutation.Create(sheetText, WidgetKind.Text), + Mutation.SetProp(sheetText, PropertyKind.Text, PropertyValue.Text("Details")), + Mutation.Insert(sheet, sheetText, 0), + Mutation.Insert(root, sheet, 1), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames) { + ComposeRustContent(rememberComposeRustHost(connection)) + } + } + waitForIdle() + + onNodeWithTag(nodeTestTag(opener)).requestFocus() + waitForIdle() + onNodeWithTag(nodeTestTag(opener)).assertIsFocused() + + // Open the sheet. + connection.scheduleFrame( + listOf(Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(true))), + ) + frames.request() + waitForIdle() + + // Escape asks the Host to dismiss; the Host closes the sheet by setting Open=false. + onAllNodes(isRoot()).onLast().performKeyInput { pressKey(Key.Escape) } + waitForIdle() + assertEquals( + listOf(HostEvent.Clicked(sheet, DISMISS_HANDLER)), + connection.events.filterIsInstance(), + ) + connection.scheduleFrame( + listOf(Mutation.SetProp(sheet, PropertyKind.Open, PropertyValue.Bool(false))), + ) + frames.request() + waitForIdle() + + onNodeWithTag(nodeTestTag(opener)).assertIsFocused() + } } diff --git a/renderer/desktop/test/TextFieldTest.kt b/renderer/desktop/test/TextFieldTest.kt index 1dfaef2ca..251f1f0d8 100644 --- a/renderer/desktop/test/TextFieldTest.kt +++ b/renderer/desktop/test/TextFieldTest.kt @@ -8,6 +8,8 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.runComposeUiTest +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -56,6 +58,20 @@ private fun ComposeUiTest.darkestInk(): Int { @OptIn(ExperimentalTestApi::class) class TextFieldTest { + + private var savedDark: Boolean? = null + + /** The placeholder test measures ink on a light field; a dark system would make both renders background. */ + @BeforeTest + fun pinLightAppearance() { + savedDark = dev.darkpyonix.composerust.runtime.systemDarkOverride + dev.darkpyonix.composerust.runtime.systemDarkOverride = false + } + + @AfterTest + fun restoreAppearance() { + dev.darkpyonix.composerust.runtime.systemDarkOverride = savedDark + } /** * A placeholder is not text, and it is not set as though it were. * @@ -247,4 +263,124 @@ class TextFieldTest { val height = onNodeWithTag(nodeTestTag(FIELD)).fetchSemanticsNode().boundsInRoot.height / density.density assertEquals(400f, height, 1f, "multiline field with FillMaxHeight must fill its 400.dp container") } + + @Test + fun fr65_request_focus_moves_and_clears_focus() = runComposeUiTest { + val column = 10 + val second = 2 + val focusLostFirst = 31L + val focusLostSecond = 32L + val connection = FakeHostConnection( + listOf( + Mutation.Create(column, WidgetKind.Column), + Mutation.Create(FIELD, WidgetKind.TextField), + Mutation.SetProp(FIELD, PropertyKind.OnFocusLost, PropertyValue.Integer(focusLostFirst)), + Mutation.Insert(column, FIELD, 0), + Mutation.Create(second, WidgetKind.TextField), + Mutation.SetProp(second, PropertyKind.OnFocusLost, PropertyValue.Integer(focusLostSecond)), + Mutation.Insert(column, second, 1), + ), + ) + lateinit var host: ComposeRustHost + setContent { host = rememberComposeRustHost(connection); ComposeRustContent(host) } + waitForIdle() + + connection.respondWith { HostResponse(listOf(Mutation.RequestFocus(FIELD))) } + host.dispatch(HostEvent.Clicked(column, 0)) + waitForIdle() + + onNodeWithTag(nodeTestTag(FIELD)).performTextInput("a") + waitForIdle() + onNodeWithTag(nodeTestTag(FIELD)).assertTextEquals("a") + + connection.respondWith { HostResponse(listOf(Mutation.RequestFocus(second))) } + host.dispatch(HostEvent.Clicked(column, 0)) + waitForIdle() + + assertEquals( + listOf(HostEvent.FocusLost(FIELD, focusLostFirst)), + connection.events.filterIsInstance(), + ) + + connection.respondWith { HostResponse(listOf(Mutation.RequestFocus(0))) } + host.dispatch(HostEvent.Clicked(column, 0)) + waitForIdle() + + assertEquals( + listOf( + HostEvent.FocusLost(FIELD, focusLostFirst), + HostEvent.FocusLost(second, focusLostSecond), + ), + connection.events.filterIsInstance(), + ) + } + + @Test + fun fr65_request_focus_unknown_node_reports_error() = runComposeUiTest { + val connection = FakeHostConnection( + field(Mutation.RequestFocus(999)), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + + val errors = connection.events.filterIsInstance() + assertEquals(1, errors.size, "events were ${connection.events}") + assertEquals(dev.darkpyonix.composerust.ui.node.TableError.UNKNOWN_NODE, errors.single().code) + } + + @Test + fun fr65_text_field_reports_composition_changes() = runComposeUiTest { + val compositionHandler = 41L + var editorUpdate: ((androidx.compose.ui.text.input.TextFieldValue) -> Unit)? = null + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = { update -> editorUpdate = update } + try { + val connection = FakeHostConnection( + field(Mutation.SetProp(FIELD, PropertyKind.OnCompositionChange, PropertyValue.Integer(compositionHandler))), + ) + setContent { ComposeRustContent(rememberComposeRustHost(connection)) } + waitForIdle() + + val update = requireNotNull(editorUpdate) { "TextField did not install value hook" } + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "ㅎ", + selection = androidx.compose.ui.text.TextRange(1), + composition = androidx.compose.ui.text.TextRange(0, 1), + ), + ) + } + waitForIdle() + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "한", + selection = androidx.compose.ui.text.TextRange(1), + composition = androidx.compose.ui.text.TextRange(0, 1), + ), + ) + } + waitForIdle() + runOnIdle { + update( + androidx.compose.ui.text.input.TextFieldValue( + text = "한", + selection = androidx.compose.ui.text.TextRange(1), + composition = null, + ), + ) + } + waitForIdle() + + assertEquals( + listOf( + HostEvent.CompositionChanged(FIELD, compositionHandler, true), + HostEvent.CompositionChanged(FIELD, compositionHandler, false), + ), + connection.events.filterIsInstance(), + ) + } finally { + dev.darkpyonix.composerust.foundation.hostTextFieldValueHook = null + } + } } diff --git a/renderer/desktop/test/Transform3DProtocolTest.kt b/renderer/desktop/test/Transform3DProtocolTest.kt new file mode 100644 index 000000000..8582a0953 --- /dev/null +++ b/renderer/desktop/test/Transform3DProtocolTest.kt @@ -0,0 +1,80 @@ +package dev.darkpyonix.composerust.test + +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.Protocol +import dev.darkpyonix.composerust.protocol.ProtocolException +import java.io.File +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +/** + * The 3D transform record against the bytes the Rust Host writes: one 92 byte `SetModifier` + * on node 3, the column-major matrix, the origin and the backface flag. + */ +class Transform3DProtocolTest { + + private val expected = ProtocolModifier.Transform3D( + 0.5f, -0.25f, 1.5f, 0.125f, + -2.0f, 3.0f, 0.75f, -0.5f, + 4.0f, -1.25f, 2.5f, -0.0625f, + 12.0f, -6.0f, 8.0f, 1.0f, + 0.5f, 0.25f, + true, + ) + + @Test + fun fr41_3_transform3d_round_trips() { + val bytes = vector() + assertEquals(104, bytes.size) + assertEquals(listOf(Mutation.SetModifier(3, 0, expected)), decode(bytes)) + // The layout the Host writes: modifier tag 27 at offset 22 of the file, the + // matrix from offset 24, the origin at 88 and 92, the flag as a whole word at 96. + val buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN) + assertEquals(92, buffer.getShort(14).toInt()) + assertEquals(27, buffer.getShort(22).toInt()) + assertEquals(0.5f, buffer.getFloat(24)) + assertEquals(1.0f, buffer.getFloat(24 + 4 * 15)) + assertEquals(0.5f, buffer.getFloat(88)) + assertEquals(0.25f, buffer.getFloat(92)) + assertEquals(1L, buffer.getLong(96)) + } + + @Test + fun fr41_3_non_finite_is_a_protocol_error() { + for (bad in listOf(Float.NaN, Float.POSITIVE_INFINITY, Float.NEGATIVE_INFINITY)) { + for (field in 0 until 18) { + val bytes = vector() + ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).putFloat(24 + 4 * field, bad) + assertFailsWith("$bad in field $field") { decode(bytes) } + } + } + // A flag that is neither 0 nor 1. + val flag = vector() + ByteBuffer.wrap(flag).order(ByteOrder.LITTLE_ENDIAN).putLong(96, 2L) + assertFailsWith("a flag of 2") { decode(flag) } + // A record whose length is not the one the tag fixes. + val shortRecord = vector() + ByteBuffer.wrap(shortRecord).order(ByteOrder.LITTLE_ENDIAN).putShort(14, 44) + assertFailsWith("a length of 44") { decode(shortRecord) } + } + + private fun decode(bytes: ByteArray): List { + val mutations = mutableListOf() + Protocol.decode(ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN), mutations::add) + return mutations + } + + private fun vector(): ByteArray { + var directory: File? = File(System.getProperty("user.dir")).absoluteFile + while (directory != null) { + val candidate = File(directory, "compose-rust/tests/vectors/transform3d.bin") + if (candidate.isFile) return candidate.readBytes() + directory = directory.parentFile + } + error("protocol vector transform3d.bin not found above ${System.getProperty("user.dir")}") + } +} diff --git a/renderer/desktop/test/Transform3DTest.kt b/renderer/desktop/test/Transform3DTest.kt new file mode 100644 index 000000000..b71da09de --- /dev/null +++ b/renderer/desktop/test/Transform3DTest.kt @@ -0,0 +1,401 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PixelMap +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.Density +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.Paint +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.tooling.HostResponse +import dev.darkpyonix.composerust.ui.Compose3DBinding +import dev.darkpyonix.composerust.ui.Transform3DPlan +import dev.darkpyonix.composerust.ui.node.RenderNodeObserver +import dev.darkpyonix.composerust.ui.node.nodeTestTag +import java.io.File +import kotlin.math.PI +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.hypot +import kotlin.math.sin +import kotlin.math.tan +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private const val WHITE = 0xFFFFFFFF.toInt() +private const val RED = 0xFFFF0000.toInt() +private const val ROOT = 1 +private const val BOX = 2 +private const val CLICK = 78L + +/** A CSS 4x4 matrix in the column-major order of `matrix3d`, as doubles. */ +private class Css3D(val m: DoubleArray) { + operator fun times(other: Css3D): Css3D { + val out = DoubleArray(16) + for (column in 0 until 4) { + for (row in 0 until 4) { + var sum = 0.0 + for (k in 0 until 4) sum += m[4 * k + row] * other.m[4 * column + k] + out[4 * column + row] = sum + } + } + return Css3D(out) + } + + /** Where the point (x, y, 0) lands on the screen, after the division by w. */ + fun apply(x: Double, y: Double): Offset { + val w = m[3] * x + m[7] * y + m[15] + return Offset(((m[0] * x + m[4] * y + m[12]) / w).toFloat(), ((m[1] * x + m[5] * y + m[13]) / w).toFloat()) + } + + /** The same matrix turning about the point (x, y), the way `transform-origin` does. */ + fun about(x: Double, y: Double): Css3D = translate(x, y) * this * translate(-x, -y) + + fun modifier(originX: Float, originY: Float, backfaceHidden: Boolean = false) = ProtocolModifier.Transform3D( + m[0].toFloat(), m[1].toFloat(), m[2].toFloat(), m[3].toFloat(), + m[4].toFloat(), m[5].toFloat(), m[6].toFloat(), m[7].toFloat(), + m[8].toFloat(), m[9].toFloat(), m[10].toFloat(), m[11].toFloat(), + m[12].toFloat(), m[13].toFloat(), m[14].toFloat(), m[15].toFloat(), + originX, originY, backfaceHidden, + ) + + fun floats(): FloatArray = FloatArray(16) { m[it].toFloat() } + + companion object { + private fun identity() = DoubleArray(16).also { it[0] = 1.0; it[5] = 1.0; it[10] = 1.0; it[15] = 1.0 } + private fun radians(degrees: Double) = degrees * PI / 180.0 + + fun perspective(d: Double) = Css3D(identity().also { it[11] = -1.0 / d }) + fun rotateX(degrees: Double) = Css3D(identity().also { + val c = cos(radians(degrees)); val s = sin(radians(degrees)) + it[5] = c; it[6] = s; it[9] = -s; it[10] = c + }) + fun rotateY(degrees: Double) = Css3D(identity().also { + val c = cos(radians(degrees)); val s = sin(radians(degrees)) + it[0] = c; it[2] = -s; it[8] = s; it[10] = c + }) + fun rotateZ(degrees: Double) = Css3D(identity().also { + val c = cos(radians(degrees)); val s = sin(radians(degrees)) + it[0] = c; it[1] = s; it[4] = -s; it[5] = c + }) + fun skewX(degrees: Double) = Css3D(identity().also { it[4] = tan(radians(degrees)) }) + fun translate(x: Double, y: Double, z: Double = 0.0) = Css3D(identity().also { it[12] = x; it[13] = y; it[14] = z }) + fun scale(x: Double, y: Double) = Css3D(identity().also { it[0] = x; it[5] = y }) + } +} + +/** + * CSS 3D `transform` on a node: drawn through the layers that reproduce the matrix, with the + * layout untouched and pointer input following the drawn shape. + * + * Every test runs at a density of one, so a dp is a pixel. + */ +@OptIn(ExperimentalTestApi::class) +class Transform3DTest { + + private fun page(build: MutableList.() -> Unit): List = buildList { + add( + Mutation.SetTheme( + Theme( + designSystem = DesignSystem.Material3, + fallback = DesignSystem.Material3, + colorScheme = ColorScheme.Light, + adaptive = false, + ), + ), + ) + add(Mutation.Create(ROOT, WidgetKind.AbsoluteBox)) + add(Mutation.SetModifier(ROOT, 0, ProtocolModifier.RequiredSize(300f, 200f))) + add(Mutation.SetModifier(ROOT, 1, ProtocolModifier.Background(Paint.Literal(WHITE)))) + build() + } + + private fun MutableList.node( + id: Int, + parent: Int, + index: Int, + vararg modifiers: ProtocolModifier, + widget: WidgetKind = WidgetKind.Box, + ) { + add(Mutation.Create(id, widget)) + modifiers.forEachIndexed { position, modifier -> add(Mutation.SetModifier(id, position, modifier)) } + add(Mutation.Insert(parent, id, index)) + } + + private fun ComposeUiTest.show(connection: FakeHostConnection): ComposeRustHost { + lateinit var host: ComposeRustHost + setContent { + CompositionLocalProvider(LocalDensity provides Density(1f)) { + host = rememberComposeRustHost(connection) + ComposeRustContent(host) + } + } + waitForIdle() + return host + } + + private fun ComposeUiTest.picture(): PixelMap = + onNodeWithTag(nodeTestTag(ROOT)).captureToImage().toPixelMap() + + private fun ComposeUiTest.toRootBox(id: Int, x: Float, y: Float): Offset { + val root = onNodeWithTag(nodeTestTag(ROOT)).fetchSemanticsNode().layoutInfo.coordinates + val node = onNodeWithTag(nodeTestTag(id)).fetchSemanticsNode().layoutInfo.coordinates + return node.localToRoot(Offset(x, y)) - root.localToRoot(Offset.Zero) + } + + private fun ComposeUiTest.clickAt(x: Float, y: Float) { + onNodeWithTag(nodeTestTag(ROOT)).performMouseInput { + moveTo(Offset(x, y)) + press() + release() + } + waitForIdle() + } + + private fun assertColor(expected: Int, actual: Color, what: String) { + val wanted = Color(expected) + val close = abs(wanted.red - actual.red) <= 2f / 255f && + abs(wanted.green - actual.green) <= 2f / 255f && + abs(wanted.blue - actual.blue) <= 2f / 255f + assertTrue(close, "$what: expected $wanted, found $actual") + } + + private val corners = listOf(0.0 to 0.0, 100.0 to 0.0, 100.0 to 40.0, 0.0 to 40.0, 50.0 to 20.0) + + /** A 100 by 40 box at (50, 50) with [matrix] about the fractions (originX, originY) of it. */ + private fun ComposeUiTest.showBox( + matrix: Css3D, + originX: Float, + originY: Float, + backfaceHidden: Boolean = false, + painted: Boolean = false, + clickable: Boolean = false, + ): FakeHostConnection { + val connection = FakeHostConnection( + page { + val modifiers = buildList { + add(ProtocolModifier.Offset(50f, 50f)) + add(ProtocolModifier.RequiredSize(100f, 40f)) + add(matrix.modifier(originX, originY, backfaceHidden)) + if (painted) add(ProtocolModifier.Background(Paint.Literal(RED))) + if (clickable) add(ProtocolModifier.Clickable(CLICK)) + } + node(BOX, ROOT, 0, *modifiers.toTypedArray()) + }, + ) + show(connection) + return connection + } + + /** The corners and centre of the box, where CSS puts them, in the root box. */ + private fun expectedRoot(matrix: Css3D, originX: Float, originY: Float, x: Double, y: Double): Offset { + val about = matrix.about(originX * 100.0, originY * 40.0) + return about.apply(x, y) + Offset(50f, 50f) + } + + private fun assertCorners(name: String, matrix: Css3D, originX: Float, originY: Float) = runComposeUiTest { + showBox(matrix, originX, originY) + for ((x, y) in corners) { + val expected = expectedRoot(matrix, originX, originY, x, y) + val actual = toRootBox(BOX, x.toFloat(), y.toFloat()) + assertTrue( + hypot(expected.x - actual.x, expected.y - actual.y) < 1f, + "$name about ($originX, $originY): ($x, $y) should be at $expected, is at $actual", + ) + } + } + + /** + * The corners of a 100 by 40 box land within a pixel of the CSS computation for + * perspective(400px) with rotateY(45deg), rotateX(60deg), the two together, and a turn + * about the corner instead of the centre. + */ + @Test + fun fr41_3_rotate_perspective_corners_match_css() { + val cases = mapOf( + "perspective(400px) rotateY(45deg)" to Css3D.perspective(400.0) * Css3D.rotateY(45.0), + "perspective(400px) rotateX(60deg)" to Css3D.perspective(400.0) * Css3D.rotateX(60.0), + "perspective(400px) rotateX(60deg) rotateY(45deg)" to + Css3D.perspective(400.0) * Css3D.rotateX(60.0) * Css3D.rotateY(45.0), + "perspective(300px) rotateX(20deg) rotateY(-35deg) rotateZ(15deg)" to + Css3D.perspective(300.0) * Css3D.rotateX(20.0) * Css3D.rotateY(-35.0) * Css3D.rotateZ(15.0), + "rotateY(45deg) with no perspective" to Css3D.rotateY(45.0), + ) + for ((name, matrix) in cases) { + for ((originX, originY) in listOf(0.5f to 0.5f, 0f to 0f)) { + assertCorners(name, matrix, originX, originY) + } + } + } + + /** + * A skew together with perspective, and a translation inside the perspective, are no + * single layer's matrix. They are drawn by the stacked layers, and the corners match the + * CSS computation just as closely. The ones a single layer can draw are not stacked. + * The file that does this names the Compose release it was read against, and the module + * depends on that release. + */ + @Test + fun fr41_3_undecomposable_matrix_uses_the_adapter_path() { + fun kindOf(matrix: Css3D, backfaceHidden: Boolean = false): Transform3DPlan.Kind = + Transform3DPlan().also { it.solve(matrix.floats(), backfaceHidden) }.kind + + val single = Css3D.perspective(400.0) * Css3D.rotateY(45.0) + val skewed = Css3D.perspective(400.0) * Css3D.rotateY(40.0) * Css3D.skewX(15.0) + val moved = Css3D.perspective(400.0) * Css3D.rotateY(30.0) * Css3D.translate(10.0, 5.0) + assertEquals(Transform3DPlan.Kind.Single, kindOf(single), "rotateY under perspective is one layer") + assertEquals(Transform3DPlan.Kind.Stacked, kindOf(skewed), "a skew under perspective") + assertEquals(Transform3DPlan.Kind.Stacked, kindOf(moved), "a translation inside the perspective") + assertEquals(Transform3DPlan.Kind.Affine, kindOf(Css3D.rotateY(45.0)), "no perspective is the affine layers") + + val plan = Transform3DPlan().also { it.solve(single.floats(), false) } + assertTrue(abs(plan.rotationY - 45f) < 0.01f, "rotationY ${plan.rotationY}") + assertTrue(abs(plan.depth - 400f) < 0.1f, "the camera distance ${plan.depth}") + + for ((name, matrix) in mapOf("skewed" to skewed, "moved" to moved)) { + for ((originX, originY) in listOf(0.5f to 0.5f, 0f to 0f)) { + assertCorners(name, matrix, originX, originY) + } + } + + val declared = File(moduleFile("renderer/desktop/module.yaml")).readText() + assertTrue( + declared.contains("org.thisisthepy.compose.ui:ui:${Compose3DBinding.COMPOSE_VERSION}"), + "Transform3DLayers.kt was read against Compose ${Compose3DBinding.COMPOSE_VERSION}, " + + "but the module depends on another release: read the file against it first", + ) + } + + /** + * A pointer is tested against the shape that is drawn. A box turned 60 degrees about the + * vertical axis under perspective is not hit near its original left corner, is hit at + * its centre, and is hit above its original top edge where the near side has grown. + */ + @Test + fun fr41_3_hit_test_follows_the_shape() = runComposeUiTest { + val matrix = Css3D.perspective(400.0) * Css3D.rotateY(60.0) + val connection = showBox(matrix, 0.5f, 0.5f, clickable = true) + fun clicks() = connection.events.filterIsInstance().count { it.handlerId == CLICK } + fun root(x: Double, y: Double) = expectedRoot(matrix, 0.5f, 0.5f, x, y) + + // The original rectangle covers (50..150, 50..90). The near corner, the left one for + // this turn, has grown above it. + val grownCorner = root(0.0, 0.0) + val centre = root(50.0, 20.0) + val above = Offset( + grownCorner.x + (centre.x - grownCorner.x) * 0.05f, + grownCorner.y + (centre.y - grownCorner.y) * 0.05f, + ) + assertTrue(above.y < 50f, "the point chosen is above the original rectangle: $above") + + clickAt(53f, 53f) + assertEquals(0, clicks(), "near the original corner, which the turned box has left") + clickAt(centre.x, centre.y) + assertEquals(1, clicks(), "the centre") + clickAt(above.x, above.y) + assertEquals(2, clicks(), "outside the original rectangle, inside the turned shape") + } + + /** + * backface-visibility: hidden. Turned half way round the box shows its back and is not + * drawn, and is not hit; without the flag the same box is drawn mirrored. Turned less + * than a quarter it shows its front and is drawn with the flag too. + */ + @Test + fun fr41_3_backface_hidden() { + val half = Css3D.perspective(400.0) * Css3D.rotateY(180.0) + val slight = Css3D.perspective(400.0) * Css3D.rotateY(30.0) + fun centreIsRed(matrix: Css3D, hidden: Boolean): Pair { + var red = false + var clicks = 0 + runComposeUiTest { + val connection = showBox(matrix, 0.5f, 0.5f, backfaceHidden = hidden, painted = true, clickable = true) + val pixel = picture()[100, 70] + red = abs(pixel.red - 1f) < 0.02f && pixel.green < 0.02f && pixel.blue < 0.02f + clickAt(100f, 70f) + clicks = connection.events.filterIsInstance().count { it.handlerId == CLICK } + } + return red to clicks + } + assertEquals(false to 0, centreIsRed(half, hidden = true), "the back, hidden") + assertEquals(true to 1, centreIsRed(half, hidden = false), "the back, not hidden") + assertEquals(true to 1, centreIsRed(slight, hidden = true), "the front, with the flag set") + } + + /** + * A change to the matrix alone is one `SetModifier` and measures nothing again: not the + * node, and not what is inside it. + */ + @Test + fun fr41_3_update_sends_one_record_and_does_not_remeasure() = runComposeUiTest { + val compositions = mutableMapOf() + val measures = mutableMapOf() + RenderNodeObserver.onCompose = { id -> compositions[id] = (compositions[id] ?: 0) + 1 } + RenderNodeObserver.onMeasure = { id -> measures[id] = (measures[id] ?: 0) + 1 } + val before = Css3D.perspective(400.0) * Css3D.rotateY(10.0) + val after = Css3D.perspective(400.0) * Css3D.rotateY(50.0) + val connection = FakeHostConnection( + page { + node( + BOX, ROOT, 0, + ProtocolModifier.Offset(50f, 50f), + ProtocolModifier.RequiredSize(100f, 40f), + before.modifier(0.5f, 0.5f), + widget = WidgetKind.AbsoluteBox, + ) + node(3, BOX, 0, ProtocolModifier.RequiredSize(20f, 20f), widget = WidgetKind.AbsoluteBox) + }, + ) + connection.respondWith { HostResponse(listOf(Mutation.SetModifier(BOX, 2, after.modifier(0.5f, 0.5f)))) } + val host = show(connection) + val measuredBefore = measures.toMap() + val composedBefore = compositions.toMap() + + host.dispatch(HostEvent.Clicked(ROOT, 0)) + waitForIdle() + + val corner = toRootBox(BOX, 0f, 0f) + val expected = expectedRoot(after, 0.5f, 0.5f, 0.0, 0.0) + assertTrue(hypot(corner.x - expected.x, corner.y - expected.y) < 1f, "the new matrix is drawn: $corner, not $expected") + assertEquals(measuredBefore[BOX], measures[BOX], "the transformed node is not measured again") + assertEquals(measuredBefore[3], measures[3], "its child is not measured again") + assertEquals(composedBefore[3], compositions[3], "its child is not recomposed") + assertEquals(composedBefore[ROOT], compositions[ROOT], "its parent is not recomposed") + } + + @AfterTest + fun clearObservers() { + RenderNodeObserver.onCompose = null + RenderNodeObserver.onMeasure = null + } + + private fun moduleFile(relative: String): String { + var directory: File? = File(System.getProperty("user.dir")).absoluteFile + while (directory != null) { + val candidate = File(directory, relative) + if (candidate.isFile) return candidate.path + directory = directory.parentFile + } + error("$relative not found above ${System.getProperty("user.dir")}") + } +} diff --git a/renderer/desktop/test/WindowDragExclusionTest.kt b/renderer/desktop/test/WindowDragExclusionTest.kt new file mode 100644 index 000000000..0089bb3b5 --- /dev/null +++ b/renderer/desktop/test/WindowDragExclusionTest.kt @@ -0,0 +1,99 @@ +package dev.darkpyonix.composerust.test + +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.LocalWindowDrag +import dev.darkpyonix.composerust.runtime.rememberComposeRustHost +import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.platform.FrameRequestSource +import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests +import kotlin.test.Test +import kotlin.test.assertEquals + +private const val REGION = 1 +private const val CHILD = 2 +private const val LABEL = 3 + +/** A `WindowDrag(false)` node inside a `WindowDrag(true)` region keeps the window still. */ +@OptIn(ExperimentalTestApi::class) +class WindowDragExclusionTest { + private val frames = FrameRequestSource() + + private fun mutations(childClickable: Boolean) = buildList { + add(Mutation.Create(REGION, WidgetKind.Box)) + add(Mutation.SetModifier(REGION, 0, ProtocolModifier.WindowDrag(true))) + add(Mutation.SetModifier(REGION, 1, ProtocolModifier.Size(150f, 150f))) + add(Mutation.Create(CHILD, WidgetKind.Box)) + add(Mutation.SetModifier(CHILD, 0, ProtocolModifier.WindowDrag(false))) + if (childClickable) add(Mutation.SetProp(CHILD, PropertyKind.OnClick, PropertyValue.Integer(1L))) + add(Mutation.Create(LABEL, WidgetKind.Text)) + add(Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("no drag"))) + add(Mutation.Insert(CHILD, LABEL, 0)) + add(Mutation.Insert(REGION, CHILD, 0)) + } + + @Test + fun fr19_no_drag_child_does_not_start_the_window_drag() = runComposeUiTest { + var drags = 0 + val connection = FakeHostConnection(mutations(childClickable = true)) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalWindowDrag provides { drags++ }) { + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(200.dp, 200.dp)) + } + } + onNodeWithText("no drag", useUnmergedTree = true).performMouseInput { + moveTo(center); press(); moveBy(Offset(30f, 0f)); release() + } + assertEquals(0, drags) + onNodeWithText("no drag", useUnmergedTree = true).performMouseInput { moveTo(center); press(); release() } + assertEquals(0, drags) + assertEquals(1, connection.events.filterIsInstance().size) + } + + /** A control case: it also passes without the fix. The regression test is the one above. */ + @Test + fun fr19_control_press_on_the_empty_drag_region_still_starts_the_window_drag() = runComposeUiTest { + var drags = 0 + val connection = FakeHostConnection(mutations(childClickable = false)) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalWindowDrag provides { drags++ }) { + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(200.dp, 200.dp)) + } + } + // The child is as small as its label, so the far corner of the 150 by 150 region is empty. + onRoot().performMouseInput { moveTo(Offset(140f, 140f)); press(); release() } + assertEquals(1, drags) + } + + @Test + fun fr19_a_press_that_starts_in_the_region_keeps_dragging_over_a_non_drag_child() = runComposeUiTest { + var drags = 0 + val connection = FakeHostConnection(mutations(childClickable = false)) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalWindowDrag provides { drags++ }) { + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(200.dp, 200.dp)) + } + } + // The press starts on the empty part of the region, then moves over the child. The child + // never saw the down, so nothing is marked and the drag goes on. + onRoot().performMouseInput { + moveTo(Offset(140f, 140f)); press(); moveTo(Offset(10f, 10f)); release() + } + assertEquals(1, drags) + } +} diff --git a/renderer/desktop/test/WindowSizeTest.kt b/renderer/desktop/test/WindowSizeTest.kt index 91417cc36..9677f4ab6 100644 --- a/renderer/desktop/test/WindowSizeTest.kt +++ b/renderer/desktop/test/WindowSizeTest.kt @@ -15,12 +15,15 @@ import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.click import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp +import dev.darkpyonix.composerust.protocol.Chrome import dev.darkpyonix.composerust.protocol.HostEvent import dev.darkpyonix.composerust.protocol.Modifier as ProtocolModifier import dev.darkpyonix.composerust.protocol.Mutation import dev.darkpyonix.composerust.protocol.PropertyKind import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.TitleBar import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.protocol.Window import dev.darkpyonix.composerust.protocol.WindowHeightClass import dev.darkpyonix.composerust.protocol.WindowSizeClass import dev.darkpyonix.composerust.runtime.ComposeRustContent @@ -33,14 +36,19 @@ import dev.darkpyonix.composerust.runtime.rememberComposeRustHost import dev.darkpyonix.composerust.runtime.windowHeightClassOf import dev.darkpyonix.composerust.runtime.windowSizeClassOf import dev.darkpyonix.composerust.tooling.FakeHostConnection +import dev.darkpyonix.composerust.ui.node.NodeTable import dev.darkpyonix.composerust.ui.platform.FrameRequestSource import dev.darkpyonix.composerust.ui.platform.LocalFrameRequests import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertTrue private const val LABEL = 1 +/** A `Window` node of its own, for the tests that need a node to carry a property. */ +private const val WINDOW = 5 + /** The token the Host would have handed out for a node it wants the size of. */ private const val SIZE_TOKEN = 7 @@ -274,6 +282,53 @@ class WindowSizeTest { ) } + @Test + fun fr20_8_opted_in_window_reports_dp_change_inside_a_class() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter(3) + + // Both sizes are Expanded wide and Medium high, so the class never changes. + assertTrue(reporter.report(1280f, 820f, dispatcher, everyDpChange = true)) + assertTrue(reporter.report(1000f, 620f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + val event = dispatcher.events[1] as HostEvent.WindowSizeChanged + assertEquals(3, event.nodeId) + assertEquals(1000f, event.widthDp) + assertEquals(620f, event.heightDp) + assertEquals(WindowSizeClass.Expanded, event.sizeClass) + assertEquals(WindowHeightClass.Medium, event.heightClass) + } + + @Test + fun fr20_8_default_window_still_reports_only_class_change() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + + assertTrue(reporter.report(1280f, 820f, dispatcher)) + assertFalse(reporter.report(1000f, 620f, dispatcher)) + assertFalse(reporter.report(900f, 500f, dispatcher)) + assertEquals(1, dispatcher.events.size) + } + + @Test + fun fr20_8_opted_in_window_ignores_sub_dp_jitter() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + + assertTrue(reporter.report(1000f, 700f, dispatcher, everyDpChange = true)) + // 999.5 rounds up to 1000 and 1000.49 rounds to 1000: the same whole dp. + assertFalse(reporter.report(999.5f, 700.2f, dispatcher, everyDpChange = true)) + assertFalse(reporter.report(1000.49f, 699.6f, dispatcher, everyDpChange = true)) + assertEquals(1, dispatcher.events.size) + // 1000.5 rounds up to 1001: one whole dp, one event, carrying the measured size. + assertTrue(reporter.report(1000.5f, 700f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + assertEquals(1000.5f, (dispatcher.events[1] as HostEvent.WindowSizeChanged).widthDp) + // Back inside that dp sends nothing more. + assertFalse(reporter.report(1001.2f, 700f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + } + @Test fun fr20_crossing_a_height_boundary_sends_one_event_and_the_two_axes_share_it() { val dispatcher = RecordingDispatcher() @@ -399,4 +454,218 @@ class WindowSizeTest { assertEquals(2, sizes().size) assertEquals(WindowSizeClass.Expanded, sizes()[1].sizeClass) } + + @Test + fun fr20_8_removing_report_size_returns_to_class_only_reports() { + val table = NodeTable() + table.apply(Mutation.Create(WINDOW, WidgetKind.Window)) + table.apply(Mutation.SetProp(WINDOW, PropertyKind.ReportSize, PropertyValue.Bool(true))) + val flag = { table.node(WINDOW)!!.flag(PropertyKind.ReportSize, false) } + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter(WINDOW) + + assertTrue(flag()) + assertTrue(reporter.report(1280f, 820f, dispatcher, everyDpChange = flag())) + assertTrue(reporter.report(1200f, 820f, dispatcher, everyDpChange = flag())) + + table.apply(Mutation.SetProp(WINDOW, PropertyKind.ReportSize, PropertyValue.None)) + assertFalse(flag()) + // Inside the class again: nothing is sent, as before the window ever opted in. + assertFalse(reporter.report(1100f, 800f, dispatcher, everyDpChange = flag())) + assertEquals(2, dispatcher.events.size) + // A safe-area change and a class change still report. + assertTrue(reporter.report(1100f, 800f, dispatcher, WindowSafeArea(top = 24f), flag())) + assertTrue(reporter.report(500f, 800f, dispatcher, WindowSafeArea(top = 24f), flag())) + assertEquals(4, dispatcher.events.size) + } + + @Test + fun fr20_8_safe_area_only_change_reports_with_and_without_the_opt_in() { + for (everyDp in listOf(true, false)) { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + assertTrue(reporter.report(1000f, 700f, dispatcher, WindowSafeArea(), everyDp)) + assertFalse(reporter.report(1000f, 700f, dispatcher, WindowSafeArea(), everyDp)) + assertTrue(reporter.report(1000f, 700f, dispatcher, WindowSafeArea(top = 28f), everyDp)) + val event = dispatcher.events[1] as HostEvent.WindowSizeChanged + assertEquals(28f, event.safeAreaTop) + assertEquals(2, dispatcher.events.size) + } + } + + @Test + fun fr20_8_fractional_dp_is_compared_after_rounding() { + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + + assertTrue(reporter.report(600.4f, 700f, dispatcher, everyDpChange = true)) + assertFalse(reporter.report(600.45f, 700f, dispatcher, everyDpChange = true)) + // 600.4 rounds to 600 and 600.6 to 601, inside one class. + assertTrue(reporter.report(600.6f, 700f, dispatcher, everyDpChange = true)) + assertEquals(2, dispatcher.events.size) + assertEquals(600.6f, (dispatcher.events[1] as HostEvent.WindowSizeChanged).widthDp) + } + + @Test + fun fr20_a_window_that_only_crosses_a_height_boundary_still_reports_at_the_first_measurement() = runComposeUiTest { + val connection = FakeHostConnection( + listOf( + Mutation.Create(LABEL, WidgetKind.Text), + Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("hello")), + ), + ) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(1f)) { + // A phone: Compact wide like the state both sides start in, Medium high. + ComposeRustContent(rememberComposeRustHost(connection), Modifier.requiredSize(390.dp, 844.dp)) + } + } + waitForIdle() + val sizes = connection.events.filterIsInstance() + assertEquals(1, sizes.size, "a height-only class change reaches the Host") + assertEquals(WindowSizeClass.Compact, sizes[0].sizeClass) + assertEquals(WindowHeightClass.Medium, sizes[0].heightClass) + assertEquals(390f, sizes[0].widthDp) + assertEquals(844f, sizes[0].heightDp) + } + + @Test + fun fr20_8_a_compact_window_that_opts_in_reports_its_size_at_the_first_measurement() { + // 390 by 300 is Compact wide and Compact high, the state both sides start in, so a + // window that did not opt in stays silent. One that did has never told the Host its + // size, and its rounded size differs from the (0, 0) it starts with. + val quiet = RecordingDispatcher() + assertFalse(WindowSizeReporter().report(390f, 300f, quiet)) + assertEquals(0, quiet.events.size) + + val dispatcher = RecordingDispatcher() + val reporter = WindowSizeReporter() + assertTrue(reporter.report(390f, 300f, dispatcher, everyDpChange = true)) + assertEquals(1, dispatcher.events.size) + val first = dispatcher.events[0] as HostEvent.WindowSizeChanged + assertEquals(390f, first.widthDp) + assertEquals(300f, first.heightDp) + // The same size again is silent. + assertFalse(reporter.report(390f, 300f, dispatcher, everyDpChange = true)) + assertEquals(1, dispatcher.events.size) + } + + private fun rootWindowBatch(report: Boolean): List = listOf( + Mutation.SetWindow(Window(Chrome.Modern, TitleBar.Normal, "", 0, 0, 0, 0, 0, true, reportSize = report)), + Mutation.Create(LABEL, WidgetKind.Text), + Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("hello")), + ) + + private fun rootWindowSizes(report: Boolean): List { + var sizes: List = emptyList() + runComposeUiTest { + val connection = FakeHostConnection(rootWindowBatch(report)) + var size by mutableStateOf(1280.dp to 820.dp) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(1f)) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(size.first, size.second), + ) + } + } + waitForIdle() + size = 1000.dp to 620.dp + waitForIdle() + sizes = connection.events.filterIsInstance() + } + return sizes + } + + @Test + fun fr20_8_implicit_root_window_reports_dp_change_inside_a_class() { + val sizes = rootWindowSizes(true) + // Both sizes are Expanded wide, and Medium high at 820 and 620 alike, so only the + // opt-in can have sent the second event. + assertTrue(sizes.size >= 2, "expected a report for the dp change, got ${sizes.size}") + assertEquals(1000f, sizes.last().widthDp) + assertEquals(620f, sizes.last().heightDp) + } + + @Test + fun fr20_8_implicit_root_window_default_is_class_only() { + val sizes = rootWindowSizes(false) + assertTrue(sizes.none { it.widthDp == 1000f }, "a window that did not opt in reported a dp change") + } + + @Test + fun fr20_8_report_size_for_root_follows_set_window() { + val table = NodeTable() + assertFalse(table.reportSizeFor(0)) + table.apply(Mutation.SetWindow(Window(Chrome.Modern, TitleBar.Normal, "", 0, 0, 0, 0, 0, true, reportSize = true))) + assertTrue(table.reportSizeFor(0)) + table.apply(Mutation.SetWindow(Window(Chrome.Modern, TitleBar.Normal, "", 0, 0, 0, 0, 0, true))) + assertFalse(table.reportSizeFor(0)) + } + + private fun windowBatch(report: Boolean): List = listOf( + Mutation.Create(WINDOW, WidgetKind.Window), + Mutation.SetProp(WINDOW, PropertyKind.ReportSize, PropertyValue.Bool(report)), + Mutation.Insert(0, WINDOW, -1), + Mutation.Create(LABEL, WidgetKind.Text), + Mutation.SetProp(LABEL, PropertyKind.Text, PropertyValue.Text("hello")), + Mutation.Insert(WINDOW, LABEL, -1), + ) + + private fun flagReachesTheReporter(report: Boolean) = runComposeUiTest { + val connection = FakeHostConnection(windowBatch(report)) + var width by mutableStateOf(1000.dp) + setContent { + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(1f)) { + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize(width, 700.dp), + windowNodeId = WINDOW, + ) + } + } + waitForIdle() + val sizes = { connection.events.filterIsInstance() } + // Compact to Expanded is a class change, so the first measurement is always sent. + assertEquals(1, sizes().size) + assertEquals(WINDOW, sizes()[0].nodeId) + + // Whole dp inside Expanded: sent only for a window that opted in. + width = 1003.dp + waitForIdle() + assertEquals(if (report) 2 else 1, sizes().size) + if (report) assertEquals(1003f, sizes()[1].widthDp) + } + + @Test + fun fr20_8_report_size_flag_reaches_the_reporter_through_the_host() = flagReachesTheReporter(true) + + @Test + fun fr20_8_window_without_report_size_stays_class_only_through_the_host() = flagReachesTheReporter(false) + + @Test + fun fr20_8_a_density_change_at_the_same_pixel_size_changes_the_reported_dp() = runComposeUiTest { + val connection = FakeHostConnection(windowBatch(true)) + var density by mutableStateOf(2f) + setContent { + val d = density + CompositionLocalProvider(LocalFrameRequests provides frames, LocalDensity provides Density(d)) { + // 1200 by 1400 pixels at either density. + ComposeRustContent( + rememberComposeRustHost(connection), + Modifier.requiredSize((1200f / d).dp, (1400f / d).dp), + windowNodeId = WINDOW, + ) + } + } + waitForIdle() + val sizes = { connection.events.filterIsInstance() } + assertEquals(1, sizes().size) + assertEquals(600f, sizes()[0].widthDp) + density = 1.5f + waitForIdle() + assertEquals(2, sizes().size) + assertEquals(800f, sizes()[1].widthDp) + assertEquals(WindowSizeClass.Medium, sizes()[1].sizeClass) + } } diff --git a/renderer/ios/src/shared/Accessibility.kt b/renderer/ios/src/shared/Accessibility.kt new file mode 120000 index 000000000..f54ae0784 --- /dev/null +++ b/renderer/ios/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/ios/src/shared/ControlLook.kt b/renderer/ios/src/shared/ControlLook.kt new file mode 120000 index 000000000..45b78b1cf --- /dev/null +++ b/renderer/ios/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/ios/src/shared/FileDialogHost.kt b/renderer/ios/src/shared/FileDialogHost.kt new file mode 120000 index 000000000..32c630324 --- /dev/null +++ b/renderer/ios/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/ios/src/shared/Transform3DLayers.kt b/renderer/ios/src/shared/Transform3DLayers.kt new file mode 120000 index 000000000..1714c8e19 --- /dev/null +++ b/renderer/ios/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/linux/src/LinuxRenderer.kt b/renderer/linux/src/LinuxRenderer.kt index f4a3190c8..20cf67ae3 100644 --- a/renderer/linux/src/LinuxRenderer.kt +++ b/renderer/linux/src/LinuxRenderer.kt @@ -109,7 +109,7 @@ private fun runWindows( ): Int { val backend = LinuxWindowBackend(main) { nodeId -> { ComposeRustContent(host, windowNodeId = nodeId) } } opened(backend) - val manager = WindowManager(host, backend, onExit = backend::requestExit) + val manager = WindowManager(host, backend, onExit = backend::requestExit, dropTargets = DropTargets.Registered) val frames = CoroutineScope(main + Job()) try { manager.attach() diff --git a/renderer/linux/src/LinuxWindow.kt b/renderer/linux/src/LinuxWindow.kt index c413470ed..7ea297ea3 100644 --- a/renderer/linux/src/LinuxWindow.kt +++ b/renderer/linux/src/LinuxWindow.kt @@ -7,6 +7,7 @@ package dev.darkpyonix.composerust.ui.platform import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -18,9 +19,14 @@ import androidx.compose.ui.platform.WindowInfo import androidx.compose.ui.scene.CanvasLayersComposeScene import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.IntSize +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.runtime.LocalSystemDarkObserver +import dev.darkpyonix.composerust.runtime.LocalZoom +import dev.darkpyonix.composerust.runtime.Zoom import java.lang.System import org.thisisthepy.compose.window.CaretRect import org.thisisthepy.compose.window.FrameClock +import org.thisisthepy.compose.window.SystemTheme import org.thisisthepy.compose.window.WindowConfig import org.thisisthepy.compose.window.WindowEvent import org.thisisthepy.compose.window.WindowFrames @@ -62,11 +68,42 @@ internal class LinuxWindow private constructor( * `Dispatchers.Main`, so the application's main-thread work runs here too, in the same queue. */ private val work: FrameDispatcher, + internal val zoom: Zoom, ) : WindowListener { private val reportFrames = System.getenv("DXC_REPORT_FRAMES") != null private val reportInput = System.getenv("DXC_REPORT_INPUT") != null + private var reportedZoomLevel: Int? = null + private var reportedOs: Float? = null + private var zoomDirty = true + + internal fun setZoomLevel(level: Int) { + if (closed) return + if (zoom.setLevel(level)) { + zoomDirty = true + x11.requestFrame() + } + reportZoom() + } + + private fun reportZoom() { + val listener = platformListener ?: return + if (reportedZoomLevel != zoom.level || reportedOs != zoom.os) { + reportedZoomLevel = zoom.level + reportedOs = zoom.os + listener.onZoomChanged(zoom.k, zoom.os, zoom.level) + } + } + + internal var colorScheme by mutableStateOf(ColorScheme.FollowSystem) + + internal fun resolvedDark(): Boolean = when (colorScheme) { + ColorScheme.Dark -> true + ColorScheme.Light -> false + ColorScheme.FollowSystem -> x11.systemTheme() == SystemTheme.Dark + } + /** * Whether this window has the keyboard, as the window last said. * @@ -189,7 +226,7 @@ internal class LinuxWindow private constructor( */ private val frames = WindowFrames({ x11.measure() }) { width, height, scale -> val size = IntSize(width, height) - val density = Density(scale) + val density = zoom.density(scale) // Told to the scene here, in the frame that is about to be drawn at that size, because // a framebuffer that fits and a scene that does not is a window drawing its old size // into a corner of its new one. @@ -233,6 +270,11 @@ internal class LinuxWindow private constructor( * Null for the one window of a single window application, which closes when asked. */ internal var platformListener: PlatformWindowListener? = null + set(value) { + field = value + reportedZoomLevel = null + reportedOs = null + } /** Whether [close] has run, so that a second call does nothing. */ private var closed = false @@ -279,7 +321,14 @@ internal class LinuxWindow private constructor( semantics.perform(id, action, argument) fun setContent(content: @Composable () -> Unit) { - scene.setContent(content) + scene.setContent { + CompositionLocalProvider( + LocalZoom provides zoom, + LocalSystemDarkObserver provides { resolvedDark() }, + ) { + content() + } + } } /** @@ -357,6 +406,8 @@ internal class LinuxWindow private constructor( * arrived this turn, which is news to every window. */ internal fun processTurn(woken: Boolean) { + if (zoom.refresh()) zoomDirty = true + reportZoom() val turnHeard = heard.isNotEmpty() || woken for (event in heard) { if (reportInput && event.kind != WindowEvent.POINTER_MOVE) { @@ -377,8 +428,8 @@ internal class LinuxWindow private constructor( // Only when there is something to draw. A window that is being resized has // already had its frame drawn by the resize, and a window where nothing is // happening should leave the screen alone. - val drew = if (!painted || turnHeard || scene.hasInvalidations() || frameWaiters.isNotEmpty()) { - frames.draw() + val drew = if (!painted || zoomDirty || turnHeard || scene.hasInvalidations() || frameWaiters.isNotEmpty()) { + frames.draw().also { if (it) zoomDirty = false } } else { false } @@ -429,7 +480,11 @@ internal class LinuxWindow private constructor( * [work] is where the scene's work runs. It must have been made on this thread, which is * the one the window is run on. */ - fun open(config: WindowConfig, work: FrameDispatcher = FrameDispatcher()): LinuxWindow? { + fun open( + config: WindowConfig, + work: FrameDispatcher = FrameDispatcher(), + zoom: Zoom = Zoom(nativeLinuxTextScale(), nativeLinuxZoomLevelStore()), + ): LinuxWindow? { val x11 = X11Window() // The listener is the window being made, and the window cannot exist before the // platform does, so it is handed over once both are there. @@ -437,7 +492,7 @@ internal class LinuxWindow private constructor( if (!x11.open(config, handoff)) { return null } - val window = LinuxWindow(x11, config.title, work) + val window = LinuxWindow(x11, config.title, work, zoom) handoff.target = window return window } diff --git a/renderer/linux/src/LinuxWindowBackend.kt b/renderer/linux/src/LinuxWindowBackend.kt index 251274525..e9cb8dde1 100644 --- a/renderer/linux/src/LinuxWindowBackend.kt +++ b/renderer/linux/src/LinuxWindowBackend.kt @@ -4,7 +4,9 @@ package dev.darkpyonix.composerust.ui.platform import androidx.compose.runtime.Composable import dev.darkpyonix.composerust.protocol.Chrome +import dev.darkpyonix.composerust.protocol.ColorScheme import dev.darkpyonix.composerust.protocol.TitleBar +import dev.darkpyonix.composerust.runtime.Zoom import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine import org.thisisthepy.compose.window.TitleBarStyle @@ -27,6 +29,7 @@ import platform.posix.usleep */ internal class LinuxWindowBackend( private val work: FrameDispatcher, + private val makeZoom: () -> Zoom = { Zoom(nativeLinuxTextScale(), nativeLinuxZoomLevelStore()) }, private val content: (nodeId: Int) -> @Composable () -> Unit, ) : WindowBackend { @@ -59,8 +62,10 @@ internal class LinuxWindowBackend( override val supportsMultipleWindows: Boolean get() = true override fun open(config: PlatformWindowConfig, listener: PlatformWindowListener): PlatformWindow? { - val window = LinuxWindow.open(windowConfigOf(config), work) ?: return null + val window = LinuxWindow.open(windowConfigOf(config), work, makeZoom()) ?: return null + window.colorScheme = config.colorScheme window.platformListener = listener + window.setZoomLevel(config.zoomLevel ?: window.zoom.level) window.setContent(content(config.nodeId)) val platform = LinuxPlatformWindow(this, window) windows += platform @@ -180,6 +185,8 @@ internal class LinuxPlatformWindow( override fun setTitle(title: String) = x11.setTitle(title) + override fun setZoomLevel(level: Int) = window.setZoomLevel(level) + override fun setMinimumSize(width: Float, height: Float) = x11.setMinimumSize(width.toInt(), height.toInt()) @@ -208,6 +215,11 @@ internal class LinuxPlatformWindow( override fun followPointer(anchorX: Float, anchorY: Float): Boolean = x11.followPointer(anchorX, anchorY) + override fun setColorScheme(scheme: ColorScheme) { + window.colorScheme = scheme + window.x11.requestFrame() + } + override suspend fun awaitFrame(onFrame: (Long) -> Unit) { var waiter: ((Long) -> Unit)? = null try { diff --git a/renderer/linux/src/shared/Accessibility.kt b/renderer/linux/src/shared/Accessibility.kt new file mode 120000 index 000000000..f54ae0784 --- /dev/null +++ b/renderer/linux/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/linux/src/shared/ControlLook.kt b/renderer/linux/src/shared/ControlLook.kt new file mode 120000 index 000000000..45b78b1cf --- /dev/null +++ b/renderer/linux/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/linux/src/shared/FileDialogHost.kt b/renderer/linux/src/shared/FileDialogHost.kt new file mode 120000 index 000000000..32c630324 --- /dev/null +++ b/renderer/linux/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/linux/src/shared/Transform3DLayers.kt b/renderer/linux/src/shared/Transform3DLayers.kt new file mode 120000 index 000000000..1714c8e19 --- /dev/null +++ b/renderer/linux/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/linux/test/LinuxMultiWindowTest.kt b/renderer/linux/test/LinuxMultiWindowTest.kt index 9845444fd..95f55c6f8 100644 --- a/renderer/linux/test/LinuxMultiWindowTest.kt +++ b/renderer/linux/test/LinuxMultiWindowTest.kt @@ -2,6 +2,29 @@ package dev.darkpyonix.composerust.ui.platform +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.platform.LocalDensity +import dev.darkpyonix.composerust.protocol.Chrome +import dev.darkpyonix.composerust.protocol.ColorScheme +import dev.darkpyonix.composerust.protocol.DesignSystem +import dev.darkpyonix.composerust.protocol.HostEvent +import dev.darkpyonix.composerust.protocol.LastWindowClosed +import dev.darkpyonix.composerust.protocol.Mutation +import dev.darkpyonix.composerust.protocol.PropertyKind +import dev.darkpyonix.composerust.protocol.PropertyValue +import dev.darkpyonix.composerust.protocol.Theme +import dev.darkpyonix.composerust.protocol.TitleBar +import dev.darkpyonix.composerust.protocol.WidgetKind +import dev.darkpyonix.composerust.protocol.Window +import dev.darkpyonix.composerust.protocol.WindowMode +import dev.darkpyonix.composerust.runtime.ComposeRustContent +import dev.darkpyonix.composerust.runtime.ComposeRustHost +import dev.darkpyonix.composerust.runtime.HostConnection +import dev.darkpyonix.composerust.runtime.LocalSystemDarkObserver +import dev.darkpyonix.composerust.runtime.WindowDropTargets +import dev.darkpyonix.composerust.runtime.Zoom +import dev.darkpyonix.composerust.runtime.ZoomLevelStore +import dev.darkpyonix.composerust.runtime.appZoomScale import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -26,9 +49,11 @@ class LinuxMultiWindowTest { val moves = ArrayList() override fun onCloseRequested() { closeRequests++ } val focusEvents = ArrayList() + val zoomEvents = ArrayList>() override fun onFocusChanged(focused: Boolean) { focusEvents += focused } override fun onMoved(bounds: ScreenBounds) { moves += bounds } override fun onFollowEnded(cancelled: Boolean) {} + override fun onZoomChanged(k: Float, os: Float, level: Int) { zoomEvents += Triple(k, os, level) } } private fun hasDisplay(): Boolean { @@ -37,7 +62,7 @@ class LinuxMultiWindowTest { return false } - private fun backend() = LinuxWindowBackend(mainForTests()) { { } } + private fun backend() = LinuxWindowBackend(mainForTests(), makeZoom = { Zoom() }) { { } } private fun config(id: Int, width: Float = 320f, height: Float = 240f) = PlatformWindowConfig(nodeId = id, title = "window $id", width = width, height = height) @@ -51,6 +76,219 @@ class LinuxMultiWindowTest { return done() } + private fun bytesOf(vararg values: Float): ByteArray { + val out = ByteArray(values.size * 4) + values.forEachIndexed { i, value -> + val bits = value.toRawBits() + for (b in 0 until 4) out[i * 4 + b] = (bits shr (8 * b)).toByte() + } + return out + } + + /** A window whose frame and pointer are scripted, so the test does not depend on a window manager. */ + private class ScriptedWindow( + private val inner: PlatformWindow, + private val frame: ScreenBounds, + private val pointer: () -> ScreenPointer, + ) : PlatformWindow by inner { + override fun bounds(): ScreenBounds = frame + override fun contentOrigin(): ScreenPoint = ScreenPoint(frame.x, frame.y) + override fun pointerOnScreen(): ScreenPointer = pointer() + override fun followPointer(anchorX: Float, anchorY: Float): Boolean = true + } + + private class ScriptedBackend( + private val inner: LinuxWindowBackend, + private val frames: Map, + private val pointer: () -> ScreenPointer, + ) : WindowBackend by inner { + override fun open(config: PlatformWindowConfig, listener: PlatformWindowListener): PlatformWindow? { + val opened = inner.open(config, listener) ?: return null + return ScriptedWindow(opened, frames.getValue(config.nodeId), pointer) + } + } + + @Test + fun fr53_53_7_linux_registered_drop_targets_follow_bounds_and_subscription() { + WindowDropTargets.update(1, 10, 20f, 30f, 120f, 130f, 2f, 1) + WindowDropTargets.update(1, 11, 40f, 50f, 80f, 90f, 2f, 2) + WindowDropTargets.update(2, 20, 20f, 30f, 120f, 130f, 1f, 1) + try { + assertEquals(DropTarget(11, 5f, 5f), DropTargets.Registered.targetAt(1, 50f, 60f)) + assertEquals(DropTarget(20, 30f, 30f), DropTargets.Registered.targetAt(2, 50f, 60f)) + WindowDropTargets.remove(1, 11) + assertEquals(DropTarget(10, 15f, 15f), DropTargets.Registered.targetAt(1, 50f, 60f)) + WindowDropTargets.update(1, 10, 200f, 200f, 300f, 300f, 2f, 1) + assertEquals(null, DropTargets.Registered.targetAt(1, 50f, 60f)) + } finally { + WindowDropTargets.remove(1, 10) + WindowDropTargets.remove(1, 11) + WindowDropTargets.remove(2, 20) + } + } + + /** + * The whole path of a dragged window: the Host's `DragFromPointer`, a real X window drawing a + * real `Text` node that subscribed with `OnWindowDragOver` (its bounds come from layout, not + * from the test), the manager polling the pointer, and the `WindowDragOver` events the Host + * receives. Only the pointer position and the window frames are scripted. + */ + @Test + fun fr53_53_7_linux_drag_over_reaches_the_host_through_a_real_window() { + if (!hasDisplay()) return + var pointer = ScreenPointer(0f, 0f, true) + val frames = mapOf( + 1 to ScreenBounds(0f, 600f, 300f, 200f), + 2 to ScreenBounds(1000f, 0f, 300f, 200f), + ) + val main = mainForTests() + lateinit var host: ComposeRustHost + val real = LinuxWindowBackend(main) { nodeId -> { ComposeRustContent(host, windowNodeId = nodeId) } } + val backend = ScriptedBackend(real, frames) { pointer } + fun window(id: Int, vararg props: Pair) = buildList { + add(Mutation.Create(id, WidgetKind.Window)) + add(Mutation.SetProp(id, PropertyKind.WindowSize, PropertyValue.Bytes(bytesOf(300f, 200f)))) + for ((kind, value) in props) add(Mutation.SetProp(id, kind, value)) + add(Mutation.Insert(0, id, -1)) + } + fun strip(parent: Int, id: Int, handler: Long) = listOf( + Mutation.Create(id, WidgetKind.Text), + Mutation.SetProp(id, PropertyKind.Text, PropertyValue.Text("tab strip")), + Mutation.SetProp(id, PropertyKind.OnWindowDragOver, PropertyValue.Integer(handler)), + Mutation.Insert(parent, id, -1), + ) + val initial = listOf( + Mutation.SetWindow( + Window( + chrome = Chrome.Modern, + titleBar = TitleBar.Normal, + title = "", + icon = 0, + width = 0, + height = 0, + minWidth = 0, + minHeight = 0, + resizable = true, + zoomLevel = null, + mode = WindowMode.Windows, + lastWindowClosed = LastWindowClosed.KeepRunning, + ), + ), + ) + window(2) + strip(2, 20, 33L) + + window( + 1, + PropertyKind.DragFromPointer to PropertyValue.Bytes( + ByteArray(12).also { + it[0] = 1 + bytesOf(0f, 0f).copyInto(it, 4) + }, + ), + PropertyKind.OnWindowDrag to PropertyValue.Integer(11L), + ) + strip(1, 10, 44L) + val conn = RecordingHost(initial) + host = ComposeRustHost(conn) + host.start() + val manager = WindowManager(host, backend, onExit = {}, dropTargets = DropTargets.Registered) + try { + manager.attach() + // The subscribed node of window 2 has been laid out and has reported its bounds. + assertTrue( + real.turnsUntil { WindowDropTargets.find(2, 2f, 4f) != null }, + "the subscribed node never registered its bounds from layout", + ) + fun overs() = conn.events.filterIsInstance() + + pointer = ScreenPointer(1002f, 4f, true) + manager.flushFrame() + assertEquals(listOf(1), overs().map { it.kind }, "no Enter reached the Host") + val enter = overs().first() + assertEquals(20, enter.nodeId) + assertEquals(33L, enter.handlerId) + assertEquals(1, enter.window) + + pointer = ScreenPointer(1004f, 5f, true) + manager.flushFrame() + assertEquals(listOf(1, 2), overs().map { it.kind }) + + pointer = ScreenPointer(1250f, 190f, true) + manager.flushFrame() + assertEquals(listOf(1, 2, 3), overs().map { it.kind }) + + pointer = ScreenPointer(1003f, 4f, true) + manager.flushFrame() + assertTrue(overs().all { it.nodeId == 20 }, "the dragged window's own strip was hit") + assertEquals(1, overs().last().kind, "re-entering the strip did not Enter again") + } finally { + manager.shutdown() + real.closeAll() + host.shutdown() + } + } + + @Test + fun fr53_53_3_linux_zoom_at_open_and_after_open_is_local_to_each_window() { + if (!hasDisplay()) return + val densities = mutableMapOf() + val backend = LinuxWindowBackend(mainForTests(), makeZoom = { Zoom() }) { id -> + { + val density = LocalDensity.current.density + SideEffect { densities[id] = density } + } + } + try { + val firstEvents = Recorder() + val secondEvents = Recorder() + val first = backend.open(config(1).copy(zoomLevel = 2), firstEvents)!! + val second = backend.open(config(2).copy(zoomLevel = 0), secondEvents)!! + assertEquals(2, firstEvents.zoomEvents.last().third) + assertEquals(0, secondEvents.zoomEvents.last().third) + assertTrue(backend.turnsUntil { densities.size == 2 }) + val base = densities.getValue(2) + assertEquals(base * appZoomScale(2), densities.getValue(1), 0.001f) + + first.setZoomLevel(-1) + assertEquals(-1, firstEvents.zoomEvents.last().third) + assertTrue(backend.turnsUntil { kotlin.math.abs(densities.getValue(1) - base * appZoomScale(-1)) < 0.001f }) + assertEquals(base, densities.getValue(2), 0.001f) + assertEquals(listOf(0), secondEvents.zoomEvents.map { it.third }) + + first.setZoomLevel(100) + assertEquals(8, firstEvents.zoomEvents.last().third) + val count = firstEvents.zoomEvents.size + first.setZoomLevel(9) + assertEquals(count, firstEvents.zoomEvents.size) + second.close() + first.close() + first.setZoomLevel(0) + assertEquals(count, firstEvents.zoomEvents.size) + } finally { + backend.closeAll() + } + } + + @Test + fun fr53_53_3_linux_new_window_reads_the_last_saved_zoom() { + if (!hasDisplay()) return + val store = object : ZoomLevelStore { + var saved: Int? = 3 + override fun load(): Int? = saved + override fun save(level: Int) { saved = level } + } + val backend = LinuxWindowBackend(mainForTests(), makeZoom = { Zoom(store = store) }) { { } } + try { + val firstEvents = Recorder() + val first = backend.open(config(1), firstEvents)!! + assertEquals(3, firstEvents.zoomEvents.last().third) + first.setZoomLevel(-2) + val secondEvents = Recorder() + backend.open(config(2), secondEvents)!! + assertEquals(-2, secondEvents.zoomEvents.last().third) + assertEquals(-2, store.saved) + } finally { + backend.closeAll() + } + } + @Test fun fr53_linux_two_window_nodes_open_two_x_windows_and_close_one() { if (!hasDisplay()) return @@ -190,4 +428,122 @@ class LinuxMultiWindowTest { backend.closeAll() } } + + @Test + fun fr53_53_3_linux_color_scheme_overrides_one_window_only() { + if (!hasDisplay()) return + val darkByWindow = mutableMapOf() + val work = mainForTests() + val backend = LinuxWindowBackend(work) { nodeId -> + { + darkByWindow[nodeId] = LocalSystemDarkObserver.current?.invoke() + } + } + val initial = listOf( + Mutation.SetWindow( + Window( + chrome = Chrome.Modern, + titleBar = TitleBar.Normal, + title = "", + icon = 0, + width = 0, + height = 0, + minWidth = 0, + minHeight = 0, + resizable = true, + zoomLevel = null, + mode = WindowMode.Windows, + lastWindowClosed = LastWindowClosed.KeepRunning, + ), + ), + Mutation.Create(10, WidgetKind.Window), + Mutation.SetProp(10, PropertyKind.WindowTitle, PropertyValue.Text("Window A")), + Mutation.SetProp(10, PropertyKind.WindowSize, PropertyValue.Bytes(bytesOf(300f, 200f))), + Mutation.Insert(0, 10, -1), + Mutation.SetWindowTheme(10, Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Dark, false)), + Mutation.Create(20, WidgetKind.Window), + Mutation.SetProp(20, PropertyKind.WindowTitle, PropertyValue.Text("Window B")), + Mutation.SetProp(20, PropertyKind.WindowSize, PropertyValue.Bytes(bytesOf(300f, 200f))), + Mutation.Insert(0, 20, -1), + Mutation.SetWindowTheme(20, Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false)), + ) + val conn = RecordingHost(initial) + val host = ComposeRustHost(conn) + host.start() + val manager = WindowManager(host, backend, onExit = {}) + try { + manager.attach() + assertTrue( + backend.turnsUntil { darkByWindow[10] != null && darkByWindow[20] != null }, + "both windows did not render", + ) + assertEquals(true, darkByWindow[10]) + assertEquals(false, darkByWindow[20]) + + conn.scheduleFrame( + listOf( + Mutation.SetWindowTheme( + 10, + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Light, false), + ), + ), + ) + host.renderFrame(1L) + assertTrue( + backend.turnsUntil { darkByWindow[10] == false }, + "window A did not update to light", + ) + assertEquals(false, darkByWindow[10]) + assertEquals(false, darkByWindow[20]) + + conn.scheduleFrame( + listOf( + Mutation.SetWindowTheme( + 20, + Theme(DesignSystem.Material3, DesignSystem.Material3, ColorScheme.Dark, false), + ), + ), + ) + host.renderFrame(2L) + assertTrue( + backend.turnsUntil { darkByWindow[20] == true }, + "window B did not update to dark", + ) + assertEquals(false, darkByWindow[10]) + assertEquals(true, darkByWindow[20]) + } finally { + manager.shutdown() + backend.closeAll() + host.shutdown() + } + } +} + +/** + * A Host that plays a fixed first batch, queues later batches for the next frame, and keeps + * the events it was sent. The linux module does not carry the desktop renderer's + * `FakeHostConnection`, so the tests that need one write what they need here. + */ +private class RecordingHost(private val initial: List) : HostConnection { + val events = mutableListOf() + private val frames = ArrayDeque>() + + fun scheduleFrame(mutations: List) { + frames.addLast(mutations) + } + + override fun init(onMutation: (Mutation) -> Unit) { + initial.forEach(onMutation) + } + + override fun dispatchEvent(event: HostEvent, onMutation: (Mutation) -> Unit): Long { + events += event + return 0 + } + + override fun renderFrame(frameTimeNanos: Long, onMutation: (Mutation) -> Unit) { + frames.removeFirstOrNull()?.forEach(onMutation) + } + + override fun shutdown() {} } diff --git a/renderer/macos/src/MacosFileDialogPlatform.kt b/renderer/macos/src/MacosFileDialogPlatform.kt new file mode 100644 index 000000000..2bcb85a07 --- /dev/null +++ b/renderer/macos/src/MacosFileDialogPlatform.kt @@ -0,0 +1,222 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package dev.darkpyonix.composerust.ui.platform + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import org.thisisthepy.compose.window.FileDialogFilter as SheetFilter +import org.thisisthepy.compose.window.FileDialogKind as SheetKind +import org.thisisthepy.compose.window.FileDialogOutcome as SheetOutcome +import org.thisisthepy.compose.window.FileDialogRequest as SheetRequest +import org.thisisthepy.compose.window.macos.MacosFileDialogs +import platform.AppKit.NSApplication +import platform.AppKit.NSWindow +import platform.AppKit.NSWindowWillCloseNotification +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSOperationQueue +import platform.darwin.DISPATCH_TIME_NOW +import platform.darwin.dispatch_after +import platform.darwin.dispatch_get_main_queue +import platform.darwin.dispatch_time +import kotlin.native.ref.WeakReference + +/** How long a request waits for its window to be on screen: this many tries, [WINDOW_WAIT_MILLIS] apart. */ +internal const val WINDOW_WAIT_ATTEMPTS = 100 + +/** The pause between two looks for a request's window. */ +internal const val WINDOW_WAIT_MILLIS = 50L + +/** + * The sheets of one window, as [SheetFileDialogPlatform] uses them: requests shown one after + * the other, and a way to end every one of them at once. + */ +internal interface WindowSheets { + /** Shows [request] without waiting for the user. [onResult] is called once, on the main thread. */ + fun show(request: SheetRequest, onResult: (SheetOutcome) -> Unit) + + /** Ends the open sheet and every waiting request with a cancel, each answered once. */ + fun cancelAll() +} + +/** + * The renderer's file dialogs on top of the fork's per-window sheets. + * + * The fork shows a sheet and keeps one window's requests in order; the interpreter's + * [FileDialogCenter] keeps the protocol's rules. This sits between the two: it finds the + * window a request belongs to, keeps one [WindowSheets] for each window that has had one, and + * translates the request and its answer between the two vocabularies. + * + * A request can arrive before its window is on screen: the batch that asked for it may be the + * first one, which is applied before any window exists, or the one that created its `Window` + * node. So a request whose window is not there yet is looked at again, [attempts] times, + * through [later], and is answered as unavailable only when the window never comes. + * + * Generic in the window type so the rules can be tested without AppKit. [windowFor] turns the + * handle the centre resolved (and the node the request names) into a window, or null when + * there is none yet. [sheetsFor] makes the sheets of one window and is given the function to + * call when that window goes away. Everything is called on the main thread. + */ +internal class SheetFileDialogPlatform( + private val windowFor: (handle: Any?, node: Int) -> W?, + private val sheetsFor: (window: W, gone: () -> Unit) -> WindowSheets, + private val later: (() -> Unit) -> Unit, + private val attempts: Int = WINDOW_WAIT_ATTEMPTS, +) : FileDialogPlatform { + private val open = HashMap() + + /** How many windows have sheets kept for them now. */ + internal val windowCount: Int get() = open.size + + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) { + showWhenThere(window, request.windowNode, sheetRequestOf(request), onResult, attempts) + } + + private fun showWhenThere( + handle: Any?, + node: Int, + request: SheetRequest, + onResult: (FileDialogOutcome) -> Unit, + left: Int, + ) { + val target = windowFor(handle, node) + if (target == null) { + if (left <= 0) { + onResult(FileDialogOutcome.Unavailable) + } else { + later { showWhenThere(handle, node, request, onResult, left - 1) } + } + return + } + val sheets = open.getOrPut(target) { sheetsFor(target) { open.remove(target) } } + sheets.show(request) { outcome -> onResult(outcomeOf(outcome)) } + } + + override fun cancelWindow(window: Any?) { + // The implicit root window is never removed, and a handle that was null when the + // dialog started is ended by the window's own close notification instead. + if (window == null) return + val target = windowFor(window, NO_NODE) ?: return + open.remove(target)?.cancelAll() + } + + companion object { + /** Passed as the node when only a handle is known. */ + const val NO_NODE = -1 + } +} + +/** The request as the fork's sheets take it. The centre has already normalised it. */ +internal fun sheetRequestOf(spec: FileDialogSpec): SheetRequest = SheetRequest( + kind = when (spec.kind) { + FileDialogKind.OpenFile -> SheetKind.OpenFile + FileDialogKind.OpenFiles -> SheetKind.OpenFiles + FileDialogKind.OpenFolder -> SheetKind.OpenFolder + FileDialogKind.OpenFolders -> SheetKind.OpenFolders + FileDialogKind.Save -> SheetKind.Save + }, + title = spec.title, + directory = spec.directory, + name = spec.name, + filters = spec.filters.map { SheetFilter(it.label, it.extensions) }, + defaultFilter = spec.defaultFilter, +) + +/** The sheet's answer as the centre takes it. A filter index below zero means there was no filter. */ +internal fun outcomeOf(outcome: SheetOutcome): FileDialogOutcome = when (outcome) { + is SheetOutcome.Chosen -> FileDialogOutcome.Chosen.ofText( + outcome.paths, + outcome.filterIndex.takeIf { it >= 0 }, + ) + SheetOutcome.Cancelled -> FileDialogOutcome.Cancelled + SheetOutcome.Unavailable -> FileDialogOutcome.Unavailable +} + +/** + * The file dialogs of the macOS renderer: `NSOpenPanel` and `NSSavePanel` as sheets on the + * owning `NSWindow`, through the fork's [MacosFileDialogs]. + * + * Installed in [FileDialogs.platform] before the Host is made, because the interpreter's + * centre takes the platform when the node table is created. What the windows are is only + * known later, so it is handed in afterwards: [nodeWindow] gives the platform window of a + * `Window` node (the window manager's handle, read through the centre's resolver), and + * [rootWindow] the window of node 0, the implicit single window, or in a multi-window run the + * window in use. + */ +internal class MacosFileDialogPlatform : FileDialogPlatform { + /** Gives the platform window of a `Window` node, or null. */ + var nodeWindow: (Int) -> Any? = { null } + + /** Gives the window a request on node 0 is shown on, or null when there is none yet. */ + var rootWindow: () -> NSWindow? = { null } + + private val inner = SheetFileDialogPlatform( + windowFor = { handle, node -> + nativeWindowOf(handle) ?: when { + node > 0 -> nativeWindowOf(nodeWindow(node)) + node == 0 -> rootWindow() + else -> null + } + }, + sheetsFor = ::macosSheets, + later = ::onMainQueueLater, + ) + + override fun show(window: Any?, request: FileDialogSpec, onResult: (FileDialogOutcome) -> Unit) = + inner.show(window, request, onResult) + + override fun cancelWindow(window: Any?) = inner.cancelWindow(window) +} + +private fun nativeWindowOf(handle: Any?): NSWindow? = when (handle) { + is MacosPlatformWindow -> handle.nativeWindow + is NSWindow -> handle + else -> null +} + +/** + * The window a multi-window run shows a node 0 request on: the key window when it is one of + * [windows], otherwise the first of them. + */ +internal fun keyOrFirstWindow(windows: List): NSWindow? { + val key = NSApplication.sharedApplication.keyWindow + return windows.firstOrNull { it === key } ?: windows.firstOrNull() +} + +/** + * One window's sheets. The window is held weakly, so a window that is closed is not kept alive + * by the dialogs it once showed, and its closing ends them: every open and waiting request is + * cancelled and the window is forgotten. + */ +@OptIn(kotlin.experimental.ExperimentalNativeApi::class) +private fun macosSheets(window: NSWindow, gone: () -> Unit): WindowSheets { + val ref = WeakReference(window) + val dialogs = MacosFileDialogs { ref.get() } + var observer: Any? = null + val sheets = object : WindowSheets { + override fun show(request: SheetRequest, onResult: (SheetOutcome) -> Unit) = + dialogs.show(request, onResult) + + override fun cancelAll() { + observer?.let { NSNotificationCenter.defaultCenter.removeObserver(it) } + observer = null + dialogs.cancelAll() + } + } + observer = NSNotificationCenter.defaultCenter.addObserverForName( + name = NSWindowWillCloseNotification, + `object` = window, + queue = NSOperationQueue.mainQueue, + ) { _ -> + gone() + sheets.cancelAll() + } + return sheets +} + +/** Runs [block] on the main queue after [WINDOW_WAIT_MILLIS], once the run loop is turning. */ +private fun onMainQueueLater(block: () -> Unit) { + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, WINDOW_WAIT_MILLIS * 1_000_000L), + dispatch_get_main_queue(), + block, + ) +} diff --git a/renderer/macos/src/MacosMultiWindow.kt b/renderer/macos/src/MacosMultiWindow.kt index 69975f3b9..ad3b819ba 100644 --- a/renderer/macos/src/MacosMultiWindow.kt +++ b/renderer/macos/src/MacosMultiWindow.kt @@ -76,6 +76,11 @@ internal fun runWindows(application: NSApplication, host: ComposeRustHost, asked } val windows = MacosWindowBackend(scene) backend = windows + // A dialog on node 0 has no `Window` node of its own here, so it goes to the window in + // use, or the first one open. + (FileDialogs.platform as? MacosFileDialogPlatform)?.rootWindow = { + keyOrFirstWindow(windows.windows.mapNotNull { (it as? MacosPlatformWindow)?.nativeWindow }) + } val manager = WindowManager(host, windows, onExit = { stopApplicationLoop() }) manager.attach() // A batch is one snapshot for every window: each is drawn again after it is applied, not diff --git a/renderer/macos/src/MacosRenderer.kt b/renderer/macos/src/MacosRenderer.kt index 355e184e2..773ff53df 100644 --- a/renderer/macos/src/MacosRenderer.kt +++ b/renderer/macos/src/MacosRenderer.kt @@ -106,7 +106,13 @@ internal fun runRenderer(connection: () -> HostConnection): Int { application.windows.forEach { (it as? NSWindow)?.deminiaturize(null) } }, ) + // The file dialogs, before the Host exists for the same reason: the node table takes the + // platform when it is made, and the first batch may already ask for one. The windows they + // attach to are handed in once they exist. + val fileDialogs = MacosFileDialogPlatform() + FileDialogs.platform = fileDialogs val host = ComposeRustHost(connection()) + fileDialogs.nodeWindow = { id -> host.table.fileDialogs.windowResolver(id) } host.start() // Quitting ends the process from inside the run loop, so `run` below never returns to // say so. What this application posted is taken back when the platform says it is @@ -155,6 +161,8 @@ internal fun runRenderer(connection: () -> HostConnection): Int { ), ) requestFrame = window::requestFrame + // The one window is where a dialog on node 0, the implicit window, is shown. + fileDialogs.rootWindow = { window.window } // The fork's window runs the scene's coroutines (the frame requests below among them, and // every delay) only inside a frame, and an idle window is not drawn. A request a Host // worker makes would wait for a frame nobody asks for, so each one is turned into a diff --git a/renderer/macos/src/shared/Accessibility.kt b/renderer/macos/src/shared/Accessibility.kt new file mode 120000 index 000000000..f54ae0784 --- /dev/null +++ b/renderer/macos/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/macos/src/shared/ControlLook.kt b/renderer/macos/src/shared/ControlLook.kt new file mode 120000 index 000000000..45b78b1cf --- /dev/null +++ b/renderer/macos/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/macos/src/shared/FileDialogHost.kt b/renderer/macos/src/shared/FileDialogHost.kt new file mode 120000 index 000000000..32c630324 --- /dev/null +++ b/renderer/macos/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/macos/src/shared/Transform3DLayers.kt b/renderer/macos/src/shared/Transform3DLayers.kt new file mode 120000 index 000000000..1714c8e19 --- /dev/null +++ b/renderer/macos/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/macos/test/MacosFileDialogTest.kt b/renderer/macos/test/MacosFileDialogTest.kt new file mode 100644 index 000000000..8d2b59b14 --- /dev/null +++ b/renderer/macos/test/MacosFileDialogTest.kt @@ -0,0 +1,204 @@ +package dev.darkpyonix.composerust.test + +import dev.darkpyonix.composerust.protocol.FileDialogKind +import dev.darkpyonix.composerust.ui.platform.FileDialogFilter +import dev.darkpyonix.composerust.ui.platform.FileDialogOutcome +import dev.darkpyonix.composerust.ui.platform.FileDialogPath +import dev.darkpyonix.composerust.ui.platform.FileDialogSpec +import dev.darkpyonix.composerust.ui.platform.SheetFileDialogPlatform +import dev.darkpyonix.composerust.ui.platform.WindowSheets +import dev.darkpyonix.composerust.ui.platform.outcomeOf +import dev.darkpyonix.composerust.ui.platform.sheetRequestOf +import org.thisisthepy.compose.window.FileDialogKind as SheetKind +import org.thisisthepy.compose.window.FileDialogOutcome as SheetOutcome +import org.thisisthepy.compose.window.FileDialogRequest as SheetRequest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** One window's sheets that remember what they were asked and answer when the test says so. */ +private class FakeSheets(val window: String, val gone: () -> Unit) : WindowSheets { + class Shown(val request: SheetRequest, val onResult: (SheetOutcome) -> Unit) + + val shown = mutableListOf() + var cancelled = 0 + + override fun show(request: SheetRequest, onResult: (SheetOutcome) -> Unit) { + shown += Shown(request, onResult) + } + + override fun cancelAll() { + cancelled++ + shown.forEach { it.onResult(SheetOutcome.Cancelled) } + shown.clear() + } +} + +private fun spec( + request: Int = 1, + window: Int = 0, + kind: FileDialogKind = FileDialogKind.OpenFiles, + filters: List = emptyList(), + defaultFilter: Int = 0, + name: String = "", +) = FileDialogSpec( + request = request, + windowNode = window, + kind = kind, + title = "파일 고르기", + directory = "/Users/한글", + name = name, + filters = filters, + defaultFilter = defaultFilter, +) + +/** + * The macOS renderer's glue between the interpreter's file dialog centre and the fork's + * sheets. The sheet itself is AppKit's and is checked by hand on a macOS build; what is + * checked here is which window a request goes to, what reaches the sheet, and what comes back. + */ +class MacosFileDialogTest { + /** Windows by node, the way the window manager's handles resolve; node 0 is "root". */ + private val windows = mutableMapOf(0 to "root") + private val made = mutableListOf() + private val queued = ArrayDeque<() -> Unit>() + + private fun platform(attempts: Int = 3) = SheetFileDialogPlatform( + windowFor = { handle, node -> (handle as? String) ?: windows[node] }, + sheetsFor = { window, gone -> FakeSheets(window, gone).also { made += it } }, + later = { queued.addLast(it) }, + attempts = attempts, + ) + + private fun runLater() { + while (queued.isNotEmpty()) queued.removeFirst()() + } + + private fun sheetsOf(window: String) = made.last { it.window == window } + + @Test + fun fr72_macos_request_reaches_the_sheet_unchanged() { + for ((kind, sheetKind) in listOf( + FileDialogKind.OpenFile to SheetKind.OpenFile, + FileDialogKind.OpenFiles to SheetKind.OpenFiles, + FileDialogKind.OpenFolder to SheetKind.OpenFolder, + FileDialogKind.OpenFolders to SheetKind.OpenFolders, + FileDialogKind.Save to SheetKind.Save, + )) { + val request = sheetRequestOf( + spec( + kind = kind, + filters = listOf(FileDialogFilter("이미지", listOf("png", "jpg")), FileDialogFilter("모두", emptyList())), + defaultFilter = 1, + name = "보고서.txt", + ), + ) + assertEquals(sheetKind, request.kind) + assertEquals("파일 고르기", request.title) + assertEquals("/Users/한글", request.directory) + assertEquals("보고서.txt", request.name) + assertEquals(listOf("이미지", "모두"), request.filters.map { it.label }) + assertEquals(listOf(listOf("png", "jpg"), emptyList()), request.filters.map { it.extensions }) + assertEquals(1, request.defaultFilter) + } + } + + @Test + fun fr72_macos_sheet_answers_become_the_centres_outcomes() { + val chosen = outcomeOf(SheetOutcome.Chosen(listOf("/Users/한글/a b.png", "/tmp/😀.jpg"), 1)) + assertIs(chosen) + assertEquals( + listOf("/Users/한글/a b.png", "/tmp/😀.jpg"), + chosen.paths.map { (it as FileDialogPath.Text).value }, + ) + assertEquals(1, chosen.filter) + + // A folder pick, or a dialog without filters, reports no filter. + val noFilter = outcomeOf(SheetOutcome.Chosen(listOf("/Users"), -1)) + assertIs(noFilter) + assertNull(noFilter.filter) + + assertSame(FileDialogOutcome.Cancelled, outcomeOf(SheetOutcome.Cancelled)) + assertSame(FileDialogOutcome.Unavailable, outcomeOf(SheetOutcome.Unavailable)) + } + + @Test + fun fr72_macos_every_request_gets_exactly_one_result() { + val platform = platform() + val answers = mutableListOf() + platform.show(null, spec(request = 1)) { answers += it } + sheetsOf("root").shown.single().onResult(SheetOutcome.Chosen(listOf("/a"), -1)) + assertEquals(1, answers.size) + assertIs(answers.single()) + } + + @Test + fun fr72_macos_dialog_waits_for_its_window_to_open() { + // The batch that asked for the dialog is applied before the window is on screen. + windows.clear() + val platform = platform() + val answers = mutableListOf() + platform.show(null, spec(request = 1, window = 7)) { answers += it } + assertTrue(made.isEmpty()) + assertEquals(1, queued.size) + + windows[7] = "seven" + runLater() + assertEquals(1, sheetsOf("seven").shown.size) + assertTrue(answers.isEmpty()) + } + + @Test + fun fr72_macos_window_that_never_opens_answers_unavailable() { + windows.clear() + val platform = platform(attempts = 3) + val answers = mutableListOf() + platform.show(null, spec(request = 1, window = 7)) { answers += it } + runLater() + assertEquals(listOf(FileDialogOutcome.Unavailable), answers) + assertTrue(made.isEmpty()) + } + + @Test + fun fr72_macos_each_window_has_its_own_sheets() { + windows[3] = "three" + val platform = platform() + platform.show(null, spec(request = 1, window = 3)) {} + platform.show(null, spec(request = 2, window = 3)) {} + platform.show(null, spec(request = 3, window = 0)) {} + // The handle the centre resolved wins over the node. + platform.show("handle", spec(request = 4, window = 3)) {} + + assertEquals(listOf("three", "root", "handle"), made.map { it.window }) + assertEquals(2, sheetsOf("three").shown.size) + assertEquals(3, platform.windowCount) + } + + @Test + fun fr72_macos_closing_the_owner_window_cancels_the_dialog() { + windows[3] = "three" + val platform = platform() + val answers = mutableListOf() + platform.show(null, spec(request = 1, window = 3)) { answers += it } + + // The node was removed: the centre names the handle it showed the dialog on. + platform.cancelWindow("three") + assertEquals(1, sheetsOf("three").cancelled) + assertEquals(listOf(FileDialogOutcome.Cancelled), answers) + assertEquals(0, platform.windowCount) + + // The window closed by itself: its sheets say so, and the window is forgotten. + platform.show(null, spec(request = 2, window = 3)) {} + assertEquals(1, platform.windowCount) + sheetsOf("three").gone() + assertEquals(0, platform.windowCount) + + // The implicit window is never removed, so a missing handle cancels nothing. + platform.show(null, spec(request = 3, window = 0)) {} + platform.cancelWindow(null) + assertEquals(0, sheetsOf("root").cancelled) + } +} diff --git a/renderer/scripts/build-compose.sh b/renderer/scripts/build-compose.sh index 29a9076e0..c726dce8a 100755 --- a/renderer/scripts/build-compose.sh +++ b/renderer/scripts/build-compose.sh @@ -40,8 +40,10 @@ REVISION="2eb80c12e7615ef80c7661ea63e0f20651f20a31" # for Snap Layouts (fork #172), the Windows wheel direction fix (fork #170) and the windows' own # accessibility bridges (the renderer hands them the scene's semantics). It replaces the # backport branch feat/windows-window-backport-57b4a0e, which carried those two fixes on an -# older base; multi-window work builds on `extended` itself. -WINDOW_REVISION="317d380b4c34fc324c47811bd71fa76543510a94" +# older base; multi-window work builds on `extended` itself. It also holds the macOS file open +# and save sheets (fork #183, `MacosFileDialogs`), which the macOS renderer shows when the Host +# asks for a file dialog. +WINDOW_REVISION="e3088cc0b01cb1d83d904dfa040a2e61d251d8e6" # The commit the Windows build takes. It was its own pin while the Windows Compose lived on a # branch apart from the Compose pin; the fork's `extended` now holds mingwX64 for every Compose UI # module, the skiko build for mingwX64 (extended/skiko/build-skiko-mingw.sh, which also compiles diff --git a/renderer/web/src/shared/Accessibility.kt b/renderer/web/src/shared/Accessibility.kt new file mode 120000 index 000000000..f54ae0784 --- /dev/null +++ b/renderer/web/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/web/src/shared/ControlLook.kt b/renderer/web/src/shared/ControlLook.kt new file mode 120000 index 000000000..45b78b1cf --- /dev/null +++ b/renderer/web/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/web/src/shared/FileDialogHost.kt b/renderer/web/src/shared/FileDialogHost.kt new file mode 120000 index 000000000..32c630324 --- /dev/null +++ b/renderer/web/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/web/src/shared/Transform3DLayers.kt b/renderer/web/src/shared/Transform3DLayers.kt new file mode 120000 index 000000000..1714c8e19 --- /dev/null +++ b/renderer/web/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/Accessibility.kt b/renderer/windows/src/shared/Accessibility.kt new file mode 120000 index 000000000..f54ae0784 --- /dev/null +++ b/renderer/windows/src/shared/Accessibility.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Accessibility.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/ControlLook.kt b/renderer/windows/src/shared/ControlLook.kt new file mode 120000 index 000000000..45b78b1cf --- /dev/null +++ b/renderer/windows/src/shared/ControlLook.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/ControlLook.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/FileDialogHost.kt b/renderer/windows/src/shared/FileDialogHost.kt new file mode 120000 index 000000000..32c630324 --- /dev/null +++ b/renderer/windows/src/shared/FileDialogHost.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/FileDialogHost.kt \ No newline at end of file diff --git a/renderer/windows/src/shared/Transform3DLayers.kt b/renderer/windows/src/shared/Transform3DLayers.kt new file mode 120000 index 000000000..1714c8e19 --- /dev/null +++ b/renderer/windows/src/shared/Transform3DLayers.kt @@ -0,0 +1 @@ +../../../desktop/src/renderer/Transform3DLayers.kt \ No newline at end of file diff --git a/samples/README.md b/samples/README.md index 475a0b200..0793068d3 100644 --- a/samples/README.md +++ b/samples/README.md @@ -1,7 +1,7 @@ # Sample apps Each of these is a real application written the way someone using this project would -write one: Rust only, on compose-rust's authoring API (`#[composable]` functions, widgets as +write one: Rust only, on compose-rust's authoring API (`#[Composable]` functions, widgets as statements, `remember` and `MutableState`), no Kotlin and no Dioxus. They exist to be used rather than admired. The widget vocabulary was chosen by reasoning diff --git a/samples/academic/assets/icon-256.png b/samples/academic/assets/icon-256.png new file mode 100644 index 000000000..79dcd03fe Binary files /dev/null and b/samples/academic/assets/icon-256.png differ diff --git a/samples/academic/src/lib.rs b/samples/academic/src/lib.rs index b996e2667..ec48b4849 100644 --- a/samples/academic/src/lib.rs +++ b/samples/academic/src/lib.rs @@ -176,7 +176,7 @@ fn skills_page(on_open: &OpenSubject) { /// One lesson as a card. What is locked is quiet and says so in a word, because a padlock /// is an icon and an icon cannot be placed from application code. -#[composable] +#[Composable] fn lesson_card(lesson: &'static Lesson) { let (fill, ink) = if lesson.open { (ColorRole::SurfaceContainer, ColorRole::OnSurface) @@ -238,7 +238,7 @@ fn plan_page(chosen: &MutableState) { // A strip rather than a segmented control. `Tabs` divides its width equally // and does not scroll, so four stages on a phone is four segments of seventy // dp with a button's own padding inside each, and every label breaks. - let strip = chosen.clone(); + let strip = *chosen; LazyRow() .modifier(Modifier.fill_max_width().height(STRIP_HEIGHT)) .content(move |list| { @@ -248,7 +248,7 @@ fn plan_page(chosen: &MutableState) { move |position| { let number = STAGES[position]; let current = strip.get(); - let pick = strip.clone(); + let pick = strip; Box() .modifier(Modifier.padding_role(SpaceRole::Xs)) .alignment(Alignment::Center) @@ -367,7 +367,7 @@ fn profile_page() { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { @@ -381,7 +381,7 @@ pub fn app() { let opened = remember(|| mutable_state_of(Option::<&'static str>::None)); let on_open: OpenSubject = { - let (opened, destination) = (opened.clone(), destination.clone()); + let (opened, destination) = (opened, destination); Rc::new(move |name: &'static str| { opened.set(Some(name)); destination.set(Destination::Plan); @@ -400,7 +400,7 @@ pub fn app() { .modifier(Modifier.background(Paint::Literal(palette::PAGE))) .content(|| { for choice in Destination::STRIP { - let pick = destination.clone(); + let pick = destination; key(choice.label(), || { // The reference's bar has no accent in it: every icon is white // and the selected one is marked by nothing but being the one diff --git a/samples/calculator/assets/icon-256.png b/samples/calculator/assets/icon-256.png new file mode 100644 index 000000000..d9a601962 Binary files /dev/null and b/samples/calculator/assets/icon-256.png differ diff --git a/samples/calculator/src/lib.rs b/samples/calculator/src/lib.rs index 876a0dc1f..70da15692 100644 --- a/samples/calculator/src/lib.rs +++ b/samples/calculator/src/lib.rs @@ -6,7 +6,7 @@ //! columns of keys along the bottom. //! //! Written with compose-rust's own API: state lives in `mutable_state_of`, screens are -//! `#[composable]` functions, and a widget is a statement. No Kotlin and no protocol types. +//! `#[Composable]` functions, and a widget is a statement. No Kotlin and no protocol types. //! //! There is no keyboard entry. A keypad is pressed, and a text field kept on screen only to //! hold the focus would be development scaffolding under the keypad, doing nothing a @@ -66,7 +66,7 @@ fn color_for(label: &str) -> Option { type Press = Rc; /// The keypad, in the shape this platform's calculator has. -#[composable] +#[Composable] fn keypad(apple: bool, press: Press) { Column() .modifier(Modifier.fill_max_width().fill_max_height()) @@ -95,7 +95,7 @@ fn keypad(apple: bool, press: Press) { } /// The memory keys, as the row the Windows and Deepin references give them. -#[composable] +#[Composable] fn memory_row(memory_set: bool, press: Press) { Row() .modifier(Modifier.fill_max_width()) @@ -119,7 +119,7 @@ fn memory_row(memory_set: bool, press: Press) { } /// The readout: what is being worked out, and what it comes to. -#[composable] +#[Composable] fn readout(status: String, display: String) { Box().modifier(Modifier.fill_max_width()).content(|| { Column() @@ -160,7 +160,7 @@ struct TapeEntry { const TAPE_LABEL: &str = "History"; /// The tape: what has been worked out, newest at the top. -#[composable] +#[Composable] fn tape(entries: Vec, recall: Rc, clear: Rc) { Column() .modifier(Modifier.fill_max_width().fill_max_height()) @@ -224,7 +224,7 @@ const INSTRUMENT_SHARE: f32 = 3.0; const TAPE_SHARE: f32 = 2.0; /// The calculator. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); // The one thing a role cannot answer: Apple's calculator has different keys. @@ -246,8 +246,7 @@ pub fn app() { // One key press, whichever key it was. The tape is written here rather than in the // engine, because a tape is a thing the application keeps. let press: Press = { - let (calculator, entries, next_entry) = - (calculator.clone(), entries.clone(), next_entry.clone()); + let (calculator, entries, next_entry) = (calculator, entries, next_entry); Rc::new(move |label: &'static str| { let mut finished = None; calculator.update(|state| { @@ -273,14 +272,13 @@ pub fn app() { }; let recall: Rc = { - let (calculator, tape_open) = (calculator.clone(), tape_open.clone()); + let (calculator, tape_open) = (calculator, tape_open); Rc::new(move |value: f64| { calculator.update(|state| state.recall(value)); tape_open.set(false); }) }; let clear_tape: Rc = { - let entries = entries.clone(); Rc::new(move || { let thrown_away = entries.get_untracked(); entries.update(Vec::clear); @@ -289,7 +287,7 @@ pub fn app() { } else { "lines" }; - let restore = entries.clone(); + let restore = entries; Message::new(format!("Cleared {} {lines}", thrown_away.len())) .with_action("Undo", move |()| restore.set(thrown_away.clone())) .with_duration(MessageDuration::Long) @@ -312,7 +310,7 @@ pub fn app() { .modifier(Modifier.weight(1.0)); } if !tape_beside { - let open = tape_open.clone(); + let open = tape_open; Button("") .icon(IconRole::History) .variant(ButtonVariant::Text) @@ -368,7 +366,7 @@ pub fn app() { // The same tape, arriving from an edge, for windows with no room beside the // keys. - let close = tape_open.clone(); + let close = tape_open; Sheet(tape_open.get() && !tape_beside) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) diff --git a/samples/chat/assets/icon-256.png b/samples/chat/assets/icon-256.png new file mode 100644 index 000000000..d6ee9e6ac Binary files /dev/null and b/samples/chat/assets/icon-256.png differ diff --git a/samples/chat/src/lib.rs b/samples/chat/src/lib.rs index a8f7e367b..8b5e0640c 100644 --- a/samples/chat/src/lib.rs +++ b/samples/chat/src/lib.rs @@ -117,7 +117,7 @@ fn thread_width(window: &WindowSize) -> Option { const THREAD_COLUMN: f32 = 787.0; /// The assistant's settings, in a sheet. -#[composable] +#[Composable] fn settings_panel(settings: Settings, change: Rc, close: Rc) { Column() .modifier(Modifier.fill_max_width()) @@ -188,7 +188,7 @@ fn settings_panel(settings: Settings, change: Rc, close: Rc = { let (messages, next_id, draft, turns, settings, conversations, current) = ( - messages.clone(), - next_id.clone(), - draft.clone(), + messages, + next_id, + draft, turns.clone(), - settings.clone(), - conversations.clone(), - current.clone(), + settings, + conversations, + current, ); Rc::new(move |text: String| { let text = text.trim().to_owned(); @@ -268,17 +268,14 @@ pub fn app() { token, turns.clone(), settings.get_untracked(), - messages.clone(), + messages, ); }) }; let start_conversation: Rc = { - let (next_conversation, conversations, current) = ( - next_conversation.clone(), - conversations.clone(), - current.clone(), - ); + let (next_conversation, conversations, current) = + (next_conversation, conversations, current); Rc::new(move || { let id = next_conversation.get_untracked(); next_conversation.set(id + 1); @@ -294,11 +291,11 @@ pub fn app() { let delete_current: Rc = { let (current, conversations, messages, turns, next_conversation) = ( - current.clone(), - conversations.clone(), - messages.clone(), + current, + conversations, + messages, turns.clone(), - next_conversation.clone(), + next_conversation, ); Rc::new(move || { let gone = current.get_untracked(); @@ -325,8 +322,7 @@ pub fn app() { } let next = conversations.with(|list| list[at.min(list.len() - 1)].id); current.set(next); - let (conversations, messages, current) = - (conversations.clone(), messages.clone(), current.clone()); + let (conversations, messages, current) = (conversations, messages, current); compose_rust::Message::new(format!("Deleted \u{201c}{}\u{201d}", removed.label())) .with_action("Undo", move |()| { conversations.update(|list| list.insert(at, removed.clone())); @@ -389,7 +385,7 @@ pub fn app() { .variant(ButtonVariant::Text) .color(Paint::Role(ColorRole::OnSurfaceVariant)) .on_click(move || start()); - let (dismiss, open) = (more_open.clone(), more_open.clone()); + let (dismiss, open) = (more_open, more_open); Menu(more_open.get()) .on_dismiss(move || dismiss.set(false)) .anchor(move || { @@ -400,7 +396,7 @@ pub fn app() { .on_click(move || open.set(true)); }) .content(|| { - let (more, settings_open) = (more_open.clone(), settings_open.clone()); + let (more, settings_open) = (more_open, settings_open); Button("Assistant settings") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -408,7 +404,7 @@ pub fn app() { more.set(false); settings_open.set(true); }); - let (more, delete) = (more_open.clone(), Rc::clone(&delete_current)); + let (more, delete) = (more_open, Rc::clone(&delete_current)); Button("Delete conversation") .variant(ButtonVariant::Text) .color(Paint::Role(ColorRole::Error)) @@ -467,7 +463,7 @@ pub fn app() { .icon(IconRole::Compose) .on_click(move || start()); if show_search { - let open = search_open.clone(); + let open = search_open; NavigationItem("Search") .icon(IconRole::Search) .on_click(move || open.set(true)); @@ -476,7 +472,7 @@ pub fn app() { NavigationItem("Videos").icon(IconRole::Video); NavigationItem("Library").icon(IconRole::Library); for conversation in recent.iter().cloned() { - let pick = current.clone(); + let pick = current; let id = conversation.id; key(id, || { NavigationItem(conversation.label()) @@ -510,7 +506,7 @@ pub fn app() { .alignment(Alignment::Center) .content(|| { let (keys, rows, messages) = - (keys.clone(), rows.clone(), messages.clone()); + (keys.clone(), rows.clone(), messages); LazyColumn() .modifier(Modifier.fill_max_width().fill_max_height()) .content(move |list| { @@ -539,12 +535,12 @@ pub fn app() { ); }); - let close = settings_open.clone(); + let close = settings_open; Sheet(settings_open.get()) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) .content(|| { - let (next, close) = (settings.clone(), settings_open.clone()); + let (next, close) = (settings, settings_open); settings_panel( current_settings, Rc::new(move |value| next.set(value)), @@ -552,7 +548,7 @@ pub fn app() { ); }); - let close = search_open.clone(); + let close = search_open; Sheet(search_open.get()) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) @@ -568,18 +564,18 @@ pub fn app() { Text("Search conversations") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let done = search_open.clone(); + let done = search_open; Button("Done") .variant(ButtonVariant::Filled) .on_click(move || done.set(false)); }); - let typed = search_query.clone(); + let typed = search_query; TextField() .modifier(Modifier.fill_max_width()) .placeholder("Search conversations") .on_value_change(move |value| typed.set(value)); if !search_query.get().is_empty() { - let clear = search_query.clone(); + let clear = search_query; Button("Clear search") .modifier(Modifier.fill_max_width()) .variant(ButtonVariant::Text) @@ -592,7 +588,7 @@ pub fn app() { } /// One message, composed when the Renderer's window reaches it. -#[composable] +#[Composable] fn message_row(rows: &[usize], position: usize, messages: &MutableState>) { let index = rows[position]; let message = messages.with(|list| list[index].clone()); @@ -650,7 +646,7 @@ fn message_row(rows: &[usize], position: usize, messages: &MutableState, @@ -673,7 +669,7 @@ fn composer_row( .spacing(BESIDE_A_COMPOSER_KEY) .alignment(Alignment::CenterStart) .content(|| { - let (dismiss, open) = (more_open.clone(), more_open.clone()); + let (dismiss, open) = (*more_open, *more_open); Menu(more_open.get()) .on_dismiss(move || dismiss.set(false)) .anchor(move || { @@ -684,7 +680,7 @@ fn composer_row( .on_click(move || open.set(true)); }) .content(|| { - let (more, search) = (more_open.clone(), search_open.clone()); + let (more, search) = (*more_open, *search_open); Button("Search conversations") .icon(IconRole::Search) .variant(ButtonVariant::Text) @@ -694,7 +690,7 @@ fn composer_row( search.set(true); }); Divider(); - let (more, settings_open) = (more_open.clone(), settings_open.clone()); + let (more, settings_open) = (*more_open, *settings_open); Button("Settings") .icon(IconRole::Settings) .variant(ButtonVariant::Text) @@ -704,14 +700,14 @@ fn composer_row( settings_open.set(true); }); }); - let (typed, submit) = (draft.clone(), Rc::clone(send)); + let (typed, submit) = (*draft, Rc::clone(send)); TextField() .modifier(Modifier.weight(1.0)) .multiline(true) .placeholder("Message") .on_value_change(move |value| typed.set(value)) .on_submit(move |value: String| submit(value)); - let (dismiss, open) = (length_open.clone(), length_open.clone()); + let (dismiss, open) = (*length_open, *length_open); Menu(length_open.get()) .on_dismiss(move || dismiss.set(false)) .anchor(move || { @@ -722,7 +718,7 @@ fn composer_row( }) .content(|| { for length in Length::ALL { - let (close, settings) = (length_open.clone(), settings.clone()); + let (close, settings) = (*length_open, *settings); key(length.label(), || { Button(length.label()) .variant(ButtonVariant::Text) @@ -735,7 +731,7 @@ fn composer_row( }); } }); - let (pressed, current) = (Rc::clone(send), draft.clone()); + let (pressed, current) = (Rc::clone(send), *draft); Button("") .icon(IconRole::Send) .variant(ButtonVariant::Tonal) diff --git a/samples/minimal/src/lib.rs b/samples/minimal/src/lib.rs index 855ca0e0d..c6f942903 100644 --- a/samples/minimal/src/lib.rs +++ b/samples/minimal/src/lib.rs @@ -112,7 +112,7 @@ fn toggle_row(label: &str, toggle: impl FnOnce()) { /// Everything a person can operate. fn controls_group(controls: &Controls) { - let amount = controls.amount.clone(); + let amount = controls.amount; let step: Rc = Rc::new(move |by: i32| amount.set((amount.get_untracked() + by).clamp(0, 99))); let reading = controls.reading.get(); @@ -163,7 +163,7 @@ fn controls_group(controls: &Controls) { .alignment(Alignment::CenterStart) .content(|| { Text("A").type_role(TypeRole::Caption); - let set = controls.reading.clone(); + let set = controls.reading; Slider(reading) .modifier(Modifier.weight(1.0)) // Ink on paper: the filled track is ink too. @@ -192,19 +192,19 @@ fn controls_group(controls: &Controls) { .space_role(SpaceRole::Sm) .content(|| { toggle_row("Notifications", || { - let set = controls.notify.clone(); + let set = controls.notify; Switch(controls.notify.get()) .on_change(move |value| set.set(value)); }); Separator(None); toggle_row("Remember me", || { - let set = controls.remember.clone(); + let set = controls.remember; Checkbox(controls.remember.get()) .on_change(move |value| set.set(value)); }); Separator(None); toggle_row("Daily digest", || { - let set = controls.digest.clone(); + let set = controls.digest; RadioButton(controls.digest.get()) .on_change(move |value| set.set(value)); }); @@ -497,7 +497,7 @@ fn colour_group() { } /// The component sheet. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { @@ -571,7 +571,7 @@ pub fn app() { .modifier(Modifier.fill_max_width()) .content(|| { for choice in Group::STRIP { - let pick = group.clone(); + let pick = group; key(choice.label(), || { Button(choice.label()) .variant(ButtonVariant::Text) diff --git a/samples/notepad/assets/icon-256.png b/samples/notepad/assets/icon-256.png new file mode 100644 index 000000000..a4594085c Binary files /dev/null and b/samples/notepad/assets/icon-256.png differ diff --git a/samples/notepad/src/lib.rs b/samples/notepad/src/lib.rs index 7e9d5a933..bc87cbc13 100644 --- a/samples/notepad/src/lib.rs +++ b/samples/notepad/src/lib.rs @@ -35,7 +35,7 @@ type Work = std::boxed::Box Outcome + Send + 'static>; /// field is keyed by `stamp`, which changes only then. A new key is a new field, and the /// field takes the contents it was built with. Ordinary typing leaves `stamp` alone, so the /// caret and the IME composition are never disturbed and nothing is written back. -#[composable] +#[Composable] fn editor(stamp: u64, contents: String, type_role: TypeRole, on_edit: Rc) { key(stamp, || { // What the field was built with, kept for as long as this key lives. Later writes @@ -56,7 +56,7 @@ fn editor(stamp: u64, contents: String, type_role: TypeRole, on_edit: Rc) { key(stamp, || { let initial = remember(|| contents.clone()); @@ -81,7 +81,7 @@ fn path_field(stamp: u64, contents: String, on_edit: Rc) { /// /// `measure` is `None` on a window with nothing to spare, where the row fills the bar and /// the bar's own inset is already the page's. -#[composable] +#[Composable] fn document_bar(measure: Option, working: bool, children: impl FnOnce()) { Column().modifier(Modifier.fill_max_width()).content(|| { TopAppBar().modifier(Modifier.fill_max_width()).content(|| { @@ -138,7 +138,7 @@ const DOCUMENT_SHARE: f32 = 3.0; /// The same element stands beside the document on a desktop window and arrives in a sheet /// on anything narrower. It is written once because it is one list: a list that had to be /// authored twice would drift the first time either copy changed. -#[composable] +#[Composable] fn document_list( entries: Vec<(u64, String)>, current: u64, @@ -187,7 +187,7 @@ fn document_list( } /// One of the three document styles in the format sheet. -#[composable] +#[Composable] fn style_choice( label: &'static str, role: TypeRole, @@ -205,7 +205,7 @@ fn style_choice( } /// The editor. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); // A document is read across its lines, so past a certain width a page that keeps @@ -244,14 +244,7 @@ pub fn app() { // The worker's result comes back here, on the UI thread, and the state writes happen // where every other state write in the app happens. let settle: Rc = { - let (busy, status, on_disk, text, path, stamp) = ( - busy.clone(), - status.clone(), - on_disk.clone(), - text.clone(), - path.clone(), - stamp.clone(), - ); + let (busy, status, on_disk, text, path, stamp) = (busy, status, on_disk, text, path, stamp); Rc::new(move |outcome: Outcome| { busy.set(false); match outcome { @@ -290,7 +283,7 @@ pub fn app() { // One piece of file work: a worker thread does the blocking call and the awaiting task // applies the answer on the UI thread. let run_on_worker: Rc = { - let (busy, scope, settle) = (busy.clone(), scope.clone(), Rc::clone(&settle)); + let (busy, scope, settle) = (busy, scope.clone(), Rc::clone(&settle)); Rc::new(move |work: Work| { busy.set(true); let settle = Rc::clone(&settle); @@ -316,14 +309,7 @@ pub fn app() { // different code path through the whole screen. let choose: Rc = { let (current, list_open, shelved, path, text, on_disk, status, stamp) = ( - current.clone(), - list_open.clone(), - shelved.clone(), - path.clone(), - text.clone(), - on_disk.clone(), - status.clone(), - stamp.clone(), + current, list_open, shelved, path, text, on_disk, status, stamp, ); Rc::new(move |id: u64| { if id == current.get_untracked() { @@ -358,15 +344,15 @@ pub fn app() { let new_document: Rc = { let (shelved, current, path, text, on_disk, next_document, status, stamp, list_open) = ( - shelved.clone(), - current.clone(), - path.clone(), - text.clone(), - on_disk.clone(), - next_document.clone(), - status.clone(), - stamp.clone(), - list_open.clone(), + shelved, + current, + path, + text, + on_disk, + next_document, + status, + stamp, + list_open, ); Rc::new(move || { let on_screen = Shelved { @@ -442,7 +428,7 @@ pub fn app() { // Only where the list is not already on screen. A button that opens what // you are looking at is a button that does nothing. if !list_beside { - let open = list_open.clone(); + let open = list_open; Button("Documents") .variant(ButtonVariant::Text) .on_click(move || open.set(true)); @@ -454,15 +440,15 @@ pub fn app() { .variant(ButtonVariant::Text) .enabled(!working) .on_click(move || start()); - let open = file_open.clone(); + let open = file_open; Button("File") .variant(ButtonVariant::Tonal) .on_click(move || open.set(true)); - let open = format_open.clone(); + let open = format_open; Button("Format") .variant(ButtonVariant::Text) .on_click(move || open.set(true)); - let (run, path, text) = (Rc::clone(&run_on_worker), path.clone(), text.clone()); + let (run, path, text) = (Rc::clone(&run_on_worker), path, text); Button("Save") .variant(ButtonVariant::Filled) .enabled(!working) @@ -484,7 +470,7 @@ pub fn app() { // sheet is where you ask. It is declared here rather than at the foot of the // screen because it belongs to the button in the bar above, and a sheet is // drawn over the page wherever it is declared. - let dismiss = file_open.clone(); + let dismiss = file_open; Sheet(file_open.get()) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) @@ -500,13 +486,13 @@ pub fn app() { Text("File") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let close = file_open.clone(); + let close = file_open; Button("Close") .variant(ButtonVariant::Filled) .on_click(move || close.set(false)); }); Separator(None); - let typed = path.clone(); + let typed = path; path_field( generation, current_path.clone(), @@ -524,11 +510,8 @@ pub fn app() { .type_role(TypeRole::Caption) .color(Paint::Role(ColorRole::OnSurfaceVariant)) .modifier(Modifier.weight(1.0)); - let (close, run, path) = ( - file_open.clone(), - Rc::clone(&run_on_worker), - path.clone(), - ); + let (close, run, path) = + (file_open, Rc::clone(&run_on_worker), path); Button("Open") .variant(ButtonVariant::Tonal) .enabled(!working) @@ -547,7 +530,7 @@ pub fn app() { // another permanent strip around the page. These choices affect the editor's // type role directly, so every design system supplies its own face, size and // weight for the selected document style. - let dismiss = format_open.clone(); + let dismiss = format_open; Sheet(format_open.get()) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) @@ -563,7 +546,7 @@ pub fn app() { Text("Formatting") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let close = format_open.clone(); + let close = format_open; Button("Done") .variant(ButtonVariant::Filled) .on_click(move || close.set(false)); @@ -580,19 +563,14 @@ pub fn app() { "Heading", TypeRole::Headline, style, - document_type.clone(), - ); - style_choice( - "Body", - TypeRole::Body, - style, - document_type.clone(), + document_type, ); + style_choice("Body", TypeRole::Body, style, document_type); style_choice( "Monospaced", TypeRole::Mono, style, - document_type.clone(), + document_type, ); }); Surface() @@ -611,7 +589,7 @@ pub fn app() { // The same list, arriving from an edge, for the windows with no room beside // the document. Which edge is the Renderer's decision. - let dismiss = list_open.clone(); + let dismiss = list_open; Sheet(list_open.get() && !list_beside) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) @@ -703,7 +681,7 @@ pub fn app() { Box() .modifier(Modifier.fill_max_width().weight(1.0)) .content(|| { - let typed = text.clone(); + let typed = text; editor( generation, contents.clone(), diff --git a/samples/podcast/assets/icon-256.png b/samples/podcast/assets/icon-256.png new file mode 100644 index 000000000..6d7540e91 Binary files /dev/null and b/samples/podcast/assets/icon-256.png differ diff --git a/samples/podcast/src/lib.rs b/samples/podcast/src/lib.rs index 4da4f332e..88e22d440 100644 --- a/samples/podcast/src/lib.rs +++ b/samples/podcast/src/lib.rs @@ -280,7 +280,7 @@ fn episode_card(found: &Episode, on_play: &Handler) { /// it and an outlined white button, and the rest are the same shape on the page with a /// hairline round them and the orange on the button instead. That is the reference: one /// row picked out, the others quiet. -#[composable] +#[Composable] fn show_row(show: &Show, followed: bool, recommended: bool, on_toggle: Rc) { let ink = if recommended { palette::PAGE @@ -340,7 +340,7 @@ fn show_row(show: &Show, followed: bool, recommended: bool, on_toggle: Rc>, on_play: Handler, on_open: Handler) { let following = followed.get(); Column() @@ -411,7 +411,7 @@ fn new_page(followed: MutableState>, on_play: Handler, on_open: .content(|| { for (index, show) in SHOWS.iter().enumerate() { let is_followed = following.contains(&index); - let list = followed.clone(); + let list = followed; key(show.name, || { show_row( show, @@ -435,7 +435,7 @@ fn new_page(followed: MutableState>, on_play: Handler, on_open: } /// Every episode, shortest first, as a plain list. -#[composable] +#[Composable] fn shows_page(on_play: Handler) { let mut ordered: Vec<&'static Episode> = EPISODES.iter().collect(); ordered.sort_by_key(|found| found.seconds); @@ -467,7 +467,7 @@ fn shows_page(on_play: Handler) { } /// One row of the plain list, with the hairline under every row but the last. -#[composable] +#[Composable] fn shows_row(found: &'static Episode, last: bool, on_play: Handler) { Column().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -509,7 +509,7 @@ fn shows_row(found: &'static Episode, last: bool, on_play: Handler) { } /// What you follow and what you have listened to. -#[composable] +#[Composable] fn you_page(followed: usize) { let listened: u32 = EPISODES.iter().map(|found| found.seconds).sum(); Column() @@ -561,7 +561,7 @@ fn you_page(followed: usize) { /// The player: the cover full bleed at the top of a white page, with the transport under /// it. -#[composable] +#[Composable] fn player_page(found: &'static Episode, position: MutableState, on_back: Rc) { let show = show_of(found); let at = position.get(); @@ -669,7 +669,7 @@ fn player_page(found: &'static Episode, position: MutableState, on_back: Rc .alignment(Alignment::Center) .arrangement(Arrangement::Center) .content(|| { - let back = position.clone(); + let back = position; Button("\u{21ba} 15") .variant(ButtonVariant::Text) .color(palette::INK) @@ -685,7 +685,7 @@ fn player_page(found: &'static Episode, position: MutableState, on_back: Rc .shape_role(ShapeRole::Full), ) .on_click(move || play()); - let ahead = position.clone(); + let ahead = position; Button("15 \u{21bb}") .variant(ButtonVariant::Text) .color(palette::INK) @@ -701,7 +701,7 @@ fn player_page(found: &'static Episode, position: MutableState, on_back: Rc /// labelled bar with a selection pill under a hairline. The reference is three circles /// sitting on the page, the one you are on filled with the accent, and that is a shape no /// design system would be right to give a set of destinations. -#[composable] +#[Composable] fn bottom_bar(destination: Destination, on_go: Handler) { Row() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Sm)) @@ -734,7 +734,7 @@ fn bottom_bar(destination: Destination, on_go: Handler) { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { @@ -748,10 +748,7 @@ pub fn app() { let position = remember(|| mutable_state_of(START_SECONDS)); let followed = remember(|| mutable_state_of(vec![0_usize])); - let on_play: Handler = { - let playing = playing.clone(); - Rc::new(move |id: u32| playing.set(Some(id))) - }; + let on_play: Handler = { Rc::new(move |id: u32| playing.set(Some(id))) }; // The player covers everything, including the bar along the bottom, because a full // screen player is what the reference draws and a bar under it would be a way out of @@ -769,8 +766,8 @@ pub fn app() { .fill_max_height(), ) .content(|| { - let close = playing.clone(); - player_page(found, position.clone(), Rc::new(move || close.set(None))); + let close = playing; + player_page(found, position, Rc::new(move || close.set(None))); }); }); return; @@ -795,7 +792,7 @@ pub fn app() { .fill_max_width_if(measure.is_none()), ) .content(|| { - let go = destination.clone(); + let go = destination; bottom_bar(current, Rc::new(move |choice| go.set(choice))); }); }); @@ -809,7 +806,7 @@ pub fn app() { .modifier(Modifier.width_if(measure)) .content(|| match current { Destination::New => { - new_page(followed.clone(), Rc::clone(&on_play), Rc::clone(&on_play)) + new_page(followed, Rc::clone(&on_play), Rc::clone(&on_play)) } Destination::Shows => shows_page(Rc::clone(&on_play)), Destination::You => you_page(followed.with(Vec::len)), diff --git a/samples/selfcare/assets/icon-256.png b/samples/selfcare/assets/icon-256.png new file mode 100644 index 000000000..ed96b0c4a Binary files /dev/null and b/samples/selfcare/assets/icon-256.png differ diff --git a/samples/selfcare/src/lib.rs b/samples/selfcare/src/lib.rs index de5be5d2f..eed3d97b6 100644 --- a/samples/selfcare/src/lib.rs +++ b/samples/selfcare/src/lib.rs @@ -90,7 +90,7 @@ enum Step { type Action = Rc; /// The mood picker: the face, the strip under it, and the question. -#[composable] +#[Composable] fn feeling_step(chosen: MutableState, on_skip: Action, on_next: Action) { let current = chosen.get(); let (fill, _ink) = current.pair(); @@ -144,7 +144,7 @@ fn feeling_step(chosen: MutableState, on_skip: Action, on_next: Action) { } else { (palette::CHIP, palette::INK) }; - let choose = chosen.clone(); + let choose = chosen; key(option.label(), || { Button(option.label()) .variant(ButtonVariant::Filled) @@ -200,7 +200,7 @@ fn feeling_step(chosen: MutableState, on_skip: Action, on_next: Action) { } /// The worry picker: ten chips, any number of them chosen. -#[composable] +#[Composable] fn worry_step(picked: MutableState>, on_skip: Action, on_next: Action) { let chosen = picked.get(); // Two to a row. There is no wrapping row in the vocabulary, so a grid is rows of a @@ -231,7 +231,7 @@ fn worry_step(picked: MutableState>, on_skip: Action, on_next: } else { ButtonVariant::Tonal }; - let list = picked.clone(); + let list = picked; key(worry, || { Button(worry) .variant(variant) @@ -277,7 +277,7 @@ fn worry_step(picked: MutableState>, on_skip: Action, on_next: } /// What the check-in leaves behind: the day, in one line, and a way back into it. -#[composable] +#[Composable] fn checked_in(chosen: Mood, worries: Vec<&'static str>, again: Action) { let (fill, ink) = chosen.pair(); let summary = if worries.is_empty() { @@ -360,7 +360,7 @@ fn session_card(session: &Session) { } /// The listening screen: a greeting, a search field and the sessions. -#[composable] +#[Composable] fn listen_page() { Column() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Md)) @@ -411,7 +411,7 @@ fn listen_page() { } /// One row of "Special for you", with the hairline under every row but the last. -#[composable] +#[Composable] fn special_row(session: &'static Session, separated: bool) { Column().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -442,7 +442,7 @@ fn special_row(session: &'static Session, separated: bool) { } /// Everything there is, as a plain list. -#[composable] +#[Composable] fn library_page() { Column() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Md)) @@ -486,7 +486,7 @@ fn library_page() { } /// The profile: who this is, how the week went, and the settings under it. -#[composable] +#[Composable] fn profile_page(chosen: Mood) { let (fill, ink) = chosen.pair(); Column() @@ -572,7 +572,7 @@ fn profile_page(chosen: Mood) { } /// One entry under the profile, with the hairline under every entry but the last. -#[composable] +#[Composable] fn settings_row(entry: &'static str, separated: bool) { Column().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -593,21 +593,18 @@ fn settings_row(entry: &'static str, separated: bool) { } /// The page for one destination, and for the check-in the step it is on. -#[composable] +#[Composable] fn page( destination: Destination, step: MutableState, chosen: MutableState, picked: MutableState>, ) { - let to = |next: Step| -> Action { - let step = step.clone(); - Rc::new(move || step.set(next)) - }; + let to = |next: Step| -> Action { Rc::new(move || step.set(next)) }; match destination { Destination::Today => match step.get() { - Step::Feeling => feeling_step(chosen.clone(), to(Step::Done), to(Step::Worry)), - Step::Worry => worry_step(picked.clone(), to(Step::Done), to(Step::Done)), + Step::Feeling => feeling_step(chosen, to(Step::Done), to(Step::Worry)), + Step::Worry => worry_step(picked, to(Step::Done), to(Step::Done)), Step::Done => checked_in(chosen.get(), picked.get(), to(Step::Feeling)), }, Destination::Listen => listen_page(), @@ -616,7 +613,7 @@ fn page( } } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { @@ -649,7 +646,7 @@ pub fn app() { .modifier(Modifier.background(Paint::Literal(palette::PAGE))) .content(|| { for choice in Destination::STRIP { - let go = destination.clone(); + let go = destination; key(choice.label(), || { NavigationItem(choice.label()) .icon(choice.icon()) @@ -670,8 +667,7 @@ pub fn app() { .modifier(Modifier.fill_max_size()) .alignment(Alignment::TopCenter) .content(|| { - let body = - || page(current, step.clone(), chosen.clone(), picked.clone()); + let body = || page(current, step, chosen, picked); if scrolls { ScrollColumn().modifier(frame).content(body); } else { diff --git a/samples/social/assets/icon-256.png b/samples/social/assets/icon-256.png new file mode 100644 index 000000000..4134f6bd7 Binary files /dev/null and b/samples/social/assets/icon-256.png differ diff --git a/samples/social/src/lib.rs b/samples/social/src/lib.rs index 6f9ef1048..80c9221b0 100644 --- a/samples/social/src/lib.rs +++ b/samples/social/src/lib.rs @@ -99,7 +99,7 @@ fn play_badge(id: u32, opened: MutableState>) { /// A reading surface under it puts both sides of the promise back. The reference does the /// same thing with a gradient scrim, which is the same idea drawn more softly than a /// closed drawing vocabulary can say. -#[composable] +#[Composable] fn hero_card(found: &'static Course, size: (f32, f32), opened: MutableState>) { Box() .modifier( @@ -125,7 +125,7 @@ fn hero_card(found: &'static Course, size: (f32, f32), opened: MutableState>) { Column() .modifier(Modifier.fill_max_width()) @@ -160,7 +160,7 @@ fn tile_card(found: &'static Course, opened: MutableState>) { .alignment(Alignment::BottomStart) .content(|| { Image(scene(found)).modifier(Modifier.fill_max_size()); - play_badge(found.id, opened.clone()); + play_badge(found.id, opened); }); // The meta reads above the title, uppercase and in the quiet green the // reference uses for it. Under the title it would read as a footnote; above @@ -180,7 +180,7 @@ fn tile_card(found: &'static Course, opened: MutableState>) { /// /// Two, because there is no wrapping row in the vocabulary: a grid is rows of a fixed /// count and the code says how many rather than the layout working it out from the width. -#[composable] +#[Composable] fn grid(items: Vec<&'static Course>, opened: MutableState>) { Column() .modifier(Modifier.fill_max_width()) @@ -197,7 +197,7 @@ fn grid(items: Vec<&'static Course>, opened: MutableState>) { key(found.id, || { Box() .modifier(Modifier.weight(1.0)) - .content(|| tile_card(found, opened.clone())); + .content(|| tile_card(found, opened)); }); } // An odd shelf leaves a gap rather than letting the last card @@ -213,7 +213,7 @@ fn grid(items: Vec<&'static Course>, opened: MutableState>) { } /// The page a destination shows: a hero, then its shelf. -#[composable] +#[Composable] fn shelf_page( destination: Destination, heading: &'static str, @@ -237,24 +237,24 @@ fn shelf_page( .color(Paint::Literal(palette::MUTED)); }); - hero_card(hero, HERO, opened.clone()); + hero_card(hero, HERO, opened); Text("Recommended for you") .type_role(TypeRole::Label) .color(Paint::Literal(palette::MUTED)); - grid(rest, opened.clone()); + grid(rest, opened); // The wide card the reference ends each shelf with: one course, given the // whole width, because a shelf that is all the same size has no shape. Text("Recommended category") .type_role(TypeRole::Label) .color(Paint::Literal(palette::MUTED)); - hero_card(last, WIDE, opened.clone()); + hero_card(last, WIDE, opened); }); } /// One course opened: its picture, what it is, and its sessions. -#[composable] +#[Composable] fn course_page(found: &'static Course, opened: MutableState>) { let (strong, quiet, ink) = found.palette.roles(); Column() @@ -270,7 +270,7 @@ fn course_page(found: &'static Course, opened: MutableState>) { // is behind it, which is what a control over a picture needs; a text // button takes a colour chosen to read on a role's fill, and the // picture is not that fill. - let back = opened.clone(); + let back = opened; Button("\u{2190}") .variant(ButtonVariant::Tonal) .on_click(move || back.set(None)); @@ -330,7 +330,7 @@ fn course_page(found: &'static Course, opened: MutableState>) { } /// The app. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { @@ -356,7 +356,7 @@ pub fn app() { .fill_max_width_if(measure.is_none()) .fill_max_height(), ) - .content(|| course_page(found, opened.clone())); + .content(|| course_page(found, opened)); }); return; } @@ -370,7 +370,7 @@ pub fn app() { .bottom_bar(|| { Navigation(chosen.index()).content(|| { for choice in Destination::STRIP { - let pick = destination.clone(); + let pick = destination; key(choice.label(), || { NavigationItem(choice.label()) // The reference's bar is the page's own ink rather than an @@ -406,20 +406,20 @@ pub fn app() { Destination::Today, "Good evening", "Three minutes is enough to start with.", - opened.clone(), + opened, ), Destination::Meditate => shelf_page( Destination::Meditate, "Meditate", "Meditation for beginners and for people who have \ been at it a while.", - opened.clone(), + opened, ), Destination::Sleep => shelf_page( Destination::Sleep, "Sleep stories", "Something to listen to on the way down.", - opened.clone(), + opened, ), }); }); diff --git a/samples/statistics/assets/icon-256.png b/samples/statistics/assets/icon-256.png new file mode 100644 index 000000000..1cef2738a Binary files /dev/null and b/samples/statistics/assets/icon-256.png differ diff --git a/samples/statistics/src/lib.rs b/samples/statistics/src/lib.rs index cfc5c4cc8..5eafb18a7 100644 --- a/samples/statistics/src/lib.rs +++ b/samples/statistics/src/lib.rs @@ -77,7 +77,7 @@ fn week_bars() -> Vec { /// is placed at a point and takes a rung of the ladder, which is enough for a day label /// under a column; a reading this size is the loudest thing on the screen and wants the /// ladder's own metrics and the ellipsis behaviour that goes with a widget. -#[composable] +#[Composable] fn dial_card() { Box() .modifier( @@ -116,7 +116,7 @@ fn dial_card() { /// The panel is the reference's sage and the chart's ink is the palette's, held to it by /// the chart tests, so the chart is readable on the panel whichever design system draws /// the rest. -#[composable] +#[Composable] fn costs_panel(title_role: TypeRole, chart: (f32, f32)) { let (width, height) = chart; let bars = week_bars(); @@ -142,7 +142,7 @@ fn costs_panel(title_role: TypeRole, chart: (f32, f32)) { /// On both pages, because a page that says how much was spent this week and never says /// what it went on is the top half of the reference with the bottom half missing, and that /// missing half is the empty third at the foot of the window. -#[composable] +#[Composable] fn sources_strip() { LazyRow() .modifier(Modifier.fill_max_width().height(SOURCE_TILE)) @@ -196,7 +196,7 @@ fn sources_strip() { /// The overview: the dial, the costs panel, a second reading beside it, and what the /// money went on. -#[composable] +#[Composable] fn today_page() { Column() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Md)) @@ -292,7 +292,7 @@ fn today_page() { } /// The costs in full: the whole page is the panel, and the sources run off the edge. -#[composable] +#[Composable] fn costs_page() { Column() .modifier( @@ -331,7 +331,7 @@ fn costs_page() { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { @@ -355,7 +355,7 @@ pub fn app() { // The reference marks this with a chevron in the page's own ink. A text // button left to itself wears the design system's accent, which is the one // colour this design does not have. - let turn = page.clone(); + let turn = page; Button(">") .variant(ButtonVariant::Text) .color(Paint::Literal(palette::INK)) diff --git a/samples/store/assets/icon-256.png b/samples/store/assets/icon-256.png new file mode 100644 index 000000000..b9947d9a8 Binary files /dev/null and b/samples/store/assets/icon-256.png differ diff --git a/samples/store/src/lib.rs b/samples/store/src/lib.rs index e25431830..6460aae36 100644 --- a/samples/store/src/lib.rs +++ b/samples/store/src/lib.rs @@ -113,7 +113,7 @@ impl Destination { /// marks the destination you are on is that its icon is drawn in the accent. The button /// carries no text and sits over the drawing, so the press lands on it whatever the /// design system drew. -#[composable] +#[Composable] fn icon_button(picture: &'static [u8], tint: Paint, on_click: Rc) { Box() .modifier(Modifier.size(ICON_TAP, ICON_TAP)) @@ -150,7 +150,7 @@ fn logo_mark() -> DrawList { /// The top of the catalogue: the mark on the left, and what you can reach from anywhere /// on the right. -#[composable] +#[Composable] fn header(waiting: u32, on_go: Rc) { Row() .modifier(Modifier.fill_max_width()) @@ -190,7 +190,7 @@ fn header(waiting: u32, on_go: Rc) { /// The card is the control. The reference has no button on it, and a tile cannot be /// tapped: `Modifier::Clickable` is on the wire but no container widget exposes it, so /// what carries the press is a `Button` with no label, filling the picture area. -#[composable] +#[Composable] fn tile(product: &'static Product, height: f32, on_open: Rc) { let id = product.id; Box() @@ -219,7 +219,7 @@ fn tile(product: &'static Product, height: f32, on_open: Rc) { /// No weight of its own. The cell's parent in the grid is a `Column`, where weight is /// vertical, and a vertical weight inside a column that is measuring its own height comes /// out as a height of zero. The width share belongs to the wrapper in the row above. -#[composable] +#[Composable] fn grid_cell(product: &'static Product, on_open: Rc) { Column() .modifier(Modifier.fill_max_width()) @@ -248,7 +248,7 @@ fn grid_cell(product: &'static Product, on_open: Rc) { /// belongs to the Renderer and is never reported back: dots driven by one would be drawn /// in the right place and never move. Pressing the card turns it, which is the nearest /// thing to the reference's swipe that the vocabulary has. -#[composable] +#[Composable] fn carousel(slide: MutableState) { let showing = slide.get() % FEATURED; Box() @@ -263,7 +263,7 @@ fn carousel(slide: MutableState) { .content(|| { Image(asset(AssetKind::Svg, catalogue::featured()[showing])) .modifier(Modifier.fill_max_size().padding_role(SpaceRole::Md)); - let turn = slide.clone(); + let turn = slide; Button("") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_size()) @@ -302,7 +302,7 @@ fn carousel(slide: MutableState) { /// The catalogue: the header, the heading, the carousel, the category strip and a two-up /// grid. -#[composable] +#[Composable] fn catalogue_screen( category: MutableState, slide: MutableState, @@ -325,7 +325,7 @@ fn catalogue_screen( .color(palette::INK) .modifier(Modifier.fill_max_width()); - carousel(slide.clone()); + carousel(slide); // A strip that runs off the edge, not a segmented control. // @@ -334,7 +334,7 @@ fn catalogue_screen( // and a button inside one carries its own horizontal padding: every label // broke into a column of single letters. The reference's own strip runs past // the right edge with the last category half shown, which is this. - let strip = category.clone(); + let strip = category; LazyRow() .modifier(Modifier.fill_max_width().height(STRIP_HEIGHT)) .content(move |list| { @@ -344,7 +344,7 @@ fn catalogue_screen( move |position| { let choice = Category::STRIP[position]; let chosen = strip.get() == choice; - let pick = strip.clone(); + let pick = strip; Box().alignment(Alignment::Center).content(|| { // Every category is a label and one of them is the one you // are looking at, which is what the reference draws: the @@ -394,7 +394,7 @@ fn catalogue_screen( } /// One garment: the picture, what it is, what size, how many, and what it costs. -#[composable] +#[Composable] fn detail_screen( product: &'static Product, size: MutableState<&'static str>, @@ -495,7 +495,7 @@ fn detail_screen( // page in the accent. The rest are the same // flat grey as the card the garment is drawn // on. - let pick = size.clone(); + let pick = size; Button(option) .modifier( Modifier @@ -521,7 +521,7 @@ fn detail_screen( .space_role(SpaceRole::Sm) .alignment(Alignment::CenterStart) .content(|| { - let fewer = quantity.clone(); + let fewer = quantity; Button("\u{2212}") .modifier( Modifier @@ -538,7 +538,7 @@ fn detail_screen( Text(count.to_string()) .type_role(TypeRole::Subtitle) .color(palette::INK); - let more = quantity.clone(); + let more = quantity; Button("+") .modifier( Modifier @@ -572,7 +572,7 @@ fn detail_screen( } /// What is waiting to be paid for. -#[composable] +#[Composable] fn bag_screen(bag: MutableState>) { let lines = bag.get(); let sum = total(&lines); @@ -609,7 +609,7 @@ fn bag_screen(bag: MutableState>) { .content(|| { for (index, line) in lines.iter().copied().enumerate() { let last = index + 1 == lines.len(); - key(index, || bag_line(bag.clone(), index, line, last)); + key(index, || bag_line(bag, index, line, last)); } }); Spacer().modifier(Modifier.weight(1.0)); @@ -641,7 +641,7 @@ fn bag_screen(bag: MutableState>) { } /// One line of the bag, with the hairline under it unless it is the last. -#[composable] +#[Composable] fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: bool) { let named = catalogue::product(line.product); Column().modifier(Modifier.fill_max_width()).content(|| { @@ -666,7 +666,7 @@ fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: Text(price(named.map_or(0, |found| found.cents) * line.quantity)) .type_role(TypeRole::BodyStrong) .color(palette::INK); - let lines = bag.clone(); + let lines = bag; Button("Remove") .variant(ButtonVariant::Text) .color(palette::ALERT) @@ -680,7 +680,7 @@ fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: let Some(removed) = removed else { return }; let name = catalogue::product(removed.product) .map_or("An item", |found| found.name); - let restore = lines.clone(); + let restore = lines; Message::new(format!("Removed {name}")) .with_action("Undo", move |()| { let at = index.min(restore.with(Vec::len)); @@ -697,7 +697,7 @@ fn bag_line(bag: MutableState>, index: usize, line: BagLine, last: } /// Search, which is a field and the shelves as a list. -#[composable] +#[Composable] fn search_screen(on_open: Rc) { Column() .modifier( @@ -765,7 +765,7 @@ fn search_screen(on_open: Rc) { } /// The account page, which is a short list of the things a shop keeps about a person. -#[composable] +#[Composable] fn account_screen() { Column() .modifier( @@ -822,7 +822,7 @@ fn account_screen() { /// labelled bar with a selection pill under a hairline. The reference is four bare icons /// on the page, the one you are on in the accent, and that is a shape no design system /// would be right to give a set of destinations. -#[composable] +#[Composable] fn bottom_bar(destination: Destination, on_go: Rc) { Row() .modifier(Modifier.fill_max_width().padding_role(SpaceRole::Sm)) @@ -845,7 +845,7 @@ fn bottom_bar(destination: Destination, on_go: Rc) { }); } -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); let measure = if window.is_compact() { @@ -872,14 +872,14 @@ pub fn app() { }); let on_open: Rc = { - let (destination, open) = (destination.clone(), open.clone()); + let (destination, open) = (destination, open); Rc::new(move |id: u32| { destination.set(Destination::Shop); open.set(Some(id)); }) }; let on_go: Rc = { - let (destination, open) = (destination.clone(), open.clone()); + let (destination, open) = (destination, open); Rc::new(move |choice: Destination| { destination.set(choice); if choice != Destination::Shop { @@ -896,27 +896,20 @@ pub fn app() { let body = || match (current, showing) { (Destination::Shop, Some(product)) => { - let back: Rc = { - let open = open.clone(); - Rc::new(move || open.set(None)) - }; + let back: Rc = { Rc::new(move || open.set(None)) }; detail_screen( product, - size.clone(), - quantity.clone(), + size, + quantity, back, - add_to_bag(bag.clone(), open.clone(), size.clone(), quantity.clone()), + add_to_bag(bag, open, size, quantity), ); } - (Destination::Shop, None) => catalogue_screen( - category.clone(), - slide.clone(), - bag.clone(), - Rc::clone(&on_open), - Rc::clone(&on_go), - ), + (Destination::Shop, None) => { + catalogue_screen(category, slide, bag, Rc::clone(&on_open), Rc::clone(&on_go)) + } (Destination::Search, _) => search_screen(Rc::clone(&on_open)), - (Destination::Bag, _) => bag_screen(bag.clone()), + (Destination::Bag, _) => bag_screen(bag), (Destination::Account, _) => account_screen(), }; diff --git a/samples/todo/assets/icon-256.png b/samples/todo/assets/icon-256.png new file mode 100644 index 000000000..958d28223 Binary files /dev/null and b/samples/todo/assets/icon-256.png differ diff --git a/samples/todo/src/lib.rs b/samples/todo/src/lib.rs index 276474f8a..5dddeefcb 100644 --- a/samples/todo/src/lib.rs +++ b/samples/todo/src/lib.rs @@ -24,7 +24,7 @@ const BULK_COUNT: usize = 5_000; const ABOUT_LABEL: &str = "About"; /// The bar across the top of the window, with its contents held to the list's measure. -#[composable] +#[Composable] fn list_bar(measure: Option, done: usize, total: usize, children: impl FnOnce()) { Column().modifier(Modifier.fill_max_width()).content(|| { TopAppBar().modifier(Modifier.fill_max_width()).content(|| { @@ -52,7 +52,7 @@ fn list_bar(measure: Option, done: usize, total: usize, children: impl FnOn } /// What this sample is and the one control that only a sample has. -#[composable] +#[Composable] fn about_panel(total: usize, fill: Rc, close: Rc) { Column() .modifier(Modifier.fill_max_width()) @@ -97,7 +97,7 @@ fn about_panel(total: usize, fill: Rc, close: Rc) { } /// The composer: one grouped strip whose field grows with the window. -#[composable] +#[Composable] fn composer(add: Rc, draft: MutableState) { Surface().modifier(Modifier.fill_max_width()).content(|| { Row() @@ -105,13 +105,13 @@ fn composer(add: Rc, draft: MutableState) { .space_role(SpaceRole::Sm) .alignment(Alignment::CenterStart) .content(|| { - let (typed, submit) = (draft.clone(), Rc::clone(&add)); + let (typed, submit) = (draft, Rc::clone(&add)); TextField() .modifier(Modifier.weight(1.0)) .placeholder("Add a task, then press Enter") .on_value_change(move |value| typed.set(value)) .on_submit(move |value: String| submit(value)); - let (pressed, current) = (Rc::clone(&add), draft.clone()); + let (pressed, current) = (Rc::clone(&add), draft); Button("Add") .variant(ButtonVariant::Filled) .on_click(move || pressed(current.get_untracked())); @@ -120,7 +120,7 @@ fn composer(add: Rc, draft: MutableState) { } /// The task list. -#[composable] +#[Composable] pub fn app() { let window = current_window_size(); // Past an expanded window the screen stops widening and centres. @@ -164,7 +164,7 @@ pub fn app() { let total = tasks.with(Vec::len); let add: Rc = { - let (tasks, next_id, draft) = (tasks.clone(), next_id.clone(), draft.clone()); + let (tasks, next_id, draft) = (tasks, next_id, draft); Rc::new(move |title: String| { let title = title.trim().to_owned(); if title.is_empty() { @@ -185,7 +185,7 @@ pub fn app() { }; let commit_edit: Rc = { - let (tasks, editing, edit_draft) = (tasks.clone(), editing.clone(), edit_draft.clone()); + let (tasks, editing, edit_draft) = (tasks, editing, edit_draft); Rc::new(move |text: String| { let Some(id) = editing.get_untracked() else { return; @@ -207,7 +207,6 @@ pub fn app() { // Reordering is expressed in what the user can see; each row works out its two // neighbours and this only performs the swap. let swap_tasks: Rc = { - let tasks = tasks.clone(); Rc::new(move |from: usize, to: usize| { tasks.update(|tasks| tasks.swap(from, to)); store::save(&tasks.get_untracked()); @@ -216,7 +215,7 @@ pub fn app() { // One way in for a new task, whether the field is on the list or in the sheet. let compose: Rc = { - let (add, compose_open) = (Rc::clone(&add), compose_open.clone()); + let (add, compose_open) = (Rc::clone(&add), compose_open); Rc::new(move |title: String| { add(title); compose_open.set(false); @@ -253,12 +252,8 @@ pub fn app() { .content(|| { let rows = rows.clone(); let keys = keys.clone(); - let (tasks, editing, edit_draft, menu_open) = ( - tasks.clone(), - editing.clone(), - edit_draft.clone(), - menu_open.clone(), - ); + let (tasks, editing, edit_draft, menu_open) = + (tasks, editing, edit_draft, menu_open); let (commit_edit, swap_tasks) = (Rc::clone(&commit_edit), Rc::clone(&swap_tasks)); LazyColumn() @@ -292,7 +287,7 @@ pub fn app() { .bottom_bar(|| { Navigation(current_filter.index()).content(|| { for choice in Filter::STRIP { - let pick = filter.clone(); + let pick = filter; key(choice.label(), || { NavigationItem(choice.label()) .icon(choice.icon()) @@ -320,13 +315,13 @@ pub fn app() { }); // Where the composer is not on the list, the bar is how a task is added. if stacked { - let open = compose_open.clone(); + let open = compose_open; Button("Add") .variant(ButtonVariant::Filled) .on_click(move || open.set(true)); } // Clearing throws work away, so it says so and offers the way back. - let clearing = tasks.clone(); + let clearing = tasks; Button(if stacked { "Clear" } else { "Clear completed" }) .variant(ButtonVariant::Text) .color(Paint::Role(ColorRole::Error)) @@ -337,7 +332,7 @@ pub fn app() { clearing.update(|tasks| tasks.retain(|task| !task.done)); store::save(&clearing.get_untracked()); let plural = if cleared == 1 { "task" } else { "tasks" }; - let restore = clearing.clone(); + let restore = clearing; Message::new(format!("Cleared {cleared} completed {plural}")) .with_action("Undo", move |()| { restore.set(before.clone()); @@ -346,7 +341,7 @@ pub fn app() { .with_duration(MessageDuration::Long) .show(); }); - let open = about_open.clone(); + let open = about_open; Button(ABOUT_LABEL) .variant(ButtonVariant::Text) .on_click(move || open.set(true)); @@ -371,7 +366,7 @@ pub fn app() { .space_role(SpaceRole::Md) .content(|| { if !stacked { - composer(Rc::clone(&compose), draft.clone()); + composer(Rc::clone(&compose), draft); } list(); }); @@ -379,7 +374,7 @@ pub fn app() { // The same composer, arriving from an edge, where there is no room for // it at the head of the list. - let close = compose_open.clone(); + let close = compose_open; Sheet(compose_open.get() && stacked) .on_dismiss(move || close.set(false)) .modifier(Modifier.fill_max_width()) @@ -395,27 +390,26 @@ pub fn app() { Text("Add Task") .type_role(TypeRole::Subtitle) .modifier(Modifier.weight(1.0)); - let close = compose_open.clone(); + let close = compose_open; Button("Close") .variant(ButtonVariant::Filled) .on_click(move || close.set(false)); }); Separator(None); if stacked { - composer(Rc::clone(&compose), draft.clone()); + composer(Rc::clone(&compose), draft); } }); }); // What this sample is, and the one control that belongs to the sample. - let dismiss = about_open.clone(); + let dismiss = about_open; Sheet(about_open.get()) .on_dismiss(move || dismiss.set(false)) .modifier(Modifier.fill_max_width()) .content(|| { let fill: Rc = { - let (tasks, next_id, about_open) = - (tasks.clone(), next_id.clone(), about_open.clone()); + let (tasks, next_id, about_open) = (tasks, next_id, about_open); Rc::new(move || { let start = next_id.get_untracked(); tasks.update(|list| { @@ -434,10 +428,7 @@ pub fn app() { about_open.set(false); }) }; - let close: Rc = { - let about_open = about_open.clone(); - Rc::new(move || about_open.set(false)) - }; + let close: Rc = { Rc::new(move || about_open.set(false)) }; about_panel(total, fill, close); }); }); @@ -445,7 +436,7 @@ pub fn app() { } /// One row of the list, composed when the Renderer's window reaches it. -#[composable] +#[Composable] #[allow(clippy::too_many_arguments)] fn task_row( rows: &[usize], @@ -470,7 +461,7 @@ fn task_row( .space_role(SpaceRole::Sm) .alignment(Alignment::CenterStart) .content(|| { - let toggle = tasks.clone(); + let toggle = *tasks; let done = task.done; Checkbox(task.done).on_change(move |_| { toggle.update(|tasks| tasks[index].done = !done); @@ -479,12 +470,12 @@ fn task_row( // The title takes the weight, so the actions line up down the list. if editing_this { let (typed, submit, lost, saved) = ( - edit_draft.clone(), + *edit_draft, Rc::clone(commit_edit), Rc::clone(commit_edit), Rc::clone(commit_edit), ); - let (lost_draft, saved_draft) = (edit_draft.clone(), edit_draft.clone()); + let (lost_draft, saved_draft) = (*edit_draft, *edit_draft); TextField() .modifier(Modifier.weight(1.0)) .text(task.title.clone()) @@ -507,7 +498,7 @@ fn task_row( .overflow(TextOverflow::Ellipsis); // What a row can have done to it lives behind one control. The // entries exist only while the menu is open. - let (dismiss, opener) = (menu_open.clone(), menu_open.clone()); + let (dismiss, opener) = (*menu_open, *menu_open); let id = task.id; Menu(open) .on_dismiss(move || dismiss.set(None)) @@ -548,7 +539,7 @@ fn menu_entries( swap_tasks: &Rc, ) { let id = task.id; - let (close, draft, edit) = (menu_open.clone(), edit_draft.clone(), editing.clone()); + let (close, draft, edit) = (*menu_open, *edit_draft, *editing); Button("Edit") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -557,7 +548,7 @@ fn menu_entries( draft.set(String::new()); edit.set(Some(id)); }); - let (close, swap) = (menu_open.clone(), Rc::clone(swap_tasks)); + let (close, swap) = (*menu_open, Rc::clone(swap_tasks)); Button("Move up") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -568,7 +559,7 @@ fn menu_entries( swap(index, above); } }); - let (close, swap) = (menu_open.clone(), Rc::clone(swap_tasks)); + let (close, swap) = (*menu_open, Rc::clone(swap_tasks)); Button("Move down") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -581,7 +572,7 @@ fn menu_entries( }); // Deleting throws work away without asking, so it says what it did and offers the // task back. - let (close, tasks) = (menu_open.clone(), tasks.clone()); + let (close, tasks) = (*menu_open, *tasks); Button("Delete") .variant(ButtonVariant::Text) .modifier(Modifier.fill_max_width()) @@ -593,7 +584,7 @@ fn menu_entries( store::save(&tasks.get_untracked()); let Some(removed) = removed else { return }; let title = removed.title.clone(); - let restore = tasks.clone(); + let restore = tasks; Message::new(format!("Deleted \u{201c}{title}\u{201d}")) .with_action("Undo", move |()| { let at = index.min(restore.with(Vec::len)); diff --git a/scripts/check-windows-consumer.sh b/scripts/check-windows-consumer.sh index a449161ea..a88005ec2 100755 --- a/scripts/check-windows-consumer.sh +++ b/scripts/check-windows-consumer.sh @@ -7,8 +7,9 @@ # with the MSVC tools installed (Visual Studio or its Build Tools, "Desktop development with # C++"), and cl.exe and dumpbin.exe reachable on PATH or through vswhere. # -# the directory desktop/scripts/build-windows.sh writes; the application -# finds it through DXC_WINDOWS_NATIVE_LIB +# the directory desktop/scripts/build-windows.sh writes, which has to be +# renderer/build/windows of this checkout: the application is given no +# variable and finds it by the crate's default resolution # where the applications are copied to; emptied first, and it should be # somewhere that has nothing to do with the build # @@ -101,8 +102,13 @@ while :; do directory="$parent" done -export DXC_WINDOWS_NATIVE_LIB -DXC_WINDOWS_NATIVE_LIB="$(cygpath -w "$renderer")" +# No variable names the renderer. A consumer with a path dependency on this crate finds the +# kit build-windows.sh wrote in renderer/build/windows, which is what this proves; the +# override has a short check of its own in 1c. +unset DXC_WINDOWS_NATIVE_LIB +[[ "$renderer" == "$(cd "$repo_root/renderer/build/windows" 2>/dev/null && pwd)" ]] || + fail "$renderer is not $repo_root/renderer/build/windows" \ + "The default resolution looks there and nowhere else in a checkout." # Target directories of its own inside the checkout, as the Linux check does, so this never # shares a build with anything else running there. The builds with the same flags share one, @@ -168,6 +174,23 @@ CARGO_TARGET_DIR="$repo_root/target/windows-consumer-check/release" \ fail "the release build did not link" "See fix-mingw-objects.py, point 3, for the LNK1223 case." [[ -f "$target/release/release/consumer.exe" ]] || fail "the release build produced no consumer.exe" +# The override still wins over the default: pointed at a directory that holds no kit, the build +# has to stop naming that directory, and not quietly link the kit it would have found anyway. +echo "== 1c. DXC_WINDOWS_NATIVE_LIB overrides the default" +empty_kit="$scratch/empty-kit" +mkdir -p "$empty_kit" +override_log="$scratch/override.log" +if DXC_WINDOWS_NATIVE_LIB="$(cygpath -w "$empty_kit")" \ + CARGO_TARGET_DIR="$repo_root/target/windows-consumer-check/override" \ + cargo build --manifest-path "$fixture/Cargo.toml" > "$override_log" 2>&1; then + fail "a build with DXC_WINDOWS_NATIVE_LIB at an empty directory succeeded" \ + "The variable is meant to beat the default resolution." +fi +grep -q 'has no libcompose_rust_renderer.a' "$override_log" || + { tail -30 "$override_log" >&2; fail "the override build failed, but not for the empty kit"; } +grep -q "empty-kit" "$override_log" || + fail "the override failure does not name the directory the variable pointed at" + echo "== 2. what it needs to start" only_windows "$binary" default_size="$(size_of "$binary")" diff --git a/scripts/make-sample-icons.py b/scripts/make-sample-icons.py index 4cf466968..db2640325 100755 --- a/scripts/make-sample-icons.py +++ b/scripts/make-sample-icons.py @@ -15,7 +15,7 @@ often than at 64, and a drawing that needs detail to be recognised is not one. Run from the repository root with `uv run scripts/make-sample-icons.py`. Writes -samples//assets/icon.png. +samples//assets/icon.png (64 pixels) and icon-256.png. """ import math @@ -23,7 +23,11 @@ import struct import zlib +# The marks are drawn in a 64 unit square. Each icon is rendered at every size in SIZES: +# 64 is what the window wears, and 256 is what a Linux store asks for (Flathub refuses +# anything under 128, and an icon scaled up from 64 is the first thing a reviewer sees). SIZE = 64 +SIZES = (64, 256) CORNER = 14 @@ -32,11 +36,13 @@ def blend(dst, src, alpha): class Canvas: - def __init__(self): - self.pixels = [[(0, 0, 0, 0)] * SIZE for _ in range(SIZE)] + def __init__(self, size=SIZE): + self.size = size + self.scale = size / SIZE + self.pixels = [[(0, 0, 0, 0)] * size for _ in range(size)] def put(self, x, y, colour, alpha=1.0): - if not (0 <= x < SIZE and 0 <= y < SIZE) or alpha <= 0: + if not (0 <= x < self.size and 0 <= y < self.size) or alpha <= 0: return r, g, b = colour existing = self.pixels[y][x] @@ -56,13 +62,14 @@ def coverage(self, x, y, inside): hits = 0 for sy in range(4): for sx in range(4): - if inside(x + (sx + 0.5) / 4, y + (sy + 0.5) / 4): + if inside((x + (sx + 0.5) / 4) / self.scale, + (y + (sy + 0.5) / 4) / self.scale): hits += 1 return hits / 16 def fill(self, inside, colour): - for y in range(SIZE): - for x in range(SIZE): + for y in range(self.size): + for x in range(self.size): a = self.coverage(x, y, inside) if a > 0: self.put(x, y, colour, a) @@ -132,7 +139,8 @@ def chunk(kind, payload): return (struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", zlib.crc32(kind + payload) & 0xFFFFFFFF)) - header = struct.pack(">IIBBBBB", SIZE, SIZE, 8, 6, 0, 0, 0) + size = len(pixels) + header = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0) data = (b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) @@ -197,13 +205,16 @@ def marks(): def main(): for name, (background, mark, shapes) in marks().items(): - canvas = Canvas() - canvas.fill(rounded_square(), background) - for shape in shapes: - canvas.fill(shape, mark) - path = os.path.join("samples", name, "assets", "icon.png") - size = write_png(path, canvas.pixels) - print(f"{path} ({size} bytes)") + for size in SIZES: + canvas = Canvas(size) + canvas.fill(rounded_square(), background) + for shape in shapes: + canvas.fill(shape, mark) + # icon.png keeps its name: every sample embeds it for its window. + file = "icon.png" if size == SIZE else f"icon-{size}.png" + path = os.path.join("samples", name, "assets", file) + written = write_png(path, canvas.pixels) + print(f"{path} ({written} bytes)") if __name__ == "__main__": diff --git a/scripts/packager-linux/Cargo.toml b/scripts/packager-linux/Cargo.toml new file mode 100644 index 000000000..c6bf717ad --- /dev/null +++ b/scripts/packager-linux/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "packager-linux" +# Tooling for this repository's releases, not a library anyone depends on. +version = "0.0.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "Wraps a built compose-rust application as an AppImage and a Flatpak" + +[lib] +bench = false + +[[bin]] +name = "packager-linux" +path = "src/main.rs" +bench = false + +[dependencies] +serde = { version = "1", features = ["derive"] } +toml = { version = "1", default-features = false, features = ["std", "parse", "serde"] } diff --git a/scripts/packager-linux/FLATHUB.md b/scripts/packager-linux/FLATHUB.md new file mode 100644 index 000000000..62d7d680e --- /dev/null +++ b/scripts/packager-linux/FLATHUB.md @@ -0,0 +1,67 @@ +# Submitting Ember to Flathub + +The application ID is `dev.darkpyonix.Ember`. The owner controls the domain `darkpyonix.dev` +and serves its verification file from a Cloudflare Worker. + +Everything Flathub reviews is generated by `scripts/packager-linux/flatpak.sh`. +`flathub/ember.sh` drives it for one Ember release. The `Linux packaging` workflow +generates and lints the submission on every run and uploads it as the +`flathub-submission-ember` artifact. Ember has no release yet, so two of the workflow's +inputs are stand-ins: the archives are the published calculator samples, and the +screenshot is Ember's large icon. Everything else is what will be submitted. + +**The submission is on hold.** Nothing here opens a pull request. + +## 1. Before the pull request (owner) + +- [x] Application ID: `dev.darkpyonix.Ember`. +- [ ] Domain verification. Once the app is accepted, flathub.org (Developer settings, + Verification) issues a token for + `https://darkpyonix.dev/.well-known/org.flathub.VerifiedApps.txt`; the Worker + serves it. +- [ ] Publish an Ember release on `DarkPyonix/darkpyonix-ember` with one archive per + architecture. Each archive holds a top directory with the `ember` executable and the + renderer in `lib/` beside it. Default asset names: `ember-linux-x64.tar.gz` and + `ember-linux-arm64.tar.gz`. +- [ ] Commit a screenshot to the Ember repository and tag the release. The listing points at + it through raw.githubusercontent.com pinned to that tag; the packager refuses a URL + that follows a branch. +- [ ] Rewrite the summary and description in `flathub/dev.darkpyonix.Ember.toml` if the + repository's one-line description is not what the store page should say. Move the file + into the Ember repository as its `Dioxus.toml` when Ember gets one. +- [ ] Release notes for the version (`[linux.store] release_notes`). + +## 2. Generate the submission + +```sh +cargo build --release -p packager-linux +PACKAGER_LINUX=target/release/packager-linux scripts/packager-linux/flathub/ember.sh \ + --tag v1.0.0 --version 1.0.0 \ + --screenshot docs/screenshot.png --screenshot-caption "The console" \ + --out flathub-ember --build repo --lint +``` + +The script downloads the release archives to compute their SHA-256, takes the icons from +`src-tauri/icons/` at the tag (or `--icon`), and writes into `flathub-ember/` the files of +the `flathub/dev.darkpyonix.Ember` repository: `dev.darkpyonix.Ember.json`, the desktop +entry, the metainfo and `icons/`. + +## 3. Check before opening the pull request + +- [ ] `lint-manifest.json` and `lint-repo.json` show no errors other than the ones only + Flathub can clear (the domain not yet verified). +- [ ] `flatpak run dev.darkpyonix.Ember` opens the window. The workflow's calculator step + shows how to do that under a virtual display. +- [ ] `appstreamcli validate --no-net flathub-ember/dev.darkpyonix.Ember.metainfo.xml` passes. +- [ ] The runtime branch (default 25.08) is the newest one Flathub offers. Flathub's appstream + listing already names 26.08, but its `flathub` remote has no `org.freedesktop.Sdk/x86_64/26.08` + ref yet (flatpak-builder: "No remote refs found"), so 25.08 is the newest installable one. + Move to 26.08 when `flatpak remote-ls flathub --runtime | grep 26.08` lists it. + +## 4. Pull request (when the hold is lifted) + +Fork `flathub/flathub`, branch from `new-pr`, add the files from `flathub-ember/` at the +repository root, and open the pull request against `new-pr` titled +`Add dev.darkpyonix.Ember`. After it is merged, every later release is a commit in +`flathub/dev.darkpyonix.Ember` that changes the archive URLs and their digests: run +`ember.sh` again with the new tag. diff --git a/scripts/packager-linux/README.md b/scripts/packager-linux/README.md new file mode 100644 index 000000000..048e49eea --- /dev/null +++ b/scripts/packager-linux/README.md @@ -0,0 +1,50 @@ +# packager-linux + +Packages a built compose-rust application for Linux in two ways, both described by the +application's `Dioxus.toml`: + +- an **AppImage**. It carries no update information: direct distribution is packaged and + updated with Velopack, not here; +- a **Flatpak** manifest in the form Flathub reviews. A Flatpak is updated by the store that + installed it. + +| File | What it does | +|---|---| +| `src/` | The `packager-linux` command: desktop entry, AppStream metainfo, icons, AppDir, Flatpak manifest | +| `appimage.sh` | Builds an AppImage with pinned AppImage tools | +| `flatpak.sh` | Writes the manifest, then builds, installs and lints it the way Flathub does | +| `flathub/` | Ember's Flathub listing (`dev.darkpyonix.Ember`) and `ember.sh`, which generates the submission from an Ember release | +| `FLATHUB.md` | Checklist for submitting to Flathub | +| `samples.toml` | Store facts shared by the sample applications, passed as an overlay | +| `demo/` | The small program the packaging workflow wraps and, as a Flatpak, updates from 1.0.0 to 1.0.1 | + +Run `packager-linux` with no arguments for its options. `.github/workflows/packager-linux.yml` +runs everything here on GitHub's Ubuntu runners. + +## Secrets + +The owner creates these in this repository's settings (Settings, Secrets and variables, +Actions). + +### APPIMAGE_SIGNING_KEY + +The armoured private key of the AppImage signing key (see below). While it is missing, the +`Sample apps` workflow builds AppImages unsigned and warns that it did. + +## AppImage signing key + +Sign from the first public AppImage onwards, so a downloaded file can be checked against +one key: + +- Make the key without an expiry date, keep an offline backup, and store only the armoured + private key in `APPIMAGE_SIGNING_KEY`. + +```sh +gpg --quick-gen-key "DarkPyonix AppImage signing" ed25519 sign never +gpg --armor --export-secret-keys # the value of APPIMAGE_SIGNING_KEY +gpg --armor --export # the public key, to publish +``` + +Fingerprint: `` + +Until the key exists, AppImages are built unsigned and only for testing. diff --git a/scripts/packager-linux/appimage.sh b/scripts/packager-linux/appimage.sh new file mode 100755 index 000000000..b66eb1104 --- /dev/null +++ b/scripts/packager-linux/appimage.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Wraps a built application as an AppImage. +# +# scripts/packager-linux/appimage.sh \ +# --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ +# --payload staging/calculator --version 1.2.3 --out-dir dist +# +# writes dist/Calculator-1.2.3-x86_64.AppImage. The AppImage carries no update +# information: direct distribution is packaged and updated with Velopack, not here. +# +# Options: +# --dioxus-toml, --overlay, --version, --date, --exec, --icon as packager-linux takes them +# --payload the executable, or the directory holding it and its renderer +# --sign-key sign with this gpg key +# --out-dir +# +# Tools: appimagetool and the AppImage runtime are downloaded at pinned versions and +# checked against pinned digests, into PACKAGER_LINUX_CACHE (default +# .scratch/packager-linux/tools in this repository). +# desktop-file-validate and appstreamcli are used when present. +# +# packager-linux itself is run through `cargo run` unless PACKAGER_LINUX names a built one. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" + +fail() { + echo "error: $1" >&2 + shift + for line in "$@"; do echo " $line" >&2; done + exit 1 +} + +# Pinned tools. A digest that does not match stops the build rather than packaging with +# something nobody reviewed. +APPIMAGETOOL_VERSION=1.9.1 +RUNTIME_VERSION=20251108 +declare -A DIGESTS=( + [appimagetool-x86_64.AppImage]=ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + [appimagetool-aarch64.AppImage]=f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + [runtime-x86_64]=2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d + [runtime-aarch64]=00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 +) + +common=() +payload="" +sign_key="" +out_dir="" +while [[ $# -gt 0 ]]; do + case "$1" in + --dioxus-toml | --overlay | --version | --date | --exec | --icon) + [[ $# -ge 2 ]] || fail "$1 needs a value" + common+=("$1" "$2") + shift 2 + ;; + --payload) payload="$2"; shift 2 ;; + --sign-key) sign_key="$2"; shift 2 ;; + --out-dir) out_dir="$2"; shift 2 ;; + *) fail "unknown option $1" "See the comment at the top of $0." ;; + esac +done +[[ -n "$payload" ]] || fail "--payload is required" +[[ -n "$out_dir" ]] || fail "--out-dir is required" + +case "$(uname -m)" in + x86_64) arch=x86_64 ;; + aarch64 | arm64) arch=aarch64 ;; + *) fail "no pinned AppImage tools for $(uname -m)" ;; +esac +[[ "$(uname -s)" == Linux ]] || fail "an AppImage is assembled on Linux" + +packager() { + if [[ -n "${PACKAGER_LINUX:-}" ]]; then + "$PACKAGER_LINUX" "$@" + else + cargo run --quiet --release --manifest-path "$repo_root/Cargo.toml" \ + -p packager-linux -- "$@" + fi +} + +cache="${PACKAGER_LINUX_CACHE:-$repo_root/.scratch/packager-linux/tools}" +mkdir -p "$cache" + +fetch() { + local name="$1" url="$2" path="$cache/$1" + local want="${DIGESTS[$name]}" + if [[ ! -f "$path" ]] || [[ "$(sha256sum "$path" | cut -d' ' -f1)" != "$want" ]]; then + echo "== fetching $name" + curl --fail --location --silent --show-error --retry 3 -o "$path.part" "$url" + local got + got="$(sha256sum "$path.part" | cut -d' ' -f1)" + [[ "$got" == "$want" ]] || fail "$name has digest $got, pinned is $want" \ + "Either the release was replaced or the download is damaged." + mv "$path.part" "$path" + chmod +x "$path" + fi +} + +fetch "appimagetool-$arch.AppImage" \ + "https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-$arch.AppImage" +fetch "runtime-$arch" \ + "https://github.com/AppImage/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-$arch" + +file_name="$(packager file-name "${common[@]}" --arch "$arch")" + +mkdir -p "$out_dir" +out_dir="$(cd "$out_dir" && pwd)" +appdir="$out_dir/${file_name%.AppImage}.AppDir" +rm -rf "$appdir" "$out_dir/$file_name" +packager appdir "${common[@]}" --arch "$arch" --payload "$payload" \ + --out "$appdir" >/dev/null + +sign_args=() +if [[ -n "$sign_key" ]]; then + sign_args=(--sign --sign-key "$sign_key") +fi + +echo "== $file_name" +# appimagetool is itself an AppImage. Extract-and-run spares the build machine FUSE. +( + cd "$out_dir" + APPIMAGE_EXTRACT_AND_RUN=1 ARCH="$arch" "$cache/appimagetool-$arch.AppImage" \ + --runtime-file "$cache/runtime-$arch" \ + "${sign_args[@]}" \ + "$appdir" "$out_dir/$file_name" +) + +rm -rf "$appdir" +(cd "$out_dir" && sha256sum "$file_name") diff --git a/scripts/packager-linux/demo/Cargo.toml b/scripts/packager-linux/demo/Cargo.toml new file mode 100644 index 000000000..138de74ad --- /dev/null +++ b/scripts/packager-linux/demo/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "packager-linux-demo" +# The packaging workflow builds this twice with different versions in +# COMPOSE_RUST_APP_VERSION and packages both. +version = "0.0.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false +description = "A small program that the packaging workflow wraps as an AppImage and a Flatpak" + +[[bin]] +name = "update-demo" +path = "src/main.rs" +bench = false + +[dependencies] +# No renderer: this program draws nothing, so it needs neither the renderer download nor +# a display, and it builds inside a Flatpak sandbox with no network. +compose-rust = { path = "../../../compose-rust", default-features = false } diff --git a/scripts/packager-linux/demo/Dioxus.toml b/scripts/packager-linux/demo/Dioxus.toml new file mode 100644 index 000000000..55e957a44 --- /dev/null +++ b/scripts/packager-linux/demo/Dioxus.toml @@ -0,0 +1,27 @@ +# What the packaging workflow wraps: a small program, described the way an +# application describes itself to dx, plus the [linux.store] facts a store listing needs. + +[application] +name = "UpdateDemo" + +[bundle] +identifier = "dev.darkpyonix.compose.UpdateDemo" +publisher = "DarkPyonix" +icon = ["icon.png"] +short_description = "A small packaged application" +long_description = """ +A small program built with compose-rust that prints its version, so a package can be +checked for the version it was built with.""" + +[linux] +categories = ["Development"] +keywords = ["appimage", "flatpak"] + +[linux.store] +exec = "update-demo" +license = "Apache-2.0" +homepage = "https://github.com/darkpyonix/compose-rust" +bugtracker = "https://github.com/darkpyonix/compose-rust/issues" +vcs_browser = "https://github.com/darkpyonix/compose-rust" +brand_light = "#dbe7ff" +brand_dark = "#1d2b4f" diff --git a/scripts/packager-linux/demo/icon.png b/scripts/packager-linux/demo/icon.png new file mode 100644 index 000000000..24c96d9a6 Binary files /dev/null and b/scripts/packager-linux/demo/icon.png differ diff --git a/scripts/packager-linux/demo/src/main.rs b/scripts/packager-linux/demo/src/main.rs new file mode 100644 index 000000000..a1766abf0 --- /dev/null +++ b/scripts/packager-linux/demo/src/main.rs @@ -0,0 +1,30 @@ +//! A small packaged application. The packaging workflow builds it twice with different +//! versions, wraps each as an AppImage and as a Flatpak, and checks that the package +//! carries the version it was built with. A Flatpak is also updated from the first +//! version to the second with `flatpak update`. +//! +//! ```text +//! update-demo --version print the version +//! ``` + +use std::process::ExitCode; + +/// Set by the build, so two builds of the same source differ the way two releases do. +const VERSION: &str = match option_env!("COMPOSE_RUST_APP_VERSION") { + Some(version) => version, + None => env!("CARGO_PKG_VERSION"), +}; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match args.first().map(String::as_str) { + Some("--version") | None => { + println!("update-demo {VERSION}"); + ExitCode::SUCCESS + } + Some(other) => { + eprintln!("unknown argument {other}; the only one is --version"); + ExitCode::from(2) + } + } +} diff --git a/scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml b/scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml new file mode 100644 index 000000000..206d04fa2 --- /dev/null +++ b/scripts/packager-linux/flathub/dev.darkpyonix.Ember.toml @@ -0,0 +1,34 @@ +# Ember's store listing for Flathub, in the shape of a Dioxus.toml. +# +# Ember lives in DarkPyonix/darkpyonix-ember. Once that repository has its own Dioxus.toml, +# pass that file with --dioxus-toml and this one with --overlay; until then this file is +# both. What changes per release (the version, the archives and their digests, the +# screenshot pinned to the release tag) is passed on the command line, as +# scripts/packager-linux/flathub/ember.sh does. +# +# The summary and description restate the repository's own one-line description. They +# are the owner's to rewrite before the listing goes public. + +[application] +name = "Ember" + +[bundle] +identifier = "dev.darkpyonix.Ember" +publisher = "DarkPyonix" +short_description = "DarkPyonix Ember Console" +long_description = """ +Ember is the DarkPyonix console, compatible with Visual Studio Code extensions and +settings.""" + +[linux] +categories = ["Development"] +keywords = ["console", "terminal", "editor"] + +[linux.store] +exec = "ember" +license = "MIT" +homepage = "https://github.com/DarkPyonix/darkpyonix-ember" +bugtracker = "https://github.com/DarkPyonix/darkpyonix-ember/issues" +vcs_browser = "https://github.com/DarkPyonix/darkpyonix-ember" +developer_id = "dev.darkpyonix" +developer_name = "DarkPyonix" diff --git a/scripts/packager-linux/flathub/ember.sh b/scripts/packager-linux/flathub/ember.sh new file mode 100755 index 000000000..e8c54c19a --- /dev/null +++ b/scripts/packager-linux/flathub/ember.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Generates the Flathub submission for Ember (dev.darkpyonix.Ember) from one of its releases. +# +# scripts/packager-linux/flathub/ember.sh --tag v1.0.0 --version 1.0.0 \ +# --screenshot docs/screenshot.png --out flathub-ember [--build repo] [--lint] +# +# writes into --out what the flathub/dev.darkpyonix.Ember repository holds: the manifest, +# the desktop entry, the metainfo and the icons. Nothing is submitted; opening the +# pull request is a separate, deliberate step (scripts/packager-linux/FLATHUB.md). +# +# Options: +# --tag the Ember release to package +# --version the version that release carries +# --screenshot a screenshot's path inside the Ember repository; the listing +# points at it pinned to --tag (repeatable) +# --screenshot-caption one per --screenshot +# --x64-asset the release asset for x86_64 (default ember-linux-x64.tar.gz) +# --arm64-asset the release asset for aarch64 (default ember-linux-arm64.tar.gz) +# --icon overrides the icons fetched from the repository at --tag +# --out , --build , --lint as flatpak.sh takes them +# +# The archives are downloaded once to compute their digests, into PACKAGER_LINUX_CACHE +# (default .scratch/packager-linux/tools in this repository). + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../../.." && pwd)" +ember=DarkPyonix/darkpyonix-ember +app_id=dev.darkpyonix.Ember + +fail() { + echo "error: $1" >&2 + shift + for line in "$@"; do echo " $line" >&2; done + exit 1 +} + +tag="" +version="" +x64_asset=ember-linux-x64.tar.gz +arm64_asset=ember-linux-arm64.tar.gz +shots=() +icons=() +rest=() +while [[ $# -gt 0 ]]; do + case "$1" in + --tag) tag="$2"; shift 2 ;; + --version) version="$2"; shift 2 ;; + --x64-asset) x64_asset="$2"; shift 2 ;; + --arm64-asset) arm64_asset="$2"; shift 2 ;; + --screenshot) shots+=(--screenshot "https://raw.githubusercontent.com/$ember/$tag/$2"); shift 2 ;; + --screenshot-caption) shots+=(--screenshot-caption "$2"); shift 2 ;; + --icon) icons+=(--icon "$2"); shift 2 ;; + --lint) rest+=("$1"); shift ;; + --out | --build) rest+=("$1" "$2"); shift 2 ;; + *) fail "unknown option $1" "See the comment at the top of $0." ;; + esac +done +[[ -n "$tag" ]] || fail "--tag is required" +[[ -n "$version" ]] || fail "--version is required" +[[ ${#shots[@]} -gt 0 ]] || fail "--screenshot is required" \ + "Flathub's linter refuses a listing without one." +case " ${shots[*]} " in + *"/$ember//"*) fail "--tag must come before --screenshot" ;; +esac + +cache="${PACKAGER_LINUX_CACHE:-$repo_root/.scratch/packager-linux/tools}/ember-$tag" +mkdir -p "$cache" + +digest() { + local name="$1" url="https://github.com/$ember/releases/download/$tag/$1" + if [[ ! -f "$cache/$name" ]]; then + curl --fail --location --silent --show-error --retry 3 -o "$cache/$name.part" "$url" || + fail "could not download $url" "Is $name an asset of the $tag release?" + mv "$cache/$name.part" "$cache/$name" + fi + sha256sum "$cache/$name" | cut -d' ' -f1 +} + +if [[ ${#icons[@]} -eq 0 ]]; then + for icon in 128x128.png 128x128@2x.png; do + curl --fail --location --silent --show-error --retry 3 -o "$cache/$icon" \ + "https://raw.githubusercontent.com/$ember/$tag/src-tauri/icons/$icon" || + fail "no src-tauri/icons/$icon in $ember at $tag" "Pass --icon instead." + icons+=(--icon "$cache/$icon") + done +fi + +x64_sha="$(digest "$x64_asset")" +arm64_sha="$(digest "$arm64_asset")" + +exec "$here/../flatpak.sh" \ + --dioxus-toml "$here/$app_id.toml" --app-id "$app_id" --version "$version" \ + "${icons[@]}" "${shots[@]}" \ + --prebuilt-url "https://github.com/$ember/releases/download/$tag/$x64_asset" \ + --prebuilt-sha256 "$x64_sha" --prebuilt-arch x86_64 \ + --prebuilt-url "https://github.com/$ember/releases/download/$tag/$arm64_asset" \ + --prebuilt-sha256 "$arm64_sha" --prebuilt-arch aarch64 \ + "${rest[@]}" diff --git a/scripts/packager-linux/flatpak.sh b/scripts/packager-linux/flatpak.sh new file mode 100755 index 000000000..36730e74d --- /dev/null +++ b/scripts/packager-linux/flatpak.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash +# Writes a Flatpak manifest for an application and, optionally, builds, installs and lints +# it the way Flathub does. +# +# scripts/packager-linux/flatpak.sh \ +# --dioxus-toml samples/calculator/Dioxus.toml --overlay linux.toml \ +# --prebuilt-url https://.../calculator-linux-x64.tar.gz --prebuilt-sha256 \ +# --icon icon-256.png --version 1.2.3 --out flatpak/ --build repo/ --lint +# +# Every option packager-linux's `flatpak` command takes is passed through to it. A build +# from source also needs the crates vendored, because a Flathub build has no network: +# +# --cargo-lock write /cargo-sources.json from this lock file with the +# pinned flatpak-cargo-generator (and pass it as --cargo-sources) +# --build build with flatpak-builder into this OSTree repository and +# install the result for the current user +# --lint run Flathub's linter on the manifest, and on the repository +# when one was built. Findings are printed and written to +# /lint-*.json; they do not stop the script, because some of +# them (an app ID whose domain is not verified yet) are only +# settled by Flathub +# +# The build and the linter both come from Flathub's own org.flatpak.Builder, installed for +# the user when missing, because a distribution's flatpak-builder is often too old for the +# options Flathub requires (--compose-url-policy, screenshot mirroring). FLATPAK_BUILDER +# and FLATPAK_BUILDER_LINT replace the commands, each a command line that takes the +# builder's or the linter's arguments. flatpak itself must be installed. +# +# Downloads (the generator) go to PACKAGER_LINUX_CACHE, default .scratch/packager-linux/tools +# in this repository. packager-linux is run through `cargo run` unless PACKAGER_LINUX +# names a built one. + +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$here/../.." && pwd)" + +fail() { + echo "error: $1" >&2 + shift + for line in "$@"; do echo " $line" >&2; done + exit 1 +} + +GENERATOR_COMMIT=41c20aa10819cdb2a4f3ca171758a96d1955c018 +GENERATOR_SHA256=0a2db6be87d75910facef28ab46d4d6460802e8419ab850d0caa6a364d26b380 + +passthrough=() +out="" +cargo_lock="" +build_repo="" +lint=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --cargo-lock) cargo_lock="$2"; shift 2 ;; + --build) build_repo="$2"; shift 2 ;; + --lint) lint=1; shift ;; + --out) out="$2"; passthrough+=("$1" "$2"); shift 2 ;; + --wayland) passthrough+=("$1"); shift ;; + --*) + [[ $# -ge 2 ]] || fail "$1 needs a value" + passthrough+=("$1" "$2") + shift 2 + ;; + *) fail "unexpected argument $1" "See the comment at the top of $0." ;; + esac +done +[[ -n "$out" ]] || fail "--out is required" +mkdir -p "$out" +out="$(cd "$out" && pwd)" + +packager() { + if [[ -n "${PACKAGER_LINUX:-}" ]]; then + "$PACKAGER_LINUX" "$@" + else + cargo run --quiet --release --manifest-path "$repo_root/Cargo.toml" \ + -p packager-linux -- "$@" + fi +} + +cache="${PACKAGER_LINUX_CACHE:-$repo_root/.scratch/packager-linux/tools}" +mkdir -p "$cache" + +if [[ -n "$cargo_lock" ]]; then + generator="$cache/flatpak-cargo-generator-$GENERATOR_COMMIT.py" + if [[ ! -f "$generator" ]]; then + curl --fail --location --silent --show-error --retry 3 -o "$generator.part" \ + "https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/$GENERATOR_COMMIT/cargo/flatpak-cargo-generator.py" + got="$(sha256sum "$generator.part" | cut -d' ' -f1)" + [[ "$got" == "$GENERATOR_SHA256" ]] || fail \ + "flatpak-cargo-generator has digest $got, pinned is $GENERATOR_SHA256" + mv "$generator.part" "$generator" + fi + command -v uv >/dev/null || fail \ + "flatpak-cargo-generator needs uv to run it with the Python modules aiohttp and tomlkit" \ + "install uv: https://docs.astral.sh/uv/" + echo "== vendoring the crates in $cargo_lock" + uv run --no-project --with 'aiohttp>=3.9.5,<4' --with 'tomlkit>=0.13.3,<1' \ + "$generator" "$cargo_lock" -o "$out/cargo-sources.json" + passthrough+=(--cargo-sources cargo-sources.json) +fi + +# The packager prints the manifest's path as it was given; make it absolute. +manifest="$out/$(basename "$(packager flatpak "${passthrough[@]}")")" +echo "== $manifest" + +# Flathub's recommended way to run both tools (docs.flathub.org/docs/for-app-authors). +builder() { + ${FLATPAK_BUILDER:-flatpak run --command=flatpak-builder org.flatpak.Builder} "$@" +} +linter() { + ${FLATPAK_BUILDER_LINT:-flatpak run --command=flatpak-builder-lint org.flatpak.Builder} "$@" +} + +if [[ -n "$build_repo" || $lint -eq 1 ]]; then + flatpak --user remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo + flatpak --user info org.flatpak.Builder >/dev/null 2>&1 || + flatpak --user install --noninteractive flathub org.flatpak.Builder +fi + +if [[ -n "$build_repo" ]]; then + mkdir -p "$build_repo" + build_repo="$(cd "$build_repo" && pwd)" + echo "== building into $build_repo" + ( + cd "$out" + # Flathub's linter requires both flags: they mirror screenshots and remote icons + # into the media repository and the screenshots ref (docs.flathub.org/linter). + builder --user --force-clean --install-deps-from=flathub \ + --compose-url-policy=full --mirror-screenshots-url=https://dl.flathub.org/media \ + --repo="$build_repo" --install --state-dir="$out/.flatpak-builder" \ + "$out/build" "$manifest" + ) +fi + +if [[ $lint -eq 1 ]]; then + lint_run() { + local name="$1" + shift + echo "== flatpak-builder-lint $*" + if linter "$@" \ + >"$out/lint-$name.json"; then + echo " clean" + else + echo " findings:" + fi + cat "$out/lint-$name.json" + } + lint_run manifest manifest "$manifest" + if [[ -n "$build_repo" ]]; then + lint_run repo repo "$build_repo" + fi +fi diff --git a/scripts/packager-linux/samples.toml b/scripts/packager-linux/samples.toml new file mode 100644 index 000000000..0e687ae4a --- /dev/null +++ b/scripts/packager-linux/samples.toml @@ -0,0 +1,13 @@ +# Overlay for the samples' Dioxus.toml files: the store facts that are the same for every +# sample and that a sample's own file, which is written to be copied, should not carry. +# Pass with --overlay, and the executable with --exec sample-. + +[linux] +categories = ["Utility"] + +[linux.store] +license = "Apache-2.0" +homepage = "https://github.com/darkpyonix/compose-rust" +bugtracker = "https://github.com/darkpyonix/compose-rust/issues" +vcs_browser = "https://github.com/darkpyonix/compose-rust" +developer_id = "dev.darkpyonix" diff --git a/scripts/packager-linux/src/appimage.rs b/scripts/packager-linux/src/appimage.rs new file mode 100644 index 000000000..feda18c9c --- /dev/null +++ b/scripts/packager-linux/src/appimage.rs @@ -0,0 +1,206 @@ +//! The AppImage channel: an AppDir laid out for `appimagetool`. + +use std::fmt; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::appstream::{appdata_file_name, metainfo}; +use crate::desktop::{desktop_entry, desktop_file_name}; +use crate::icons::{Icon, best}; +use crate::metadata::AppMetadata; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppImageError(pub String); + +impl fmt::Display for AppImageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for AppImageError {} + +/// The architecture name AppImage files and tools use for a Rust target architecture. +pub fn appimage_arch(rust_arch: &str) -> Option<&'static str> { + match rust_arch { + "x86_64" => Some("x86_64"), + "aarch64" => Some("aarch64"), + "x86" | "i686" => Some("i686"), + "arm" | "armv7" => Some("armhf"), + _ => None, + } +} + +/// The application name as it appears in file names: anything but letters, digits, `.`, +/// `_` and `-` becomes `_`. +pub fn file_safe(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect() +} + +/// `--.AppImage`, the shape the AppImage tools expect. +pub fn appimage_file_name(meta: &AppMetadata, arch: &str) -> String { + format!("{}-{}-{arch}.AppImage", file_safe(&meta.name), meta.version) +} + +/// The script the runtime starts. It runs the application from the directory it was +/// copied into, so the renderer and its companions sit beside it the way they did when +/// it was built. +pub fn apprun(meta: &AppMetadata) -> String { + format!( + "#!/bin/sh\n\ + # Written by packager-linux. Starts {name} from inside the AppImage.\n\ + # The runtime sets APPDIR to where the image is mounted. Falling back to this\n\ + # file's directory lets the unpacked AppDir run too.\n\ + APPDIR=\"${{APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}}\"\n\ + export APPDIR\n\ + # The renderer sits in lib/ beside the executable (or, in older packages, beside\n\ + # it). An executable with an rpath finds it on its own; this covers one without.\n\ + LD_LIBRARY_PATH=\"$APPDIR/usr/lib/{exec}/lib:$APPDIR/usr/lib/{exec}${{LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}}\"\n\ + export LD_LIBRARY_PATH\n\ + exec \"$APPDIR/usr/lib/{exec}/{exec}\" \"$@\"\n", + name = meta.name.replace('\n', " "), + exec = meta.exec + ) +} + +/// What goes into an AppDir. +#[derive(Debug, Clone)] +pub struct AppDirInputs<'a> { + pub meta: &'a AppMetadata, + /// A directory holding the executable and whatever must sit beside it (the renderer + /// and its companions), or the executable alone. + pub payload: &'a Path, + pub icons: &'a [Icon], +} + +/// Lays out an AppDir at `out`, which must not exist yet. Returns the files it wrote, +/// relative to `out`, in the order written. +pub fn assemble_appdir(inputs: &AppDirInputs, out: &Path) -> Result, AppImageError> { + let meta = inputs.meta; + if out.exists() { + return Err(AppImageError(format!( + "{} already exists; an AppDir is assembled from nothing", + out.display() + ))); + } + let io_error = |what: &str, error: io::Error| AppImageError(format!("{what}: {error}")); + let mut written = Vec::new(); + let mut write = |relative: &str, contents: &[u8], executable: bool| { + let path = out.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| io_error(&parent.display().to_string(), e))?; + } + fs::write(&path, contents).map_err(|e| io_error(&path.display().to_string(), e))?; + if executable { + set_executable(&path).map_err(|e| io_error(&path.display().to_string(), e))?; + } + written.push(PathBuf::from(relative)); + Ok::<(), AppImageError>(()) + }; + + let icon = best(inputs.icons).ok_or_else(|| { + AppImageError("no icon: an AppImage shows one in file managers and menus".into()) + })?; + + let desktop = desktop_entry(meta); + write("AppRun", apprun(meta).as_bytes(), true)?; + write(&desktop_file_name(meta), desktop.as_bytes(), false)?; + let icon_bytes = + fs::read(&icon.source).map_err(|e| io_error(&icon.source.display().to_string(), e))?; + write(&icon.root_name(&meta.id), &icon_bytes, false)?; + write( + &format!("usr/share/applications/{}", desktop_file_name(meta)), + desktop.as_bytes(), + false, + )?; + write( + &format!("usr/share/metainfo/{}", appdata_file_name(meta)), + metainfo(meta).as_bytes(), + false, + )?; + for icon in inputs.icons { + let bytes = + fs::read(&icon.source).map_err(|e| io_error(&icon.source.display().to_string(), e))?; + write( + &format!("usr/{}", icon.install_path(&meta.id)), + &bytes, + false, + )?; + } + + let lib = out.join("usr/lib").join(&meta.exec); + if inputs.payload.is_dir() { + copy_tree(inputs.payload, &lib) + .map_err(|e| io_error(&inputs.payload.display().to_string(), e))?; + } else { + fs::create_dir_all(&lib).map_err(|e| io_error(&lib.display().to_string(), e))?; + fs::copy(inputs.payload, lib.join(&meta.exec)) + .map_err(|e| io_error(&inputs.payload.display().to_string(), e))?; + } + let executable = lib.join(&meta.exec); + if !executable.is_file() { + return Err(AppImageError(format!( + "the payload has no {} at its top level", + meta.exec + ))); + } + set_executable(&executable).map_err(|e| io_error(&executable.display().to_string(), e))?; + written.push(PathBuf::from(format!("usr/lib/{0}/{0}", meta.exec))); + + Ok(written) +} + +/// Copies a directory tree, keeping symbolic links as links and permissions as they are. +pub fn copy_tree(from: &Path, to: &Path) -> io::Result<()> { + fs::create_dir_all(to)?; + for entry in fs::read_dir(from)? { + let entry = entry?; + let kind = entry.file_type()?; + let target = to.join(entry.file_name()); + if kind.is_symlink() { + let link = fs::read_link(entry.path())?; + symlink(&link, &target, &entry.path())?; + } else if kind.is_dir() { + copy_tree(&entry.path(), &target)?; + } else { + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +#[cfg(unix)] +fn symlink(link: &Path, target: &Path, _original: &Path) -> io::Result<()> { + std::os::unix::fs::symlink(link, target) +} + +#[cfg(not(unix))] +fn symlink(_link: &Path, target: &Path, original: &Path) -> io::Result<()> { + if original.is_dir() { + copy_tree(original, target) + } else { + fs::copy(original, target).map(|_| ()) + } +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(path)?.permissions(); + permissions.set_mode(permissions.mode() | 0o755); + fs::set_permissions(path, permissions) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> io::Result<()> { + Ok(()) +} diff --git a/scripts/packager-linux/src/appstream.rs b/scripts/packager-linux/src/appstream.rs new file mode 100644 index 000000000..52e923e72 --- /dev/null +++ b/scripts/packager-linux/src/appstream.rs @@ -0,0 +1,157 @@ +//! The AppStream metainfo file: what a store page shows. Flathub builds its listing from +//! this file alone, and `appstreamcli validate` is what decides whether it is accepted. + +use crate::metadata::AppMetadata; + +/// The file name Flathub expects under `share/metainfo`. +pub fn metainfo_file_name(meta: &AppMetadata) -> String { + format!("{}.metainfo.xml", meta.id) +} + +/// The file name `appimagetool` looks for under `usr/share/metainfo`. It derives the name +/// from the desktop entry's and only knows the older suffix. +pub fn appdata_file_name(meta: &AppMetadata) -> String { + format!("{}.appdata.xml", meta.id) +} + +/// The metainfo XML document. +pub fn metainfo(meta: &AppMetadata) -> String { + let mut x = String::new(); + x.push_str("\n"); + x.push_str("\n"); + element(&mut x, 1, "id", &meta.id); + element(&mut x, 1, "metadata_license", &meta.metadata_license); + element(&mut x, 1, "project_license", &meta.license); + element(&mut x, 1, "name", &meta.name); + element(&mut x, 1, "summary", &meta.summary); + x.push_str(&format!( + " \n", + escape(&meta.developer_id) + )); + element(&mut x, 2, "name", &meta.developer_name); + x.push_str(" \n"); + + x.push_str(" \n"); + for paragraph in &meta.description { + element(&mut x, 2, "p", paragraph); + } + x.push_str(" \n"); + + x.push_str(&format!( + " {}.desktop\n", + escape(&meta.id) + )); + for (kind, url) in [ + ("homepage", Some(&meta.homepage)), + ("bugtracker", meta.bugtracker.as_ref()), + ("vcs-browser", meta.vcs_browser.as_ref()), + ("donation", meta.donation.as_ref()), + ] { + if let Some(url) = url { + x.push_str(&format!(" {}\n", escape(url))); + } + } + + if !meta.keywords.is_empty() { + x.push_str(" \n"); + for keyword in &meta.keywords { + element(&mut x, 2, "keyword", keyword); + } + x.push_str(" \n"); + } + + if !meta.screenshots.is_empty() { + x.push_str(" \n"); + for (index, shot) in meta.screenshots.iter().enumerate() { + if index == 0 { + x.push_str(" \n"); + } else { + x.push_str(" \n"); + } + element(&mut x, 3, "image", &shot.url); + if let Some(caption) = &shot.caption { + element(&mut x, 3, "caption", caption); + } + x.push_str(" \n"); + } + x.push_str(" \n"); + } + + if meta.brand_light.is_some() || meta.brand_dark.is_some() { + x.push_str(" \n"); + for (scheme, colour) in [ + ("light", meta.brand_light.as_ref()), + ("dark", meta.brand_dark.as_ref()), + ] { + if let Some(colour) = colour { + x.push_str(&format!( + " {}\n", + escape(colour) + )); + } + } + x.push_str(" \n"); + } + + if meta.content_rating.is_empty() { + x.push_str(" \n"); + } else { + x.push_str(" \n"); + for (attribute, value) in &meta.content_rating { + x.push_str(&format!( + " {}\n", + escape(attribute), + escape(value) + )); + } + x.push_str(" \n"); + } + + x.push_str(" \n"); + element(&mut x, 2, "binary", &meta.exec); + x.push_str(" \n"); + + x.push_str(" \n"); + let release = format!( + " \n"); + } else { + x.push_str(&release); + x.push_str(">\n \n"); + for note in &meta.release_notes { + element(&mut x, 4, "p", note); + } + x.push_str(" \n \n"); + } + x.push_str(" \n"); + x.push_str("\n"); + x +} + +fn element(x: &mut String, depth: usize, name: &str, text: &str) { + for _ in 0..depth { + x.push_str(" "); + } + x.push_str(&format!("<{name}>{}\n", escape(text))); +} + +/// Escapes text and attribute values. +pub fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} diff --git a/scripts/packager-linux/src/desktop.rs b/scripts/packager-linux/src/desktop.rs new file mode 100644 index 000000000..84b9ee21f --- /dev/null +++ b/scripts/packager-linux/src/desktop.rs @@ -0,0 +1,66 @@ +//! The desktop entry: how a menu, a launcher and a store's install button start the +//! application. + +use crate::metadata::AppMetadata; + +/// The entry's file name. Flatpak and AppStream both require it to be the application +/// identifier. +pub fn desktop_file_name(meta: &AppMetadata) -> String { + format!("{}.desktop", meta.id) +} + +/// The `[Desktop Entry]` group. +/// +/// `Exec` is the bare executable name. Inside a Flatpak that name is on `PATH`; inside an +/// AppImage the runtime starts `AppRun` and never reads `Exec`, and the desktop +/// integration tools rewrite it to the AppImage's path when they install the entry. +pub fn desktop_entry(meta: &AppMetadata) -> String { + let mut out = String::from("[Desktop Entry]\n"); + let mut line = |key: &str, value: &str| { + out.push_str(key); + out.push('='); + out.push_str(value); + out.push('\n'); + }; + line("Type", "Application"); + line("Version", "1.5"); + line("Name", &escape(&meta.name)); + line("Comment", &escape(&meta.summary)); + line("Exec", &meta.exec); + line("Icon", &meta.id); + line("Terminal", "false"); + line("Categories", &list(&meta.categories)); + if !meta.keywords.is_empty() { + line("Keywords", &list(&meta.keywords)); + } + if !meta.mime_types.is_empty() { + line("MimeType", &list(&meta.mime_types)); + } + if let Some(class) = &meta.startup_wm_class { + line("StartupWMClass", &escape(class)); + } + out +} + +/// Escapes a value the way the desktop entry specification requires. +pub fn escape(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for c in value.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\t' => out.push_str("\\t"), + '\r' => out.push_str("\\r"), + c => out.push(c), + } + } + out +} + +/// A `;`-terminated list, with `;` inside an item escaped. +fn list(items: &[String]) -> String { + items + .iter() + .map(|item| format!("{};", escape(item).replace(';', "\\;"))) + .collect() +} diff --git a/scripts/packager-linux/src/flatpak.rs b/scripts/packager-linux/src/flatpak.rs new file mode 100644 index 000000000..cbf817119 --- /dev/null +++ b/scripts/packager-linux/src/flatpak.rs @@ -0,0 +1,395 @@ +//! The Flatpak channel: a manifest `flatpak-builder` builds, in the shape Flathub reviews. +//! +//! Two kinds of payload, because Flathub accepts both and an application built with this +//! project can need either: +//! +//! - **From source.** The Rust program is compiled inside the sandbox from a source +//! archive, with every crate vendored by `flatpak-cargo-generator` (the build has no +//! network). This is what Flathub expects of an open source application. +//! - **Prebuilt.** An archive of the built program, fetched by URL and checked by SHA-256. +//! This is the path for the renderer: it is compiled by GraalVM native-image, which no +//! Flathub SDK extension provides, so it arrives as a release artifact either way. +//! +//! The window is AWT's on X11, so the sandbox gets an X11 socket, shared IPC (X11's +//! shared memory transport needs it, and without it every frame is copied through the +//! socket) and the GPU. Wayland sessions reach it through XWayland. A Wayland socket with +//! X11 only as a fallback is for a renderer that speaks Wayland itself, which this one +//! does not yet. + +use crate::appstream::metainfo_file_name; +use crate::desktop::desktop_file_name; +use crate::icons::{Icon, IconKind}; +use crate::metadata::AppMetadata; + +/// The runtime branch used when the application's file does not name one. +pub const DEFAULT_RUNTIME_VERSION: &str = "25.08"; + +/// Where a source comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SourceRef { + /// Fetched by `flatpak-builder`, checked against the digest. What Flathub requires. + /// `arch` limits it to one architecture, for an archive built per architecture. + Url { + url: String, + sha256: String, + arch: Option, + }, + /// A file beside the manifest. For local builds and tests; Flathub refuses it for + /// anything but small packaging files. + Path(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Payload { + /// An archive whose top directory holds the executable and everything beside it, one + /// per architecture. + Prebuilt { archives: Vec }, + /// A source archive of a Cargo workspace. + FromSource { + source: SourceRef, + /// The file `flatpak-cargo-generator` wrote, relative to the manifest. + cargo_sources: String, + package: String, + bin: String, + /// Extra arguments for `cargo build`, such as `--no-default-features`. + cargo_args: Vec, + /// The renderer's release archive, when the program draws, one per architecture. + renderer: Vec, + }, +} + +#[derive(Debug, Clone)] +pub struct FlatpakOptions<'a> { + pub payload: Payload, + pub icons: &'a [Icon], + /// Give the sandbox a Wayland socket and keep X11 only as a fallback. + pub wayland: bool, +} + +/// The sandbox permissions: the window's own, then the application's extras. +pub fn finish_args(meta: &AppMetadata, wayland: bool) -> Vec { + let mut args: Vec = if wayland { + vec![ + "--share=ipc".into(), + "--socket=wayland".into(), + "--socket=fallback-x11".into(), + "--device=dri".into(), + ] + } else { + vec![ + "--share=ipc".into(), + "--socket=x11".into(), + "--device=dri".into(), + ] + }; + for extra in &meta.finish_args { + if !args.contains(extra) { + args.push(extra.clone()); + } + } + args +} + +/// Where a packaging file sits beside the manifest, and where the build finds it. +pub fn icon_source_path(meta: &AppMetadata, icon: &Icon) -> String { + match icon.kind { + IconKind::Png(size) => format!("icons/{size}x{size}/{}.png", meta.id), + IconKind::Svg => format!("icons/scalable/{}.svg", meta.id), + } +} + +/// The manifest, as JSON. +pub fn manifest(meta: &AppMetadata, options: &FlatpakOptions) -> String { + let module = meta.exec.clone(); + let lib = format!("/app/lib/{}", meta.exec); + let mut sources = Vec::new(); + let mut commands: Vec = Vec::new(); + let mut build_options: Vec<(String, Json)> = Vec::new(); + let mut top_extra: Vec<(String, Json)> = Vec::new(); + + match &options.payload { + Payload::Prebuilt { archives } => { + for archive in archives { + sources.push(archive_source(archive, Some("payload"))); + } + commands.push(format!("mkdir -p {lib} /app/bin")); + commands.push(format!("cp -a payload/. {lib}/")); + // A release archive whose executable was built without an rpath only finds the + // renderer beside it when told to look there. + commands.push(format!( + "readelf -d {lib}/{exec} | grep -qE '\\((RPATH|RUNPATH)\\)' || patchelf --set-rpath '$ORIGIN:$ORIGIN/lib' {lib}/{exec}", + exec = meta.exec + )); + } + Payload::FromSource { + source, + cargo_sources, + package, + bin, + cargo_args, + renderer, + } => { + top_extra.push(( + "sdk-extensions".into(), + Json::Array(vec![Json::str("org.freedesktop.Sdk.Extension.rust-stable")]), + )); + build_options.push(( + "append-path".into(), + Json::str("/usr/lib/sdk/rust-stable/bin"), + )); + let mut env = vec![ + ( + "CARGO_HOME".to_owned(), + Json::str(&format!("/run/build/{module}/cargo")), + ), + ("CARGO_NET_OFFLINE".to_owned(), Json::str("true")), + ( + "COMPOSE_RUST_APP_VERSION".to_owned(), + Json::str(&meta.version), + ), + ]; + sources.push(archive_source(source, None)); + sources.push(Json::str(cargo_sources)); + for archive in renderer { + sources.push(archive_source(archive, Some("renderer"))); + } + if !renderer.is_empty() { + env.push(( + "COMPOSE_RUST_RENDERER_DIR".to_owned(), + Json::str(&format!("/run/build/{module}/renderer")), + )); + } + build_options.push(("env".into(), Json::Object(env))); + + let mut build = + format!("cargo --offline build --release --locked -p {package} --bin {bin}"); + for arg in cargo_args { + build.push(' '); + build.push_str(&shell_quote(arg)); + } + commands.push("cargo --offline fetch --manifest-path Cargo.toml --verbose".into()); + commands.push(build); + commands.push(format!("mkdir -p {lib} /app/bin")); + if !renderer.is_empty() { + // The renderer keeps its own `lib` directory beside the executable. AWT + // looks for its companions in the parent of the renderer's directory plus + // `lib`, so the files flattened beside the executable are not found. + commands.push(format!( + "mkdir -p {lib}/lib && if [ -d renderer/lib ]; then cp -a renderer/lib/. {lib}/lib/; else cp -a renderer/. {lib}/lib/; fi" + )); + } + commands.push(format!( + "install -m755 target/release/{bin} {lib}/{}", + meta.exec + )); + if !renderer.is_empty() { + // The build records where the renderer was inside the build sandbox. Point + // the executable at the copy installed beside it instead. Executables built + // before the rename to compose-rust name the renderer + // libdioxus_compose_renderer.so, so the match takes either name. + commands.push(format!( + "recorded=\"$(readelf -d {lib}/{exec} | sed -n 's/.*(NEEDED).*\\[\\(.*lib[a-z_]*_renderer.so\\)\\]/\\1/p')\"; \ + if [ -n \"$recorded\" ] && [ \"$recorded\" != \"${{recorded##*/}}\" ]; then \ + patchelf --replace-needed \"$recorded\" \"${{recorded##*/}}\" {lib}/{exec}; fi; \ + patchelf --set-rpath '$ORIGIN/lib' {lib}/{exec}", + exec = meta.exec + )); + } + } + } + commands.push(format!("test -x {lib}/{}", meta.exec)); + commands.push(format!("ln -s ../lib/{0}/{0} /app/bin/{0}", meta.exec)); + + let desktop = desktop_file_name(meta); + let metainfo = metainfo_file_name(meta); + sources.push(file_source(&desktop, "packaging")); + sources.push(file_source(&metainfo, "packaging")); + commands.push(format!( + "install -Dm644 packaging/{desktop} /app/share/applications/{desktop}" + )); + commands.push(format!( + "install -Dm644 packaging/{metainfo} /app/share/metainfo/{metainfo}" + )); + for icon in options.icons { + let path = icon_source_path(meta, icon); + let dir = path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or(""); + sources.push(file_source(&path, &format!("packaging/{dir}"))); + commands.push(format!( + "install -Dm644 packaging/{path} /app/{}", + icon.install_path(&meta.id) + )); + } + + let mut module_fields = vec![ + ("name".to_owned(), Json::str(&module)), + ("buildsystem".to_owned(), Json::str("simple")), + ]; + if !build_options.is_empty() { + module_fields.push(("build-options".to_owned(), Json::Object(build_options))); + } + module_fields.push(( + "build-commands".to_owned(), + Json::Array(commands.iter().map(|c| Json::str(c)).collect()), + )); + module_fields.push(("sources".to_owned(), Json::Array(sources))); + + let runtime_version = meta + .runtime_version + .clone() + .unwrap_or_else(|| DEFAULT_RUNTIME_VERSION.to_owned()); + let mut top = vec![ + ("id".to_owned(), Json::str(&meta.id)), + ("runtime".to_owned(), Json::str("org.freedesktop.Platform")), + ("runtime-version".to_owned(), Json::str(&runtime_version)), + ("sdk".to_owned(), Json::str("org.freedesktop.Sdk")), + ]; + top.extend(top_extra); + top.push(("command".to_owned(), Json::str(&meta.exec))); + top.push(( + "finish-args".to_owned(), + Json::Array( + finish_args(meta, options.wayland) + .iter() + .map(|a| Json::str(a)) + .collect(), + ), + )); + let needs_patchelf = match &options.payload { + Payload::Prebuilt { .. } => true, + Payload::FromSource { renderer, .. } => !renderer.is_empty(), + }; + let mut modules = Vec::new(); + if needs_patchelf { + modules.push(patchelf_module()); + } + modules.push(Json::Object(module_fields)); + top.push(("modules".to_owned(), Json::Array(modules))); + render_manifest(top) +} + +/// The SDK has `readelf` but not `patchelf`, so a build that has to rewrite the +/// executable's library names builds it first. `cleanup` keeps it out of the application. +pub const PATCHELF_URL: &str = + "https://github.com/NixOS/patchelf/releases/download/0.19.1/patchelf-0.19.1.tar.gz"; +pub const PATCHELF_SHA256: &str = + "491108728f120ce05b539934b41a750235031a6df8abc6b47e57aff7de15094d"; + +fn patchelf_module() -> Json { + Json::Object(vec![ + ("name".to_owned(), Json::str("patchelf")), + ("buildsystem".to_owned(), Json::str("autotools")), + ("cleanup".to_owned(), Json::Array(vec![Json::str("*")])), + ( + "sources".to_owned(), + Json::Array(vec![archive_source( + &SourceRef::Url { + url: PATCHELF_URL.to_owned(), + sha256: PATCHELF_SHA256.to_owned(), + arch: None, + }, + None, + )]), + ), + ]) +} + +fn render_manifest(top: Vec<(String, Json)>) -> String { + let mut out = Json::Object(top).render(0); + out.push('\n'); + out +} + +fn archive_source(source: &SourceRef, dest: Option<&str>) -> Json { + let mut fields = vec![("type".to_owned(), Json::str("archive"))]; + match source { + SourceRef::Url { url, sha256, arch } => { + fields.push(("url".to_owned(), Json::str(url))); + fields.push(("sha256".to_owned(), Json::str(sha256))); + if let Some(arch) = arch { + fields.push(("only-arches".to_owned(), Json::Array(vec![Json::str(arch)]))); + } + } + SourceRef::Path(path) => fields.push(("path".to_owned(), Json::str(path))), + } + if let Some(dest) = dest { + fields.push(("dest".to_owned(), Json::str(dest))); + } + Json::Object(fields) +} + +fn file_source(path: &str, dest: &str) -> Json { + Json::Object(vec![ + ("type".to_owned(), Json::str("file")), + ("path".to_owned(), Json::str(path)), + ("dest".to_owned(), Json::str(dest)), + ]) +} + +/// Quotes one word for `sh` unless it is plainly safe. +pub fn shell_quote(word: &str) -> String { + if !word.is_empty() + && word + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_=.,/:+@".contains(c)) + { + word.to_owned() + } else { + format!("'{}'", word.replace('\'', "'\\''")) + } +} + +/// Just enough JSON to write a manifest whose keys stay in the order a reader expects. +#[derive(Debug, Clone, PartialEq)] +pub enum Json { + String(String), + Array(Vec), + Object(Vec<(String, Json)>), +} + +impl Json { + pub fn str(value: &str) -> Json { + Json::String(value.to_owned()) + } + + pub fn render(&self, depth: usize) -> String { + let pad = " ".repeat(depth + 1); + let close = " ".repeat(depth); + match self { + Json::String(value) => quote(value), + Json::Array(items) if items.is_empty() => "[]".to_owned(), + Json::Array(items) => { + let inner: Vec = items + .iter() + .map(|item| format!("{pad}{}", item.render(depth + 1))) + .collect(); + format!("[\n{}\n{close}]", inner.join(",\n")) + } + Json::Object(fields) if fields.is_empty() => "{}".to_owned(), + Json::Object(fields) => { + let inner: Vec = fields + .iter() + .map(|(key, value)| format!("{pad}{}: {}", quote(key), value.render(depth + 1))) + .collect(); + format!("{{\n{}\n{close}}}", inner.join(",\n")) + } + } + } +} + +fn quote(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/scripts/packager-linux/src/icons.rs b/scripts/packager-linux/src/icons.rs new file mode 100644 index 000000000..ee5532740 --- /dev/null +++ b/scripts/packager-linux/src/icons.rs @@ -0,0 +1,145 @@ +//! Application icons in the freedesktop icon theme layout. +//! +//! The size directory an icon goes in has to be its real size, so it is read from the +//! file rather than trusted from its name. Scaling is not done here: a store wants an icon +//! drawn for the size, and an upscaled one is the one thing a reviewer reliably notices. + +use std::fmt; +use std::path::{Path, PathBuf}; + +/// The sizes the hicolor theme defines directories for. +pub const HICOLOR_SIZES: &[u32] = &[16, 22, 24, 32, 48, 64, 96, 128, 192, 256, 512]; + +/// The smallest raster icon Flathub accepts. +pub const FLATHUB_MINIMUM: u32 = 128; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IconKind { + /// A square PNG of this many pixels a side. + Png(u32), + Svg, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Icon { + pub source: PathBuf, + pub kind: IconKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IconError(pub String); + +impl fmt::Display for IconError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for IconError {} + +/// Width and height from a PNG's header. +pub fn png_size(bytes: &[u8]) -> Option<(u32, u32)> { + const SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; + if bytes.len() < 24 || &bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" { + return None; + } + let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?); + let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?); + Some((width, height)) +} + +impl Icon { + /// Classifies the icon file at `source`, whose contents are `bytes`. + pub fn classify(source: &Path, bytes: &[u8]) -> Result { + let shown = source.display(); + if let Some((width, height)) = png_size(bytes) { + if width != height { + return Err(IconError(format!( + "{shown} is {width}x{height}; an application icon must be square" + ))); + } + if !HICOLOR_SIZES.contains(&width) { + return Err(IconError(format!( + "{shown} is {width}x{width}; the icon theme has directories for {}", + HICOLOR_SIZES + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + ))); + } + return Ok(Icon { + source: source.to_owned(), + kind: IconKind::Png(width), + }); + } + let head = String::from_utf8_lossy(&bytes[..bytes.len().min(512)]).to_lowercase(); + if source + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("svg")) + || head.contains(" String { + match self.kind { + IconKind::Png(size) => { + format!("share/icons/hicolor/{size}x{size}/apps/{app_id}.png") + } + IconKind::Svg => format!("share/icons/hicolor/scalable/apps/{app_id}.svg"), + } + } + + /// File name beside the desktop entry at an AppDir's root. + pub fn root_name(&self, app_id: &str) -> String { + match self.kind { + IconKind::Png(_) => format!("{app_id}.png"), + IconKind::Svg => format!("{app_id}.svg"), + } + } +} + +/// The icon to show where only one is used: a vector one if there is one, else the largest. +pub fn best(icons: &[Icon]) -> Option<&Icon> { + icons.iter().max_by_key(|icon| match icon.kind { + IconKind::Svg => u32::MAX, + IconKind::Png(size) => size, + }) +} + +/// Checks a set of icons against what Flathub accepts: one vector icon, or a PNG of at +/// least 128 pixels, and never two for the same size. +pub fn check_for_flathub(icons: &[Icon]) -> Result<(), IconError> { + if icons.is_empty() { + return Err(IconError( + "no icon: pass --icon or set [bundle] icon".into(), + )); + } + let mut seen = Vec::new(); + for icon in icons { + if seen.contains(&icon.kind) { + return Err(IconError(format!( + "two icons for the same size ({})", + icon.source.display() + ))); + } + seen.push(icon.kind); + } + let large_enough = icons.iter().any(|icon| match icon.kind { + IconKind::Svg => true, + IconKind::Png(size) => size >= FLATHUB_MINIMUM, + }); + if !large_enough { + return Err(IconError(format!( + "the largest icon is smaller than {FLATHUB_MINIMUM}x{FLATHUB_MINIMUM}; Flathub needs an SVG or a PNG at least that size" + ))); + } + Ok(()) +} diff --git a/scripts/packager-linux/src/lib.rs b/scripts/packager-linux/src/lib.rs new file mode 100644 index 000000000..9d16610dd --- /dev/null +++ b/scripts/packager-linux/src/lib.rs @@ -0,0 +1,16 @@ +//! Packages a built compose-rust application for Linux: an AppImage and a Flatpak +//! manifest for Flathub, both described from the application's +//! `Dioxus.toml`. +//! +//! The pieces are plain functions from metadata to file contents, so they are tested +//! without building anything. `main.rs` reads files and writes the results; the shell +//! scripts beside this crate fetch the AppImage tools and run them. + +pub mod appimage; +pub mod appstream; +pub mod desktop; +pub mod flatpak; +pub mod icons; +pub mod metadata; + +pub use metadata::{AppMetadata, BuildFacts, MetadataError}; diff --git a/scripts/packager-linux/src/main.rs b/scripts/packager-linux/src/main.rs new file mode 100644 index 000000000..ebbca8713 --- /dev/null +++ b/scripts/packager-linux/src/main.rs @@ -0,0 +1,405 @@ +//! Command line for the Linux packager. Run with no arguments for usage. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::time::{SystemTime, UNIX_EPOCH}; + +use packager_linux::appimage::{AppDirInputs, appimage_arch, appimage_file_name, assemble_appdir}; +use packager_linux::appstream::{metainfo, metainfo_file_name}; +use packager_linux::desktop::{desktop_entry, desktop_file_name}; +use packager_linux::flatpak::{FlatpakOptions, Payload, SourceRef, icon_source_path, manifest}; +use packager_linux::icons::{Icon, check_for_flathub}; +use packager_linux::metadata::{ + AppMetadata, BuildFacts, Screenshot, check_screenshot_url, release_date, +}; + +const USAGE: &str = "\ +usage: packager-linux --dioxus-toml [common options] [command options] + +commands: + metadata write .desktop, .metainfo.xml and the icons into --out + appdir lay out an AppDir for appimagetool at --out + file-name print the AppImage file name, --.AppImage + flatpak write a flatpak-builder manifest and its packaging files into --out + +common options: + --dioxus-toml the application's Dioxus.toml + --overlay a file of the same shape merged over it (repeatable) + --version overrides [bundle] version + --date release date; defaults to SOURCE_DATE_EPOCH, then today (UTC) + --exec installed executable name; overrides [linux.store] exec + --icon a square PNG or an SVG (repeatable); overrides [bundle] icon + --arch x86_64 or aarch64; defaults to this machine's + --app-id overrides the identifier, for a store listing under another ID + --screenshot a screenshot for the store listing (repeatable, first is the + default); replaces [linux.store] screenshots. A raw GitHub URL + must be pinned to a tag or a commit + --screenshot-caption a caption for each --screenshot, in the same order + +appdir: + --payload the executable, or a directory holding it and its renderer + --out + +flatpak: + --out + --prebuilt-url --prebuilt-sha256 [--prebuilt-arch ] (repeatable, + one per architecture) | --prebuilt-path + --source-url --source-sha256 | --source-path + with --cargo-sources --package --bin [--cargo-arg ]... + and optionally --renderer-url --renderer-sha256 [--renderer-arch ] + (repeatable) | --renderer-path + --wayland Wayland socket with X11 as fallback, for a Wayland-capable renderer +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + match run(&args) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("error: {message}"); + ExitCode::FAILURE + } + } +} + +/// Options as given: each name maps to every value it was given, in order. +struct Options { + values: BTreeMap>, +} + +const FLAGS: &[&str] = &["wayland"]; + +impl Options { + fn parse(args: &[String]) -> Result { + let mut values: BTreeMap> = BTreeMap::new(); + let mut iter = args.iter(); + while let Some(arg) = iter.next() { + let Some(name) = arg.strip_prefix("--") else { + return Err(format!("unexpected argument `{arg}`\n\n{USAGE}")); + }; + let (name, value) = match name.split_once('=') { + Some((name, value)) => (name.to_owned(), value.to_owned()), + None if FLAGS.contains(&name) => (name.to_owned(), String::new()), + None => { + let value = iter + .next() + .ok_or_else(|| format!("--{name} needs a value"))?; + (name.to_owned(), value.clone()) + } + }; + values.entry(name).or_default().push(value); + } + Ok(Options { values }) + } + + fn one(&self, name: &str) -> Result, String> { + match self.values.get(name).map(Vec::as_slice) { + None => Ok(None), + Some([value]) => Ok(Some(value.clone())), + Some(_) => Err(format!("--{name} was given more than once")), + } + } + + fn required(&self, name: &str) -> Result { + self.one(name)? + .ok_or_else(|| format!("--{name} is required")) + } + + fn all(&self, name: &str) -> Vec { + self.values.get(name).cloned().unwrap_or_default() + } + + fn flag(&self, name: &str) -> bool { + self.values.contains_key(name) + } + + fn reject_unknown(&self, known: &[&str]) -> Result<(), String> { + for name in self.values.keys() { + if !known.contains(&name.as_str()) { + return Err(format!( + "--{name} is not an option of this command\n\n{USAGE}" + )); + } + } + Ok(()) + } +} + +const COMMON: &[&str] = &[ + "dioxus-toml", + "overlay", + "version", + "date", + "exec", + "icon", + "arch", + "app-id", + "screenshot", + "screenshot-caption", +]; + +fn run(args: &[String]) -> Result<(), String> { + let Some((command, rest)) = args.split_first() else { + return Err(USAGE.to_owned()); + }; + let options = Options::parse(rest)?; + let extra: &[&str] = match command.as_str() { + "metadata" => &["out"], + "appdir" => &["payload", "out"], + "file-name" => &[], + "flatpak" => &[ + "out", + "prebuilt-url", + "prebuilt-sha256", + "prebuilt-path", + "prebuilt-arch", + "source-url", + "source-sha256", + "source-path", + "cargo-sources", + "package", + "bin", + "cargo-arg", + "renderer-url", + "renderer-sha256", + "renderer-path", + "renderer-arch", + "wayland", + ], + "help" | "--help" | "-h" => { + print!("{USAGE}"); + return Ok(()); + } + other => return Err(format!("unknown command `{other}`\n\n{USAGE}")), + }; + let known: Vec<&str> = COMMON.iter().chain(extra).copied().collect(); + options.reject_unknown(&known)?; + + let (meta, icons) = load(&options)?; + let arch = match options.one("arch")? { + Some(arch) => arch, + None => appimage_arch(std::env::consts::ARCH) + .ok_or_else(|| format!("no AppImage architecture for {}", std::env::consts::ARCH))? + .to_owned(), + }; + + match command.as_str() { + "metadata" => { + let out = PathBuf::from(options.required("out")?); + write( + &out.join(desktop_file_name(&meta)), + desktop_entry(&meta).as_bytes(), + )?; + write( + &out.join(metainfo_file_name(&meta)), + metainfo(&meta).as_bytes(), + )?; + for icon in &icons { + copy(&icon.source, &out.join(icon_source_path(&meta, icon)))?; + } + } + "appdir" => { + let out = PathBuf::from(options.required("out")?); + let payload = PathBuf::from(options.required("payload")?); + let written = assemble_appdir( + &AppDirInputs { + meta: &meta, + payload: &payload, + icons: &icons, + }, + &out, + ) + .map_err(|e| e.to_string())?; + for path in written { + println!("{}", path.display()); + } + } + "file-name" => println!("{}", appimage_file_name(&meta, &arch)), + "flatpak" => { + check_for_flathub(&icons).map_err(|e| e.to_string())?; + let out = PathBuf::from(options.required("out")?); + let payload = flatpak_payload(&options)?; + let text = manifest( + &meta, + &FlatpakOptions { + payload, + icons: &icons, + wayland: options.flag("wayland"), + }, + ); + write(&out.join(format!("{}.json", meta.id)), text.as_bytes())?; + write( + &out.join(desktop_file_name(&meta)), + desktop_entry(&meta).as_bytes(), + )?; + write( + &out.join(metainfo_file_name(&meta)), + metainfo(&meta).as_bytes(), + )?; + for icon in &icons { + copy(&icon.source, &out.join(icon_source_path(&meta, icon)))?; + } + println!("{}", out.join(format!("{}.json", meta.id)).display()); + } + _ => unreachable!("every command was matched above"), + } + Ok(()) +} + +fn load(options: &Options) -> Result<(AppMetadata, Vec), String> { + let toml_path = PathBuf::from(options.required("dioxus-toml")?); + let text = read_text(&toml_path)?; + let mut overlays: Vec = options + .all("overlay") + .iter() + .map(|path| read_text(Path::new(path))) + .collect::>()?; + if let Some(id) = options.one("app-id")? { + if id.contains(['"', '\\', '\n']) { + return Err(format!("--app-id `{id}` is not an application identifier")); + } + overlays.push(format!("[linux]\nidentifier = \"{id}\"\n")); + } + let overlay_refs: Vec<&str> = overlays.iter().map(String::as_str).collect(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let date = match options.one("date")? { + Some(date) => date, + None => release_date(std::env::var("SOURCE_DATE_EPOCH").ok().as_deref(), now), + }; + let facts = BuildFacts { + version: options.one("version")?, + date, + exec: options.one("exec")?, + }; + let mut meta = AppMetadata::resolve(&text, &overlay_refs, &facts).map_err(|e| e.to_string())?; + let shots = options.all("screenshot"); + let captions = options.all("screenshot-caption"); + if !captions.is_empty() && captions.len() != shots.len() { + return Err("give --screenshot-caption for every --screenshot or for none".into()); + } + if !shots.is_empty() { + meta.screenshots = shots + .into_iter() + .enumerate() + .map(|(index, url)| { + check_screenshot_url(&url).map_err(|e| e.to_string())?; + Ok(Screenshot { + url, + caption: captions.get(index).cloned(), + }) + }) + .collect::>()?; + } + + let given = options.all("icon"); + let paths: Vec = if given.is_empty() { + let base = toml_path.parent().unwrap_or(Path::new(".")); + meta.icons.iter().map(|icon| base.join(icon)).collect() + } else { + given.iter().map(PathBuf::from).collect() + }; + let icons = paths + .iter() + .map(|path| { + let bytes = fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + Icon::classify(path, &bytes).map_err(|e| e.to_string()) + }) + .collect::, _>>()?; + Ok((meta, icons)) +} + +/// The sources named by `---url`/`---sha256` (repeatable, with an +/// optional `---arch` for each) or by one `---path`. +fn sources(options: &Options, prefix: &str) -> Result, String> { + let urls = options.all(&format!("{prefix}-url")); + let shas = options.all(&format!("{prefix}-sha256")); + let arches = options.all(&format!("{prefix}-arch")); + let path = options.one(&format!("{prefix}-path"))?; + if let Some(path) = path { + if !urls.is_empty() || !shas.is_empty() || !arches.is_empty() { + return Err(format!("--{prefix}-path stands alone")); + } + return Ok(vec![SourceRef::Path(path)]); + } + if urls.len() != shas.len() { + return Err(format!( + "every --{prefix}-url needs its own --{prefix}-sha256, in the same order" + )); + } + if !arches.is_empty() && arches.len() != urls.len() { + return Err(format!( + "give --{prefix}-arch for every --{prefix}-url or for none" + )); + } + if urls.len() > 1 && arches.is_empty() { + return Err(format!( + "several --{prefix}-url need a --{prefix}-arch each" + )); + } + urls.into_iter() + .zip(shas) + .enumerate() + .map(|(index, (url, sha256))| { + if sha256.len() != 64 || !sha256.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(format!("--{prefix}-sha256 must be 64 hex digits")); + } + Ok(SourceRef::Url { + url, + sha256: sha256.to_ascii_lowercase(), + arch: arches.get(index).cloned(), + }) + }) + .collect() +} + +fn flatpak_payload(options: &Options) -> Result { + let prebuilt = sources(options, "prebuilt")?; + let source = sources(options, "source")?; + match (prebuilt.is_empty(), source.len()) { + (false, 0) => { + for name in [ + "cargo-sources", + "package", + "bin", + "cargo-arg", + "renderer-url", + "renderer-path", + ] { + if options.flag(name) { + return Err(format!("--{name} belongs to a build from source")); + } + } + Ok(Payload::Prebuilt { archives: prebuilt }) + } + (true, 1) => Ok(Payload::FromSource { + source: source.into_iter().next().expect("one source"), + cargo_sources: options.required("cargo-sources")?, + package: options.required("package")?, + bin: options.required("bin")?, + cargo_args: options.all("cargo-arg"), + renderer: sources(options, "renderer")?, + }), + _ => Err("pass prebuilt archives or one source archive, not both and not neither".into()), + } +} + +fn read_text(path: &Path) -> Result { + fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display())) +} + +fn write(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?; + } + fs::write(path, bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +fn copy(from: &Path, to: &Path) -> Result<(), String> { + let bytes = fs::read(from).map_err(|e| format!("{}: {e}", from.display()))?; + write(to, &bytes) +} diff --git a/scripts/packager-linux/src/metadata.rs b/scripts/packager-linux/src/metadata.rs new file mode 100644 index 000000000..871b025f4 --- /dev/null +++ b/scripts/packager-linux/src/metadata.rs @@ -0,0 +1,549 @@ +//! What a Linux package says about an application, read from its `Dioxus.toml`. +//! +//! The file is dx's, and so are most of the keys read here: `[application] name`, the +//! `[bundle]` table every platform shares, and dx's own `[linux]` table, whose keys +//! (`identifier`, `publisher`, `short_description`, `categories`, `keywords`, +//! `mime_types`, `flatpak_permissions` and the rest) override `[bundle]` for Linux. What +//! only a store listing needs and dx has no key for (a licence, a homepage, screenshots, +//! brand colours, a content rating) goes in a `[linux.store]` table, which dx ignores. +//! +//! A second file passed with `--overlay` has the same shape and is merged over the first, +//! table by table, so a release can add what does not belong in the application's own +//! file (release notes, for one) without editing it. + +use std::collections::BTreeMap; +use std::fmt; + +use serde::Deserialize; + +/// The freedesktop main categories. A desktop entry needs at least one, or menus file it +/// under "Other" and Flathub rejects it. +pub const MAIN_CATEGORIES: &[&str] = &[ + "AudioVideo", + "Audio", + "Video", + "Development", + "Education", + "Game", + "Graphics", + "Network", + "Office", + "Science", + "Settings", + "System", + "Utility", +]; + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct DioxusToml { + application: ApplicationTable, + bundle: BundleTable, + linux: DxLinuxTable, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct ApplicationTable { + name: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct BundleTable { + identifier: Option, + publisher: Option, + short_description: Option, + long_description: Option, + category: Option, + icon: Option>, + version: Option, +} + +/// dx's `[linux]` table: overrides of `[bundle]`, plus desktop entry and Flatpak keys. +#[derive(Debug, Default, Deserialize)] +#[serde(default)] +struct DxLinuxTable { + identifier: Option, + publisher: Option, + short_description: Option, + long_description: Option, + category: Option, + icon: Option>, + flatpak_permissions: Vec, + categories: Vec, + keywords: Vec, + mime_types: Vec, + store: StoreTable, +} + +/// The `[linux.store]` table: what a store listing needs that dx has no key for. Every +/// key is optional here; [`AppMetadata::resolve`] says which ones a package cannot do +/// without. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct StoreTable { + /// The executable's file name, as installed. + pub exec: Option, + /// SPDX expression for the application's own licence. + pub license: Option, + /// SPDX expression for the AppStream file itself. Defaults to `CC0-1.0`, which is what + /// Flathub asks for. + pub metadata_license: Option, + pub homepage: Option, + pub bugtracker: Option, + pub vcs_browser: Option, + pub donation: Option, + /// Reverse-DNS identifier of the developer. Defaults to the first two components of + /// the application identifier. + pub developer_id: Option, + /// Defaults to the publisher. + pub developer_name: Option, + pub screenshots: Vec, + /// Brand colours for store pages, `#rrggbb`. + pub brand_light: Option, + pub brand_dark: Option, + /// The `WM_CLASS` the window reports, so a desktop can match the window to its entry. + pub startup_wm_class: Option, + /// Release notes for this version, one paragraph per entry. + pub release_notes: Vec, + /// OARS 1.1 content rating attributes that are not `none`, such as + /// `social-chat = "intense"`. + pub content_rating: BTreeMap, + /// Branch of `org.freedesktop.Platform` the Flatpak runs on. + pub flatpak_runtime_version: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Screenshot { + pub url: String, + #[serde(default)] + pub caption: Option, +} + +/// What the command line adds: the facts that are about a build, not about the +/// application. +#[derive(Debug, Default, Clone)] +pub struct BuildFacts { + /// Overrides `[bundle] version`. + pub version: Option, + /// `YYYY-MM-DD`. + pub date: String, + /// Overrides `[linux] exec`. + pub exec: Option, +} + +/// Everything a desktop entry, an AppStream file, an AppDir and a Flatpak manifest need, +/// checked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppMetadata { + pub id: String, + pub name: String, + pub summary: String, + pub description: Vec, + pub exec: String, + pub version: String, + pub date: String, + pub license: String, + pub metadata_license: String, + pub homepage: String, + pub bugtracker: Option, + pub vcs_browser: Option, + pub donation: Option, + pub developer_id: String, + pub developer_name: String, + pub categories: Vec, + pub keywords: Vec, + pub mime_types: Vec, + /// Icon paths as the file lists them, relative to its directory. + pub icons: Vec, + pub screenshots: Vec, + pub brand_light: Option, + pub brand_dark: Option, + pub startup_wm_class: Option, + pub release_notes: Vec, + pub content_rating: BTreeMap, + pub runtime_version: Option, + pub finish_args: Vec, +} + +/// Why metadata could not be produced. Each one names the key to set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MetadataError(pub String); + +impl fmt::Display for MetadataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for MetadataError {} + +fn error(message: impl Into) -> Result { + Err(MetadataError(message.into())) +} + +/// Merges `top` over `base`: tables recursively, every other value replaced. +pub fn merge(base: &mut toml::Table, top: toml::Table) { + for (key, value) in top { + match (base.get_mut(&key), value) { + (Some(toml::Value::Table(base)), toml::Value::Table(top)) => merge(base, top), + (_, value) => { + base.insert(key, value); + } + } + } +} + +/// Parses `Dioxus.toml` and any overlays, in order, into one table. +pub fn merged(dioxus_toml: &str, overlays: &[&str]) -> Result { + let mut table: toml::Table = toml::from_str(dioxus_toml) + .map_err(|e| MetadataError(format!("Dioxus.toml is not valid TOML: {e}")))?; + for overlay in overlays { + let top: toml::Table = toml::from_str(overlay) + .map_err(|e| MetadataError(format!("the overlay is not valid TOML: {e}")))?; + merge(&mut table, top); + } + Ok(table) +} + +impl AppMetadata { + /// Reads `Dioxus.toml` with its overlays merged in, adds the build facts, and checks + /// the result. + pub fn resolve( + dioxus_toml: &str, + overlays: &[&str], + facts: &BuildFacts, + ) -> Result { + let table = merged(dioxus_toml, overlays)?; + let config: DioxusToml = toml::Value::Table(table) + .try_into() + .map_err(|e| MetadataError(format!("Dioxus.toml is not usable: {e}")))?; + let DioxusToml { + application, + bundle, + linux: dx, + } = config; + let linux = dx.store; + + let id = required(dx.identifier.or(bundle.identifier), "[bundle] identifier")?; + check_app_id(&id)?; + let name = required(application.name, "[application] name")?; + let summary = required( + dx.short_description.or(bundle.short_description), + "[bundle] short_description", + )?; + // AppStream reads a summary as a fragment, not a sentence. + let summary = summary.trim().trim_end_matches('.').trim().to_owned(); + let description = paragraphs( + dx.long_description + .or(bundle.long_description) + .as_deref() + .unwrap_or(""), + ); + let description = if description.is_empty() { + vec![format!("{summary}.")] + } else { + description + }; + + let exec = facts.exec.clone().or(linux.exec).ok_or_else(|| { + MetadataError("no executable name: pass --exec or set [linux.store] exec".into()) + })?; + if exec.is_empty() + || !exec + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-' | '+')) + { + return error(format!( + "the executable name `{exec}` must be a bare file name of letters, digits, `.`, `_`, `-` and `+`" + )); + } + + let version = required( + facts.version.clone().or(bundle.version), + "a version (--version or [bundle] version)", + )?; + if version + .chars() + .any(|c| c.is_whitespace() || c == '/' || c == '|') + { + return error(format!( + "the version `{version}` must not contain spaces, slashes or `|`" + )); + } + check_date(&facts.date)?; + + let license = required(linux.license, "[linux.store] license (an SPDX expression)")?; + let homepage = required(linux.homepage, "[linux.store] homepage")?; + for (key, url) in [ + ("homepage", Some(&homepage)), + ("bugtracker", linux.bugtracker.as_ref()), + ("vcs_browser", linux.vcs_browser.as_ref()), + ("donation", linux.donation.as_ref()), + ] { + if let Some(url) = url { + check_url(key, url)?; + } + } + for shot in &linux.screenshots { + check_screenshot_url(&shot.url)?; + } + + let developer_name = linux + .developer_name + .or(dx.publisher) + .or(bundle.publisher) + .ok_or_else(|| { + MetadataError( + "no developer: set [linux.store] developer_name or [bundle] publisher".into(), + ) + })?; + let developer_id = linux + .developer_id + .unwrap_or_else(|| id.split('.').take(2).collect::>().join(".")); + + let mut categories = dx.categories; + if categories.is_empty() { + if let Some(category) = dx.category.or(bundle.category) { + categories.push(dx_category(&category).to_owned()); + } + } + if !categories + .iter() + .any(|c| MAIN_CATEGORIES.contains(&c.as_str())) + { + return error(format!( + "[linux] categories needs at least one freedesktop main category ({})", + MAIN_CATEGORIES.join(", ") + )); + } + for mime in &dx.mime_types { + if !mime.contains('/') || mime.contains(';') { + return error(format!("[linux] mime_types: `{mime}` is not a MIME type")); + } + } + for category in &categories { + if category.is_empty() || category.contains(';') { + return error(format!("the category `{category}` is not a single name")); + } + } + + for (key, colour) in [ + ("brand_light", linux.brand_light.as_ref()), + ("brand_dark", linux.brand_dark.as_ref()), + ] { + if let Some(colour) = colour { + let hex = colour.strip_prefix('#').unwrap_or(""); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return error(format!( + "[linux.store] {key} must be #rrggbb, not `{colour}`" + )); + } + } + } + for (attribute, value) in &linux.content_rating { + if !["none", "mild", "moderate", "intense"].contains(&value.as_str()) { + return error(format!( + "[linux.store.content_rating] {attribute} must be none, mild, moderate or intense" + )); + } + } + + Ok(AppMetadata { + id, + name, + summary, + description, + exec, + version, + date: facts.date.clone(), + license, + metadata_license: linux.metadata_license.unwrap_or_else(|| "CC0-1.0".into()), + homepage, + bugtracker: linux.bugtracker, + vcs_browser: linux.vcs_browser, + donation: linux.donation, + developer_id, + developer_name, + categories, + keywords: dx.keywords, + mime_types: dx.mime_types, + icons: dx.icon.or(bundle.icon).unwrap_or_default(), + screenshots: linux.screenshots, + brand_light: linux.brand_light, + brand_dark: linux.brand_dark, + startup_wm_class: linux.startup_wm_class, + release_notes: linux.release_notes, + content_rating: linux.content_rating, + runtime_version: linux.flatpak_runtime_version, + finish_args: dx.flatpak_permissions, + }) + } +} + +fn required(value: Option, key: &str) -> Result { + match value { + Some(value) if !value.trim().is_empty() => Ok(value.trim().to_owned()), + _ => error(format!("{key} is required for a Linux package")), + } +} + +/// dx's bundle category names, mapped to freedesktop categories. Anything else is passed +/// through, so a freedesktop name written there also works. +fn dx_category(category: &str) -> &str { + match category { + "Developer Tool" | "DeveloperTool" => "Development", + "Entertainment" | "Game" => "Game", + "Graphics and Design" | "GraphicsAndDesign" | "Photography" => "Graphics", + "Music" => "Audio", + "Productivity" | "Business" => "Office", + "Social Networking" | "SocialNetworking" | "News" => "Network", + "Utility" | "Utilities" => "Utility", + other => other, + } +} + +/// Blank lines separate paragraphs; single newlines inside one are folded to spaces. +pub fn paragraphs(text: &str) -> Vec { + text.split("\n\n") + .map(|p| p.split_whitespace().collect::>().join(" ")) + .filter(|p| !p.is_empty()) + .collect() +} + +/// A reverse-DNS application identifier that Flatpak, AppStream and desktop entries all +/// accept. +pub fn check_app_id(id: &str) -> Result<(), MetadataError> { + let components: Vec<&str> = id.split('.').collect(); + if id.len() > 255 { + return error(format!( + "the identifier `{id}` is longer than 255 characters" + )); + } + if components.len() < 3 { + return error(format!( + "the identifier `{id}` needs at least three dot-separated components, like dev.example.App" + )); + } + for component in &components { + let Some(first) = component.chars().next() else { + return error(format!("the identifier `{id}` has an empty component")); + }; + if first.is_ascii_digit() { + return error(format!( + "the identifier `{id}` has a component starting with a digit, which Flatpak refuses" + )); + } + if !component + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return error(format!( + "the identifier `{id}` may only use letters, digits, `_` and `-`" + )); + } + } + // Flatpak allows a hyphen anywhere, D-Bus (which a desktop entry's name is checked + // against) only in the last component. + if components[..components.len() - 1] + .iter() + .any(|c| c.contains('-')) + { + return error(format!( + "the identifier `{id}` has `-` before its last component; use `_`" + )); + } + Ok(()) +} + +fn check_date(date: &str) -> Result<(), MetadataError> { + let bytes = date.as_bytes(); + let shape = bytes.len() == 10 + && bytes[4] == b'-' + && bytes[7] == b'-' + && date + .chars() + .enumerate() + .all(|(i, c)| i == 4 || i == 7 || c.is_ascii_digit()); + let month = date.get(5..7).and_then(|m| m.parse::().ok()); + let day = date.get(8..10).and_then(|d| d.parse::().ok()); + if !shape || !matches!(month, Some(1..=12)) || !matches!(day, Some(1..=31)) { + return error(format!("the release date `{date}` must be YYYY-MM-DD")); + } + Ok(()) +} + +/// A screenshot URL a store can keep showing. Flathub copies the image when it builds, but +/// a listing that points at a branch shows whatever the branch holds next, so a raw GitHub +/// URL has to name a tag or a commit. +pub fn check_screenshot_url(url: &str) -> Result<(), MetadataError> { + check_url("screenshots.url", url)?; + let Some(rest) = url + .strip_prefix("https://raw.githubusercontent.com/") + .or_else(|| url.strip_prefix("http://raw.githubusercontent.com/")) + else { + return Ok(()); + }; + let parts: Vec<&str> = rest.split('/').collect(); + // owner / repository / ref / path..., or owner / repository / refs/tags/ / path... + let reference = match parts.as_slice() { + [_, _, "refs", "tags", tag, _, ..] => return check_tag(url, tag), + [_, _, "refs", "heads", branch, _, ..] => branch, + [_, _, reference, _, ..] => reference, + _ => { + return error(format!( + "the screenshot `{url}` is not owner/repository/ref/path on raw.githubusercontent.com" + )); + } + }; + if ["main", "master", "develop", "HEAD", "trunk"].contains(reference) + || url.contains("/refs/heads/") + { + return error(format!( + "the screenshot `{url}` follows the branch `{reference}`; pin it to a tag or a commit" + )); + } + Ok(()) +} + +fn check_tag(url: &str, tag: &str) -> Result<(), MetadataError> { + if tag.is_empty() { + return error(format!("the screenshot `{url}` names an empty tag")); + } + Ok(()) +} + +fn check_url(key: &str, url: &str) -> Result<(), MetadataError> { + if !(url.starts_with("https://") || url.starts_with("http://")) || url.contains(' ') { + return error(format!( + "[linux.store] {key} must be an http(s) URL, not `{url}`" + )); + } + Ok(()) +} + +/// Today's date in UTC, or the date of `SOURCE_DATE_EPOCH` when that is set, so that two +/// builds of the same commit write the same files. +pub fn release_date(source_date_epoch: Option<&str>, now_unix: u64) -> String { + let seconds = source_date_epoch + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(now_unix); + let (y, m, d) = civil_from_days((seconds / 86_400) as i64); + format!("{y:04}-{m:02}-{d:02}") +} + +/// Days since 1970-01-01 to a proleptic Gregorian date (Howard Hinnant's algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + (if m <= 2 { y + 1 } else { y }, m, d) +} diff --git a/scripts/packager-linux/tests/metadata.rs b/scripts/packager-linux/tests/metadata.rs new file mode 100644 index 000000000..bfce5ed0e --- /dev/null +++ b/scripts/packager-linux/tests/metadata.rs @@ -0,0 +1,704 @@ +//! Metadata generation for the Linux packages: from `Dioxus.toml` to a desktop entry, an +//! AppStream file, icons, an AppDir and a Flatpak manifest. +//! +//! Whether the generated files are accepted by the tools that consume them +//! (`desktop-file-validate`, `appstreamcli validate`, `appimagetool`, `flatpak-builder` +//! and Flathub's linter) is checked by `.github/workflows/packager-linux.yml`, which runs +//! them. These tests pin what the files say. + +use std::path::{Path, PathBuf}; + +use packager_linux::appimage::{ + AppDirInputs, appimage_arch, appimage_file_name, apprun, assemble_appdir, file_safe, +}; +use packager_linux::appstream::{appdata_file_name, metainfo, metainfo_file_name}; +use packager_linux::desktop::{desktop_entry, desktop_file_name}; +use packager_linux::flatpak::{ + DEFAULT_RUNTIME_VERSION, FlatpakOptions, Json, Payload, SourceRef, finish_args, manifest, + shell_quote, +}; +use packager_linux::icons::{Icon, IconKind, best, check_for_flathub, png_size}; +use packager_linux::metadata::{check_app_id, check_screenshot_url, paragraphs, release_date}; +use packager_linux::{AppMetadata, BuildFacts}; + +const DIOXUS_TOML: &str = r##" +[application] +name = "Calculator" +asset_dir = "assets" + +[bundle] +identifier = "dev.example.Calculator" +publisher = "Example & Sons" +short_description = "A calculator built with compose-rust." +long_description = """ +Dense button grids, nested Row and Column, +keyboard input. + +Memory keys that remember.""" +icon = ["assets/icon-256.png"] + +[macos] +bundle_name = "Calculator" + +[linux] +categories = ["Utility", "Calculator"] +keywords = ["math", "arithmetic"] +flatpak_permissions = ["--share=network"] + +[linux.store] +exec = "sample-calculator" +license = "Apache-2.0" +homepage = "https://example.dev/calculator" +bugtracker = "https://example.dev/calculator/issues" +brand_light = "#e8f0ff" +brand_dark = "#1b2a4a" +screenshots = [{ url = "https://example.dev/shot.png", caption = "The keypad" }] +"##; + +fn facts() -> BuildFacts { + BuildFacts { + version: Some("1.2.3".into()), + date: "2026-10-03".into(), + exec: None, + } +} + +fn meta() -> AppMetadata { + AppMetadata::resolve(DIOXUS_TOML, &[], &facts()).unwrap() +} + +fn resolve_error(text: &str, overlays: &[&str]) -> String { + AppMetadata::resolve(text, overlays, &facts()) + .unwrap_err() + .to_string() +} + +#[test] +fn fr22_6_3_metadata_comes_from_dioxus_toml() { + let meta = meta(); + assert_eq!(meta.id, "dev.example.Calculator"); + assert_eq!(meta.name, "Calculator"); + // A summary is a fragment, not a sentence. + assert_eq!(meta.summary, "A calculator built with compose-rust"); + assert_eq!( + meta.description, + vec![ + "Dense button grids, nested Row and Column, keyboard input.".to_owned(), + "Memory keys that remember.".to_owned(), + ] + ); + assert_eq!(meta.exec, "sample-calculator"); + assert_eq!(meta.version, "1.2.3"); + assert_eq!(meta.developer_name, "Example & Sons"); + assert_eq!(meta.developer_id, "dev.example"); + assert_eq!(meta.metadata_license, "CC0-1.0"); + assert_eq!(meta.categories, ["Utility", "Calculator"]); + assert_eq!(meta.finish_args, ["--share=network"]); + assert_eq!(meta.icons, ["assets/icon-256.png"]); +} + +#[test] +fn fr22_6_3_dx_linux_keys_override_the_shared_bundle_table() { + let overlay = r#" +[linux] +identifier = "dev.example.CalculatorLinux" +short_description = "Sums, on Linux" +publisher = "Linux Desk" +"#; + let meta = AppMetadata::resolve(DIOXUS_TOML, &[overlay], &facts()).unwrap(); + assert_eq!(meta.id, "dev.example.CalculatorLinux"); + assert_eq!(meta.summary, "Sums, on Linux"); + assert_eq!(meta.developer_name, "Linux Desk"); +} + +#[test] +fn fr22_6_3_an_overlay_adds_what_the_application_file_lacks() { + // The shape of a sample's own file: nothing Linux-specific at all. + let bare = r#" +[application] +name = "Todo" +[bundle] +identifier = "dev.example.Todo" +publisher = "Example" +short_description = "Things to do." +"#; + let overlay = r#" +[linux] +categories = ["Office"] +[linux.store] +exec = "sample-todo" +license = "Apache-2.0" +homepage = "https://example.dev/" +release_notes = ["Now updates itself."] +"#; + let meta = AppMetadata::resolve(bare, &[overlay], &facts()).unwrap(); + assert_eq!(meta.exec, "sample-todo"); + assert_eq!(meta.release_notes, ["Now updates itself."]); + // Without a long description the summary stands in, as a sentence. + assert_eq!(meta.description, ["Things to do."]); +} + +#[test] +fn fr22_6_3_every_sample_resolves_with_an_overlay() { + let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); + let overlay = r#" +[linux] +categories = ["Utility"] +[linux.store] +exec = "sample" +license = "Apache-2.0" +homepage = "https://github.com/darkpyonix/compose-rust" +"#; + let mut seen = 0; + for entry in std::fs::read_dir(&samples).unwrap() { + let path = entry.unwrap().path().join("Dioxus.toml"); + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + let meta = AppMetadata::resolve(&text, &[overlay], &facts()) + .unwrap_or_else(|e| panic!("{}: {e}", path.display())); + assert!(meta.id.starts_with("dev.darkpyonix."), "{}", meta.id); + seen += 1; + } + assert!(seen >= 4, "found only {seen} samples"); +} + +#[test] +fn fr22_6_3_missing_store_facts_name_the_key_to_set() { + let without = |key: &str| { + DIOXUS_TOML + .lines() + .filter(|line| !line.starts_with(key)) + .collect::>() + .join("\n") + }; + assert!(resolve_error(&without("license"), &[]).contains("[linux.store] license")); + assert!(resolve_error(&without("homepage"), &[]).contains("[linux.store] homepage")); + assert!(resolve_error(&without("identifier"), &[]).contains("[bundle] identifier")); + assert!(resolve_error(&without("exec"), &[]).contains("--exec")); + assert!(resolve_error(&without("categories"), &[]).contains("main category")); + + let no_version = AppMetadata::resolve( + DIOXUS_TOML, + &[], + &BuildFacts { + version: None, + ..facts() + }, + ) + .unwrap_err(); + assert!(no_version.to_string().contains("--version"), "{no_version}"); +} + +#[test] +fn fr22_6_3_bad_values_are_refused_before_any_file_is_written() { + for (overlay, expected) in [ + ("[linux.store]\nhomepage = \"example.dev\"", "http(s) URL"), + ("[linux.store]\nbrand_light = \"blue\"", "#rrggbb"), + ("[linux.store]\nexec = \"bin/app\"", "bare file name"), + ("[linux.store]\nexec = \"my app\"", "bare file name"), + ( + "[linux.store.content_rating]\nviolence-cartoon = \"lots\"", + "none, mild, moderate or intense", + ), + ("[linux]\nmime_types = [\"text\"]", "not a MIME type"), + ("[linux.store]\nunknown_key = 1", "unknown_key"), + ] { + let message = resolve_error(DIOXUS_TOML, &[overlay]); + assert!(message.contains(expected), "{overlay}: {message}"); + } + let bad_date = AppMetadata::resolve( + DIOXUS_TOML, + &[], + &BuildFacts { + date: "3 Oct 2026".into(), + ..facts() + }, + ) + .unwrap_err(); + assert!(bad_date.to_string().contains("YYYY-MM-DD")); +} + +#[test] +fn fr22_6_9_application_ids_follow_flatpak_and_dbus_rules() { + for good in [ + "dev.darkpyonix.composerust.samples.calculator", + "org.example.My_App", + "org.example.my-app", + ] { + check_app_id(good).unwrap_or_else(|e| panic!("{good}: {e}")); + } + for bad in [ + "calculator", + "dev.example", + "dev..example.App", + "dev.example.1App", + "dev.ex-ample.App", + "dev.example.App!", + ] { + assert!(check_app_id(bad).is_err(), "{bad}"); + } +} + +#[test] +fn fr22_6_3_desktop_entry_names_the_icon_and_executable() { + let meta = meta(); + assert_eq!(desktop_file_name(&meta), "dev.example.Calculator.desktop"); + assert_eq!( + desktop_entry(&meta), + "[Desktop Entry]\n\ + Type=Application\n\ + Version=1.5\n\ + Name=Calculator\n\ + Comment=A calculator built with compose-rust\n\ + Exec=sample-calculator\n\ + Icon=dev.example.Calculator\n\ + Terminal=false\n\ + Categories=Utility;Calculator;\n\ + Keywords=math;arithmetic;\n" + ); +} + +#[test] +fn fr22_6_3_desktop_entry_escapes_what_the_format_reserves() { + let mut meta = meta(); + meta.name = "Back\\slash\nNew".into(); + meta.keywords = vec!["semi;colon".into()]; + meta.startup_wm_class = Some("sample-calculator".into()); + let entry = desktop_entry(&meta); + assert!(entry.contains("Name=Back\\\\slash\\nNew\n"), "{entry}"); + assert!(entry.contains("Keywords=semi\\;colon;\n"), "{entry}"); + assert!( + entry.contains("StartupWMClass=sample-calculator\n"), + "{entry}" + ); +} + +#[test] +fn fr22_6_3_metainfo_carries_what_flathub_requires() { + let meta = meta(); + assert_eq!( + metainfo_file_name(&meta), + "dev.example.Calculator.metainfo.xml" + ); + assert_eq!( + appdata_file_name(&meta), + "dev.example.Calculator.appdata.xml" + ); + let xml = metainfo(&meta); + for expected in [ + "", + "dev.example.Calculator", + "CC0-1.0", + "Apache-2.0", + "A calculator built with compose-rust", + "", + "Example & Sons", + "

Memory keys that <really> remember.

", + "dev.example.Calculator.desktop", + "https://example.dev/calculator", + "https://example.dev/calculator/issues", + "", + "https://example.dev/shot.png", + "The keypad", + "#e8f0ff", + "#1b2a4a", + "", + "sample-calculator", + "", + ] { + assert!(xml.contains(expected), "missing {expected} in\n{xml}"); + } + assert!(xml.starts_with("\n")); + assert!(xml.ends_with("
\n")); +} + +#[test] +fn fr22_6_3_metainfo_writes_release_notes_and_content_rating() { + let overlay = r#" +[linux.store] +release_notes = ["Updates itself & more."] +[linux.store.content_rating] +social-chat = "intense" +"#; + let xml = metainfo(&AppMetadata::resolve(DIOXUS_TOML, &[overlay], &facts()).unwrap()); + assert!( + xml.contains( + "\n \n

Updates itself & more.

" + ), + "{xml}" + ); + assert!( + xml.contains("intense"), + "{xml}" + ); +} + +fn png(size: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n\0\0\0\x0dIHDR".to_vec(); + bytes.extend_from_slice(&size.to_be_bytes()); + bytes.extend_from_slice(&size.to_be_bytes()); + bytes.extend_from_slice(&[8, 6, 0, 0, 0]); + bytes +} + +#[test] +fn fr22_6_3_icons_go_where_their_real_size_says() { + assert_eq!(png_size(&png(256)), Some((256, 256))); + let icon = Icon::classify(Path::new("whatever.png"), &png(256)).unwrap(); + assert_eq!(icon.kind, IconKind::Png(256)); + assert_eq!( + icon.install_path("dev.example.App"), + "share/icons/hicolor/256x256/apps/dev.example.App.png" + ); + let svg = Icon::classify(Path::new("logo.svg"), b"").unwrap(); + assert_eq!( + svg.install_path("dev.example.App"), + "share/icons/hicolor/scalable/apps/dev.example.App.svg" + ); + assert_eq!(best(&[icon.clone(), svg.clone()]), Some(&svg)); + + let mut wide = png(64); + wide[16..20].copy_from_slice(&128u32.to_be_bytes()); + assert!( + Icon::classify(Path::new("w.png"), &wide) + .unwrap_err() + .0 + .contains("square") + ); + assert!(Icon::classify(Path::new("odd.png"), &png(100)).is_err()); + assert!(Icon::classify(Path::new("x.bmp"), b"BM....").is_err()); +} + +#[test] +fn fr22_6_3_flathub_needs_a_large_enough_icon() { + let small = Icon::classify(Path::new("64.png"), &png(64)).unwrap(); + let large = Icon::classify(Path::new("128.png"), &png(128)).unwrap(); + assert!( + check_for_flathub(std::slice::from_ref(&small)) + .unwrap_err() + .0 + .contains("128") + ); + check_for_flathub(&[small.clone(), large.clone()]).unwrap(); + assert!(check_for_flathub(&[]).is_err()); + assert!(check_for_flathub(&[large.clone(), large]).is_err()); +} + +#[test] +fn fr22_6_3_appimage_names_follow_the_tools() { + let meta = meta(); + assert_eq!(appimage_arch("x86_64"), Some("x86_64")); + assert_eq!(appimage_arch("aarch64"), Some("aarch64")); + assert_eq!(appimage_arch("riscv64"), None); + assert_eq!( + appimage_file_name(&meta, "x86_64"), + "Calculator-1.2.3-x86_64.AppImage" + ); + assert_eq!(file_safe("My App: 2"), "My_App__2"); +} + +#[test] +fn fr22_6_3_apprun_starts_the_payload_from_the_mount_point() { + let script = apprun(&meta()); + assert!(script.starts_with("#!/bin/sh\n")); + assert!(script.contains("APPDIR=\"${APPDIR:-$(dirname \"$(readlink -f \"$0\")\")}\"\n")); + assert!(script.contains( + "LD_LIBRARY_PATH=\"$APPDIR/usr/lib/sample-calculator/lib:$APPDIR/usr/lib/sample-calculator${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}\"\nexport LD_LIBRARY_PATH\n" + )); + assert!( + script.ends_with("exec \"$APPDIR/usr/lib/sample-calculator/sample-calculator\" \"$@\"\n") + ); +} + +fn scratch(test: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("packager_linux") + .join(test); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn fr22_6_3_appdir_holds_entry_icon_metainfo_and_payload() { + let dir = scratch("appdir"); + let meta = meta(); + let payload = dir.join("payload"); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("sample-calculator"), b"#!/bin/sh\necho hi\n").unwrap(); + std::fs::create_dir_all(payload.join("lib")).unwrap(); + std::fs::write(payload.join("lib/libcompose_rust_renderer.so"), b"\x7fELF").unwrap(); + let icon_path = dir.join("icon.png"); + std::fs::write(&icon_path, png(256)).unwrap(); + let icons = vec![Icon::classify(&icon_path, &png(256)).unwrap()]; + + let out = dir.join("Calculator.AppDir"); + let written = assemble_appdir( + &AppDirInputs { + meta: &meta, + payload: &payload, + icons: &icons, + }, + &out, + ) + .unwrap(); + for path in [ + "AppRun", + "dev.example.Calculator.desktop", + "dev.example.Calculator.png", + "usr/share/applications/dev.example.Calculator.desktop", + "usr/share/metainfo/dev.example.Calculator.appdata.xml", + "usr/share/icons/hicolor/256x256/apps/dev.example.Calculator.png", + "usr/lib/sample-calculator/sample-calculator", + "usr/lib/sample-calculator/lib/libcompose_rust_renderer.so", + ] { + assert!(out.join(path).is_file(), "missing {path}"); + } + assert!(written.contains(&PathBuf::from( + "usr/lib/sample-calculator/sample-calculator" + ))); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for executable in ["AppRun", "usr/lib/sample-calculator/sample-calculator"] { + let mode = std::fs::metadata(out.join(executable)) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o111, 0o111, "{executable} is not executable"); + } + } + assert_eq!( + std::fs::read_to_string(out.join("dev.example.Calculator.desktop")).unwrap(), + desktop_entry(&meta) + ); + + // Assembling over an existing directory would mix two builds. + let again = assemble_appdir( + &AppDirInputs { + meta: &meta, + payload: &payload, + icons: &icons, + }, + &out, + ); + assert!(again.unwrap_err().0.contains("already exists")); +} + +#[test] +fn fr22_6_3_appdir_refuses_a_payload_without_the_executable() { + let dir = scratch("appdir_missing"); + let payload = dir.join("payload"); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("other"), b"x").unwrap(); + let icon_path = dir.join("icon.png"); + std::fs::write(&icon_path, png(64)).unwrap(); + let icons = vec![Icon::classify(&icon_path, &png(64)).unwrap()]; + let error = assemble_appdir( + &AppDirInputs { + meta: &meta(), + payload: &payload, + icons: &icons, + }, + &dir.join("out"), + ) + .unwrap_err(); + assert!(error.0.contains("sample-calculator"), "{error}"); +} + +#[test] +fn fr22_6_9_flatpak_sandbox_gets_x11_ipc_and_the_gpu() { + let meta = meta(); + assert_eq!( + finish_args(&meta, false), + [ + "--share=ipc", + "--socket=x11", + "--device=dri", + "--share=network" + ] + ); + assert_eq!( + finish_args(&meta, true), + [ + "--share=ipc", + "--socket=wayland", + "--socket=fallback-x11", + "--device=dri", + "--share=network" + ] + ); + let mut duplicate = meta.clone(); + duplicate.finish_args = vec!["--share=ipc".into()]; + assert_eq!(finish_args(&duplicate, false).len(), 3); +} + +fn icons_256() -> Vec { + vec![Icon { + source: "icon.png".into(), + kind: IconKind::Png(256), + }] +} + +#[test] +fn fr22_6_9_flatpak_manifest_for_a_prebuilt_archive() { + let meta = meta(); + let icons = icons_256(); + let text = manifest( + &meta, + &FlatpakOptions { + payload: Payload::Prebuilt { + archives: vec![ + SourceRef::Url { + url: "https://example.dev/calculator-linux-x64.tar.gz".into(), + sha256: "ab".repeat(32), + arch: Some("x86_64".into()), + }, + SourceRef::Url { + url: "https://example.dev/calculator-linux-arm64.tar.gz".into(), + sha256: "ef".repeat(32), + arch: Some("aarch64".into()), + }, + ], + }, + icons: &icons, + wayland: false, + }, + ); + let expected_start = format!( + "{{\n \"id\": \"dev.example.Calculator\",\n \"runtime\": \"org.freedesktop.Platform\",\n \"runtime-version\": \"{DEFAULT_RUNTIME_VERSION}\",\n \"sdk\": \"org.freedesktop.Sdk\",\n \"command\": \"sample-calculator\",\n" + ); + assert!(text.starts_with(&expected_start), "{text}"); + for expected in [ + "\"--socket=x11\"", + "\"--share=ipc\"", + "\"--device=dri\"", + "\"type\": \"archive\"", + "\"url\": \"https://example.dev/calculator-linux-x64.tar.gz\"", + "\"url\": \"https://example.dev/calculator-linux-arm64.tar.gz\"", + "\"only-arches\": [\n \"aarch64\"\n ]", + "\"dest\": \"payload\"", + "\"cp -a payload/. /app/lib/sample-calculator/\"", + "\"test -x /app/lib/sample-calculator/sample-calculator\"", + "\"ln -s ../lib/sample-calculator/sample-calculator /app/bin/sample-calculator\"", + "\"install -Dm644 packaging/dev.example.Calculator.desktop /app/share/applications/dev.example.Calculator.desktop\"", + "\"install -Dm644 packaging/dev.example.Calculator.metainfo.xml /app/share/metainfo/dev.example.Calculator.metainfo.xml\"", + "\"install -Dm644 packaging/icons/256x256/dev.example.Calculator.png /app/share/icons/hicolor/256x256/apps/dev.example.Calculator.png\"", + "\"dest\": \"packaging/icons/256x256\"", + ] { + assert!(text.contains(expected), "missing {expected} in\n{text}"); + } + assert!(!text.contains("rust-stable"), "{text}"); + // An executable without an rpath is pointed at the renderer beside it. + assert!(text.contains("\"name\": \"patchelf\""), "{text}"); + assert!( + text.contains("readelf -d /app/lib/sample-calculator/sample-calculator | grep -qE '\\\\((RPATH|RUNPATH)\\\\)' || patchelf --set-rpath '$ORIGIN:$ORIGIN/lib' /app/lib/sample-calculator/sample-calculator"), + "{text}" + ); + assert!(!text.contains("cargo"), "{text}"); +} + +#[test] +fn fr22_6_9_flatpak_manifest_builds_from_source_offline() { + let mut meta = meta(); + meta.runtime_version = Some("24.08".into()); + let icons = icons_256(); + let text = manifest( + &meta, + &FlatpakOptions { + payload: Payload::FromSource { + source: SourceRef::Path("source.tar.gz".into()), + cargo_sources: "cargo-sources.json".into(), + package: "sample-calculator".into(), + bin: "sample-calculator".into(), + cargo_args: vec!["--no-default-features".into(), "it's".into()], + renderer: vec![SourceRef::Url { + url: "https://example.dev/renderer.tar.gz".into(), + sha256: "cd".repeat(32), + arch: None, + }], + }, + icons: &icons, + wayland: true, + }, + ); + for expected in [ + "\"runtime-version\": \"24.08\"", + "\"sdk-extensions\": [\n \"org.freedesktop.Sdk.Extension.rust-stable\"\n ]", + "\"append-path\": \"/usr/lib/sdk/rust-stable/bin\"", + "\"CARGO_HOME\": \"/run/build/sample-calculator/cargo\"", + "\"CARGO_NET_OFFLINE\": \"true\"", + "\"COMPOSE_RUST_APP_VERSION\": \"1.2.3\"", + "\"COMPOSE_RUST_RENDERER_DIR\": \"/run/build/sample-calculator/renderer\"", + "\"path\": \"source.tar.gz\"", + "\"cargo-sources.json\"", + "\"dest\": \"renderer\"", + "\"cargo --offline fetch --manifest-path Cargo.toml --verbose\"", + "\"cargo --offline build --release --locked -p sample-calculator --bin sample-calculator --no-default-features 'it'\\\\''s'\"", + "\"install -m755 target/release/sample-calculator /app/lib/sample-calculator/sample-calculator\"", + "\"mkdir -p /app/lib/sample-calculator/lib && if [ -d renderer/lib ]; then cp -a renderer/lib/. /app/lib/sample-calculator/lib/; else cp -a renderer/. /app/lib/sample-calculator/lib/; fi\"", + "\"name\": \"patchelf\"", + "\"buildsystem\": \"autotools\"", + "patchelf --set-rpath '$ORIGIN/lib' /app/lib/sample-calculator/sample-calculator", + "\"--socket=wayland\"", + "\"--socket=fallback-x11\"", + ] { + assert!(text.contains(expected), "missing {expected} in\n{text}"); + } + // patchelf is built before the application that needs it. + assert!( + text.find("\"name\": \"patchelf\"").unwrap() + < text.find("\"name\": \"sample-calculator\"").unwrap() + ); + // The vendored crates come before the build that needs them. + assert!( + text.find("cargo --offline fetch").unwrap() < text.find("cargo --offline build").unwrap() + ); +} + +#[test] +fn json_and_shell_quoting_survive_awkward_text() { + assert_eq!( + Json::Object(vec![( + "k\"ey".into(), + Json::Array(vec![Json::str("a\\b\n\u{1}"), Json::Array(vec![])]) + )]) + .render(0), + "{\n \"k\\\"ey\": [\n \"a\\\\b\\n\\u0001\",\n []\n ]\n}" + ); + assert_eq!(shell_quote("--features=a,b"), "--features=a,b"); + assert_eq!(shell_quote("two words"), "'two words'"); + assert_eq!(shell_quote(""), "''"); +} + +#[test] +fn fr22_6_9_release_dates_are_reproducible() { + assert_eq!(release_date(None, 0), "1970-01-01"); + assert_eq!(release_date(None, 1_790_985_600), "2026-10-03"); + assert_eq!(release_date(Some("951782400"), 1_790_985_600), "2000-02-29"); + assert_eq!(release_date(Some("not a number"), 86_400), "1970-01-02"); + assert_eq!(paragraphs("a\nb\n\n\n c "), ["a b", "c"]); +} + +#[test] +fn fr22_6_3_screenshots_are_pinned_to_a_tag_or_commit() { + for good in [ + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/v1.0.0/docs/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/refs/tags/v1.0.0/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/0123456789abcdef0123456789abcdef01234567/shot.png", + "https://example.dev/shot.png", + ] { + check_screenshot_url(good).unwrap_or_else(|e| panic!("{good}: {e}")); + } + for bad in [ + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/main/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/refs/heads/v1/shot.png", + "https://raw.githubusercontent.com/DarkPyonix/darkpyonix-ember/shot.png", + "ftp://example.dev/shot.png", + ] { + assert!(check_screenshot_url(bad).is_err(), "{bad}"); + } + let overlay = "[linux.store]\nscreenshots = [{ url = \"https://raw.githubusercontent.com/a/b/develop/s.png\" }]"; + assert!(resolve_error(DIOXUS_TOML, &[overlay]).contains("pin it to a tag")); +} diff --git a/scripts/packager-macos/dxc_packager/__init__.py b/scripts/packager-macos/dxc_packager/__init__.py new file mode 100644 index 000000000..dbe162638 --- /dev/null +++ b/scripts/packager-macos/dxc_packager/__init__.py @@ -0,0 +1 @@ +"""Packaging compose-rust applications for macOS: bundles, signing, disk images.""" diff --git a/scripts/packager-macos/dxc_packager/bundle.py b/scripts/packager-macos/dxc_packager/bundle.py new file mode 100644 index 000000000..fd68a7270 --- /dev/null +++ b/scripts/packager-macos/dxc_packager/bundle.py @@ -0,0 +1,283 @@ +"""Making, signing and wrapping the .app. + +Layout of what this makes: + + Name.app/Contents/ + Info.plist, PkgInfo + MacOS/ + Frameworks/lib/ the renderer's own lib directory, whole, when the + executable loads the renderer as a library + Resources/AppIcon.icns + +The renderer directory goes to Frameworks/lib and not straight into Frameworks because AWT +reads its companions from the parent of the renderer's directory plus "lib"; that is +scripts/bundle-renderer.sh's finding, and that script is what points the executable at the +copy. An executable that is the whole application (one native image with the renderer +linked in) has no renderer library, and then there is nothing to copy. + +Every bundle is signed ad hoc (`codesign --sign -`). That is not optional: Apple silicon +runs no arm64 code without a signature, and copying a library or rewriting its load +commands invalidates the one the linker left. An ad hoc signature names no developer, so +Gatekeeper does not let a downloaded copy open without the person's say-so (see +install-notes.txt), but once it is open nothing else differs. +""" + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from . import plist as plists +from .metadata import PackagingError + +RENDERER_LIBRARY = "libcompose_rust_renderer.dylib" +REPOSITORY = Path(__file__).resolve().parents[3] +BUNDLE_RENDERER = REPOSITORY / "scripts" / "bundle-renderer.sh" +INSTALL_NOTES = Path(__file__).resolve().parent / "install-notes.txt" + + +def run(command, **kwargs): + try: + return subprocess.run(command, check=True, text=True, capture_output=True, **kwargs) + except FileNotFoundError as error: + raise PackagingError(f"{command[0]} is not installed: {error}") from error + except subprocess.CalledProcessError as error: + raise PackagingError( + f"{' '.join(map(str, command))} failed with status {error.returncode}:\n" + f"{error.stdout}{error.stderr}" + ) from error + + +def load_commands(path): + """The libraries a Mach-O file names, from every architecture it has.""" + output = run(["otool", "-L", str(path)]).stdout + # Libraries are indented; the unindented lines are "file:" or, for a universal file, + # "file (architecture arm64):" headers, one per architecture. A library lists its own + # install name first, which is what it is called and not something it loads. + own = set( + line.strip() + for line in run(["otool", "-D", str(path)]).stdout.splitlines() + if line.strip() and not line.rstrip().endswith(":") + ) + return [ + name + for name in ( + line.strip().split(" (", 1)[0] + for line in output.splitlines() + if line[:1].isspace() and line.strip() + ) + if name not in own + ] + + +def renderer_reference(executable): + """How the executable names the renderer library, or None if it does not load one.""" + for path in load_commands(executable): + if path.endswith("/" + RENDERER_LIBRARY): + return path + return None + + +def renderer_dir_of(executable, explicit=None): + """The directory to copy the renderer from, or None for an executable without one. + + The crate's build records the renderer's absolute path in the executable, and that + path is where the directory is. An executable that names it through @rpath (what the + first published crate did) does not say where it is, and `explicit` has to. + """ + reference = renderer_reference(executable) + if reference is None: + if explicit is not None: + raise PackagingError( + f"--renderer-dir was given, but {executable} does not load " + f"{RENDERER_LIBRARY}; it is either the whole application already or not a " + f"compose-rust executable" + ) + return None + if explicit is not None: + return Path(explicit) + if reference.startswith("@executable_path/"): + raise PackagingError( + f"{executable} already loads the renderer relative to itself ({reference}); " + f"package the executable cargo built, not one taken from a bundle" + ) + if not reference.startswith("/"): + raise PackagingError( + f"{executable} loads the renderer as {reference}, which does not say where the " + f"renderer is. Pass --renderer-dir with the directory that holds " + f"{RENDERER_LIBRARY}." + ) + return Path(reference).parent + + +def is_mach_o(path): + try: + with open(path, "rb") as handle: + magic = handle.read(4) + except OSError: + return False + return magic in ( + b"\xcf\xfa\xed\xfe", # 64 bit, little endian + b"\xce\xfa\xed\xfe", + b"\xca\xfe\xba\xbe", # universal + b"\xbe\xba\xfe\xca", + ) + + +def make_icns(source, destination): + source = Path(source) + if source.suffix.lower() == ".icns": + shutil.copyfile(source, destination) + return + width = int(run(["sips", "-g", "pixelWidth", str(source)]).stdout.split()[-1]) + if width < 1024: + print( + f"warning: {source} is {width} pixels wide and is scaled up to the 1024 pixels " + f"a Retina Dock and Finder draw it at; give a 1024 pixel icon to keep it sharp." + ) + with tempfile.TemporaryDirectory() as scratch: + iconset = Path(scratch) / "AppIcon.iconset" + iconset.mkdir() + for points in (16, 32, 128, 256, 512): + for scale in (1, 2): + pixels = points * scale + suffix = "" if scale == 1 else "@2x" + run( + [ + "sips", "-z", str(pixels), str(pixels), str(source), + "--out", str(iconset / f"icon_{points}x{points}{suffix}.png"), + ] + ) + run(["iconutil", "-c", "icns", str(iconset), "-o", str(destination)]) + + +def assemble( + metadata, + executable, + out_dir, + *, + extra_plist=None, + renderer_dir=None, +): + """Build Name.app in `out_dir` and return its path. Replaces one already there.""" + executable = Path(executable) + if not executable.is_file(): + raise PackagingError(f"no executable at {executable}") + info = plists.info_plist(metadata, extra=extra_plist) + + app = Path(out_dir) / f"{metadata.name}.app" + if app.exists(): + shutil.rmtree(app) + contents = app / "Contents" + (contents / "MacOS").mkdir(parents=True) + (contents / "Resources").mkdir() + (contents / "Frameworks").mkdir() + + staged = contents / "MacOS" / metadata.executable + shutil.copy2(executable, staged) + os.chmod(staged, 0o755) + + renderer = renderer_dir_of(executable, renderer_dir) + if renderer is not None: + if not (renderer / RENDERER_LIBRARY).exists(): + raise PackagingError(f"there is no {RENDERER_LIBRARY} in {renderer}") + # Contents/Frameworks may hold only code. codesign seals anything else there as an + # unsigned subcomponent and refuses the bundle, so say which file it is now rather + # than leave the person to read that out of a codesign failure. The macOS renderer + # directory is all dylibs today. + strays = [ + str(path.relative_to(renderer)) + for path in sorted(renderer.rglob("*")) + if path.is_file() and not is_mach_o(path) + ] + if strays: + raise PackagingError( + f"the renderer directory {renderer} holds files that are not code " + f"({', '.join(strays)}). Contents/Frameworks/lib may hold only code, so " + f"these would make the bundle unsignable; data the renderer reads belongs " + f"in Contents/Resources." + ) + # symlinks=True keeps the directory as it was laid out; nothing in it is a link + # today, and a link pointing outside the bundle would be refused by codesign. + shutil.copytree(renderer, contents / "Frameworks" / "lib", symlinks=True) + run(["bash", str(BUNDLE_RENDERER), str(staged), "../Frameworks/lib"]) + + if metadata.icon is not None: + make_icns(metadata.icon, contents / "Resources" / "AppIcon.icns") + + (contents / "Info.plist").write_bytes(plists.dumps(info)) + (contents / "PkgInfo").write_text("APPL????") + return app + + +def _codesign(path, *, preserve_entitlements=False): + command = ["codesign", "--force", "--sign", "-"] + if preserve_entitlements: + command.append("--preserve-metadata=entitlements") + command.append(str(path)) + run(command) + + +def sign(app): + """Sign every piece of code in `app` ad hoc, innermost first, then the app itself.""" + app = Path(app) + contents = app / "Contents" + + libraries = contents / "Frameworks" / "lib" + if libraries.is_dir(): + for path in sorted(libraries.rglob("*")): + if path.is_file() and not path.is_symlink() and is_mach_o(path): + _codesign(path) + + _codesign(app) + run(["codesign", "--verify", "--strict", "--deep", "--verbose=2", str(app)]) + return app + + +def dmg(app, out, volume_name): + """A disk image for the first install: the app, a link to /Applications, and the notes + that say how to open an application macOS cannot attribute to a developer.""" + out = Path(out) + if out.exists(): + out.unlink() + with tempfile.TemporaryDirectory() as scratch: + staging = Path(scratch) / volume_name + staging.mkdir() + run(["ditto", str(app), str(staging / Path(app).name)]) + (staging / "Applications").symlink_to("/Applications") + shutil.copyfile(INSTALL_NOTES, staging / "If macOS will not open the app.txt") + run( + [ + "hdiutil", "create", "-volname", volume_name, "-srcfolder", str(staging), + "-ov", "-format", "UDZO", str(out), + ] + ) + return out + + +def absolute_references(app): + """Libraries the bundle's code loads from outside the bundle and outside the system.""" + app = Path(app) + problems = [] + for path in sorted(app.rglob("*")): + if not path.is_file() or path.is_symlink() or not is_mach_o(path): + continue + for target in load_commands(path): + if target.startswith("/") and not target.startswith(("/usr/lib/", "/System/")): + problems.append(f"{path.relative_to(app)} loads {target}") + return problems + + +def unsigned_code(app): + """Code in the bundle that has no valid signature, which Apple silicon will not run.""" + app = Path(app) + problems = [] + for path in sorted(app.rglob("*")): + if path.is_file() and not path.is_symlink() and is_mach_o(path): + result = subprocess.run( + ["codesign", "--verify", "--strict", str(path)], capture_output=True, text=True + ) + if result.returncode != 0: + problems.append(f"{path.relative_to(app)}: {result.stderr.strip()}") + return problems diff --git a/scripts/packager-macos/dxc_packager/cli.py b/scripts/packager-macos/dxc_packager/cli.py new file mode 100644 index 000000000..306f4fcac --- /dev/null +++ b/scripts/packager-macos/dxc_packager/cli.py @@ -0,0 +1,121 @@ +"""package-macos: bundle, sign and publish a compose-rust application for macOS. + +Every bundle is signed ad hoc, which needs no Apple developer account: Apple silicon runs +it, Gatekeeper asks the person once before the first launch of a downloaded copy (the +disk image carries the notes that say how). + + package-macos app --manifest-dir . --executable target/release/app --out dist + package-macos dmg --app dist/App.app --out dist/App-1.2.0.dmg + +The bundle carries no update settings: direct distribution is packaged and updated with +Velopack. +""" + +import argparse +import plistlib +import sys +from pathlib import Path + +from . import bundle, metadata, plist +from .metadata import PackagingError + + +def _yes_no(value): + if value in ("yes", "true"): + return True + if value in ("no", "false"): + return False + raise argparse.ArgumentTypeError("answer yes or no") + + +def _add_app_arguments(parser): + parser.add_argument("--manifest-dir", required=True, type=Path, + help="directory holding the application's Cargo.toml and Dioxus.toml") + parser.add_argument("--executable", required=True, type=Path, + help="the executable cargo built (not one already taken from a bundle)") + parser.add_argument("--renderer-dir", type=Path, + help="the renderer's lib directory, when the executable names it " + "through @rpath rather than by its path") + parser.add_argument("--out", required=True, type=Path, help="directory to put Name.app in") + parser.add_argument("--version", help="CFBundleShortVersionString; default from Cargo.toml") + parser.add_argument("--build", help="CFBundleVersion; default the version") + parser.add_argument("--category", help="LSApplicationCategoryType or a dx category name") + parser.add_argument("--minimum-system-version") + parser.add_argument("--plist", action="append", metavar="KEY=VALUE", + help="an extra Info.plist entry; repeatable") + parser.add_argument("--plist-file", type=Path, + help="a plist whose entries are added to Info.plist") + + +def command_app(args): + meta = metadata.load(args.manifest_dir, version=args.version, build=args.build) + meta = metadata.with_overrides( + meta, category=args.category, minimum_system_version=args.minimum_system_version + ) + extra = plist.parse_extra(args.plist) + if args.plist_file: + extra.update(plistlib.loads(args.plist_file.read_bytes())) + + args.out.mkdir(parents=True, exist_ok=True) + app = bundle.assemble( + meta, + args.executable, + args.out, + extra_plist=extra, + renderer_dir=args.renderer_dir, + ) + bundle.sign(app) + problems = _verify(app) + if problems: + raise PackagingError("the bundle is not shippable:\n " + "\n ".join(problems)) + print(app) + + +def _verify(app): + return bundle.unsigned_code(app) + bundle.absolute_references(app) + + +def command_verify(args): + problems = _verify(args.app) + for problem in problems: + print(f"error: {problem}", file=sys.stderr) + if problems: + return 1 + print(f"ok {args.app}: every piece of code is signed and loads nothing from outside") + return 0 + + +def command_dmg(args): + print(bundle.dmg(args.app, args.out, args.volume_name or args.app.stem)) + + +def parser(): + root = argparse.ArgumentParser( + prog="package-macos", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + commands = root.add_subparsers(dest="command", required=True) + + app = commands.add_parser("app", help="make Name.app and sign it ad hoc") + _add_app_arguments(app) + app.set_defaults(run=command_app) + + verify = commands.add_parser("verify", help="check a bundle is signed and self-contained") + verify.add_argument("--app", required=True, type=Path) + verify.set_defaults(run=command_verify) + + dmg = commands.add_parser("dmg", help="a disk image for the first install, with install notes") + dmg.add_argument("--app", required=True, type=Path) + dmg.add_argument("--out", required=True, type=Path) + dmg.add_argument("--volume-name") + dmg.set_defaults(run=command_dmg) + return root + + +def main(argv=None): + args = parser().parse_args(argv) + try: + status = args.run(args) + except PackagingError as error: + print(f"error: {error}", file=sys.stderr) + return 1 + return status or 0 diff --git a/scripts/packager-macos/dxc_packager/install-notes.txt b/scripts/packager-macos/dxc_packager/install-notes.txt new file mode 100644 index 000000000..d74000d96 --- /dev/null +++ b/scripts/packager-macos/dxc_packager/install-notes.txt @@ -0,0 +1,30 @@ +If macOS will not open the app +============================== + +This app is signed, but not by a developer Apple has verified, so the first time you open +a copy you downloaded, macOS asks you to confirm. You do this once. After that the app +opens normally. + +First drag the app into Applications. Opening it straight from the disk image or the +Downloads folder works too, but macOS then runs it from a hidden temporary copy. + +macOS 15 (Sequoia) and newer +---------------------------- +1. Open the app. macOS says it could not verify the app and does not open it. + Click Done. +2. Open System Settings, choose Privacy & Security, and scroll down to Security. + Next to the line saying the app was blocked, click Open Anyway. + The button is there for about an hour after step 1; if it is gone, repeat step 1. +3. Click Open Anyway again in the dialog that follows, and enter your login password. + +macOS 14 (Sonoma) and older +--------------------------- +Control-click (or right-click) the app in Applications, choose Open, then click Open in +the dialog. + +Any version, from Terminal +-------------------------- + xattr -dr com.apple.quarantine "/Applications/.app" + +This removes the mark your browser put on the download, and macOS then opens the app +without asking. Only do this for an app you downloaded from a source you trust. diff --git a/scripts/packager-macos/dxc_packager/metadata.py b/scripts/packager-macos/dxc_packager/metadata.py new file mode 100644 index 000000000..a359f52fd --- /dev/null +++ b/scripts/packager-macos/dxc_packager/metadata.py @@ -0,0 +1,201 @@ +"""What an application says about itself, read from the files its project already has. + +An application built with compose-rust is an ordinary dx project, so its name, bundle +identifier and description are in `Dioxus.toml` and its version is in `Cargo.toml`. The +packager reads them there rather than asking for them again, so the bundle cannot drift +from the project it was built from. + +`Dioxus.toml` keys read (all optional except the identifier): + + [application] name + [bundle] identifier, publisher, copyright, category, short_description, icon + [bundle.macos] or [macos] + bundle_name, minimum_system_version +""" + +import re +import sys +from dataclasses import dataclass, replace +from pathlib import Path + +if sys.version_info < (3, 11): + raise SystemExit("the packager reads TOML with tomllib, which needs Python 3.11 or newer") + +import tomllib + + +class PackagingError(Exception): + """Something the person packaging has to fix, worded so they can.""" + + +# dx (through tauri-bundler) names categories in CamelCase; macOS wants the uniform type +# identifier. Either spelling is accepted in Dioxus.toml. +CATEGORIES = { + "Business": "public.app-category.business", + "DeveloperTool": "public.app-category.developer-tools", + "Education": "public.app-category.education", + "Entertainment": "public.app-category.entertainment", + "Finance": "public.app-category.finance", + "Game": "public.app-category.games", + "GraphicsAndDesign": "public.app-category.graphics-design", + "HealthcareAndFitness": "public.app-category.healthcare-fitness", + "Lifestyle": "public.app-category.lifestyle", + "Medical": "public.app-category.medical", + "Music": "public.app-category.music", + "News": "public.app-category.news", + "Photography": "public.app-category.photography", + "Productivity": "public.app-category.productivity", + "Reference": "public.app-category.reference", + "SocialNetworking": "public.app-category.social-networking", + "Sports": "public.app-category.sports", + "Travel": "public.app-category.travel", + "Utility": "public.app-category.utilities", + "Video": "public.app-category.video", + "Weather": "public.app-category.weather", +} + +# The App Store compares CFBundleVersion as dot separated integers, and App +# Store Connect refuses anything else, so a version is checked here rather than at upload. +_BUNDLE_VERSION = re.compile(r"^\d+(\.\d+){0,2}$") +_IDENTIFIER = re.compile(r"^[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$") + +# Compose's AWT and Metal paths and the renderer are built and tested on macOS 13 and +# newer, so nothing older is promised. +DEFAULT_MINIMUM_SYSTEM_VERSION = "13.0" + + +@dataclass(frozen=True) +class AppMetadata: + name: str + display_name: str + identifier: str + executable: str + version: str + build: str + minimum_system_version: str = DEFAULT_MINIMUM_SYSTEM_VERSION + copyright: str | None = None + category: str | None = None + description: str | None = None + icon: Path | None = None + + +def category_type(value): + if value is None: + return None + if value.startswith("public.app-category."): + return value + if value in CATEGORIES: + return CATEGORIES[value] + known = ", ".join(sorted(CATEGORIES)) + raise PackagingError( + f"category {value!r} is neither a public.app-category.* identifier nor one of the " + f"names dx uses ({known})" + ) + + +def check_bundle_version(field, value): + if not _BUNDLE_VERSION.match(value): + raise PackagingError( + f"{field} is {value!r}, and macOS wants one to three dot separated integers " + f"(1, 1.2 or 1.2.3). A prerelease version from Cargo.toml needs --version and " + f"--build given explicitly." + ) + return value + + +def _read_toml(path): + try: + with open(path, "rb") as handle: + return tomllib.load(handle) + except FileNotFoundError: + return None + except tomllib.TOMLDecodeError as error: + raise PackagingError(f"{path} is not valid TOML: {error}") from error + + +def load(manifest_dir, *, executable=None, version=None, build=None): + """Metadata for the project at `manifest_dir`, with explicit overrides applied last.""" + manifest_dir = Path(manifest_dir) + cargo = _read_toml(manifest_dir / "Cargo.toml") + if cargo is None: + raise PackagingError(f"no Cargo.toml in {manifest_dir}") + dioxus = _read_toml(manifest_dir / "Dioxus.toml") or {} + package = cargo.get("package", {}) + + cargo_version = package.get("version") + if isinstance(cargo_version, dict): + # `version.workspace = true`: the number is in a workspace this tool does not walk. + cargo_version = None + version = version or cargo_version + if not version: + raise PackagingError( + f"{manifest_dir}/Cargo.toml does not state a version here (it may inherit one " + f"from the workspace); pass --version" + ) + + application = dioxus.get("application", {}) + bundle = dioxus.get("bundle", {}) + macos = {**dioxus.get("macos", {}), **bundle.get("macos", {})} + + identifier = bundle.get("identifier") + if not identifier: + raise PackagingError( + f"{manifest_dir}/Dioxus.toml has no [bundle] identifier. The bundle identifier " + f"is what macOS and App Store Connect know the application by, so it " + f"has to be chosen rather than made up here." + ) + if not _IDENTIFIER.match(identifier): + raise PackagingError( + f"bundle identifier {identifier!r} must be reverse DNS made of letters, digits, " + f"hyphens and dots" + ) + + name = macos.get("bundle_name") or application.get("name") or package.get("name") + executable = executable or package.get("name") + + icon = None + icons = bundle.get("icon") + if isinstance(icons, str): + icons = [icons] + for candidate in icons or []: + path = manifest_dir / candidate + if path.suffix.lower() in (".png", ".icns") and path.exists(): + icon = path + break + if icon is None: + fallback = manifest_dir / application.get("asset_dir", "assets") / "icon.png" + if fallback.exists(): + icon = fallback + + copyright_text = bundle.get("copyright") + if copyright_text is None and bundle.get("publisher"): + copyright_text = f"Copyright {bundle['publisher']}" + + metadata = AppMetadata( + name=name, + display_name=name, + identifier=identifier, + executable=executable, + version=version, + build=build or version, + minimum_system_version=macos.get( + "minimum_system_version", DEFAULT_MINIMUM_SYSTEM_VERSION + ), + copyright=copyright_text, + category=category_type(bundle.get("category")), + description=bundle.get("short_description"), + icon=icon, + ) + check_bundle_version("CFBundleShortVersionString", metadata.version) + check_bundle_version("CFBundleVersion", metadata.build) + return metadata + + +def with_overrides(metadata, **changes): + changes = {key: value for key, value in changes.items() if value is not None} + if "category" in changes: + changes["category"] = category_type(changes["category"]) + updated = replace(metadata, **changes) + check_bundle_version("CFBundleShortVersionString", updated.version) + check_bundle_version("CFBundleVersion", updated.build) + return updated diff --git a/scripts/packager-macos/dxc_packager/plist.py b/scripts/packager-macos/dxc_packager/plist.py new file mode 100644 index 000000000..897d62898 --- /dev/null +++ b/scripts/packager-macos/dxc_packager/plist.py @@ -0,0 +1,72 @@ +"""Info.plist for a compose-rust application bundle. + +The bundle carries no update settings: direct distribution is packaged and updated with +Velopack, which needs nothing in the Info.plist. + +Nothing here asks for an entitlement. The bundle is signed ad hoc without the hardened +runtime and without a sandbox, which is what runs on a Mac without an Apple developer +account behind it, and the renderer needs no entitlement to run that way: it is compiled +ahead of time, so nothing generates code at run time, and Skia draws through Metal. +""" + +import plistlib + +from .metadata import PackagingError + + +def info_plist(metadata, *, extra=None): + """The Info.plist dictionary for `metadata`.""" + plist = { + "CFBundleDevelopmentRegion": "en", + "CFBundleDisplayName": metadata.display_name, + "CFBundleExecutable": metadata.executable, + "CFBundleIdentifier": metadata.identifier, + "CFBundleInfoDictionaryVersion": "6.0", + "CFBundleName": metadata.name, + "CFBundlePackageType": "APPL", + "CFBundleShortVersionString": metadata.version, + "CFBundleSupportedPlatforms": ["MacOSX"], + "CFBundleVersion": metadata.build, + "LSMinimumSystemVersion": metadata.minimum_system_version, + # Without this a bundled application is drawn at 1x and scaled up, which shows as + # stepped edges on every rounded corner. A bare executable gets 2x by default, so + # the regression appears the moment an application is first put in a bundle. + "NSHighResolutionCapable": True, + "NSPrincipalClass": "NSApplication", + "NSSupportsAutomaticGraphicsSwitching": True, + } + if metadata.icon is not None: + plist["CFBundleIconFile"] = "AppIcon" + if metadata.copyright: + plist["NSHumanReadableCopyright"] = metadata.copyright + if metadata.category: + plist["LSApplicationCategoryType"] = metadata.category + + for key, value in (extra or {}).items(): + if key in plist and plist[key] != value: + raise PackagingError( + f"the extra Info.plist entries set {key}, which the packager already " + f"derives ({plist[key]!r}); change it where it comes from instead" + ) + plist[key] = value + return plist + + +def dumps(dictionary): + return plistlib.dumps(dictionary, fmt=plistlib.FMT_XML, sort_keys=True) + + +def parse_extra(pairs): + """`KEY=VALUE` pairs from the command line. true/false become booleans, digits integers.""" + extra = {} + for pair in pairs or []: + key, separator, value = pair.partition("=") + if not separator or not key: + raise PackagingError(f"--plist expects KEY=VALUE, not {pair!r}") + if value in ("true", "false"): + extra[key] = value == "true" + elif value.isdigit(): + extra[key] = int(value) + else: + extra[key] = value + return extra diff --git a/scripts/packager-macos/package-macos b/scripts/packager-macos/package-macos new file mode 100755 index 000000000..b4a56b41a --- /dev/null +++ b/scripts/packager-macos/package-macos @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +"""Entry point; see dxc_packager/cli.py for what it does.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from dxc_packager.cli import main # noqa: E402 + +sys.exit(main()) diff --git a/scripts/packager-macos/tests/support.py b/scripts/packager-macos/tests/support.py new file mode 100644 index 000000000..a773f7700 --- /dev/null +++ b/scripts/packager-macos/tests/support.py @@ -0,0 +1,44 @@ +"""Shared helpers for the packager tests.""" + +import sys +import tempfile +from pathlib import Path + +PACKAGER = Path(__file__).resolve().parents[1] +REPOSITORY = PACKAGER.parents[1] +sys.path.insert(0, str(PACKAGER)) + + +def project(cargo, dioxus=None, files=()): + """A throwaway project directory with the given Cargo.toml and Dioxus.toml text.""" + directory = Path(tempfile.mkdtemp(prefix="packager-test-")) + (directory / "Cargo.toml").write_text(cargo) + if dioxus is not None: + (directory / "Dioxus.toml").write_text(dioxus) + for relative, data in files: + path = directory / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return directory + + +CARGO = """ +[package] +name = "sample-demo" +version = "1.4.2" +""" + +DIOXUS = """ +[application] +name = "Demo" +asset_dir = "assets" + +[bundle] +identifier = "dev.example.demo" +publisher = "Example" +category = "DeveloperTool" +short_description = "A demonstration." + +[macos] +bundle_name = "Demo App" +""" diff --git a/scripts/packager-macos/tests/test_bundle.py b/scripts/packager-macos/tests/test_bundle.py new file mode 100644 index 000000000..713a2b36f --- /dev/null +++ b/scripts/packager-macos/tests/test_bundle.py @@ -0,0 +1,162 @@ +"""Assembling, signing and wrapping real bundles. macOS only: codesign, ditto and hdiutil. + +The executables are built from one line of C, so these run in seconds and need no Rust +build. What a compose-rust executable adds, a renderer library loaded from where the +build found it, is made with a stand-in library linked the same way. +""" + +import plistlib +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from support import CARGO, DIOXUS, REPOSITORY, project + +from dxc_packager import bundle, cli, metadata + +ICON = REPOSITORY / "samples" / "calculator" / "assets" / "icon.png" + + +def compile_c(source, out, *flags): + subprocess.run( + # The flags come before `-x c`, which would otherwise read a library as C source. + ["cc", "-o", str(out), "-Wl,-headerpad_max_install_names", *flags, "-x", "c", "-"], + input=source + "\n", text=True, check=True, + ) + + +def renderer_and_executable(scratch, install_name=None): + """A stand-in renderer directory and an executable linked against it. + + By default the library is named by its absolute path, as the crate's build names the + real one; both have header room for the longer relative name, which rustc asks for too. + """ + renderer = Path(scratch) / "renderer" / "lib" + renderer.mkdir(parents=True) + library = renderer / bundle.RENDERER_LIBRARY + compile_c( + "int compose_rust_renderer_stand_in(void) { return 0; }", + library, "-dynamiclib", "-install_name", install_name or str(library), + ) + compile_c( + "int skiko_stand_in(void) { return 0; }", + renderer / "libskiko-stand-in.dylib", "-dynamiclib", + ) + executable = Path(scratch) / "sample-demo" + compile_c( + "int compose_rust_renderer_stand_in(void);\n" + "int main(void) { return compose_rust_renderer_stand_in(); }", + executable, str(library), + ) + return renderer, executable + + +@unittest.skipUnless(sys.platform == "darwin", "codesign and hdiutil are macOS tools") +class Bundle(unittest.TestCase): + def setUp(self): + self.scratch = Path(tempfile.mkdtemp(prefix="packager-bundle-")) + self.renderer, self.executable = renderer_and_executable(self.scratch) + meta = metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), icon=ICON) + self.app = bundle.sign(bundle.assemble(meta, self.executable, self.scratch / "out")) + + def test_fr22_6_3_layout_and_info_plist(self): + contents = self.app / "Contents" + self.assertEqual(self.app.name, "Demo App.app") + self.assertTrue((contents / "MacOS" / "sample-demo").is_file()) + self.assertTrue((contents / "Resources" / "AppIcon.icns").is_file()) + self.assertEqual((contents / "PkgInfo").read_text(), "APPL????") + info = plistlib.loads((contents / "Info.plist").read_bytes()) + self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") + self.assertEqual(info["CFBundleIconFile"], "AppIcon") + self.assertIs(info["NSHighResolutionCapable"], True) + self.assertFalse([key for key in info if key.startswith("SU")]) + + def test_fr22_6_2_renderer_directory_travels_whole_and_is_loaded_relatively(self): + lib = self.app / "Contents" / "Frameworks" / "lib" + self.assertTrue((lib / bundle.RENDERER_LIBRARY).is_file()) + self.assertTrue((lib / "libskiko-stand-in.dylib").is_file()) + self.assertIn( + "@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, + bundle.load_commands(self.app / "Contents" / "MacOS" / "sample-demo"), + ) + self.assertEqual(bundle.absolute_references(self.app), []) + + def test_fr22_6_14_every_piece_of_code_is_signed_ad_hoc(self): + subprocess.run(["codesign", "--verify", "--strict", "--deep", str(self.app)], check=True) + self.assertEqual(bundle.unsigned_code(self.app), []) + for path in ( + self.app, + self.app / "Contents/Frameworks/lib" / bundle.RENDERER_LIBRARY, + self.app / "Contents/Frameworks/lib/libskiko-stand-in.dylib", + ): + details = subprocess.run( + ["codesign", "-dv", str(path)], capture_output=True, text=True + ).stderr + self.assertIn("Signature=adhoc", details, path) + details = subprocess.run( + ["codesign", "-dv", str(self.app)], capture_output=True, text=True + ).stderr + self.assertIn("Identifier=dev.example.demo", details) + self.assertEqual(cli._verify(self.app), []) + + def test_fr22_6_14_the_signed_application_runs(self): + # The point of signing at all: Apple silicon kills unsigned arm64 code on sight, + # and rewriting the load command left the linker's signature invalid. + result = subprocess.run([str(self.app / "Contents" / "MacOS" / "sample-demo")]) + self.assertEqual(result.returncode, 0) + + def test_fr22_6_14_code_whose_signature_broke_is_reported(self): + library = self.app / "Contents/Frameworks/lib/libskiko-stand-in.dylib" + with open(library, "ab") as handle: + handle.write(b"\0changed after signing") + problems = bundle.unsigned_code(self.app) + self.assertTrue(any("libskiko-stand-in.dylib" in problem for problem in problems)) + + def test_fr22_6_2_a_renderer_named_through_rpath_needs_its_directory_given(self): + scratch = self.scratch / "rpath" + renderer, executable = renderer_and_executable( + scratch, install_name="@rpath/" + bundle.RENDERER_LIBRARY + ) + meta = metadata.load(project(CARGO, DIOXUS)) + with self.assertRaisesRegex(metadata.PackagingError, "--renderer-dir"): + bundle.assemble(meta, executable, scratch / "out") + app = bundle.assemble(meta, executable, scratch / "out", renderer_dir=renderer) + self.assertIn( + "@executable_path/../Frameworks/lib/" + bundle.RENDERER_LIBRARY, + bundle.load_commands(app / "Contents/MacOS/sample-demo"), + ) + + def test_fr22_6_2_a_renderer_directory_holding_data_is_refused_by_name(self): + (self.renderer / "fonts.conf").write_text("data") + meta = metadata.load(project(CARGO, DIOXUS)) + with self.assertRaisesRegex(metadata.PackagingError, "fonts.conf"): + bundle.assemble(meta, self.executable, self.scratch / "again") + + def test_fr22_6_5_disk_image_carries_the_app_applications_and_install_notes(self): + image = bundle.dmg(self.app, self.scratch / "Demo.dmg", "Demo App") + mount = self.scratch / "mount" + attached = subprocess.run( + ["hdiutil", "attach", "-nobrowse", "-readonly", "-mountpoint", str(mount), str(image)], + capture_output=True, text=True, + ) + if attached.returncode != 0: + # A sandboxed shell may make images but not mount them; CI can do both. + self.skipTest(f"this shell cannot mount a disk image: {attached.stderr.strip()}") + try: + self.assertTrue((mount / "Demo App.app" / "Contents" / "Info.plist").is_file()) + self.assertEqual(str((mount / "Applications").readlink()), "/Applications") + notes = (mount / "If macOS will not open the app.txt").read_text() + self.assertIn("Open Anyway", notes) + self.assertIn("xattr -dr com.apple.quarantine", notes) + subprocess.run( + ["codesign", "--verify", "--strict", "--deep", str(mount / "Demo App.app")], + check=True, + ) + finally: + subprocess.run(["hdiutil", "detach", str(mount)], capture_output=True) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/packager-macos/tests/test_metadata.py b/scripts/packager-macos/tests/test_metadata.py new file mode 100644 index 000000000..b5fddfb4c --- /dev/null +++ b/scripts/packager-macos/tests/test_metadata.py @@ -0,0 +1,76 @@ +"""Reading what an application says about itself from Cargo.toml and Dioxus.toml.""" + +import unittest + +from support import CARGO, DIOXUS, REPOSITORY, project + +from dxc_packager import metadata +from dxc_packager.metadata import PackagingError + + +class Load(unittest.TestCase): + def test_fr22_6_3_metadata_comes_from_the_project_files(self): + meta = metadata.load(project(CARGO, DIOXUS)) + self.assertEqual(meta.identifier, "dev.example.demo") + self.assertEqual(meta.name, "Demo App") + self.assertEqual(meta.executable, "sample-demo") + self.assertEqual(meta.version, "1.4.2") + self.assertEqual(meta.build, "1.4.2") + self.assertEqual(meta.category, "public.app-category.developer-tools") + self.assertEqual(meta.copyright, "Copyright Example") + self.assertEqual(meta.minimum_system_version, "13.0") + + def test_fr22_6_3_every_sample_in_the_repository_loads(self): + samples = sorted(path for path in (REPOSITORY / "samples").iterdir() + if (path / "Dioxus.toml").exists()) + self.assertTrue(samples) + for sample in samples: + meta = metadata.load(sample) + self.assertTrue(meta.identifier.startswith("dev.darkpyonix."), sample) + self.assertTrue(meta.executable.startswith("sample-"), sample) + + def test_fr22_6_3_bundle_macos_table_is_read_as_well_as_the_samples_macos_table(self): + dioxus = DIOXUS.replace("[macos]\nbundle_name = \"Demo App\"", "") + ( + "\n[bundle.macos]\nbundle_name = \"Other\"\nminimum_system_version = \"14.0\"\n" + ) + meta = metadata.load(project(CARGO, dioxus)) + self.assertEqual(meta.name, "Other") + self.assertEqual(meta.minimum_system_version, "14.0") + + def test_fr22_6_3_overrides_win_and_build_follows_version(self): + meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0") + self.assertEqual((meta.version, meta.build), ("2.0.0", "2.0.0")) + meta = metadata.load(project(CARGO, DIOXUS), version="2.0.0", build="57") + self.assertEqual(meta.build, "57") + + def test_fr22_6_3_icon_falls_back_to_the_asset_directory(self): + directory = project(CARGO, DIOXUS, files=[("assets/icon.png", b"png")]) + self.assertEqual(metadata.load(directory).icon, directory / "assets" / "icon.png") + + def test_fr22_6_3_missing_identifier_is_an_error_not_a_guess(self): + with self.assertRaisesRegex(PackagingError, "identifier"): + metadata.load(project(CARGO, "[application]\nname = \"X\"\n")) + + def test_fr22_6_3_prerelease_version_needs_explicit_numbers(self): + cargo = CARGO.replace("1.4.2", "1.4.2-beta.1") + with self.assertRaisesRegex(PackagingError, "one to three dot separated integers"): + metadata.load(project(cargo, DIOXUS)) + meta = metadata.load(project(cargo, DIOXUS), version="1.4.2", build="142") + self.assertEqual(meta.version, "1.4.2") + + def test_fr22_6_3_workspace_version_has_to_be_given(self): + cargo = CARGO.replace('version = "1.4.2"', "version.workspace = true") + with self.assertRaisesRegex(PackagingError, "--version"): + metadata.load(project(cargo, DIOXUS)) + + def test_fr22_6_3_categories_accept_both_spellings_and_refuse_others(self): + self.assertEqual(metadata.category_type("Utility"), "public.app-category.utilities") + self.assertEqual( + metadata.category_type("public.app-category.music"), "public.app-category.music" + ) + with self.assertRaisesRegex(PackagingError, "DeveloperTool"): + metadata.category_type("Tools") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/packager-macos/tests/test_plist.py b/scripts/packager-macos/tests/test_plist.py new file mode 100644 index 000000000..2620d5d31 --- /dev/null +++ b/scripts/packager-macos/tests/test_plist.py @@ -0,0 +1,55 @@ +"""Info.plist for an application bundle.""" + +import unittest + +from support import CARGO, DIOXUS, project + +from dxc_packager import metadata, plist +from dxc_packager.metadata import PackagingError + + +def meta(**changes): + return metadata.with_overrides(metadata.load(project(CARGO, DIOXUS)), **changes) + + +class InfoPlist(unittest.TestCase): + def test_fr22_6_3_common_keys_come_from_the_metadata(self): + info = plist.info_plist(meta()) + self.assertEqual(info["CFBundleIdentifier"], "dev.example.demo") + self.assertEqual(info["CFBundleExecutable"], "sample-demo") + self.assertEqual(info["CFBundleName"], "Demo App") + self.assertEqual(info["CFBundleShortVersionString"], "1.4.2") + self.assertEqual(info["CFBundleVersion"], "1.4.2") + self.assertEqual(info["CFBundlePackageType"], "APPL") + self.assertEqual(info["LSMinimumSystemVersion"], "13.0") + self.assertEqual(info["LSApplicationCategoryType"], "public.app-category.developer-tools") + self.assertIs(info["NSHighResolutionCapable"], True) + + def test_fr22_6_3_a_bundle_has_no_update_settings(self): + info = plist.info_plist(meta()) + self.assertFalse([key for key in info if key.startswith("SU")]) + + def test_fr22_6_3_extra_entries_are_added_but_cannot_contradict_derived_ones(self): + info = plist.info_plist( + meta(), + extra={"NSAppTransportSecurity": {"NSAllowsLocalNetworking": True}}, + ) + self.assertEqual(info["NSAppTransportSecurity"], {"NSAllowsLocalNetworking": True}) + with self.assertRaisesRegex(PackagingError, "CFBundleIdentifier"): + plist.info_plist(meta(), extra={"CFBundleIdentifier": "other.id"}) + + def test_fr22_6_3_command_line_entries_are_typed(self): + self.assertEqual( + plist.parse_extra(["A=true", "B=12", "C=text"]), {"A": True, "B": 12, "C": "text"} + ) + with self.assertRaisesRegex(PackagingError, "KEY=VALUE"): + plist.parse_extra(["nothing"]) + + def test_fr22_6_3_plist_serialises_as_xml(self): + data = plist.dumps(plist.info_plist(meta())) + self.assertTrue(data.startswith(b"CFBundleIdentifier", data) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/packager-windows/Cargo.toml b/scripts/packager-windows/Cargo.toml new file mode 100644 index 000000000..ec0fbaebb --- /dev/null +++ b/scripts/packager-windows/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "compose-rust-msix" +version = "0.1.0" +description = "Packages a compose-rust desktop executable as an MSIX for the Microsoft Store and App Installer." +edition.workspace = true +license.workspace = true +rust-version.workspace = true +publish = false + +# The packager runs on the machine that ships the application, not inside it, so it is a +# plain command line tool. Everything it decides (identity, version, manifest, icons) is ordinary Rust and is tested on any host; only the last step, handing +# the staged directory to the Windows SDK's MakeAppx, needs Windows. +[dependencies] +png = "0.17" +toml = "0.8" + +[[bin]] +name = "compose-rust-msix" +path = "src/main.rs" +bench = false + +[lib] +bench = false + +# A tiny windowed program the pipeline can package when no application build is at hand. +# It shows a message box and waits, which is what the certification kit's launch test +# needs: a process that starts, draws a window and stays up until it is closed. +[[example]] +name = "fixture-app" +path = "examples/fixture_app.rs" diff --git a/scripts/packager-windows/build.rs b/scripts/packager-windows/build.rs new file mode 100644 index 000000000..62bb0ac6b --- /dev/null +++ b/scripts/packager-windows/build.rs @@ -0,0 +1,23 @@ +//! Embeds an application manifest in the stand-in program. +//! +//! The certification kit reads DPI awareness from the executable's embedded manifest. A +//! program that only declares it at run time (as the renderer does, with +//! SetProcessDpiAwarenessContext) draws correctly but is reported as not DPI aware. An +//! application that wants a clean report embeds the same manifest the same way: these +//! two linker arguments in its own build script. + +fn main() { + let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("fixture_app.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + let windows = std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows"); + let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc"); + if windows && msvc { + println!("cargo:rustc-link-arg-examples=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg-examples=/MANIFESTINPUT:{}", + manifest.display() + ); + } +} diff --git a/scripts/packager-windows/examples/fixture_app.manifest b/scripts/packager-windows/examples/fixture_app.manifest new file mode 100644 index 000000000..1ad43ed80 --- /dev/null +++ b/scripts/packager-windows/examples/fixture_app.manifest @@ -0,0 +1,9 @@ + + + + + true/pm + PerMonitorV2 + + + diff --git a/scripts/packager-windows/examples/fixture_app.rs b/scripts/packager-windows/examples/fixture_app.rs new file mode 100644 index 000000000..e2f0ef7d0 --- /dev/null +++ b/scripts/packager-windows/examples/fixture_app.rs @@ -0,0 +1,39 @@ +//! A stand-in application for proving the packaging pipeline when no application build +//! is at hand: it opens a window (a message box) and stays up until it is closed, which +//! is all the certification kit's launch checks ask of a program. +//! +//! Built for Windows with the GUI subsystem so that no console window appears; on other +//! hosts it only prints, so the example still compiles in the workspace's checks. + +#![cfg_attr(windows, windows_subsystem = "windows")] + +#[cfg(windows)] +mod win { + use std::ffi::c_void; + + #[link(name = "user32")] + unsafe extern "system" { + fn MessageBoxW(hwnd: *mut c_void, text: *const u16, caption: *const u16, kind: u32) -> i32; + } + + fn wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() + } + + pub fn show() { + let text = wide("This program stands in for an application while its package is checked."); + let caption = wide("compose-rust packaging fixture"); + // SAFETY: both strings are NUL-terminated UTF-16 that outlive the call, and a + // null owner window is allowed. + unsafe { + MessageBoxW(std::ptr::null_mut(), text.as_ptr(), caption.as_ptr(), 0x40); + } + } +} + +fn main() { + #[cfg(windows)] + win::show(); + #[cfg(not(windows))] + println!("the packaging fixture only shows a window on Windows"); +} diff --git a/scripts/packager-windows/scripts/common.ps1 b/scripts/packager-windows/scripts/common.ps1 new file mode 100644 index 000000000..2f8c811df --- /dev/null +++ b/scripts/packager-windows/scripts/common.ps1 @@ -0,0 +1,31 @@ +<# +.SYNOPSIS +Helpers shared by the MSIX scripts: finding Windows SDK tools. +#> + +function Find-SdkTool { + param([Parameter(Mandatory)][string]$Name) + if ($env:DXC_WINDOWS_SDK_BIN) { + $candidate = Join-Path $env:DXC_WINDOWS_SDK_BIN $Name + if (Test-Path $candidate) { return $candidate } + throw "DXC_WINDOWS_SDK_BIN is set, and $candidate is not there" + } + $kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' + $found = Get-ChildItem -Path $kits -Directory -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } | + Sort-Object { [version]$_.Name } -Descending | + ForEach-Object { Join-Path $_.FullName "x64\$Name" } | + Where-Object { Test-Path $_ } | + Select-Object -First 1 + if ($found) { return $found } + $onPath = Get-Command $Name -ErrorAction SilentlyContinue + if ($onPath) { return $onPath.Source } + throw "$Name was not found: install the Windows SDK, or set DXC_WINDOWS_SDK_BIN" +} + +function Set-StepOutput { + param([Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Value) + if ($env:GITHUB_OUTPUT) { + Add-Content -Path $env:GITHUB_OUTPUT -Value "$Name=$Value" -Encoding utf8 + } +} diff --git a/scripts/packager-windows/scripts/new-test-certificate.ps1 b/scripts/packager-windows/scripts/new-test-certificate.ps1 new file mode 100644 index 000000000..21b23de63 --- /dev/null +++ b/scripts/packager-windows/scripts/new-test-certificate.ps1 @@ -0,0 +1,61 @@ +<# +.SYNOPSIS +Creates a throwaway code-signing certificate whose subject is the package publisher. + +.DESCRIPTION +Windows installs a package only when its signature chains to a certificate the machine +trusts, and only when the certificate subject is exactly the Publisher in the manifest. +A release to the Store needs neither: Partner Center signs the package itself when it +takes it in. A test install and the certification kit need both, so this makes a +certificate that lives for a week, exports it, and with -Trust adds it to the machine's +TrustedPeople store. + +Never use this for anything a person downloads. A self-signed certificate asks every +user to trust it by hand, and teaching people to do that is the problem signing exists +to stop. + +.PARAMETER Publisher +The manifest's Publisher, for example CN=DarkPyonix. + +.PARAMETER OutDir +Where test-signing.pfx and test-signing.cer are written. + +.PARAMETER Trust +Also trust the certificate on this machine (needs an elevated shell). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Publisher, + [Parameter(Mandatory)][string]$OutDir, + [switch]$Trust +) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null +$cert = New-SelfSignedCertificate -Type Custom -Subject $Publisher ` + -KeyUsage DigitalSignature -KeyAlgorithm RSA -KeyLength 2048 ` + -FriendlyName 'compose-rust MSIX test signing' ` + -CertStoreLocation 'Cert:\CurrentUser\My' ` + -NotAfter (Get-Date).AddDays(7) ` + -TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3', '2.5.29.19={text}') + +$password = [guid]::NewGuid().ToString('N') +if ($env:GITHUB_ACTIONS) { Write-Host "::add-mask::$password" } +$secure = ConvertTo-SecureString -String $password -Force -AsPlainText +$pfx = Join-Path $OutDir 'test-signing.pfx' +$cer = Join-Path $OutDir 'test-signing.cer' +Export-PfxCertificate -Cert $cert -FilePath $pfx -Password $secure | Out-Null +Export-Certificate -Cert $cert -FilePath $cer | Out-Null + +if ($Trust) { + Import-Certificate -FilePath $cer -CertStoreLocation 'Cert:\LocalMachine\TrustedPeople' | Out-Null + Write-Host "trusted $($cert.Subject) ($($cert.Thumbprint)) on this machine" +} + +Set-StepOutput -Name pfx -Value $pfx +Set-StepOutput -Name cer -Value $cer +Set-StepOutput -Name password -Value $password +Write-Host "certificate $($cert.Subject), thumbprint $($cert.Thumbprint)" +Write-Host "pfx $pfx" +Write-Host "cer $cer" diff --git a/scripts/packager-windows/scripts/run-wack.ps1 b/scripts/packager-windows/scripts/run-wack.ps1 new file mode 100644 index 000000000..bdb89664f --- /dev/null +++ b/scripts/packager-windows/scripts/run-wack.ps1 @@ -0,0 +1,234 @@ +<# +.SYNOPSIS +Runs the Windows App Certification Kit against a package and fails on a failed test. + +.DESCRIPTION +The kit is the check Partner Center runs when a package is submitted, so a package that +passes it here is not refused for the same reasons there. It inspects the manifest, the +binaries and the package contents and writes an XML report; for a full-trust desktop +package run from the command line it does not launch the program. Sign the package +with a certificate this machine trusts first, as a machine installing it would. + +Warnings and failures of tests the kit marks optional do not fail the run; they are +printed, and listed in the step summary, so that they are read. The Store accepts a +package with them. A required test failing fails the run, and so does any test named in +-RequirePass that did not pass. + +The optional "Blocked executables" test reports only a file name and a short string. +That is not enough to answer a reviewer who asks about it, so for each such finding this +script opens the package, finds the bytes in the named file, and prints where they are +and what surrounds them: whether the string is an imported function, part of a longer +word, or a few bytes of compressed data that happen to spell a blocked name. + +.PARAMETER Package +The signed .msix or .msixbundle. + +.PARAMETER Report +Where the XML report is written. The explanations go beside it, in +.findings.txt. + +.PARAMETER RequirePass +Names of tests that must pass outright, not merely avoid failing (for example +DPIAwarenessValidation, which is otherwise only a warning). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string]$Package, + [Parameter(Mandatory)][string]$Report, + [string[]]$RequirePass = @() +) +$ErrorActionPreference = 'Stop' + +$appcert = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\App Certification Kit\appcert.exe' +if (-not (Test-Path $appcert)) { + throw "the Windows App Certification Kit is not installed ($appcert); it ships with the Windows SDK" +} + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Report) | Out-Null +if (Test-Path $Report) { Remove-Item $Report } + +& $appcert reset +& $appcert test -appxpackagepath (Resolve-Path $Package).Path -reportoutputpath $Report +$exit = $LASTEXITCODE +if (-not (Test-Path $Report)) { + throw "the certification kit wrote no report (exit $exit)" +} + +# --- Explaining "Blocked executables" findings ------------------------------------------- + +$script:unpacked = $null + +function Get-UnpackedPackage { + if ($script:unpacked) { return $script:unpacked } + Add-Type -AssemblyName System.IO.Compression.FileSystem + $root = Join-Path ([System.IO.Path]::GetTempPath()) ("wack-" + [guid]::NewGuid().ToString('N')) + [System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $Package).Path, $root) + # A bundle holds packages; open those too so a file name finds the file inside them. + Get-ChildItem -Path $root -Filter '*.msix' -File | ForEach-Object { + [System.IO.Compression.ZipFile]::ExtractToDirectory($_.FullName, (Join-Path $root $_.BaseName)) + } + $script:unpacked = $root + return $root +} + +function Find-PackageFile([string]$name) { + $root = Get-UnpackedPackage + $leaf = Split-Path -Leaf $name + $candidates = @(Get-ChildItem -Path $root -Recurse -File -Filter $leaf) + $suffix = $name -replace '/', '\' + $exact = @($candidates | Where-Object { $_.FullName.EndsWith($suffix, [StringComparison]::OrdinalIgnoreCase) }) + if ($exact.Count -gt 0) { return $exact[0] } + if ($candidates.Count -gt 0) { return $candidates[0] } + return $null +} + +function Format-Context([byte[]]$bytes, [int]$at, [int]$length, [int]$step) { + # Printable ASCII as itself, everything else as a dot. $step is 2 for UTF-16 text, + # where every other byte is the zero half of a character. + $from = [Math]::Max(0, $at - 32 * $step) + $to = [Math]::Min($bytes.Length, $at + ($length + 32) * $step) + $sb = [System.Text.StringBuilder]::new() + for ($i = $from; $i -lt $to; $i += $step) { + if ($i -eq $at) { [void]$sb.Append('[') } + if ($i -eq $at + $length * $step) { [void]$sb.Append(']') } + $b = $bytes[$i] + if ($b -ge 0x20 -and $b -lt 0x7F) { [void]$sb.Append([char]$b) } else { [void]$sb.Append('.') } + } + return $sb.ToString() +} + +function Get-Occurrences([byte[]]$bytes, [string]$needle) { + $latin1 = [System.Text.Encoding]::Latin1 + $haystack = $latin1.GetString($bytes) + $found = @() + foreach ($encoding in @(@{ Name = 'ASCII'; Step = 1; Text = $needle }, + @{ Name = 'UTF-16'; Step = 2; Text = $latin1.GetString([System.Text.Encoding]::Unicode.GetBytes($needle)) })) { + $at = 0 + while ($found.Count -lt 3) { + $at = $haystack.IndexOf($encoding.Text, $at, [StringComparison]::Ordinal) + if ($at -lt 0) { break } + $found += [pscustomobject]@{ Offset = $at; Encoding = $encoding.Name; Step = $encoding.Step } + $at += 1 + } + } + return $found +} + +function Get-Kind([byte[]]$bytes, $hit, [int]$length) { + # A string that is a whole word between non-letters reads as a name; one with letters + # on either side is part of a longer word; one with no text around it is data. + $before = if ($hit.Offset - $hit.Step -ge 0) { $bytes[$hit.Offset - $hit.Step] } else { 0 } + $afterAt = $hit.Offset + $length * $hit.Step + $after = if ($afterAt -lt $bytes.Length) { $bytes[$afterAt] } else { 0 } + $isLetter = { param($b) ($b -ge 0x41 -and $b -le 0x5A) -or ($b -ge 0x61 -and $b -le 0x7A) } + $isText = { param($b) $b -ge 0x20 -and $b -lt 0x7F } + if ((& $isLetter $before) -or (& $isLetter $after)) { return 'inside a longer word' } + if (-not (& $isText $before) -and -not (& $isText $after)) { return 'binary data that happens to spell it' } + return 'a standalone name' +} + +function Explain-Message([string]$text) { + $lines = @() + if ($text -match '^File (.+?) contains a reference to a "Launch Process" related API (.+?)!(\S+)') { + $file = $Matches[1]; $dll = $Matches[2]; $api = $Matches[3] + $lines += " $file imports $api from $dll. The kit flags any import of a process-launch API; it does not mean the function is called." + $found = Find-PackageFile $file + if ($found) { + $bytes = [System.IO.File]::ReadAllBytes($found.FullName) + foreach ($hit in (Get-Occurrences $bytes $api | Select-Object -First 1)) { + $lines += (' at 0x{0:X} ({1}): {2}' -f $hit.Offset, $hit.Encoding, (Format-Context $bytes $hit.Offset $api.Length $hit.Step)) + } + } + } elseif ($text -match '^File (.+?) contains a blocked executable reference to "(.+?)"') { + $file = $Matches[1]; $needle = $Matches[2] + $found = Find-PackageFile $file + if (-not $found) { + $lines += " $file is not in the package as named; nothing to show." + } else { + $bytes = [System.IO.File]::ReadAllBytes($found.FullName) + $hits = @(Get-Occurrences $bytes $needle) + if ($hits.Count -eq 0) { + $lines += " `"$needle`" does not occur verbatim in $file; the kit matched it ignoring case or in a form this script does not search." + } + foreach ($hit in $hits) { + $kind = Get-Kind $bytes $hit $needle.Length + $lines += (' "{0}" in {1} at 0x{2:X} ({3}, {4}): {5}' -f $needle, $file, $hit.Offset, $hit.Encoding, $kind, + (Format-Context $bytes $hit.Offset $needle.Length $hit.Step)) + } + } + } + return $lines +} + +# --- Reading the report ------------------------------------------------------------------ + +[xml]$xml = Get-Content -Raw -Path $Report +$overall = $xml.REPORT.OVERALL_RESULT +$failed = @() +$optionalFailed = @() +$notPassed = @{} +$findings = @() +foreach ($test in $xml.SelectNodes('//TEST')) { + $resultNode = $test.SelectSingleNode('RESULT') + $result = if ($resultNode) { $resultNode.InnerText.Trim() } else { '' } + $optional = $test.OPTIONAL -eq 'TRUE' + $label = if ($optional) { "$result (optional)" } else { $result } + if ($result -ne 'PASS') { $notPassed[$test.NAME] = $result } + if ($result -eq 'FAIL' -and -not $optional) { + $failed += $test + Write-Host "::error title=WACK::$($test.NAME)" + } elseif ($result -eq 'FAIL') { + $optionalFailed += $test + Write-Host "::warning title=WACK optional test failed::$($test.NAME)" + } elseif ($result -eq 'WARNING') { + Write-Host "::warning title=WACK::$($test.NAME)" + } + Write-Host ('{0,-18} {1}' -f $label, $test.NAME) + foreach ($message in $test.SelectNodes('MESSAGES/MESSAGE')) { + Write-Host " $($message.TEXT)" + if ($result -ne 'PASS') { + $explained = @(Explain-Message $message.TEXT) + foreach ($line in $explained) { Write-Host " $line" } + if ($explained.Count -gt 0) { + $findings += "$($test.NAME): $($message.TEXT)" + $findings += $explained + } + } + } +} + +if ($findings.Count -gt 0) { + $findingsPath = "$Report.findings.txt" + $findings | Set-Content -Path $findingsPath -Encoding utf8 + Write-Host "explanations written to $findingsPath" +} +if ($script:unpacked) { Remove-Item -Recurse -Force $script:unpacked -ErrorAction SilentlyContinue } + +Write-Host "overall result: $overall (appcert exit $exit)" +if ($env:GITHUB_STEP_SUMMARY) { + $summary = @("WACK on $(Split-Path -Leaf $Package): **$overall**") + if ($optionalFailed.Count -gt 0) { + $summary[0] += " (optional tests failed: $(($optionalFailed | ForEach-Object { $_.NAME }) -join ', '))" + } + if ($findings.Count -gt 0) { + $summary += '' + $summary += '```' + $summary += $findings + $summary += '```' + } + Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8 +} + +$missing = @($RequirePass | Where-Object { $notPassed.ContainsKey($_) }) +foreach ($name in $missing) { + Write-Host "::error title=WACK::$name is $($notPassed[$name]), and this run requires it to pass" +} +# The kit marks some tests optional. Their failures make the overall result WARNING, +# and the Store accepts a package with them; a required test failing is a FAIL overall +# and is what a submission would be refused for. +if ($failed.Count -gt 0 -or ($overall -ne 'PASS' -and $overall -ne 'WARNING')) { + throw "the certification kit reported $overall with $($failed.Count) failing required test(s)" +} +if ($missing.Count -gt 0) { + throw "required to pass and did not: $($missing -join ', ')" +} diff --git a/scripts/packager-windows/scripts/sign.ps1 b/scripts/packager-windows/scripts/sign.ps1 new file mode 100644 index 000000000..c44c1e76c --- /dev/null +++ b/scripts/packager-windows/scripts/sign.ps1 @@ -0,0 +1,41 @@ +<# +.SYNOPSIS +Signs packages with SignTool. + +.DESCRIPTION +Signs each file given with a PFX, using SHA-256 as MSIX requires. A bundle is signed as +a whole; the packages inside it do not need signatures of their own. + +.PARAMETER Path +The .msix, .msixbundle or executable files to sign. + +.PARAMETER Pfx +The certificate with its private key. + +.PARAMETER Password +The PFX password. + +.PARAMETER TimestampUrl +An RFC 3161 timestamp server. A release signature should be timestamped so that it +outlives the certificate; a test signature does not need to be. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][string[]]$Path, + [Parameter(Mandatory)][string]$Pfx, + [Parameter(Mandatory)][string]$Password, + [string]$TimestampUrl +) +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'common.ps1') + +$signtool = Find-SdkTool 'signtool.exe' +foreach ($file in $Path) { + $arguments = @('sign', '/fd', 'SHA256', '/f', $Pfx, '/p', $Password) + if ($TimestampUrl) { $arguments += @('/tr', $TimestampUrl, '/td', 'SHA256') } + $arguments += $file + & $signtool @arguments + if ($LASTEXITCODE -ne 0) { throw "signtool could not sign $file (exit $LASTEXITCODE)" } + & $signtool verify /pa $file + if ($LASTEXITCODE -ne 0) { throw "the signature on $file does not verify (exit $LASTEXITCODE)" } +} diff --git a/scripts/packager-windows/src/assets.rs b/scripts/packager-windows/src/assets.rs new file mode 100644 index 000000000..728492747 --- /dev/null +++ b/scripts/packager-windows/src/assets.rs @@ -0,0 +1,381 @@ +//! The images a package has to carry, drawn from the application's one icon. +//! +//! The manifest names four: the 44 pixel logo the taskbar and Start list use, the 150 +//! pixel medium tile, the 310 by 150 wide tile, and the 50 pixel Store logo. Each is +//! written at its exact size under the unqualified name the manifest uses, so the package +//! needs no resource index to find them. +//! +//! An icon smaller than the largest image is scaled up and the packager says so: the +//! Store accepts it, but a tile drawn from 64 pixels looks like one. + +use std::path::Path; + +use crate::Error; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Rgba { + pub width: u32, + pub height: u32, + /// Straight (not premultiplied) RGBA, row major. + pub pixels: Vec, +} + +/// One image the manifest refers to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct AssetSpec { + pub file: &'static str, + pub width: u32, + pub height: u32, +} + +pub const STORE_LOGO: AssetSpec = AssetSpec { + file: "StoreLogo.png", + width: 50, + height: 50, +}; +pub const SQUARE_44: AssetSpec = AssetSpec { + file: "Square44x44Logo.png", + width: 44, + height: 44, +}; +pub const SQUARE_150: AssetSpec = AssetSpec { + file: "Square150x150Logo.png", + width: 150, + height: 150, +}; +pub const WIDE_310: AssetSpec = AssetSpec { + file: "Wide310x150Logo.png", + width: 310, + height: 150, +}; + +pub const ALL: [AssetSpec; 4] = [STORE_LOGO, SQUARE_44, SQUARE_150, WIDE_310]; + +/// The directory inside the package the images live in. +pub const DIR: &str = "Assets"; + +impl Rgba { + pub fn decode(bytes: &[u8]) -> Result { + let mut decoder = png::Decoder::new(bytes); + decoder.set_transformations(png::Transformations::normalize_to_color8()); + let mut reader = decoder + .read_info() + .map_err(|e| Error::new(format!("icon is not a readable PNG: {e}")))?; + let mut buf = vec![0; reader.output_buffer_size()]; + let info = reader + .next_frame(&mut buf) + .map_err(|e| Error::new(format!("icon is not a readable PNG: {e}")))?; + let (w, h) = (info.width, info.height); + let src = &buf[..info.buffer_size()]; + let count = (w * h) as usize; + let mut pixels = Vec::with_capacity(count * 4); + match info.color_type { + png::ColorType::Rgba => pixels.extend_from_slice(src), + png::ColorType::Rgb => { + for p in src.chunks_exact(3) { + pixels.extend_from_slice(&[p[0], p[1], p[2], 255]); + } + } + png::ColorType::GrayscaleAlpha => { + for p in src.chunks_exact(2) { + pixels.extend_from_slice(&[p[0], p[0], p[0], p[1]]); + } + } + png::ColorType::Grayscale => { + for &g in src { + pixels.extend_from_slice(&[g, g, g, 255]); + } + } + png::ColorType::Indexed => { + return Err(Error::new("icon PNG kept its palette after expansion")); + } + } + if pixels.len() != count * 4 { + return Err(Error::new("icon PNG decoded to the wrong number of pixels")); + } + Ok(Rgba { + width: w, + height: h, + pixels, + }) + } + + pub fn encode(&self) -> Result, Error> { + let fail = |e: png::EncodingError| Error::new(format!("cannot encode PNG: {e}")); + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, self.width, self.height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let mut writer = encoder.write_header().map_err(fail)?; + writer.write_image_data(&self.pixels).map_err(fail)?; + writer.finish().map_err(fail)?; + Ok(out) + } + + pub fn transparent(width: u32, height: u32) -> Self { + Rgba { + width, + height, + pixels: vec![0; (width * height * 4) as usize], + } + } + + pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { + let i = ((y * self.width + x) * 4) as usize; + [ + self.pixels[i], + self.pixels[i + 1], + self.pixels[i + 2], + self.pixels[i + 3], + ] + } + + /// Scales to `width` by `height` with a triangle filter whose support widens when + /// shrinking, so a large icon is averaged rather than sampled. Colour is weighted by + /// alpha, which keeps the transparent surround from bleeding a dark fringe into the + /// edge of the mark. + pub fn resize(&self, width: u32, height: u32) -> Self { + let horizontal = weights(self.width, width); + let vertical = weights(self.height, height); + + // Premultiplied floats for the intermediate. + let src: Vec<[f32; 4]> = self + .pixels + .chunks_exact(4) + .map(|p| { + let a = p[3] as f32 / 255.0; + [ + p[0] as f32 * a, + p[1] as f32 * a, + p[2] as f32 * a, + p[3] as f32, + ] + }) + .collect(); + + let mut rows = vec![[0f32; 4]; (width * self.height) as usize]; + for y in 0..self.height { + for (x, taps) in horizontal.iter().enumerate() { + let mut acc = [0f32; 4]; + for &(sx, w) in taps { + let p = src[(y * self.width + sx) as usize]; + for (a, v) in acc.iter_mut().zip(p) { + *a += v * w; + } + } + rows[(y * width) as usize + x] = acc; + } + } + + let mut out = Rgba::transparent(width, height); + for (y, taps) in vertical.iter().enumerate() { + for x in 0..width { + let mut acc = [0f32; 4]; + for &(sy, w) in taps { + let p = rows[(sy * width + x) as usize]; + for (a, v) in acc.iter_mut().zip(p) { + *a += v * w; + } + } + let a = acc[3].clamp(0.0, 255.0); + let i = ((y as u32 * width + x) * 4) as usize; + if a > 0.0 { + let scale = 255.0 / a; + for (dst, v) in out.pixels[i..i + 3].iter_mut().zip(acc) { + *dst = (v * scale).round().clamp(0.0, 255.0) as u8; + } + } + out.pixels[i + 3] = a.round() as u8; + } + } + out + } + + /// Copies `other` onto this image with its top left corner at (`x`, `y`). + pub fn blit(&mut self, other: &Rgba, x: u32, y: u32) { + for oy in 0..other.height { + for ox in 0..other.width { + let (tx, ty) = (x + ox, y + oy); + if tx >= self.width || ty >= self.height { + continue; + } + let s = ((oy * other.width + ox) * 4) as usize; + let d = ((ty * self.width + tx) * 4) as usize; + self.pixels[d..d + 4].copy_from_slice(&other.pixels[s..s + 4]); + } + } + } +} + +/// For each destination index, the source indices and normalised weights that make it. +fn weights(src: u32, dst: u32) -> Vec> { + let scale = src as f32 / dst as f32; + let support = scale.max(1.0); + (0..dst) + .map(|d| { + let centre = (d as f32 + 0.5) * scale - 0.5; + let lo = (centre - support).floor().max(0.0) as u32; + let hi = ((centre + support).ceil() as u32).min(src - 1); + let mut taps: Vec<(u32, f32)> = (lo..=hi) + .map(|s| { + let w = (1.0 - (s as f32 - centre).abs() / support).max(0.0); + (s, w) + }) + .filter(|&(_, w)| w > 0.0) + .collect(); + if taps.is_empty() { + taps.push((centre.round().clamp(0.0, (src - 1) as f32) as u32, 1.0)); + } + let total: f32 = taps.iter().map(|&(_, w)| w).sum(); + for t in &mut taps { + t.1 /= total; + } + taps + }) + .collect() +} + +/// Draws one asset from the icon. Square assets are the icon at that size. The wide tile +/// is the icon at the tile's height, centred on a transparent field, which is how Windows +/// draws a tile that has no wide artwork of its own. +pub fn render(icon: &Rgba, spec: AssetSpec) -> Rgba { + if spec.width == spec.height { + return icon.resize(spec.width, spec.height); + } + let side = spec.width.min(spec.height); + let mark = icon.resize(side, side); + let mut canvas = Rgba::transparent(spec.width, spec.height); + canvas.blit(&mark, (spec.width - side) / 2, (spec.height - side) / 2); + canvas +} + +/// The largest side any asset needs. An icon smaller than this is being enlarged. +pub fn largest_side() -> u32 { + ALL.iter().map(|s| s.width.max(s.height)).max().unwrap_or(0) +} + +/// Writes every asset into `/Assets`. Returns a warning when the icon had +/// to be enlarged. +pub fn write_all(icon_png: &[u8], package_root: &Path) -> Result, Error> { + let icon = Rgba::decode(icon_png)?; + let dir = package_root.join(DIR); + std::fs::create_dir_all(&dir) + .map_err(|e| Error::new(format!("cannot create {}: {e}", dir.display())))?; + for spec in ALL { + let bytes = render(&icon, spec).encode()?; + let path = dir.join(spec.file); + std::fs::write(&path, bytes) + .map_err(|e| Error::new(format!("cannot write {}: {e}", path.display())))?; + } + let needed = 150; // the medium tile, the largest square the icon is drawn into + let warning = (icon.width.min(icon.height) < needed).then(|| { + format!( + "the icon is {}x{} and is enlarged for the {needed}x{needed} tile; give the application a 256x256 icon for sharp tiles", + icon.width, icon.height + ) + }); + Ok(warning) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn solid(w: u32, h: u32, rgba: [u8; 4]) -> Rgba { + Rgba { + width: w, + height: h, + pixels: rgba + .iter() + .copied() + .cycle() + .take((w * h * 4) as usize) + .collect(), + } + } + + #[test] + fn fr22_6_3_every_asset_has_its_exact_size() { + let icon = solid(64, 64, [200, 80, 40, 255]); + for spec in ALL { + let img = render(&icon, spec); + assert_eq!( + (img.width, img.height), + (spec.width, spec.height), + "{}", + spec.file + ); + let decoded = Rgba::decode(&img.encode().unwrap()).unwrap(); + assert_eq!((decoded.width, decoded.height), (spec.width, spec.height)); + } + } + + #[test] + fn fr22_6_3_resizing_keeps_a_solid_colour_solid() { + let icon = solid(64, 64, [10, 120, 230, 255]); + for (w, h) in [(44, 44), (150, 150), (50, 50), (3, 3), (256, 256)] { + let r = icon.resize(w, h); + for y in 0..h { + for x in 0..w { + assert_eq!(r.pixel(x, y), [10, 120, 230, 255], "{w}x{h} at {x},{y}"); + } + } + } + } + + #[test] + fn fr22_6_3_transparent_surround_does_not_darken_the_edge() { + // Left half opaque red, right half fully transparent black. + let mut icon = solid(8, 8, [0, 0, 0, 0]); + for y in 0..8 { + for x in 0..4 { + let i = ((y * 8 + x) * 4) as usize; + icon.pixels[i..i + 4].copy_from_slice(&[255, 0, 0, 255]); + } + } + let r = icon.resize(3, 3); + let middle = r.pixel(1, 1); + assert!(middle[3] > 0 && middle[3] < 255, "{middle:?}"); + assert_eq!(&middle[..3], &[255, 0, 0], "edge colour bled: {middle:?}"); + } + + #[test] + fn fr22_6_3_wide_tile_centres_the_icon() { + let icon = solid(16, 16, [0, 255, 0, 255]); + let wide = render(&icon, WIDE_310); + assert_eq!(wide.pixel(0, 75)[3], 0); + assert_eq!(wide.pixel(309, 75)[3], 0); + assert_eq!(wide.pixel(155, 75), [0, 255, 0, 255]); + } + + #[test] + fn fr22_6_3_small_icons_are_reported() { + let dir = + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples/notepad/assets/icon.png"); + let bytes = std::fs::read(dir).unwrap(); + let icon = Rgba::decode(&bytes).unwrap(); + assert_eq!((icon.width, icon.height), (64, 64)); + assert!(largest_side() >= 150); + } + + #[test] + fn fr22_6_3_gray_and_rgb_pngs_decode() { + for (color, channels) in [ + (png::ColorType::Rgb, 3usize), + (png::ColorType::Grayscale, 1), + (png::ColorType::GrayscaleAlpha, 2), + ] { + let mut out = Vec::new(); + { + let mut e = png::Encoder::new(&mut out, 2, 2); + e.set_color(color); + e.set_depth(png::BitDepth::Eight); + let mut w = e.write_header().unwrap(); + w.write_image_data(&vec![128u8; 4 * channels]).unwrap(); + } + let img = Rgba::decode(&out).unwrap(); + assert_eq!(img.pixels.len(), 16, "{color:?}"); + assert_eq!(img.pixel(0, 0)[0], 128); + } + } +} diff --git a/scripts/packager-windows/src/lib.rs b/scripts/packager-windows/src/lib.rs new file mode 100644 index 000000000..8740512c0 --- /dev/null +++ b/scripts/packager-windows/src/lib.rs @@ -0,0 +1,299 @@ +//! Packages a compose-rust desktop application as MSIX. +//! +//! The input is what a release build already produces: one executable, or a directory +//! holding the executable with the renderer beside it. The output is a package the +//! Microsoft Store accepts for submission and a bundle wrapping it. Name, publisher, description, +//! icon and version come from the application's own `Dioxus.toml` and `Cargo.toml`. +//! +//! The steps are split so that every decision can be checked anywhere: [`stage`] builds +//! the package directory (payload, manifest, images) on any host, and [`pack`] hands that +//! directory to the Windows SDK's MakeAppx, which only exists on Windows. + +pub mod assets; +pub mod manifest; +pub mod metadata; +pub mod sdk; +pub mod version; + +use std::ffi::OsStr; +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use manifest::Arch; +pub use metadata::AppMetadata; +pub use version::{Channel, PackageVersion}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Error(String); + +impl Error { + pub fn new(message: impl Into) -> Self { + Error(message.into()) + } +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for Error {} + +/// What goes into the package besides the manifest and images. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Payload { + /// One self-contained executable, placed at the root of the package. + Executable(PathBuf), + /// A directory copied whole, with the executable at `executable` inside it. This is + /// the shape a build with the renderer beside the program has (`bin\app.exe` next + /// to the renderer DLL, `lib` next to `bin`). + Directory { root: PathBuf, executable: PathBuf }, +} + +impl Payload { + /// The executable's path inside the package, with backslashes as the manifest wants. + pub fn executable_in_package(&self) -> Result { + let relative: PathBuf = match self { + Payload::Executable(file) => PathBuf::from( + file.file_name() + .ok_or_else(|| Error::new(format!("{} is not a file", file.display())))?, + ), + Payload::Directory { executable, .. } => executable.clone(), + }; + let parts: Vec = relative + .components() + .map(|c| match c { + std::path::Component::Normal(s) => Ok(s.to_string_lossy().into_owned()), + _ => Err(Error::new(format!( + "the executable `{}` has to be a plain path inside the payload", + relative.display() + ))), + }) + .collect::>()?; + if parts.is_empty() { + return Err(Error::new("the executable path is empty")); + } + let joined = parts.join("\\"); + if !joined.to_ascii_lowercase().ends_with(".exe") { + return Err(Error::new(format!( + "the executable `{joined}` does not end in .exe" + ))); + } + Ok(joined) + } +} + +/// Everything one packaging run needs to know. +#[derive(Clone, Debug)] +pub struct Plan { + pub meta: AppMetadata, + pub version: PackageVersion, + pub channel: Channel, + pub arch: Arch, + pub payload: Payload, + pub icon: Option, +} + +impl Plan { + /// `__.msix`, the name MakeAppx and the Store expect. + pub fn package_file_name(&self) -> String { + format!( + "{}_{}_{}.msix", + self.meta.identity_name, + self.version, + self.arch.as_str() + ) + } + + pub fn bundle_file_name(&self) -> String { + format!("{}_{}.msixbundle", self.meta.identity_name, self.version) + } +} + +/// Names a package directory must keep for itself. +const RESERVED: &[&str] = &[ + "AppxManifest.xml", + "AppxBlockMap.xml", + "AppxSignature.p7x", + "[Content_Types].xml", + "AppxMetadata", +]; + +fn copy_dir(from: &Path, to: &Path) -> Result<(), Error> { + std::fs::create_dir_all(to) + .map_err(|e| Error::new(format!("cannot create {}: {e}", to.display())))?; + let entries = std::fs::read_dir(from) + .map_err(|e| Error::new(format!("cannot read {}: {e}", from.display())))?; + for entry in entries { + let entry = + entry.map_err(|e| Error::new(format!("cannot read {}: {e}", from.display())))?; + let src = entry.path(); + let dst = to.join(entry.file_name()); + let kind = entry + .file_type() + .map_err(|e| Error::new(format!("cannot inspect {}: {e}", src.display())))?; + if kind.is_dir() { + copy_dir(&src, &dst)?; + } else { + std::fs::copy(&src, &dst).map_err(|e| { + Error::new(format!( + "cannot copy {} to {}: {e}", + src.display(), + dst.display() + )) + })?; + } + } + Ok(()) +} + +/// Builds the package directory at `layout`: the payload, `AppxManifest.xml` and the +/// images. Runs anywhere. Returns warnings worth showing the person packaging. +pub fn stage(plan: &Plan, layout: &Path) -> Result, Error> { + let mut warnings = Vec::new(); + if layout.exists() { + std::fs::remove_dir_all(layout) + .map_err(|e| Error::new(format!("cannot clear {}: {e}", layout.display())))?; + } + std::fs::create_dir_all(layout) + .map_err(|e| Error::new(format!("cannot create {}: {e}", layout.display())))?; + + let executable = plan.payload.executable_in_package()?; + match &plan.payload { + Payload::Executable(file) => { + if !file.is_file() { + return Err(Error::new(format!("{} does not exist", file.display()))); + } + std::fs::copy(file, layout.join(&executable)) + .map_err(|e| Error::new(format!("cannot copy {}: {e}", file.display())))?; + } + Payload::Directory { + root, + executable: exe, + } => { + if !root.join(exe).is_file() { + return Err(Error::new(format!( + "{} is not in the payload directory {}", + exe.display(), + root.display() + ))); + } + for name in RESERVED.iter().chain([&assets::DIR]) { + if root.join(name).exists() { + return Err(Error::new(format!( + "the payload already has `{name}`, which the package writes itself" + ))); + } + } + copy_dir(root, layout)?; + } + } + + let xml = manifest::render(&plan.meta, plan.version, plan.arch, &executable); + std::fs::write(layout.join("AppxManifest.xml"), xml) + .map_err(|e| Error::new(format!("cannot write the manifest: {e}")))?; + + let icon_path = plan.icon.clone().or_else(|| plan.meta.icon.clone()).ok_or_else(|| { + Error::new( + "the application has no icon: name a PNG in [bundle] icon, or put one at /icon.png", + ) + })?; + let icon = std::fs::read(&icon_path) + .map_err(|e| Error::new(format!("cannot read the icon {}: {e}", icon_path.display())))?; + if let Some(w) = assets::write_all(&icon, layout)? { + warnings.push(w); + } + + if plan.channel == Channel::Store && !plan.meta.store_identity { + warnings.push(format!( + "the identity `{}` / `{}` was derived, not taken from [windows.msix]; Partner Center will refuse \ + this package until identity_name and publisher are set to the values it shows for the reserved name", + plan.meta.identity_name, plan.meta.publisher + )); + } + Ok(warnings) +} + +/// What [`pack`] produced. +#[derive(Clone, Debug)] +pub struct Outputs { + pub package: PathBuf, + pub bundle: PathBuf, +} + +/// Turns a staged directory into `.msix` and `.msixbundle` with MakeAppx, Windows only, because MakeAppx is. +pub fn pack(plan: &Plan, layout: &Path, out_dir: &Path) -> Result { + let makeappx = sdk::find_tool("makeappx.exe")?; + std::fs::create_dir_all(out_dir) + .map_err(|e| Error::new(format!("cannot create {}: {e}", out_dir.display())))?; + let package = out_dir.join(plan.package_file_name()); + sdk::run( + &makeappx, + &[ + OsStr::new("pack"), + OsStr::new("/o"), + OsStr::new("/d"), + layout.as_os_str(), + OsStr::new("/p"), + package.as_os_str(), + ], + )?; + + // MakeAppx bundles every package in a directory, so the package goes into one of + // its own. Other architectures join it here when they are built. + let bundle_src = out_dir.join("bundle-src"); + if bundle_src.exists() { + std::fs::remove_dir_all(&bundle_src) + .map_err(|e| Error::new(format!("cannot clear {}: {e}", bundle_src.display())))?; + } + std::fs::create_dir_all(&bundle_src) + .map_err(|e| Error::new(format!("cannot create {}: {e}", bundle_src.display())))?; + std::fs::copy(&package, bundle_src.join(plan.package_file_name())) + .map_err(|e| Error::new(format!("cannot copy the package: {e}")))?; + let bundle = out_dir.join(plan.bundle_file_name()); + let version = plan.version.to_string(); + sdk::run( + &makeappx, + &[ + OsStr::new("bundle"), + OsStr::new("/o"), + OsStr::new("/bv"), + OsStr::new(&version), + OsStr::new("/d"), + bundle_src.as_os_str(), + OsStr::new("/p"), + bundle.as_os_str(), + ], + )?; + std::fs::remove_dir_all(&bundle_src).ok(); + + Ok(Outputs { package, bundle }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr22_6_4_executable_paths_use_backslashes() { + let p = Payload::Directory { + root: "/x".into(), + executable: Path::new("bin").join("app.exe"), + }; + assert_eq!(p.executable_in_package().unwrap(), "bin\\app.exe"); + let single = Payload::Executable(Path::new("/build/release/ember.exe").into()); + assert_eq!(single.executable_in_package().unwrap(), "ember.exe"); + } + + #[test] + fn fr22_6_4_executable_must_stay_inside_the_payload() { + let p = Payload::Directory { + root: "/x".into(), + executable: Path::new("..").join("app.exe"), + }; + assert!(p.executable_in_package().is_err()); + let not_exe = Payload::Executable("/build/ember".into()); + assert!(not_exe.executable_in_package().is_err()); + } +} diff --git a/scripts/packager-windows/src/main.rs b/scripts/packager-windows/src/main.rs new file mode 100644 index 000000000..91d55c252 --- /dev/null +++ b/scripts/packager-windows/src/main.rs @@ -0,0 +1,202 @@ +//! `compose-rust-msix`: package a desktop build as MSIX. +//! +//! ```text +//! compose-rust-msix --project --exe --out [options] +//! ``` +//! +//! Run with `--help` for the options. + +use std::io::Write; +use std::path::PathBuf; +use std::process::ExitCode; + +use compose_rust_msix::{ + AppMetadata, Arch, Channel, Error, PackageVersion, Payload, Plan, pack, stage, +}; + +const HELP: &str = "\ +compose-rust-msix: package a compose-rust desktop build as MSIX + +USAGE: + compose-rust-msix --project --exe --out [options] + +REQUIRED: + --project The application's directory, holding Dioxus.toml and Cargo.toml + --exe The executable. With --payload-dir, a path inside that directory + --out Where the layout, .msix and .msixbundle are written + +OPTIONS: + --payload-dir Package this whole directory (renderer beside the program) + --channel store (default) or sideload + --revision Fourth version part; sideload only (a build number that grows) + --version Use this instead of the version in Cargo.toml + --arch x64 (default) or arm64 + --icon Use this icon instead of the one Dioxus.toml names + --test-identity Allow the Store channel with a derived identity (for certification runs) + --layout-only Stage the package directory and stop; runs on any host + --summary Append key=value lines describing the outputs (for CI) + -h, --help Show this text +"; + +struct Args { + project: PathBuf, + exe: PathBuf, + out: PathBuf, + payload_dir: Option, + channel: Channel, + revision: Option, + version: Option, + arch: Arch, + icon: Option, + test_identity: bool, + layout_only: bool, + summary: Option, +} + +fn parse_args() -> Result, Error> { + let mut project = None; + let mut exe = None; + let mut out = None; + let mut payload_dir = None; + let mut channel = Channel::Store; + let mut revision = None; + let mut version = None; + let mut arch = Arch::X64; + let mut icon = None; + let mut test_identity = false; + let mut layout_only = false; + let mut summary = None; + + let mut it = std::env::args().skip(1); + while let Some(flag) = it.next() { + let mut value = || { + it.next() + .ok_or_else(|| Error::new(format!("{flag} needs a value"))) + }; + match flag.as_str() { + "-h" | "--help" => return Ok(None), + "--project" => project = Some(PathBuf::from(value()?)), + "--exe" => exe = Some(PathBuf::from(value()?)), + "--out" => out = Some(PathBuf::from(value()?)), + "--payload-dir" => payload_dir = Some(PathBuf::from(value()?)), + "--channel" => channel = Channel::parse(&value()?)?, + "--revision" => { + let v = value()?; + revision = + Some(v.parse().map_err(|_| { + Error::new(format!("--revision `{v}` is not a whole number")) + })?); + } + "--version" => version = Some(value()?), + "--arch" => { + let v = value()?; + arch = Arch::parse(&v) + .ok_or_else(|| Error::new(format!("--arch `{v}`: use x64 or arm64")))?; + } + "--icon" => icon = Some(PathBuf::from(value()?)), + "--test-identity" => test_identity = true, + "--layout-only" => layout_only = true, + "--summary" => summary = Some(PathBuf::from(value()?)), + other => return Err(Error::new(format!("unknown option `{other}`; see --help"))), + } + } + let need = |v: Option, name: &str| { + v.ok_or_else(|| Error::new(format!("{name} is required; see --help"))) + }; + Ok(Some(Args { + project: need(project, "--project")?, + exe: need(exe, "--exe")?, + out: need(out, "--out")?, + payload_dir, + channel, + revision, + version, + arch, + icon, + test_identity, + layout_only, + summary, + })) +} + +fn run() -> Result<(), Error> { + let Some(args) = parse_args()? else { + print!("{HELP}"); + return Ok(()); + }; + let meta = AppMetadata::load(&args.project)?; + if args.channel == Channel::Store + && !meta.store_identity + && !args.test_identity + && !args.layout_only + { + return Err(Error::new( + "the Store channel needs the identity Partner Center assigned: set identity_name and publisher \ + in [windows.msix] of Dioxus.toml, or pass --test-identity for a certification run", + )); + } + let semver = args.version.clone().unwrap_or_else(|| meta.version.clone()); + let version = PackageVersion::from_semver(&semver, args.channel, args.revision)?; + let payload = match &args.payload_dir { + Some(root) => Payload::Directory { + root: root.clone(), + executable: args.exe.clone(), + }, + None => Payload::Executable(args.exe.clone()), + }; + let plan = Plan { + meta, + version, + channel: args.channel, + arch: args.arch, + payload, + icon: args.icon.clone(), + }; + + let layout = args.out.join("layout"); + for warning in stage(&plan, &layout)? { + eprintln!("warning: {warning}"); + } + println!( + "staged {} {} at {}", + plan.meta.identity_name, + plan.version, + layout.display() + ); + + let mut lines = vec![ + format!("identity={}", plan.meta.identity_name), + format!("publisher={}", plan.meta.publisher), + format!("version={}", plan.version), + format!("layout={}", layout.display()), + ]; + if !args.layout_only { + let outputs = pack(&plan, &layout, &args.out)?; + println!("package {}", outputs.package.display()); + println!("bundle {}", outputs.bundle.display()); + lines.push(format!("package={}", outputs.package.display())); + lines.push(format!("bundle={}", outputs.bundle.display())); + } + if let Some(path) = &args.summary { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| Error::new(format!("cannot open {}: {e}", path.display())))?; + for line in lines { + writeln!(file, "{line}") + .map_err(|e| Error::new(format!("cannot write {}: {e}", path.display())))?; + } + } + Ok(()) +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/scripts/packager-windows/src/manifest.rs b/scripts/packager-windows/src/manifest.rs new file mode 100644 index 000000000..4b806f95b --- /dev/null +++ b/scripts/packager-windows/src/manifest.rs @@ -0,0 +1,287 @@ +//! `AppxManifest.xml` for a packaged full-trust desktop application. +//! +//! The application is an ordinary Win32 executable, so the package declares it as one: +//! `Windows.FullTrustApplication` as the entry point and the `runFullTrust` restricted +//! capability. That capability is what lets the renderer load its DLL, read its font +//! configuration and open files the way it does unpackaged; the Store accepts it for +//! desktop applications, with a sentence of justification at submission. + +use crate::assets; +use crate::metadata::AppMetadata; +use crate::version::PackageVersion; + +/// Escapes text for an XML attribute or element. +pub fn xml_escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + c => out.push(c), + } + } + out +} + +/// Capabilities declared in the `uap` namespace. Everything else in +/// `[windows] capabilities` is a foundation capability (`internetClient` and the like). +const UAP_CAPABILITIES: &[&str] = &[ + "appointments", + "blockedChatMessages", + "chat", + "contacts", + "documentsLibrary", + "enterpriseAuthentication", + "musicLibrary", + "objects3D", + "phoneCall", + "picturesLibrary", + "removableStorage", + "sharedUserCertificates", + "userAccountInformation", + "videosLibrary", + "voipCall", +]; + +/// The application id inside the package: a letter followed by letters and digits. +pub fn application_id(display_name: &str) -> String { + let id: String = display_name + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .take(64) + .collect(); + if id.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) { + id + } else { + "App".to_owned() + } +} + +/// The CPU the executable was built for, as the manifest spells it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Arch { + X64, + Arm64, +} + +impl Arch { + pub fn parse(text: &str) -> Option { + match text { + "x64" => Some(Arch::X64), + "arm64" => Some(Arch::Arm64), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Arch::X64 => "x64", + Arch::Arm64 => "arm64", + } + } +} + +/// `executable` is the path of the program inside the package, with backslashes. +pub fn render(meta: &AppMetadata, version: PackageVersion, arch: Arch, executable: &str) -> String { + let e = xml_escape; + let mut x = String::new(); + x.push_str("\n"); + x.push_str( + "\n", + ); + x.push_str(&format!( + " \n", + e(&meta.identity_name), + e(&meta.publisher), + version, + arch.as_str() + )); + x.push_str(" \n"); + x.push_str(&format!( + " {}\n", + e(&meta.display_name) + )); + x.push_str(&format!( + " {}\n", + e(&meta.publisher_display_name) + )); + x.push_str(&format!( + " {}\\{}\n", + assets::DIR, + assets::STORE_LOGO.file + )); + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(&format!( + " \n", + e(&meta.min_version), + e(&meta.max_version_tested) + )); + x.push_str(" \n"); + x.push_str(" \n"); + for lang in &meta.languages { + x.push_str(&format!(" \n", e(lang))); + } + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(&format!( + " \n", + application_id(&meta.display_name), + e(executable) + )); + x.push_str(&format!( + " \n", + e(&meta.display_name), + e(&meta.description), + e(&meta.background_color), + assets::SQUARE_150.file, + assets::SQUARE_44.file, + d = assets::DIR, + )); + x.push_str(&format!( + " \n", + assets::DIR, + assets::WIDE_310.file + )); + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(" \n"); + x.push_str(" \n"); + // The schema wants every Capability before any DeviceCapability. + for cap in meta + .capabilities + .iter() + .filter(|c| !UAP_CAPABILITIES.contains(&c.as_str())) + { + x.push_str(&format!(" \n", e(cap))); + } + for cap in meta + .capabilities + .iter() + .filter(|c| UAP_CAPABILITIES.contains(&c.as_str())) + { + x.push_str(&format!(" \n", e(cap))); + } + x.push_str(" \n"); + for cap in meta + .restricted_capabilities + .iter() + .filter(|c| c.as_str() != "runFullTrust") + { + x.push_str(&format!(" \n", e(cap))); + } + for cap in &meta.device_capabilities { + x.push_str(&format!(" \n", e(cap))); + } + x.push_str(" \n"); + x.push_str("\n"); + x +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::metadata::{DEFAULT_MAX_VERSION_TESTED, DEFAULT_MIN_VERSION}; + use crate::version::Channel; + + fn meta() -> AppMetadata { + AppMetadata { + display_name: "Ember".into(), + publisher_display_name: "DarkPyonix".into(), + description: "Notes & ".into(), + identity_name: "DarkPyonix.Ember".into(), + publisher: "CN=\"Dark, Pyonix\"".into(), + store_identity: true, + version: "1.2.3".into(), + executable: "ember.exe".into(), + icon: None, + languages: vec!["en-us".into(), "ko-kr".into()], + capabilities: vec!["internetClient".into(), "picturesLibrary".into()], + restricted_capabilities: vec![], + device_capabilities: vec!["microphone".into()], + min_version: DEFAULT_MIN_VERSION.into(), + max_version_tested: DEFAULT_MAX_VERSION_TESTED.into(), + background_color: "transparent".into(), + } + } + + fn version() -> PackageVersion { + PackageVersion::from_semver("1.2.3", Channel::Store, None).unwrap() + } + + #[test] + fn fr22_6_4_manifest_carries_identity_and_version() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + assert!(xml.contains( + "" + ), "{xml}"); + assert!(xml.contains("Ember")); + assert!(xml.contains("DarkPyonix")); + } + + #[test] + fn fr22_6_4_manifest_declares_a_full_trust_desktop_application() { + let xml = render(&meta(), version(), Arch::Arm64, "bin\\ember.exe"); + assert!(xml.contains("EntryPoint=\"Windows.FullTrustApplication\"")); + assert!(xml.contains("Executable=\"bin\\ember.exe\"")); + assert!(xml.contains("")); + assert!(xml.contains("Name=\"Windows.Desktop\"")); + assert!(xml.contains("ProcessorArchitecture=\"arm64\"")); + assert_eq!(xml.matches("runFullTrust").count(), 1); + } + + #[test] + fn fr22_6_4_manifest_escapes_text() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + assert!(xml.contains("Description=\"Notes & <things>\"")); + assert!(!xml.contains("Notes & <")); + } + + #[test] + fn fr22_6_4_manifest_names_every_asset_it_ships() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + for spec in assets::ALL { + assert!( + xml.contains(&format!("Assets\\{}", spec.file)), + "{} is generated but not referenced", + spec.file + ); + } + } + + #[test] + fn fr22_6_4_capabilities_are_ordered_and_namespaced() { + let xml = render(&meta(), version(), Arch::X64, "ember.exe"); + let foundation = xml.find("")); + assert!(xml.contains("")); + } + + #[test] + fn fr22_6_4_application_id_is_alphanumeric() { + assert_eq!(application_id("Ember"), "Ember"); + assert_eq!(application_id("My App 2"), "MyApp2"); + assert_eq!(application_id("2048"), "App"); + assert_eq!(application_id("노트"), "App"); + } +} diff --git a/scripts/packager-windows/src/metadata.rs b/scripts/packager-windows/src/metadata.rs new file mode 100644 index 000000000..c845daaa7 --- /dev/null +++ b/scripts/packager-windows/src/metadata.rs @@ -0,0 +1,492 @@ +//! What the package says about the application, read from the files the application +//! already has. +//! +//! An application describes itself once: its name, identifier, publisher and +//! description in `Dioxus.toml` (the file `dx` reads for every other bundle it makes), +//! and its version in `Cargo.toml`. The package is built from those, so a release only +//! ever changes one number in one place. +//! +//! The Store adds two values that nothing else has: the package identity name and the +//! publisher certificate subject that Partner Center assigns when the application's name +//! is reserved. They go in `[windows.msix]`, which `dx` does not read: +//! +//! ```toml +//! [windows.msix] +//! identity_name = "DarkPyonix.Ember" # Partner Center: Package/Identity/Name +//! publisher = "CN=01234567-89AB-..." # Partner Center: Package/Identity/Publisher +//! display_name = "Ember" # the reserved name, if it differs from [application] +//! languages = ["en-us", "ko-kr"] +//! ``` +//! +//! Without them the identity is derived from `[bundle] identifier` and the publisher +//! becomes `CN=`, which is enough to build, sign with a test certificate, +//! install and certify, but is not what the Store will accept for upload. + +use std::path::{Path, PathBuf}; + +use toml::{Table, Value}; + +use crate::Error; + +/// The oldest Windows a package installs on. 1809 is the first release that runs a +/// packaged full-trust application with everything this packager writes, and the +/// oldest the Store still offers desktop packages to. +pub const DEFAULT_MIN_VERSION: &str = "10.0.17763.0"; +/// The newest Windows the package was tested on. Windows uses it to decide which +/// compatibility behaviour applies, so it moves when a newer Windows has been tried. +pub const DEFAULT_MAX_VERSION_TESTED: &str = "10.0.26100.0"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AppMetadata { + pub display_name: String, + pub publisher_display_name: String, + pub description: String, + pub identity_name: String, + pub publisher: String, + /// Whether the identity came from `[windows.msix]` rather than being derived. The + /// Store channel insists on it. + pub store_identity: bool, + pub version: String, + pub executable: String, + pub icon: Option, + pub languages: Vec, + pub capabilities: Vec, + pub restricted_capabilities: Vec, + pub device_capabilities: Vec, + pub min_version: String, + pub max_version_tested: String, + pub background_color: String, +} + +fn read_table(path: &Path) -> Result { + let text = std::fs::read_to_string(path) + .map_err(|e| Error::new(format!("cannot read {}: {e}", path.display())))?; + text.parse::() + .map_err(|e| Error::new(format!("{} is not valid TOML: {e}", path.display()))) +} + +fn get<'a>(table: &'a Table, path: &[&str]) -> Option<&'a Value> { + let (last, parents) = path.split_last()?; + let mut current = table; + for key in parents { + current = current.get(*key)?.as_table()?; + } + current.get(*last) +} + +fn get_str(table: &Table, path: &[&str]) -> Option { + get(table, path) + .and_then(Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +fn get_strings(table: &Table, path: &[&str]) -> Vec { + match get(table, path) { + Some(Value::Array(items)) => items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + Some(Value::String(one)) => vec![one.clone()], + _ => Vec::new(), + } +} + +/// The package version from `Cargo.toml`, following `version.workspace = true` up to +/// the workspace that defines it. +fn cargo_version(project: &Path, cargo: &Table) -> Result { + match get(cargo, &["package", "version"]) { + Some(Value::String(v)) => return Ok(v.clone()), + Some(Value::Table(t)) if t.get("workspace").and_then(Value::as_bool) == Some(true) => {} + None => { + return Err(Error::new(format!( + "{}/Cargo.toml has no [package] version", + project.display() + ))); + } + Some(other) => { + return Err(Error::new(format!( + "{}/Cargo.toml: [package] version is `{other}`, expected a string or `{{ workspace = true }}`", + project.display() + ))); + } + } + let mut dir = project.parent(); + while let Some(d) = dir { + let candidate = d.join("Cargo.toml"); + if candidate.is_file() { + let root = read_table(&candidate)?; + if root.contains_key("workspace") { + return get_str(&root, &["workspace", "package", "version"]).ok_or_else(|| { + Error::new(format!( + "{} inherits its version from the workspace, and {} has no [workspace.package] version", + project.display(), + candidate.display() + )) + }); + } + } + dir = d.parent(); + } + Err(Error::new(format!( + "{} inherits its version from a workspace, and no workspace was found above it", + project.display() + ))) +} + +/// Turns a reverse-DNS identifier into a valid package identity name. +/// +/// A package name is 3 to 50 characters of ASCII letters, digits, periods and hyphens. +/// Anything else is dropped (an underscore becomes a hyphen), and a name that is too long +/// keeps its last labels, which are the ones that tell applications from one publisher +/// apart. +pub fn identity_from_identifier(identifier: &str) -> Result { + let cleaned: String = identifier + .chars() + .filter_map(|c| match c { + 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | '-' => Some(c), + '_' => Some('-'), + _ => None, + }) + .collect(); + let labels: Vec<&str> = cleaned.split('.').filter(|l| !l.is_empty()).collect(); + let mut name = labels.join("."); + let mut start = 0; + while name.len() > 50 && start + 1 < labels.len() { + start += 1; + name = labels[start..].join("."); + } + if name.len() > 50 { + name.truncate(50); + } + validate_identity_name(&name).map(|()| name) +} + +pub fn validate_identity_name(name: &str) -> Result<(), Error> { + let ok_chars = name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-'); + if !(3..=50).contains(&name.len()) || !ok_chars { + return Err(Error::new(format!( + "package identity name `{name}` must be 3 to 50 characters of letters, digits, `.` and `-`" + ))); + } + if name.starts_with('.') || name.ends_with('.') { + return Err(Error::new(format!( + "package identity name `{name}` cannot start or end with `.`" + ))); + } + Ok(()) +} + +/// Quotes a value for a distinguished name when it holds a character a DN treats as +/// syntax. +pub fn dn_value(value: &str) -> String { + let needs_quotes = value + .chars() + .any(|c| matches!(c, ',' | '+' | '=' | '"' | '\\' | '<' | '>' | ';' | '#')) + || value.starts_with(' ') + || value.ends_with(' '); + if needs_quotes { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_owned() + } +} + +pub fn validate_publisher(publisher: &str) -> Result<(), Error> { + if !publisher.starts_with("CN=") && !publisher.contains(", CN=") && !publisher.contains(",CN=") + { + return Err(Error::new(format!( + "publisher `{publisher}` is not a certificate subject: it has to be a distinguished name \ + with a CN, such as `CN=Contoso` or the value Partner Center shows under Package/Identity/Publisher" + ))); + } + if publisher.len() > 8192 { + return Err(Error::new("publisher is longer than 8192 characters")); + } + Ok(()) +} + +fn validate_label(what: &str, value: &str) -> Result<(), Error> { + if value.is_empty() || value.chars().count() > 256 { + return Err(Error::new(format!( + "{what} must be 1 to 256 characters, and is `{value}`" + ))); + } + Ok(()) +} + +impl AppMetadata { + /// Reads `/Dioxus.toml` and `/Cargo.toml`. + pub fn load(project: &Path) -> Result { + let dioxus_path = project.join("Dioxus.toml"); + let dioxus = read_table(&dioxus_path)?; + let cargo = read_table(&project.join("Cargo.toml"))?; + let version = cargo_version(project, &cargo)?; + Self::from_tables(project, &dioxus, &cargo, version) + } + + pub fn from_tables( + project: &Path, + dioxus: &Table, + cargo: &Table, + version: String, + ) -> Result { + let package_name = get_str(cargo, &["package", "name"]).ok_or_else(|| { + Error::new(format!( + "{}/Cargo.toml has no [package] name", + project.display() + )) + })?; + + let display_name = get_str(dioxus, &["windows", "msix", "display_name"]) + .or_else(|| get_str(dioxus, &["application", "name"])) + .unwrap_or_else(|| package_name.clone()); + let publisher_display_name = get_str(dioxus, &["windows", "publisher"]) + .or_else(|| get_str(dioxus, &["bundle", "publisher"])) + .ok_or_else(|| { + Error::new( + "Dioxus.toml has no publisher: set [bundle] publisher to the name the Store shows as the developer", + ) + })?; + let description = get_str(dioxus, &["windows", "short_description"]) + .or_else(|| get_str(dioxus, &["bundle", "short_description"])) + .unwrap_or_else(|| display_name.clone()); + + let explicit_identity = get_str(dioxus, &["windows", "msix", "identity_name"]); + let explicit_publisher = get_str(dioxus, &["windows", "msix", "publisher"]); + let store_identity = explicit_identity.is_some() && explicit_publisher.is_some(); + + let identity_name = match explicit_identity { + Some(name) => { + validate_identity_name(&name)?; + name + } + None => { + let identifier = get_str(dioxus, &["windows", "identifier"]) + .or_else(|| get_str(dioxus, &["bundle", "identifier"])) + .ok_or_else(|| { + Error::new( + "Dioxus.toml has neither [windows.msix] identity_name nor [bundle] identifier to derive one from", + ) + })?; + identity_from_identifier(&identifier)? + } + }; + let publisher = explicit_publisher + .unwrap_or_else(|| format!("CN={}", dn_value(&publisher_display_name))); + validate_publisher(&publisher)?; + validate_label("display name", &display_name)?; + validate_label("publisher display name", &publisher_display_name)?; + + // The executable is the binary Cargo builds for this package. A package with more + // than one binary names the one to launch in [windows.msix] executable. + let executable = get_str(dioxus, &["windows", "msix", "executable"]) + .or_else(|| { + cargo + .get("bin") + .and_then(Value::as_array) + .and_then(|bins| bins.first()) + .and_then(|b| b.get("name")) + .and_then(Value::as_str) + .map(str::to_owned) + }) + .unwrap_or(package_name); + let executable = if executable.to_ascii_lowercase().ends_with(".exe") { + executable + } else { + format!("{executable}.exe") + }; + + let icon = icon_path(project, dioxus); + + let mut languages = get_strings(dioxus, &["windows", "msix", "languages"]); + if languages.is_empty() { + languages.push("en-us".to_owned()); + } + + Ok(AppMetadata { + display_name, + publisher_display_name, + description, + identity_name, + publisher, + store_identity, + version, + executable, + icon, + languages, + capabilities: get_strings(dioxus, &["windows", "capabilities"]), + restricted_capabilities: get_strings(dioxus, &["windows", "restricted_capabilities"]), + device_capabilities: get_strings(dioxus, &["windows", "device_capabilities"]), + min_version: get_str(dioxus, &["windows", "msix", "min_version"]) + .unwrap_or_else(|| DEFAULT_MIN_VERSION.to_owned()), + max_version_tested: get_str(dioxus, &["windows", "msix", "max_version_tested"]) + .unwrap_or_else(|| DEFAULT_MAX_VERSION_TESTED.to_owned()), + background_color: get_str(dioxus, &["windows", "msix", "background_color"]) + .unwrap_or_else(|| "transparent".to_owned()), + }) + } +} + +/// The largest PNG the application names as its icon, or `/icon.png`. +fn icon_path(project: &Path, dioxus: &Table) -> Option { + let mut named = get_strings(dioxus, &["windows", "icon"]); + if named.is_empty() { + named = get_strings(dioxus, &["bundle", "icon"]); + } + let mut pngs: Vec = named + .iter() + .filter(|p| p.to_ascii_lowercase().ends_with(".png")) + .map(|p| project.join(p)) + .filter(|p| p.is_file()) + .collect(); + pngs.sort_by_key(|p| std::fs::metadata(p).map(|m| m.len()).unwrap_or(0)); + if let Some(largest) = pngs.pop() { + return Some(largest); + } + let asset_dir = + get_str(dioxus, &["application", "asset_dir"]).unwrap_or_else(|| "assets".into()); + let fallback = project.join(asset_dir).join("icon.png"); + fallback.is_file().then_some(fallback) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tables(dioxus: &str, cargo: &str) -> (Table, Table) { + (dioxus.parse().unwrap(), cargo.parse().unwrap()) + } + + const NOTEPAD: &str = r#" +[application] +name = "Notepad" +asset_dir = "assets" + +[bundle] +identifier = "dev.darkpyonix.composerust.samples.notepad" +publisher = "DarkPyonix" +short_description = "A plain text editor built with compose-rust." +"#; + + const CARGO: &str = r#" +[package] +name = "sample-notepad" +version = "0.1.0" + +[[bin]] +name = "sample-notepad" +path = "src/main.rs" +"#; + + #[test] + fn fr22_6_3_metadata_comes_from_dioxus_toml_and_cargo_toml() { + let (d, c) = tables(NOTEPAD, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "0.1.0".into()).unwrap(); + assert_eq!(m.display_name, "Notepad"); + assert_eq!(m.publisher_display_name, "DarkPyonix"); + assert_eq!( + m.description, + "A plain text editor built with compose-rust." + ); + assert_eq!( + m.identity_name, + "dev.darkpyonix.composerust.samples.notepad" + ); + assert_eq!(m.publisher, "CN=DarkPyonix"); + assert!(!m.store_identity); + assert_eq!(m.executable, "sample-notepad.exe"); + assert_eq!(m.languages, vec!["en-us"]); + assert_eq!(m.min_version, DEFAULT_MIN_VERSION); + } + + #[test] + fn fr22_6_3_store_identity_overrides_the_derived_one() { + let dioxus = format!( + "{NOTEPAD}\n[windows.msix]\nidentity_name = \"DarkPyonix.Ember\"\npublisher = \"CN=1F2E3D4C-0000-1111-2222-333344445555\"\ndisplay_name = \"Ember\"\nlanguages = [\"en-us\", \"ko-kr\"]\n" + ); + let (d, c) = tables(&dioxus, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); + assert_eq!(m.identity_name, "DarkPyonix.Ember"); + assert_eq!(m.publisher, "CN=1F2E3D4C-0000-1111-2222-333344445555"); + assert_eq!(m.display_name, "Ember"); + assert!(m.store_identity); + assert_eq!(m.languages, vec!["en-us", "ko-kr"]); + } + + #[test] + fn fr22_6_3_windows_section_overrides_bundle() { + let dioxus = format!( + "{NOTEPAD}\n[windows]\npublisher = \"Dark Pyonix, Inc.\"\nshort_description = \"Windows text\"\ncapabilities = [\"internetClient\"]\n" + ); + let (d, c) = tables(&dioxus, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); + assert_eq!(m.publisher_display_name, "Dark Pyonix, Inc."); + assert_eq!(m.publisher, "CN=\"Dark Pyonix, Inc.\""); + assert_eq!(m.description, "Windows text"); + assert_eq!(m.capabilities, vec!["internetClient"]); + } + + #[test] + fn fr22_6_3_missing_publisher_is_an_error() { + let (d, c) = tables( + "[application]\nname = \"X\"\n[bundle]\nidentifier = \"a.b.c\"\n", + CARGO, + ); + let err = + AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap_err(); + assert!(err.to_string().contains("publisher"), "{err}"); + } + + #[test] + fn fr22_6_3_identity_names_are_made_valid() { + assert_eq!( + identity_from_identifier("com.example.my_app").unwrap(), + "com.example.my-app" + ); + assert_eq!( + identity_from_identifier("com.ex ample.app!").unwrap(), + "com.example.app" + ); + let long = "dev.darkpyonix.composerust.samples.a-very-long-application-name"; + let name = identity_from_identifier(long).unwrap(); + assert!(name.len() <= 50, "{name}"); + assert!(name.ends_with("a-very-long-application-name"), "{name}"); + assert!(identity_from_identifier("ab").is_err()); + } + + #[test] + fn fr22_6_3_publisher_must_be_a_distinguished_name() { + assert!(validate_publisher("CN=DarkPyonix").is_ok()); + assert!(validate_publisher("O=Org, CN=Name").is_ok()); + assert!(validate_publisher("DarkPyonix").is_err()); + } + + #[test] + fn fr22_6_3_executable_follows_the_explicit_override() { + let dioxus = format!("{NOTEPAD}\n[windows.msix]\nexecutable = \"ember\"\n"); + let (d, c) = tables(&dioxus, CARGO); + let m = AppMetadata::from_tables(Path::new("/nowhere"), &d, &c, "1.0.0".into()).unwrap(); + assert_eq!(m.executable, "ember.exe"); + } + + #[test] + fn fr22_6_3_the_real_samples_produce_valid_metadata() { + let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); + let mut seen = 0; + for entry in std::fs::read_dir(&samples).unwrap() { + let dir = entry.unwrap().path(); + if !dir.join("Dioxus.toml").is_file() { + continue; + } + let m = AppMetadata::load(&dir).unwrap_or_else(|e| panic!("{}: {e}", dir.display())); + assert!(m.icon.is_some(), "{} has no icon", dir.display()); + seen += 1; + } + assert!(seen > 0, "no samples found under {}", samples.display()); + } +} diff --git a/scripts/packager-windows/src/sdk.rs b/scripts/packager-windows/src/sdk.rs new file mode 100644 index 000000000..cf5df88d4 --- /dev/null +++ b/scripts/packager-windows/src/sdk.rs @@ -0,0 +1,105 @@ +//! Finding and running the Windows SDK's packaging tools. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::Error; + +/// Parses `10.0.22621.0` into something that orders numerically. +fn sdk_version(name: &str) -> Option> { + let parts: Option> = name.split('.').map(|p| p.parse().ok()).collect(); + parts.filter(|p| p.len() == 4) +} + +/// The newest `\bin\\\` under `kits`. +pub fn newest_in_kits(kits: &Path, host: &str, tool: &str) -> Option { + let mut found: Vec<(Vec, PathBuf)> = std::fs::read_dir(kits.join("bin")) + .ok()? + .filter_map(|e| e.ok()) + .filter_map(|e| { + let version = sdk_version(&e.file_name().to_string_lossy())?; + let candidate = e.path().join(host).join(tool); + candidate.is_file().then_some((version, candidate)) + }) + .collect(); + found.sort(); + found.pop().map(|(_, p)| p) +} + +/// Finds an SDK tool: `$DXC_WINDOWS_SDK_BIN\` if set, else the newest installed +/// Windows 10/11 SDK, else whatever is on `PATH`. +pub fn find_tool(tool: &str) -> Result { + if let Some(dir) = std::env::var_os("DXC_WINDOWS_SDK_BIN") { + let candidate = Path::new(&dir).join(tool); + if candidate.is_file() { + return Ok(candidate); + } + return Err(Error::new(format!( + "DXC_WINDOWS_SDK_BIN is set, and {} is not there", + candidate.display() + ))); + } + let host = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x64" + }; + for root in ["ProgramFiles(x86)", "ProgramFiles"] { + if let Some(pf) = std::env::var_os(root) { + let kits = Path::new(&pf).join("Windows Kits").join("10"); + if let Some(found) = newest_in_kits(&kits, host, tool) { + return Ok(found); + } + } + } + if let Some(path) = std::env::var_os("PATH") { + for dir in std::env::split_paths(&path) { + let candidate = dir.join(tool); + if candidate.is_file() { + return Ok(candidate); + } + } + } + Err(Error::new(format!( + "{tool} was not found: install the Windows SDK, or set DXC_WINDOWS_SDK_BIN to the directory that holds it. \ + Packing needs Windows; `--layout-only` stages the package directory on any host" + ))) +} + +pub fn run(tool: &Path, args: &[&OsStr]) -> Result<(), Error> { + let status = Command::new(tool) + .args(args) + .status() + .map_err(|e| Error::new(format!("cannot start {}: {e}", tool.display())))?; + if status.success() { + Ok(()) + } else { + Err(Error::new(format!( + "{} {} failed with {status}", + tool.display(), + args.iter() + .map(|a| a.to_string_lossy()) + .collect::>() + .join(" ") + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr22_6_13_sdk_versions_order_numerically() { + let mut v = [ + sdk_version("10.0.9999.0").unwrap(), + sdk_version("10.0.22621.0").unwrap(), + sdk_version("10.0.19041.0").unwrap(), + ]; + v.sort(); + assert_eq!(v.last().unwrap(), &vec![10, 0, 22621, 0]); + assert!(sdk_version("wdf").is_none()); + assert!(sdk_version("10.0.1").is_none()); + } +} diff --git a/scripts/packager-windows/src/version.rs b/scripts/packager-windows/src/version.rs new file mode 100644 index 000000000..fd70c3569 --- /dev/null +++ b/scripts/packager-windows/src/version.rs @@ -0,0 +1,206 @@ +//! The four-part package version. +//! +//! An application states one version, the `version` in its `Cargo.toml`, and every +//! package is versioned from it. MSIX wants `Major.Minor.Build.Revision`, each part a +//! 16-bit number, and Windows only ever replaces an installed package with a strictly +//! greater one. Both delivery channels lean on that rule, so the mapping has to be +//! monotonic: a later release of the application must never produce a smaller version. + +use std::fmt; + +use crate::Error; + +/// Where a package is going. The two differ in who owns the fourth part of the version. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Channel { + /// Partner Center. The Store reserves the revision for its own use and rejects a + /// package whose revision is not zero, so a Store release is `Major.Minor.Patch.0`. + Store, + /// Direct download outside the Store. The revision is a build number + /// that only grows, so successive builds of one release (or of a pre-release) each + /// install over the last. + Sideload, +} + +impl Channel { + pub fn parse(text: &str) -> Result { + match text { + "store" => Ok(Channel::Store), + "sideload" => Ok(Channel::Sideload), + other => Err(Error::new(format!( + "unknown channel `{other}`: use `store` for Partner Center or `sideload` for a direct download" + ))), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PackageVersion { + pub major: u16, + pub minor: u16, + pub build: u16, + pub revision: u16, +} + +impl fmt::Display for PackageVersion { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}.{}.{}.{}", + self.major, self.minor, self.build, self.revision + ) + } +} + +fn part(name: &str, text: &str, whole: &str) -> Result { + let value: u64 = text.parse().map_err(|_| { + Error::new(format!( + "version `{whole}`: the {name} part `{text}` is not a number" + )) + })?; + u16::try_from(value).map_err(|_| { + Error::new(format!( + "version `{whole}`: the {name} part is {value}, and a package version part cannot exceed 65535" + )) + }) +} + +impl PackageVersion { + /// Maps an application's semantic version onto a package version for `channel`. + /// + /// `revision` is the build number for the sideload channel. It is refused for the + /// Store, where the revision has to stay zero. A pre-release (`1.2.0-beta.3`) is + /// refused for the Store as well: the Store orders packages by version alone, so + /// `1.2.0-beta.3` and `1.2.0` would both be `1.2.0.0` and the release could never + /// replace the beta. Ship a pre-release on the sideload channel, or give it its + /// own patch number. + pub fn from_semver( + semver: &str, + channel: Channel, + revision: Option, + ) -> Result { + let trimmed = semver.trim(); + // Build metadata never takes part in ordering, so it is dropped first. + let without_build = trimmed.split('+').next().unwrap_or(trimmed); + let (core, pre) = match without_build.split_once('-') { + Some((core, pre)) => (core, Some(pre)), + None => (without_build, None), + }; + let parts: Vec<&str> = core.split('.').collect(); + if parts.len() != 3 { + return Err(Error::new(format!( + "version `{trimmed}` is not MAJOR.MINOR.PATCH" + ))); + } + let major = part("major", parts[0], trimmed)?; + let minor = part("minor", parts[1], trimmed)?; + let build = part("patch", parts[2], trimmed)?; + + let revision = match channel { + Channel::Store => { + if let Some(pre) = pre { + return Err(Error::new(format!( + "version `{trimmed}` is a pre-release (`{pre}`), and the Store cannot tell it apart \ + from the release it precedes: both would be {major}.{minor}.{build}.0. \ + Package it for the sideload channel, or give it a patch number of its own" + ))); + } + if let Some(r) = revision.filter(|r| *r != 0) { + return Err(Error::new(format!( + "a Store package must have revision 0 (the Store reserves the fourth part), \ + and revision {r} was given" + ))); + } + 0 + } + Channel::Sideload => { + let r = revision.unwrap_or(0); + u16::try_from(r).map_err(|_| { + Error::new(format!( + "revision {r} cannot exceed 65535; use a counter that restarts with each release" + )) + })? + } + }; + + Ok(PackageVersion { + major, + minor, + build, + revision, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fr22_6_4_store_version_is_the_crate_version_with_revision_zero() { + let v = PackageVersion::from_semver("1.4.2", Channel::Store, None).unwrap(); + assert_eq!(v.to_string(), "1.4.2.0"); + } + + #[test] + fn fr22_6_4_store_refuses_a_nonzero_revision() { + assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(7)).is_err()); + assert!(PackageVersion::from_semver("1.4.2", Channel::Store, Some(0)).is_ok()); + } + + #[test] + fn fr22_6_4_store_refuses_a_prerelease() { + let err = PackageVersion::from_semver("1.4.2-beta.1", Channel::Store, None).unwrap_err(); + assert!(err.to_string().contains("pre-release"), "{err}"); + } + + #[test] + fn fr22_6_4_sideload_revision_is_the_build_number() { + let v = + PackageVersion::from_semver("1.4.2-beta.1+abc", Channel::Sideload, Some(31)).unwrap(); + assert_eq!(v.to_string(), "1.4.2.31"); + } + + #[test] + fn fr22_6_4_build_metadata_is_ignored() { + let v = PackageVersion::from_semver("2.0.1+git.abc", Channel::Store, None).unwrap(); + assert_eq!(v.to_string(), "2.0.1.0"); + } + + #[test] + fn fr22_6_4_parts_above_sixteen_bits_are_refused() { + assert!(PackageVersion::from_semver("1.70000.0", Channel::Store, None).is_err()); + assert!(PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(70_000)).is_err()); + } + + #[test] + fn fr22_6_4_malformed_versions_are_refused() { + for bad in ["1.0", "1.0.0.0", "a.b.c", "", "1..0"] { + assert!( + PackageVersion::from_semver(bad, Channel::Store, None).is_err(), + "{bad} was accepted" + ); + } + } + + #[test] + fn fr22_6_4_later_releases_map_to_greater_versions() { + let order = ["0.9.9", "1.0.0", "1.0.1", "1.1.0", "2.0.0"]; + let mapped: Vec<_> = order + .iter() + .map(|v| PackageVersion::from_semver(v, Channel::Store, None).unwrap()) + .collect(); + assert!(mapped.windows(2).all(|w| w[0] < w[1])); + + let a = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(4)).unwrap(); + let b = PackageVersion::from_semver("1.0.0", Channel::Sideload, Some(5)).unwrap(); + assert!(a < b); + } + + #[test] + fn fr22_6_4_channel_names_parse() { + assert_eq!(Channel::parse("store").unwrap(), Channel::Store); + assert_eq!(Channel::parse("sideload").unwrap(), Channel::Sideload); + assert!(Channel::parse("beta").is_err()); + } +} diff --git a/scripts/packager-windows/tests/stage.rs b/scripts/packager-windows/tests/stage.rs new file mode 100644 index 000000000..41f924cbb --- /dev/null +++ b/scripts/packager-windows/tests/stage.rs @@ -0,0 +1,189 @@ +//! Staging a package directory from a real sample, through the public API. + +use std::path::{Path, PathBuf}; + +use compose_rust_msix::{AppMetadata, Arch, Channel, PackageVersion, Payload, Plan, assets, stage}; + +fn scratch(name: &str) -> PathBuf { + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join("msix") + .join(name); + if dir.exists() { + std::fs::remove_dir_all(&dir).unwrap(); + } + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn notepad() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples/notepad") +} + +fn plan(meta: AppMetadata, payload: Payload) -> Plan { + let version = PackageVersion::from_semver(&meta.version, Channel::Store, None).unwrap(); + Plan { + meta, + version, + channel: Channel::Store, + arch: Arch::X64, + payload, + icon: None, + } +} + +#[test] +fn fr22_6_4_a_single_executable_is_staged_with_manifest_and_images() { + let dir = scratch("single"); + let exe = dir.join("sample-notepad.exe"); + std::fs::write(&exe, b"MZ not really").unwrap(); + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan(meta, Payload::Executable(exe)); + let layout = dir.join("layout"); + let warnings = stage(&p, &layout).unwrap(); + + assert!(layout.join("sample-notepad.exe").is_file()); + let manifest = std::fs::read_to_string(layout.join("AppxManifest.xml")).unwrap(); + assert!( + manifest.contains("Executable=\"sample-notepad.exe\""), + "{manifest}" + ); + assert!(manifest.contains("Version=\"0.1.0.0\""), "{manifest}"); + assert!(manifest.contains("Notepad")); + + for spec in assets::ALL { + let bytes = std::fs::read(layout.join("Assets").join(spec.file)).unwrap(); + let img = assets::Rgba::decode(&bytes).unwrap(); + assert_eq!( + (img.width, img.height), + (spec.width, spec.height), + "{}", + spec.file + ); + } + // The samples draw 64 pixel icons and the derived identity is not the Store's, so + // both are reported rather than passed over. + assert!( + warnings.iter().any(|w| w.contains("enlarged")), + "{warnings:?}" + ); + assert!( + warnings.iter().any(|w| w.contains("Partner Center")), + "{warnings:?}" + ); +} + +#[test] +fn fr22_6_4_a_payload_directory_is_copied_whole() { + let dir = scratch("directory"); + let payload = dir.join("payload"); + std::fs::create_dir_all(payload.join("bin")).unwrap(); + std::fs::create_dir_all(payload.join("lib").join("fonts")).unwrap(); + std::fs::write(payload.join("bin").join("sample-notepad.exe"), b"MZ").unwrap(); + std::fs::write(payload.join("bin").join("renderer.dll"), b"MZ").unwrap(); + std::fs::write( + payload + .join("lib") + .join("fonts") + .join("fontconfig.properties"), + b"x", + ) + .unwrap(); + + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan( + meta, + Payload::Directory { + root: payload, + executable: Path::new("bin").join("sample-notepad.exe"), + }, + ); + let layout = dir.join("layout"); + stage(&p, &layout).unwrap(); + assert!(layout.join("bin").join("renderer.dll").is_file()); + assert!( + layout + .join("lib") + .join("fonts") + .join("fontconfig.properties") + .is_file() + ); + let manifest = std::fs::read_to_string(layout.join("AppxManifest.xml")).unwrap(); + assert!( + manifest.contains("Executable=\"bin\\sample-notepad.exe\""), + "{manifest}" + ); +} + +#[test] +fn fr22_6_4_a_payload_that_already_has_a_manifest_is_refused() { + let dir = scratch("reserved"); + let payload = dir.join("payload"); + std::fs::create_dir_all(&payload).unwrap(); + std::fs::write(payload.join("app.exe"), b"MZ").unwrap(); + std::fs::write(payload.join("AppxManifest.xml"), b"").unwrap(); + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan( + meta, + Payload::Directory { + root: payload, + executable: "app.exe".into(), + }, + ); + let err = stage(&p, &dir.join("layout")).unwrap_err(); + assert!(err.to_string().contains("AppxManifest.xml"), "{err}"); +} + +#[test] +fn fr22_6_4_a_missing_executable_is_refused() { + let dir = scratch("missing"); + let meta = AppMetadata::load(¬epad()).unwrap(); + let p = plan(meta, Payload::Executable(dir.join("nothing.exe"))); + assert!(stage(&p, &dir.join("layout")).is_err()); +} + +#[test] +fn fr22_6_3_workspace_version_is_followed() { + let root = scratch("workspace"); + let project = root.join("app"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::write( + root.join("Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\n[workspace.package]\nversion = \"3.2.1\"\n", + ) + .unwrap(); + std::fs::write( + project.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion.workspace = true\n", + ) + .unwrap(); + std::fs::write( + project.join("Dioxus.toml"), + "[application]\nname = \"App\"\n[bundle]\nidentifier = \"dev.example.app\"\npublisher = \"Example\"\n", + ) + .unwrap(); + let m = AppMetadata::load(&project).unwrap(); + assert_eq!(m.version, "3.2.1"); + assert_eq!(m.executable, "app.exe"); + assert!(m.icon.is_none()); +} + +#[test] +fn fr22_6_3_every_sample_stages() { + let samples = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../samples"); + let dir = scratch("every"); + let mut staged = 0; + for entry in std::fs::read_dir(&samples).unwrap() { + let project = entry.unwrap().path(); + if !project.join("Dioxus.toml").is_file() { + continue; + } + let meta = AppMetadata::load(&project).unwrap(); + let exe = dir.join(&meta.executable); + std::fs::write(&exe, b"MZ").unwrap(); + let name = project.file_name().unwrap().to_owned(); + let p = plan(meta, Payload::Executable(exe)); + stage(&p, &dir.join(name)).unwrap(); + staged += 1; + } + assert!(staged >= 10, "only {staged} samples staged"); +} diff --git a/scripts/tests/cache-pr-restore-only.test.sh b/scripts/tests/cache-pr-restore-only.test.sh new file mode 100755 index 000000000..a9441e2a5 --- /dev/null +++ b/scripts/tests/cache-pr-restore-only.test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# A pull request run only restores caches. Saving happens on push, schedule and dispatch, +# on the default branch, where every pull request can read what it saved. A pull request that +# saved its own copy filled the repository's 10 GB limit and pushed the others out. +# +# Read from the workflow files, since no tool here runs a workflow: no step uses the combined +# actions/cache (it saves after the job whatever the event), every restore has a save in its +# job that needs both "not a pull request" and "no cache hit" and saves under that restore's +# primary key, and setup-gradle is read-only on pull requests. +# +# The workflows are read line by line with the standard library only: PyYAML is not on every +# runner (the macOS one lacks it), and a test that skips without it checks nothing there. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +command -v python3 >/dev/null || { echo "FAIL - python3 is needed to read the workflows"; exit 1; } +python3 -I - "$repo_root/.github/workflows" <<'PY' +import glob, re, sys + +failures = 0 +def bad(msg): + global failures + print("FAIL -", msg); failures += 1 + +def steps_of(lines): + """Yields (job, step lines) for every step of every job: steps sit at six spaces.""" + job, current = None, None + for line in lines + ["x:"]: + m = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) + if m: + if current: yield job, current + job, current = m.group(1), None + continue + if re.match(r"^ -( |$)", line): + if current: yield job, current + current = [line] + elif current is not None: + if line.strip() and not line.startswith(" ") and not line.lstrip().startswith("#"): + yield job, current + current = None + else: + current.append(line) + if current: yield job, current + +def field(step, name): + for line in step: + m = re.match(rf"^(?: - | ){name}:\s*(.*)$", line) + if m: return m.group(1).strip() + return "" + +def with_field(step, name): + for line in step: + m = re.match(rf"^ {name}:\s*(.*)$", line) + if m: return m.group(1).strip() + return "" + +saves = 0 +for path in sorted(glob.glob(sys.argv[1] + "/*.yml")): + lines = open(path).read().split("\n") + per_job = {} + for job, step in steps_of(lines): + per_job.setdefault(job, []).append(step) + for job, steps in per_job.items(): + restore_ids, saved_ids = {}, set() + for pos, step in enumerate(steps): + uses = field(step, "uses") + name = field(step, "name") or uses + where = f"{path.split('/')[-1]}:{job}:{name}" + if uses.startswith("actions/cache@"): + bad(f"{where} uses the combined actions/cache, which saves on pull requests") + if uses.startswith("actions/cache/restore@"): + sid = field(step, "id") + if not sid: bad(f"{where} has no id, so nothing can save its key") + else: restore_ids[sid] = (where, pos) + if uses.startswith("actions/cache/save@"): + saves += 1 + cond = " ".join(field(step, "if").split()) + if "github.event_name != 'pull_request'" not in cond: + bad(f"{where} can save on a pull request: if: {cond!r}") + hit = re.search(r"steps\.([\w-]+)\.outputs\.cache-hit != 'true'", cond) + if not hit: bad(f"{where} does not require cache-hit != 'true': if: {cond!r}") + if "||" in re.sub(r"\([^()]*\)", "", cond): + bad(f"{where} has a top-level || in its condition, which can bypass the guards: {cond!r}") + key = with_field(step, "key") + k = re.search(r"steps\.([\w-]+)\.outputs\.cache-primary-key", key) + if not k: bad(f"{where} does not save under a restore step's cache-primary-key: {key!r}") + elif hit and k.group(1) != hit.group(1): bad(f"{where} checks {hit.group(1)} but saves {k.group(1)}'s key") + if k: + saved_ids.add(k.group(1)) + if k.group(1) in restore_ids and restore_ids[k.group(1)][1] > pos: + bad(f"{where} comes before its restore step") + if uses.startswith("gradle/actions/setup-gradle@"): + ro = with_field(step, "cache-read-only") + if "pull_request" not in ro: bad(f"{where} is not read-only on pull requests: {ro!r}") + for rid, (where, _) in restore_ids.items(): + if rid not in saved_ids: bad(f"{where} is restored but never saved by a step in its job") +if saves < 10: bad(f"only {saves} save steps found; the reader is looking at nothing") +if failures: sys.exit(1) +print(f"ok - {saves} save steps are guarded, no combined cache, gradle read-only on pull requests") +PY +rc=$? +[[ $rc -eq 0 ]] && echo "all passed" +exit $rc diff --git a/scripts/tests/delete-pr-caches.test.sh b/scripts/tests/delete-pr-caches.test.sh new file mode 100755 index 000000000..89c8369d6 --- /dev/null +++ b/scripts/tests/delete-pr-caches.test.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# .github/scripts/delete-pr-caches.sh: deletes one pull request's caches and nothing else, +# and never fails the run. A fake gh records what it was asked. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/delete-pr-caches.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/delete-caches.XXXXXX")" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" +cat > "$work/bin/gh" <<'GH' +#!/usr/bin/env bash +echo "$*" >> "$FAKE_LOG" +case "$1 $2" in + "cache list") [[ "${FAKE_LIST_FAILS:-}" ]] && { echo "HTTP 403" >&2; exit 1; }; printf '11\n12\n' ;; + "cache delete") [[ "$3" == "${FAKE_DELETE_FAILS:-}" ]] && exit 1; exit 0 ;; +esac +GH +chmod +x "$work/bin/gh" +failures=0 +ok() { echo "ok - $1"; } +bad() { echo "FAIL - $1"; failures=$((failures + 1)); } +run() { : > "$work/log"; PATH="$work/bin:$PATH" FAKE_LOG="$work/log" "$@"; } + +run "$script" 42 >/dev/null; rc=$? +[[ $rc -eq 0 ]] && ok "exits 0" || bad "exit $rc" +grep -q -- "--ref refs/pull/42/merge" "$work/log" && ok "lists only the pull request's ref" || bad "list ref" +[[ "$(grep -c 'cache delete' "$work/log")" -eq 2 ]] && ok "deletes each listed cache" || bad "delete count" +! grep -q 'refs/heads\|refs/tags' "$work/log" && ok "touches no branch or tag ref" || bad "other ref" + +out="$(FAKE_LIST_FAILS=1 run "$script" 42)"; rc=$? +[[ $rc -eq 0 && "$out" == *"::warning"* ]] && ok "a failing listing warns and exits 0" || bad "list failure: $rc $out" +out="$(FAKE_DELETE_FAILS=11 run "$script" 42)"; rc=$? +[[ $rc -eq 0 && "$out" == *"::warning"* && "$out" == *"deleted cache 12"* ]] && ok "a failing delete warns, the rest go on" || bad "delete failure: $out" +for arg in "" "abc" "42; rm -rf /" "../x"; do + out="$(run "$script" "$arg")"; rc=$? + [[ $rc -eq 0 && "$out" == *"::warning"* && ! -s "$work/log" ]] && ok "refuses '$arg'" || bad "accepted '$arg'" +done +wf="$repo_root/.github/workflows/cache-cleanup.yml" +grep -q 'types: \[closed\]' "$wf" && ok "runs on closed pull requests" || bad "trigger" +grep -q 'actions: write' "$wf" && ! grep -qE 'contents: write|pull-requests: write' "$wf" && ok "only actions: write" || bad "permissions" +grep -q 'base.sha' "$wf" && ok "checks out the base commit's script" || bad "checkout is not base.sha" +! grep -q 'pull_request_target' "$wf" && ok "no pull_request_target" || bad "pull_request_target" +grep -q -- "--limit 1000" "$script" && ok "lists up to 1000 caches" || bad "list limit" +! grep -qE 'run:.*\$\{\{' "$wf" && ok "no expression inside a run line" || bad "expression in run" +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" diff --git a/scripts/tests/find-released-renderer.test.sh b/scripts/tests/find-released-renderer.test.sh new file mode 100755 index 000000000..388c6e9bd --- /dev/null +++ b/scripts/tests/find-released-renderer.test.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# .github/scripts/find-released-renderer.sh: when the samples use the released renderer. +# A fake gh stands in for GitHub. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/find-released-renderer.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/find-released.XXXXXX")" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" +cat > "$work/bin/gh" <<'GH' +#!/usr/bin/env bash +case "$FAKE_MODE" in + all) for t in macos-aarch64 linux-x64 linux-arm64 windows-x64; do + echo "compose-rust-renderer-v1.0.0-$t.tar.gz"; echo "compose-rust-renderer-v1.0.0-$t.tar.gz.sha256"; done ;; + partial) echo "compose-rust-renderer-v1.0.0-linux-x64.tar.gz" ;; + missing) echo "release not found" >&2; exit 1 ;; + broken) echo "HTTP 401: Bad credentials" >&2; exit 1 ;; +esac +GH +chmod +x "$work/bin/gh" +failures=0 +check() { # mode expected_output expected_warning(yes|no) + out="$(PATH="$work/bin:$PATH" FAKE_MODE="$1" "$script" 1.0.0 2>/dev/null)" + if [[ "$out" == *"released=$2"* ]]; then echo "ok - $1 -> $2"; else echo "FAIL - $1 gave: $out"; failures=$((failures + 1)); fi + if [[ "$3" == yes ]]; then + [[ "$out" == *"::warning"* ]] && echo "ok - $1 warns" || { echo "FAIL - $1 did not warn"; failures=$((failures + 1)); } + else + [[ "$out" != *"::warning"* ]] && echo "ok - $1 is quiet" || { echo "FAIL - $1 warned"; failures=$((failures + 1)); } + fi +} +check all true no +check partial false no +check missing false no +check broken false yes +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" diff --git a/scripts/tests/native-needs-macos.test.sh b/scripts/tests/native-needs-macos.test.sh new file mode 100755 index 000000000..f63c1b953 --- /dev/null +++ b/scripts/tests/native-needs-macos.test.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# .github/scripts/native-needs-macos.sh: which pull requests let test-native-renderer.yml +# leave its macOS jobs out. Anything unknown, or nothing at all, must run them. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/native-needs-macos.sh" +failures=0 +check() { # expected description files... + local expected="$1" what="$2"; shift 2 + local got; got="$(printf '%s\n' "$@" | "$script")" + if [[ "$got" == "macos=$expected" ]]; then echo "ok - $what"; else echo "FAIL - $what: $got"; failures=$((failures + 1)); fi +} +check false "Windows renderer only" renderer/windows/src/Main.kt renderer/desktop/scripts/build-windows.sh .github/workflows/windows-static.yml +check false "Linux renderer only" renderer/linux/src/shared/Window.kt .github/scripts/check-linux-input-access.sh +check true "the packager and the single-executable check (macOS legs run them)" .github/scripts/package-static-renderer.sh renderer/desktop/scripts/build-windows.sh +check true "shared desktop sources" renderer/desktop/src/Main.kt +check true "macOS renderer" renderer/macos/src/Main.kt +check true "iOS renderer" renderer/ios/src/shared/Interpreter.kt +check true "compose-rust build script" compose-rust/build.rs +check true "this workflow" .github/workflows/test-native-renderer.yml +check true "mixed: Windows and shared" renderer/windows/src/Main.kt renderer/libs.versions.toml +check true "a quoted name" '"renderer/windows/\303\244.kt"' +out="$("$script" < /dev/null)"; [[ "$out" == "macos=true" ]] && echo "ok - no file list runs macOS" || { echo "FAIL - empty: $out"; failures=$((failures + 1)); } + +# Wiring, read as text (PyYAML is not on every runner, and a check that skips there proves +# nothing): no job-level if reads the matrix, the macOS jobs wait on the changed job's +# decision, and the single-executable matrix comes from it. +wf="$repo_root/.github/workflows/test-native-renderer.yml" +check_wf() { # description pattern expected(yes|no) + if grep -qE "$2" "$wf"; then found=yes; else found=no; fi + if [[ "$found" == "$3" ]]; then echo "ok - $1"; else echo "FAIL - $1"; failures=$((failures + 1)); fi +} +check_wf "no job-level if reads the matrix" '^ if:.*matrix\.' no +check_wf "macos-static follows the macOS decision" "needs.changed.outputs.renderer == 'true' && needs.changed.outputs.macos != 'false'" yes +check_wf "single-executable takes its matrix from changed" 'fromJSON\(needs.changed.outputs.executables\)' yes +[[ "$(grep -c "needs.changed.outputs.macos != 'false'" "$wf")" -eq 2 ]] && echo "ok - exactly macos-static and ios follow it" || { echo "FAIL - the macOS decision is used by other jobs than macos-static and ios"; failures=$((failures + 1)); } + +# The macOS Kotlin/Native renderer test is not started by the Windows build's own files. +kn="$repo_root/.github/workflows/test-kn-desktop-renderer.yml" +for excluded in build-windows.sh '*.ps1' windows-build-contract.test.sh 'windows-metadata/**'; do + if grep -qF "'!renderer/desktop/scripts/$excluded'" "$kn"; then echo "ok - kn-desktop filter leaves out $excluded" + else echo "FAIL - kn-desktop filter does not exclude $excluded"; failures=$((failures + 1)); fi +done +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" diff --git a/scripts/tests/needs-macos.test.sh b/scripts/tests/needs-macos.test.sh new file mode 100755 index 000000000..29a3bf2cb --- /dev/null +++ b/scripts/tests/needs-macos.test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# .github/scripts/needs-macos.sh: which changes let test.yml leave its macOS jobs out. +# Anything unknown, or nothing at all, must run them. +set -uo pipefail +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +script="$repo_root/.github/scripts/needs-macos.sh" +failures=0 +check() { # expected description files... + local expected="$1" what="$2"; shift 2 + local got; got="$(printf '%s\n' "$@" | "$script")" + if [[ "$got" == "macos=$expected" ]]; then echo "ok - $what"; else echo "FAIL - $what: $got"; failures=$((failures + 1)); fi +} +check false "docs only" docs/notes/plan.txt docs/guide/index.md +check false "root markdown and licence" README.md CHANGELOG.md PROJECT.md LICENSE +check false "workflows no shell test reads" .github/workflows/pages.yml .github/workflows/windows-static.yml +check true "a workflow the shell tests read" .github/workflows/test-native-renderer.yml +check true "static-renderer workflow" .github/workflows/static-renderer.yml +check false "a path with spaces under docs" "docs/a file with spaces.md" +check true "a quoted non-ASCII name git could not print plainly" '"docs/\303\244.md"' +check false "Windows renderer files" renderer/windows/src/Main.kt renderer/desktop/scripts/build-windows.sh scripts/check-windows-consumer.sh +check true "test.yml itself" .github/workflows/test.yml +check true "the reusable fork workflow" .github/workflows/fork-desktop-modules.yml +check true "a CI script (the shell suite tests it on macOS)" .github/scripts/package-static-renderer.sh +check true "Rust source" compose-rust/src/lib.rs +check true "a docs change beside Rust source" docs/notes/plan.txt compose-rust/src/lib.rs +check true "the macOS renderer" renderer/macos/src/Main.kt +check true "a root file nobody listed" Cargo.toml +check true "a script under scripts/" scripts/check.sh +check true "Windows desktop sources the JVM job compiles" renderer/desktop/src/NativeWindowShell.kt +# Wiring: test.yml must be valid, and the macOS legs must leave the matrices. Read as text +# (PyYAML is not on every runner, and a check that skips there proves nothing). +wf="$repo_root/.github/workflows/test.yml" +# A job-level if sits at four spaces; GitHub rejects one that reads the matrix. +if grep -E '^ if:.*matrix\.' "$wf" >/dev/null; then echo "FAIL - a job-level if reads the matrix"; failures=$((failures + 1)); else echo "ok - no job-level if reads the matrix"; fi +from_json="$(grep -c 'fromJSON(needs.changed.outputs' "$wf")" +[[ "$from_json" -ge 3 ]] && echo "ok - rust, kotlin and shell take their matrix from changed" || { echo "FAIL - only $from_json matrices come from changed"; failures=$((failures + 1)); } + +# Renames: the old path counts, as the workflow lists it with --no-renames. +tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT +( cd "$tmp" && git init -q . && git config user.email t@t && git config user.name t && + mkdir -p renderer docs && echo x > renderer/Notes.md && echo y > "docs/seed.md" && + git add -A && git commit -qm base && git mv renderer/Notes.md docs/Notes.md && git commit -qam move ) +got="$(cd "$tmp" && git -c core.quotepath=off diff --no-renames --name-only HEAD^1 HEAD | "$script")" +[[ "$got" == "macos=true" ]] && echo "ok - a file moved out of renderer/ into docs/ runs macOS" || { echo "FAIL - rename: $got"; failures=$((failures + 1)); } + +out="$("$script" < /dev/null)"; [[ "$out" == "macos=true" ]] && echo "ok - no file list runs macOS" || { echo "FAIL - empty: $out"; failures=$((failures + 1)); } +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed" diff --git a/scripts/tests/package-static-renderer.test.sh b/scripts/tests/package-static-renderer.test.sh new file mode 100755 index 000000000..40d5ac9fe --- /dev/null +++ b/scripts/tests/package-static-renderer.test.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# .github/scripts/package-static-renderer.sh: what a static kit tarball holds per target. +# +# The windows-x64 kit is the Kotlin/Native renderer plus the pieces an MSVC link needs beside +# it. A kit that loses one of them links nowhere, so the packager has to refuse it, and the +# tarball has to carry all of them with the same lib/ and include/ layout as the others. +set -uo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +packager="$repo_root/.github/scripts/package-static-renderer.sh" +work="$(mktemp -d "${TMPDIR:-/tmp}/package-static.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +failures=0 +pass() { echo "ok - $1"; } +fail() { echo "FAIL - $1"; failures=$((failures + 1)); } + +make_unix_kit() { + mkdir -p "$1" + echo a > "$1/libcompose_rust_renderer.a" + echo h > "$1/libcompose_rust_renderer_api.h" + echo 0x0000000000000001 > "$1/schema-hash.txt" +} +make_windows_kit() { + make_unix_kit "$1" + mkdir -p "$1/gcc" "$1/native" "$1/skiko" + for f in gcc/libstdc++.a gcc/libgcc.a gcc/libgcc_eh.a gcc/libwinpthread.a \ + native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib \ + skiko/skiko-bridges.lib skiko/skia.lib skiko/skparagraph.lib; do + echo x > "$1/$f" + done + echo obj > "$1/native/win32_window.obj" +} + +# windows-x64 packages the whole kit +make_windows_kit "$work/win" +if out="$("$packager" "$work/win" windows-x64 1.2.3 "$work/out" 2>&1)"; then + tarball="$work/out/compose-rust-renderer-v1.2.3-windows-x64.tar.gz" + list="$(tar -tzf "$tarball")" + [[ -f "$tarball.sha256" ]] && pass "windows-x64 writes the checksum" || fail "no checksum" + for entry in libcompose_rust_renderer.a libcompose_rust_renderer_api.h \ + schema-hash.txt renderer.version gcc/libstdc++.a gcc/libwinpthread.a \ + native/dxc-windows-native.lib native/dxc-windows-static-ucrt.lib \ + skiko/skiko-bridges.lib skiko/skia.lib skiko/skparagraph.lib; do + grep -qxF -e "$entry" -e "./$entry" <<< "$list" && pass "windows-x64 carries $entry" || fail "windows-x64 lacks $entry" + done + ! grep -qE '(^|/)(lib|include)/' <<< "$list" && pass "windows-x64 keeps the kit root layout" || fail "windows-x64 uses lib/ or include/" + ! grep -q '\.obj$' <<< "$list" && pass "windows-x64 carries no loose objects" || fail "windows-x64 carries an .obj" +else + fail "windows-x64 packaging failed: $out" +fi + +# a Windows kit missing the static-UCRT bridge is refused +rm "$work/win/native/dxc-windows-static-ucrt.lib" +if "$packager" "$work/win" windows-x64 1.2.3 "$work/out2" >/dev/null 2>&1; then + fail "a Windows kit with no dxc-windows-static-ucrt.lib was packaged" +else + pass "a Windows kit with no dxc-windows-static-ucrt.lib is refused" +fi + +# the other targets need none of it +make_unix_kit "$work/linux" +if "$packager" "$work/linux" linux-x64 1.2.3 "$work/out3" >/dev/null 2>&1; then + pass "linux-x64 still packages the archive alone" +else + fail "linux-x64 packaging broke" +fi + +# an unknown target is still refused +if "$packager" "$work/linux" windows-arm64 1.2.3 "$work/out4" >/dev/null 2>&1; then + fail "windows-arm64 was packaged" +else + pass "an unknown target is refused" +fi + +[[ "$failures" -eq 0 ]] || exit 1 +echo "all passed"