diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70bb14815be..4527fba5879 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -421,6 +421,7 @@ jobs: cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} --bin cap-gpui fi cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-gpui-kernels + cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-glass cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked --target ${{ matrix.settings.target }} -p cap-i18n-build # Cap only builds gpui's crates as dependencies, so their own tests never run diff --git a/.github/workflows/glass-probe.yml b/.github/workflows/glass-probe.yml new file mode 100644 index 00000000000..57f960e09b4 --- /dev/null +++ b/.github/workflows/glass-probe.yml @@ -0,0 +1,207 @@ +name: Glass probe (real app) +# Draws Cap Glass in the real GPUI app on macOS (Metal), Windows (Direct3D 11 +# on WARP) and Linux (wgpu on lavapipe), captures the window and scores every +# scene and preset against the CPU model in apps/desktop-gpui/glass, which is +# fitted to Apple's own glass. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - "apps/desktop-gpui/glass/**" + - "apps/desktop-gpui/patches/zed-glass.patch" + - "apps/desktop-gpui/src/ui/glass.rs" + - "apps/desktop-gpui/src/glass_probe.rs" + - "scripts/glass-probe/**" + - ".github/workflows/glass-probe.yml" + workflow_dispatch: + +permissions: + contents: read + pull-requests: read + +concurrency: + group: glass-probe-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # A pull request from a fork that changes only this workflow or the probe's + # scripts does not queue the three-runner matrix; one that also changes the + # glass itself does, and so does any change from this repository. + scope: + name: Scope + runs-on: blacksmith-2vcpu-ubuntu-2404 + timeout-minutes: 5 + outputs: + run: ${{ steps.scope.outputs.run }} + steps: + - name: Scope + id: scope + env: + GH_TOKEN: ${{ github.token }} + EVENT: ${{ github.event_name }} + PR: ${{ github.event.pull_request.number }} + SAME_REPO: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + run: | + set -euo pipefail + if [[ "$EVENT" != pull_request || "$SAME_REPO" == true ]]; then + echo "run=true" >> "$GITHUB_OUTPUT"; exit 0 + fi + gh api "repos/$GITHUB_REPOSITORY/pulls/$PR/files" --paginate --jq '.[].filename' > "$RUNNER_TEMP/changed.txt" + glass='^apps/desktop-gpui/(glass/.*|patches/zed-glass\.patch|src/ui/glass\.rs|src/glass_probe\.rs)$' + if grep -qE "$glass" "$RUNNER_TEMP/changed.txt"; then + echo "run=true" >> "$GITHUB_OUTPUT" + else + echo "run=false" >> "$GITHUB_OUTPUT" + echo "A fork pull request that changes no glass code does not run the glass probe." | tee -a "$GITHUB_STEP_SUMMARY" + fi + + probe: + name: Glass probe (${{ matrix.settings.name }}) + needs: scope + if: needs.scope.outputs.run == 'true' + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + settings: + - name: macOS + slug: macos + target: aarch64-apple-darwin + runner: blacksmith-6vcpu-macos-15 + - name: Windows + slug: windows + target: x86_64-pc-windows-msvc + runner: windows-2022 + - name: Linux X11 + slug: linux-x11 + target: x86_64-unknown-linux-gnu + runner: blacksmith-8vcpu-ubuntu-2404 + runs-on: ${{ matrix.settings.runner }} + env: + CARGO_PROFILE_DEV_DEBUG: "0" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Linux desktop dependencies + if: ${{ runner.os == 'Linux' }} + uses: ./.github/actions/install-desktop-deps + + - name: Install a window manager and software Vulkan + if: ${{ runner.os == 'Linux' }} + run: sudo apt-get install -y openbox mesa-vulkan-drivers libvulkan1 + + - name: Rust setup + uses: dtolnay/rust-toolchain@1.95.0 + with: + targets: ${{ matrix.settings.target }} + + - uses: ./.github/actions/setup-rust-cache + with: + target: ${{ matrix.settings.target }} + prefix-key: v1-rust-gpui + save-cache: "false" + workspaces: apps/desktop-gpui -> target + + - name: Run setup + shell: bash + run: node scripts/setup.js + env: + RUST_TARGET_TRIPLE: ${{ matrix.settings.target }} + + - name: Prepare GPUI dependency + shell: bash + run: | + node scripts/sync-desktop-versions.mjs + ./scripts/prepare-gpui-dependency.sh + + - name: Test the glass model + shell: bash + run: cargo +1.95.0 test --manifest-path apps/desktop-gpui/Cargo.toml --locked -p cap-glass + + - name: Build the app + shell: bash + run: | + cargo +1.95.0 build --manifest-path apps/desktop-gpui/Cargo.toml --locked \ + --target ${{ matrix.settings.target }} --bin cap-gpui + if [[ "$RUNNER_OS" == "macOS" ]]; then + for frameworks in "$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/Frameworks" \ + "$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/Frameworks"; do + mkdir -p "$frameworks" + ln -sfn "$GITHUB_WORKSPACE/target/Frameworks/Spacedrive.framework" "$frameworks/Spacedrive.framework" + done + fi + + - name: Screen capture permission + if: ${{ runner.os == 'macOS' }} + run: | + set -euo pipefail + sudo pmset -a displaysleep 0 sleep 0 || true + caffeinate -u -t 2 + app="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/cap-gpui" + db="/Library/Application Support/com.apple.TCC/TCC.db" + now=$(date +%s) + for client in "$app" /bin/bash /Users/runner/bin/Runner.Worker /Users/runner/bin/Runner.Listener; do + sudo sqlite3 "$db" "INSERT OR REPLACE INTO access (service,client,client_type,auth_value,auth_reason,auth_version,indirect_object_identifier,flags,last_modified) VALUES ('kTCCServiceScreenCapture','$client',1,2,4,1,'UNUSED',0,$now);" + done + sudo killall tccd || true + sleep 2 + + - name: Glass probe + shell: bash + run: | + app="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/cap-gpui" + out="$RUNNER_TEMP/glass-probe" + case "$RUNNER_OS" in + macOS) + scripts/glass-probe/run.sh "$app" "$out" + ;; + Windows) + export PATH="$(cygpath "$GITHUB_WORKSPACE/target/debug"):$PATH" + scripts/glass-probe/run.sh "$app.exe" "$out" + ;; + Linux) + export LD_LIBRARY_PATH="$GITHUB_WORKSPACE/target/native-deps/lib:$GITHUB_WORKSPACE/target/debug${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + Xvfb :99 -screen 0 1920x1080x24 -noreset & + export DISPLAY=:99 + sleep 1 + openbox & + sleep 1 + scripts/glass-probe/run.sh "$app" "$out" + ;; + esac + + - name: Upload captures + if: always() + uses: actions/upload-artifact@v4 + with: + name: glass-probe-${{ matrix.settings.slug }} + path: ${{ runner.temp }}/glass-probe + retention-days: 7 + + # The probed build itself, so the same binary can run on machines with + # real GPUs (the hosted runners draw on WARP and lavapipe). + - name: Stage the build + if: ${{ success() && runner.os != 'macOS' }} + shell: bash + run: | + out="$RUNNER_TEMP/cap-gpui-build" + mkdir -p "$out" + bin="$GITHUB_WORKSPACE/apps/desktop-gpui/target/${{ matrix.settings.target }}/debug/cap-gpui" + if [[ "$RUNNER_OS" == Windows ]]; then + cp "$bin.exe" "$out/" + cp "$GITHUB_WORKSPACE"/target/debug/*.dll "$out/" 2>/dev/null || true + else + cp "$bin" "$out/" + cp -r "$GITHUB_WORKSPACE/target/native-deps/lib" "$out/lib" 2>/dev/null || true + fi + + - name: Upload the build + if: ${{ success() && runner.os != 'macOS' }} + uses: actions/upload-artifact@v4 + with: + name: cap-gpui-debug-${{ matrix.settings.slug }} + path: ${{ runner.temp }}/cap-gpui-build + if-no-files-found: ignore + retention-days: 3 diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 900791a5cb8..2516cffb207 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -1551,6 +1551,7 @@ dependencies = [ "cap-editor", "cap-enc-ffmpeg", "cap-export", + "cap-glass", "cap-gpui-kernels", "cap-i18n-build", "cap-media-info", @@ -1773,6 +1774,15 @@ dependencies = [ "workspace-hack", ] +[[package]] +name = "cap-glass" +version = "0.1.0" +dependencies = [ + "image 0.25.10", + "serde", + "serde_json", +] + [[package]] name = "cap-gpui-kernels" version = "0.1.0" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index e408a1de589..0386dbe05b4 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -5,7 +5,7 @@ # wgpu-hal, tao) that only exist for the Tauri app. [workspace] resolver = "2" -members = [".", "kernels", "i18n-build"] +members = [".", "kernels", "i18n-build", "glass"] [package] name = "cap-desktop-gpui" @@ -27,6 +27,7 @@ path = "../../crates/recording/examples/studio-finalization-benchmark.rs" cap-audio = { path = "../../crates/audio" } cap-cli-install = { path = "../../crates/cli-install" } cap-gpui-kernels = { path = "kernels" } +cap-glass = { path = "glass" } # gpui — the wingleeio/zed fork Comet ships. The pinned rev carries f596cde # ("destination alpha on transparent windows: Porter-Duff OVER, not additive"), # which our transparent rounded main-window shell depends on. @@ -333,6 +334,10 @@ debug = "line-tables-only" [profile.dev.package.cap-gpui-kernels] opt-level = 3 +# The glass probe renders the CPU model of every scene it captures. +[profile.dev.package.cap-glass] +opt-level = 3 + # Incremental halves a one-line rebuild for about 2 GB of cache, and line tables # cut its codegen and link time; breakpoints and backtraces keep their lines. [profile.dev.package.cap-desktop-gpui] diff --git a/apps/desktop-gpui/glass/Cargo.toml b/apps/desktop-gpui/glass/Cargo.toml new file mode 100644 index 00000000000..2eeea4765df --- /dev/null +++ b/apps/desktop-gpui/glass/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cap-glass" +version = "0.1.0" +edition = "2024" +license = "AGPL-3.0" +publish = false + +[dependencies] +image = { version = "0.25.1", default-features = false, features = ["png"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/apps/desktop-gpui/glass/presets.json b/apps/desktop-gpui/glass/presets.json new file mode 100644 index 00000000000..fb713621e21 --- /dev/null +++ b/apps/desktop-gpui/glass/presets.json @@ -0,0 +1,276 @@ +{ + "clear-dark": { + "bend_base": 0.0, + "bend_k": 0.5220050811767578, + "bend_max": 44.700069427490234, + "blur_k": 0.2080000936985016, + "blur_max": 20.0, + "glow": -0.023217225447297096, + "glow_width": 0.5, + "hairline": [ + 0.593367338180542, -0.05059341341257095, 1.0214955806732178, + 0.0020442637614905834 + ], + "lens_curve": 1.0, + "light_angle": -0.21393775939941406, + "line": 0.022001737728714943, + "line_lit": 4.008400719612837e-5, + "line_width": 0.977599024772644, + "opacity": 0.6000000238418579, + "poly": [ + 0.9304253458976746, -0.12481314688920975, -0.10088877379894257, + -0.004045877140015364, -0.07585885375738144, 0.14717964828014374, + -0.07393107563257217, 0.06024130806326866, -0.03879432752728462, + -0.000815805746242404, 0.03807489201426506, -0.07791057974100113, + 0.016926679760217667, 0.0058880094438791275, 0.03510991483926773, + -0.025886960327625275, 0.016199838370084763, -0.03267030790448189, + 0.01904219388961792, 0.09808459132909775, -0.00837408285588026, + 0.7795435786247253, -0.07242636382579803, -0.04168546944856644, + 0.04919590428471565, 0.11554957926273346, -0.059605952352285385, + -0.02326720952987671, -0.007376297377049923, -0.0005821718950755894, + -0.04080246761441231, -0.05716409534215927, 0.043717119842767715, + 0.023880789056420326, 0.012365260161459446, 0.026850342750549316, + -0.011099698953330517, -0.011699579656124115, -0.0034550407435745, + 0.0981355756521225, -0.040654897689819336, -0.15084543824195862, + 0.8951388597488403, -0.00773319648578763, -0.06471528857946396, + 0.08432544767856598, 0.0036153215914964676, 0.015856880694627762, + -0.013019285164773464, -0.008954270742833614, 0.056587692350149155, + -0.06041482836008072, 0.033091913908720016, 0.0052201892249286175, + -0.029888352379202843, -0.02021406590938568, 0.01316642202436924, + 0.017514236271381378, -0.01460269745439291, 0.0980868712067604 + ], + "rim_base": 3.5, + "rim_k": 0.039999820291996, + "rim_max": 7.899994850158691, + "tint": { + "high": [ + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, 0.1021, 0.1161], + [-0.1202, 0.1449, 0.1839, 0.211, 0.2499], + [-0.0202, 0.2129, 0.2659, 0.3087, 0.3616], + [0.0908, 0.2828, 0.3405, 0.4175, 0.4843], + [0.1908, 0.3508, 0.4318, 0.5262, 0.6071], + [0.2908, 0.4314, 0.5405, 0.6505, 0.7616], + [0.4018, 0.5174, 0.6456, 0.7749, 0.9161], + [0.4908, 0.5777, 0.7453, 0.9103, 1.0044], + [0.6675, 0.6379, 0.8026, 0.9573, 1.0044], + [0.8162, 0.5092, 0.8019, 0.9504, 1.0044], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] + ], + "kind": "layer", + "low": [ + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, -0.0162, -0.0302], + [-0.1202, 0.0719, 0.0329, 0.0092, -0.0297], + [-0.0202, 0.1013, 0.0482, 0.0018, -0.0511], + [0.0908, 0.1434, 0.0782, 0.0054, -0.0615], + [0.1908, 0.1728, 0.0917, -0.0131, -0.0939], + [0.2908, 0.2046, 0.1028, -0.0032, -0.1142], + [0.4018, 0.2309, 0.1102, -0.0153, -0.1565], + [0.4908, 0.2536, 0.1011, -0.0822, -0.1764], + [0.6675, 0.3201, 0.1707, 0.0269, -0.0202], + [0.8162, 0.5092, 0.2201, 0.0576, 0.014], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] + ] + } + }, + "clear-light": { + "bend_base": 0.0, + "bend_k": 0.5219979286193848, + "bend_max": 44.700042724609375, + "blur_k": 0.20799961686134338, + "blur_max": 20.0, + "glow": -0.07008297741413116, + "glow_width": 0.5, + "hairline": [ + 0.8242620229721069, -0.1320314109325409, 0.9752746820449829, + 0.04035332053899765 + ], + "lens_curve": 1.0, + "light_angle": -2.847440719604492, + "line": 0.041799671947956085, + "line_lit": -0.0001845387159846723, + "line_width": 0.8613333702087402, + "opacity": 0.6000000238418579, + "poly": [ + 1.1434636116027832, -0.27574145793914795, -0.17895224690437317, + -0.0836833268404007, 0.1225225031375885, 0.1864597499370575, + -0.37334734201431274, 0.4921723008155823, -0.3727247416973114, + -0.12790314853191376, -0.02433956041932106, -0.05253470316529274, + 0.29910096526145935, 0.11252079159021378, 0.02607177011668682, + -0.18502143025398254, 0.15787704288959503, -0.2634184658527374, + 0.075639508664608, 0.27857351303100586, -0.03583918884396553, + 0.7502926588058472, -0.02740371786057949, -0.01758371666073799, + 0.02899535372853279, 0.023246919736266136, -0.019552607089281082, + -0.0588289238512516, 0.0193873830139637, -0.005965698044747114, + -0.034728698432445526, -0.0037371099460870028, 0.019717223942279816, + 0.017275473102927208, 0.005123075097799301, 0.04545024037361145, + -0.0219881571829319, 0.006534123793244362, -0.012194417417049408, + 0.27857986092567444, -0.07504594326019287, -0.19895681738853455, + 0.9592817425727844, -0.06376186013221741, 0.014718386344611645, + -0.15277686715126038, 0.07323915511369705, 0.006354332435876131, + 0.10439865291118622, 0.04123719036579132, 0.035259801894426346, + -0.11292031407356262, 0.010606243275105953, 0.0005314881564117968, + -9.800001862458885e-5, -0.18178176879882812, 0.060928262770175934, + 0.37380093336105347, -0.21683937311172485, 0.27857956290245056 + ], + "rim_base": 3.5, + "rim_k": 0.04000019654631615, + "rim_max": 7.90000057220459, + "tint": { + "high": [ + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, 0.1021, 0.1161], + [-0.1202, 0.1449, 0.1839, 0.211, 0.2499], + [-0.0202, 0.2129, 0.2659, 0.3087, 0.3616], + [0.0908, 0.2828, 0.3405, 0.4175, 0.4843], + [0.1908, 0.3508, 0.4318, 0.5262, 0.6071], + [0.2908, 0.4314, 0.5405, 0.6505, 0.7616], + [0.4018, 0.5174, 0.6456, 0.7749, 0.9161], + [0.4908, 0.5777, 0.7453, 0.9103, 1.0044], + [0.6675, 0.6379, 0.8026, 0.9573, 1.0044], + [0.8162, 0.5092, 0.8019, 0.9504, 1.0044], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] + ], + "kind": "layer", + "low": [ + [-0.068, -0.068, -0.068, -0.068, -0.068], + [-0.1654, 0.043, 0.043, -0.0162, -0.0302], + [-0.1202, 0.0719, 0.0329, 0.0092, -0.0297], + [-0.0202, 0.1013, 0.0482, 0.0018, -0.0511], + [0.0908, 0.1434, 0.0782, 0.0054, -0.0615], + [0.1908, 0.1728, 0.0917, -0.0131, -0.0939], + [0.2908, 0.2046, 0.1028, -0.0032, -0.1142], + [0.4018, 0.2309, 0.1102, -0.0153, -0.1565], + [0.4908, 0.2536, 0.1011, -0.0822, -0.1764], + [0.6675, 0.3201, 0.1707, 0.0269, -0.0202], + [0.8162, 0.5092, 0.2201, 0.0576, 0.014], + [0.9759, 0.9759, 0.9759, 0.9759, 0.9759] + ] + } + }, + "regular-dark": { + "adapt": { + "large": 104.5999984741211, + "poly": [ + -2.121854066848755, 4.518572807312012, -1.3435695171356201, + 0.776678204536438, -9.252964973449707, 0.28634369373321533, + 4.4394073486328125, -1.3082177639007568, 4.613157272338867, + 0.1885642260313034, 4.356483459472656, 2.1629786491394043, + -1.000849723815918, -1.141960620880127, -1.716369390487671, + 4.243387222290039, -2.109083414077759, -3.6303977966308594, + -1.3335038423538208, 0.07930468022823334, -0.06321439892053604, + 1.6568504571914673, -0.5502462983131409, 0.47407275438308716, + -0.5093795657157898, 0.18556971848011017, -1.3741369247436523, + 0.4993451237678528, 0.30742496252059937, -0.13183802366256714, + -0.43906792998313904, 0.5691431164741516, -0.260586142539978, + -0.2801496684551239, 1.1938122510910034, 0.6415687203407288, + 0.2897544205188751, -1.767791986465454, 0.18552877008914948, + 0.07926828414201736, -0.7065837979316711, -1.389380693435669, + 3.1453685760498047, 1.1397459506988525, 1.6847290992736816, + -3.872493028640747, -0.043841056525707245, 0.8904445767402649, + -0.2337447702884674, 0.0994030013680458, -0.5763757824897766, + 1.5949156284332275, -1.1367660760879517, -1.1690083742141724, + 0.5194790959358215, -1.9919798374176025, 0.5003051161766052, + 0.38825687766075134, 1.7843526601791382, 0.07925731688737869 + ], + "small": 64.0 + }, + "bend_base": 10.7000732421875, + "bend_k": 0.0820002481341362, + "bend_max": 26.200000762939453, + "blur_k": 0.14000000059604645, + "blur_max": 12.0, + "glow": -0.0030187764205038548, + "glow_width": 0.5, + "hairline": [ + 0.1722264587879181, 0.17626123130321503, 0.48439785838127136, + 0.10737959295511246 + ], + "lens_curve": 1.0, + "light_angle": 3.786133289337158, + "line": -0.061254773288965225, + "line_lit": -0.001504245214164257, + "line_width": 0.30000001192092896, + "opacity": 0.6000000238418579, + "poly": [ + 1.1187022924423218, 0.03367934003472328, -0.10441520810127258, + -0.13338716328144073, 0.29741689562797546, 0.19946272671222687, + -0.5520410537719727, -0.9366043210029602, 0.44745180010795593, + 0.0030512954108417034, -0.45639705657958984, -0.0059659117832779884, + -0.07397174090147018, 0.006216675043106079, 0.13561110198497772, + 0.7081236839294434, -0.3326506018638611, 0.11330801993608475, + -0.0989147275686264, 0.07836921513080597, -0.060900237411260605, + 1.11102294921875, -0.017686933279037476, 0.029460275545716286, + -0.4791528880596161, 0.013812395744025707, -0.1606086790561676, + -0.02373814396560192, -0.028927847743034363, -0.017582418397068977, + -0.002322347601875663, -0.009515616111457348, 0.012796386145055294, + 0.0010435275034978986, 0.0005133572849445045, -0.019619032740592957, + 0.012490831315517426, -0.012097016908228397, 0.01967502385377884, + 0.0784754678606987, -0.06453773379325867, -0.00016743599553592503, + 1.1111270189285278, 0.025668099522590637, 0.23321135342121124, + 0.05709727853536606, -0.4604215919971466, -0.7881916761398315, + 0.26137059926986694, -0.050472091883420944, -0.392466276884079, + 0.03832476586103439, 0.047914065420627594, 0.004019365645945072, + 0.4532164931297302, 0.2794245183467865, -0.20846489071846008, + 0.008654672652482986, -0.18662935495376587, 0.07838035374879837 + ], + "rim_base": 4.0, + "rim_k": 0.07199979573488235, + "rim_max": 8.5, + "tint": { + "desaturate": 0.32, + "kind": "grey", + "shift": 0.101 + } + }, + "regular-light": { + "bend_base": 10.70003604888916, + "bend_k": 0.08199961483478546, + "bend_max": 26.200000762939453, + "blur_k": 0.25999999046325684, + "blur_max": 13.5, + "glow": 0.0010122086387127638, + "glow_width": 2.685718297958374, + "hairline": [ + 0.7884511947631836, -0.04988262057304382, 0.8663737773895264, + -0.03755432367324829 + ], + "lens_curve": 1.0, + "light_angle": 0.20863288640975952, + "line": -0.15016056597232819, + "line_lit": 0.00040745738078840077, + "line_width": 0.30000001192092896, + "opacity": 0.6000000238418579, + "poly": [ + 1.2788854837417603, -0.8453500866889954, 0.003082971554249525, + 0.2738867998123169, -0.27242588996887207, -0.500204861164093, + -0.4753827750682831, 1.9009149074554443, -0.9198514223098755, + -0.7914177775382996, 0.7524295449256897, 0.33018216490745544, + 1.1248490810394287, 0.14570458233356476, -0.5863457918167114, + -1.1137068271636963, 0.4047931730747223, -0.5792659521102905, + 0.2896379828453064, 0.5211592316627502, -0.036302655935287476, + 0.41237375140190125, 0.05753712356090546, -0.13990472257137299, + 0.19397875666618347, -0.044933948665857315, 0.13752412796020508, + 0.055802471935749054, -0.1938532590866089, 0.07925422489643097, + -0.11504264175891876, -0.030976055189967155, -0.05894238501787186, + 0.06816978752613068, -0.0008735256269574165, -0.02404271997511387, + 0.12015138566493988, 0.011716355569660664, -0.07116328924894333, + 0.5211941599845886, -0.599159300327301, -0.7419008612632751, + 1.7605870962142944, 0.01254228688776493, 0.24147452414035797, + -0.6960784792900085, 1.0482441186904907, -0.8146082162857056, + 0.2614218294620514, 0.20659644901752472, 0.24794399738311768, + -0.708295464515686, -0.10671452432870865, -0.4012254476547241, + -0.5572263598442078, -0.5958108305931091, 0.4865833520889282, + 1.8127862215042114, -0.43663233518600464, 0.5212134122848511 + ], + "rim_base": 4.0, + "rim_k": 0.07199995219707489, + "rim_max": 8.5, + "tint": { + "desaturate": 0.32, + "kind": "grey", + "shift": -0.103 + } + } +} diff --git a/apps/desktop-gpui/glass/src/bin/glass-fit.rs b/apps/desktop-gpui/glass/src/bin/glass-fit.rs new file mode 100644 index 00000000000..445cd565a20 --- /dev/null +++ b/apps/desktop-gpui/glass/src/bin/glass-fit.rs @@ -0,0 +1,772 @@ +//! Fits a `GlassMaterial` to Apple's reference captures. +//! +//! glass-fit [,...] [style-appearance ...] +//! +//! Reads each `/manifest.json`, `scenes/*.png` and +//! `apple/*.png` (from scripts/glass-reference: `reference` and `sweep`) +//! and writes `/params.json`, +//! `/report.json` and side-by-side renders. The colour transform is +//! linear in its coefficients, so every evaluation solves it exactly by +//! least squares; Nelder-Mead only searches blur, lensing and lighting. +//! `GLASS_PARAMS=` skips the search and renders those. + +use std::path::{Path, PathBuf}; + +use cap_glass::{ + Rgba, Shape, metrics, + model::{self, Adapt, GlassMaterial, POLY_TERMS}, +}; + +/// Where Regular dark glass switches between its small-pane and large-pane +/// colour transforms, in points on the shorter side: measured from the +/// `sweep` captures, where every pane up to 64 pt matches and every pane +/// from 128 pt matches, with 80 pt and 96 pt on one straight line between. +const ADAPT: [(&str, f32, f32); 1] = [("regular-dark", 64., 104.6)]; + +/// Apple's blur sigma, `min(max, k * shorter side)`, measured from the +/// edge in the `split` scene under the 48, 120 and 240 pt panes: a Gaussian +/// within 0.004 of Apple's edge profile for Clear. Regular's profile has a +/// heavier dark-side tail than any Gaussian; this is its best single fit. +const BLUR: [(&str, f32, f32); 2] = [("clear", 0.208, 20.), ("regular", 0.26, 13.5)]; + +/// Apple's refraction, measured from the `sweep lens` captures (UV ramp +/// under panes from 20 to 270 pt): decay length `[base, k, max]` and edge +/// bend `[base, k, max]`, in points of the shorter side. Light and dark +/// match. The fit keeps these fixed. +const LENS: [(&str, [f32; 3], [f32; 3]); 2] = [ + ("clear", [3.5, 0.04, 7.9], [0., 0.522, 44.7]), + ("regular", [4., 0.072, 8.5], [10.7, 0.082, 26.2]), +]; + +fn with_lens(preset: &str, material: GlassMaterial) -> GlassMaterial { + let (_, rim, bend) = LENS + .iter() + .find(|(style, ..)| preset.starts_with(style)) + .expect("every preset has a measured lens"); + GlassMaterial { + rim_base: rim[0], + rim_k: rim[1], + rim_max: rim[2], + bend_base: bend[0], + bend_k: bend[1], + bend_max: bend[2], + lens_curve: 1., + ..material + } +} + +fn adapt_for(preset: &str, poly: [f32; 3 * POLY_TERMS]) -> Option { + ADAPT + .iter() + .find(|(name, ..)| *name == preset) + .map(|&(_, small, large)| Adapt { small, large, poly }) +} + +#[derive(serde::Deserialize)] +struct Manifest { + shapes: Vec, + cases: Vec, + /// Image pixels per point: the captures are at the display's backing + /// scale, the shapes in points. + #[serde(default = "one")] + scale: f32, +} + +fn one() -> f32 { + 1. +} + +static SCALE: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// A shape's material in image pixels: the material's laws are in points. +fn style_for(material: &GlassMaterial, shape: &Shape) -> model::PaneStyle { + let scale = *SCALE.get().unwrap_or(&1.); + material + .resolve(shape.width / scale, shape.height / scale) + .scaled(scale) +} + +#[derive(serde::Deserialize)] +struct ManifestShape { + rect: [f32; 4], + radius: f32, +} + +#[derive(serde::Deserialize, Clone)] +struct Case { + name: String, + scene: String, + style: String, + appearance: String, + tint: String, + /// The sweep's falling pass repeats its rising one. + #[serde(default)] + pass: String, +} + +struct Sample { + name: String, + backdrop: Rgba, + apple: Rgba, + shapes: Vec, + /// One blurred crop per shape: (origin x, origin y, crop). + crops: Vec<(usize, usize, Rgba)>, +} + +const PAD: f32 = 110.; + +/// How much more the black, grey and white scenes count in the colour fit. +const NEUTRAL_WEIGHT: f64 = 12.; + +fn main() { + let args: Vec = std::env::args().collect(); + let references: Vec<(PathBuf, Manifest)> = args[1] + .split(',') + .map(|dir| { + let dir = PathBuf::from(dir); + let manifest = + serde_json::from_slice(&std::fs::read(dir.join("manifest.json")).unwrap()).unwrap(); + (dir, manifest) + }) + .collect(); + let out = PathBuf::from(&args[2]); + std::fs::create_dir_all(out.join("renders")).unwrap(); + let scale = references[0].1.scale; + assert!( + references + .iter() + .all(|(_, manifest)| manifest.scale == scale), + "references must share a backing scale" + ); + SCALE.set(scale).unwrap(); + let presets: Vec = if args.len() > 3 { + args[3..].to_vec() + } else { + ["regular-light", "regular-dark", "clear-light", "clear-dark"] + .map(String::from) + .to_vec() + }; + // `GLASS_ALL_GREYS=1` scores every level of the sweep; the search uses + // every other one, which pins the tone curve as well at half the cost. + let all_greys = std::env::var_os("GLASS_ALL_GREYS").is_some(); + let fixed: Option = std::env::var("GLASS_PARAMS") + .ok() + .and_then(|path| serde_json::from_slice(&std::fs::read(path).ok()?).ok()); + + let mut fitted = serde_json::Map::new(); + let mut report = serde_json::Map::new(); + for preset in presets { + let mut samples: Vec = references + .iter() + .flat_map(|(reference, manifest)| { + let shapes: Vec = manifest + .shapes + .iter() + .map(|shape| Shape { + x: shape.rect[0] * scale, + y: shape.rect[1] * scale, + width: shape.rect[2] * scale, + height: shape.rect[3] * scale, + radius: shape.radius * scale, + }) + .collect(); + manifest + .cases + .iter() + .filter(|case| { + format!("{}-{}", case.style, case.appearance) == preset + && case.tint == "none" + && case.scene != "uvfine" + && case.pass != "down" + && (all_greys || sweep_level_kept(&case.scene)) + }) + .map(move |case| Sample { + name: case.name.clone(), + backdrop: Rgba::load(&reference.join(format!("scenes/{}.png", case.scene))) + .unwrap(), + apple: Rgba::load(&reference.join(format!("apple/{}.png", case.name))) + .unwrap(), + shapes: shapes.clone(), + crops: Vec::new(), + }) + .collect::>() + }) + .collect(); + + let material = match fixed + .as_ref() + .and_then(|all| serde_json::from_value::(all.get(&preset)?.clone()).ok()) + .map(|material| GlassMaterial { + adapt: material.adapt.or_else(|| adapt_for(&preset, material.poly)), + ..material + }) { + // `GLASS_RESOLVE=1`: keep the given blur, lensing and lighting but + // solve the colour transform again. + Some(material) if std::env::var_os("GLASS_RESOLVE").is_some() => { + prepare(&mut samples, &material); + let (loss, material) = evaluate(&material, &samples); + eprintln!("{preset}: re-solved colour, loss {loss:.2}"); + material + } + Some(material) => material, + None => fit(&preset, &mut samples), + }; + eprintln!("{preset}:\n{material:#?}"); + + prepare(&mut samples, &material); + let mut scores = serde_json::Map::new(); + for sample in &samples { + let mask = region_mask(&sample.shapes, &sample.backdrop); + let rendered = render_sample(&material, sample); + let ssim = metrics::ssim(&rendered, &sample.apple, &mask); + let (mean, p95) = metrics::delta_e(&rendered, &sample.apple, &mask); + scores.insert( + sample.name.clone(), + serde_json::json!({ "ssim": ssim, "deltaE": mean, "deltaE95": p95 }), + ); + side_by_side( + &rendered, + &sample.apple, + &out.join(format!("renders/{}.png", sample.name)), + ); + } + fitted.insert(preset.clone(), serde_json::to_value(material).unwrap()); + report.insert(preset, serde_json::Value::Object(scores)); + std::fs::write( + out.join("params.json"), + serde_json::to_string_pretty(&fitted).unwrap(), + ) + .unwrap(); + std::fs::write( + out.join("report.json"), + serde_json::to_string_pretty(&report).unwrap(), + ) + .unwrap(); + } +} + +/// `GLASS_NEUTRAL_WEIGHT` and `GLASS_SWEEP_WEIGHT` override how much the +/// flat scenes and the sweep's greys count in the colour fit. +fn neutral_weight() -> f64 { + std::env::var("GLASS_NEUTRAL_WEIGHT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(NEUTRAL_WEIGHT) +} + +fn sweep_weight() -> f64 { + std::env::var("GLASS_SWEEP_WEIGHT") + .ok() + .and_then(|value| value.parse().ok()) + .unwrap_or(1.) +} + +/// The sweep's greys the search uses: every 32 levels, plus white. +fn sweep_level_kept(scene: &str) -> bool { + scene + .strip_prefix("grey") + .and_then(|level| level.parse::().ok()) + .is_none_or(|level| level % 32 == 0 || level == 255) +} + +fn fit(preset: &str, samples: &mut [Sample]) -> GlassMaterial { + let start = with_lens( + preset, + GlassMaterial { + blur_k: 0.19, + blur_max: 21., + rim_base: 0., + rim_k: 0., + rim_max: 0., + bend_base: 0., + bend_k: 0., + bend_max: 0., + lens_curve: 1., + poly: [0.; 3 * POLY_TERMS], + opacity: 0.6, + line: 0., + line_lit: 0., + line_width: 1., + light_angle: -2.3, + glow: 0., + glow_width: 4., + hairline: [0.85, -0.12, 0.9, -0.08], + adapt: adapt_for(preset, [0.; 3 * POLY_TERMS]), + tint: None, + }, + ); + if std::env::var_os("GLASS_SEARCH_BLUR").is_none() { + // `GLASS_BLUR=,` overrides the measured law. + let &(_, measured_k, measured_max) = BLUR + .iter() + .find(|(style, ..)| preset.starts_with(style)) + .expect("every preset has a measured blur"); + let (blur_k, blur_max) = std::env::var("GLASS_BLUR") + .ok() + .and_then(|law| { + let (k, max) = law.split_once(',')?; + Some((k.parse().ok()?, max.parse().ok()?)) + }) + .unwrap_or((measured_k, measured_max)); + let (loss, material) = fit_at_law( + samples, + GlassMaterial { + blur_k, + blur_max, + ..start + }, + ); + eprintln!( + "{preset}: measured blur {blur_k:.3} x side, max {blur_max:.1} -> loss {loss:.2}" + ); + return material; + } + let mut best: Option<(f64, GlassMaterial)> = None; + let mut try_law = |blur_k: f32, blur_max: f32, best: &mut Option<(f64, GlassMaterial)>| { + let seed = GlassMaterial { + blur_k, + blur_max, + ..best.map_or(start, |(_, material)| material) + }; + let (loss, material) = fit_at_law(samples, seed); + eprintln!("{preset}: blur {blur_k:.3} x side, max {blur_max:.1} -> loss {loss:.2}"); + if best.is_none_or(|(current, _)| loss < current) { + *best = Some((loss, material)); + } + }; + for blur_k in [0.06, 0.1, 0.14, 0.19, 0.25] { + try_law(blur_k, 60., &mut best); + } + let k = best.unwrap().1.blur_k; + for blur_max in [8., 12., 16., 20., 26., 34.] { + try_law(k, blur_max, &mut best); + } + for _ in 0..2 { + let law = best.unwrap().1; + for (dk, dm) in [(-0.02, 0.), (0.02, 0.), (0., -2.), (0., 2.)] { + try_law( + (law.blur_k + dk).max(0.01), + (law.blur_max + dm).max(1.), + &mut best, + ); + } + } + best.unwrap().1 +} + +fn crop(image: &Rgba, x0: usize, y0: usize, x1: usize, y1: usize) -> Rgba { + let mut out = Rgba::solid(x1 - x0, y1 - y0, [0.; 4]); + for y in y0..y1 { + for x in x0..x1 { + out.set(x - x0, y - y0, image.get(x, y)); + } + } + out +} + +fn prepare(samples: &mut [Sample], material: &GlassMaterial) { + std::thread::scope(|scope| { + for sample in samples.iter_mut() { + scope.spawn(move || { + sample.crops = sample + .shapes + .iter() + .map(|shape| { + let blur = style_for(material, shape).blur; + let pad = PAD * SCALE.get().unwrap_or(&1.) + blur * 3.; + let x0 = (shape.x - pad).max(0.) as usize; + let y0 = (shape.y - pad).max(0.) as usize; + let x1 = + ((shape.x + shape.width + pad) as usize).min(sample.backdrop.width); + let y1 = + ((shape.y + shape.height + pad) as usize).min(sample.backdrop.height); + (x0, y0, crop(&sample.backdrop, x0, y0, x1, y1).blurred(blur)) + }) + .collect(); + }); + } + }); +} + +/// Visit glass pixels with their global position, weight and how much of +/// the small-pane colour transform their pane uses. With `sparse`, pixels +/// deeper than 12 px inside are visited on every other row and column at +/// four times the weight: they carry far less than the rim does. +fn for_each_glass_pixel( + material: &GlassMaterial, + sample: &Sample, + sparse: bool, + mut visit: impl FnMut(&model::GlassSample, &model::PaneStyle, usize, usize, f64, f32), +) { + let scale = *SCALE.get().unwrap_or(&1.); + for (shape, (ox, oy, blurred)) in sample.shapes.iter().zip(&sample.crops) { + let style = style_for(material, shape); + let small = material.adapt.map_or(0., |adapt| { + adapt.weight(shape.width.min(shape.height) / scale) + }); + let local = Shape { + x: shape.x - *ox as f32, + y: shape.y - *oy as f32, + ..*shape + }; + let x0 = (local.x - 3.).floor().max(0.) as usize; + let y0 = (local.y - 3.).floor().max(0.) as usize; + let x1 = ((local.x + local.width + 3.).ceil() as usize).min(blurred.width); + let y1 = ((local.y + local.height + 3.).ceil() as usize).min(blurred.height); + for y in y0..y1 { + for x in x0..x1 { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + let weight = if sparse && local.sdf(px, py) < -12. { + if x % 2 != 0 || y % 2 != 0 { + continue; + } + 4. + } else { + 1. + }; + let glass = model::sample(&style, &local, blurred, px, py); + if glass.coverage > 0. || glass.ring > 0. { + visit(&glass, &style, x + ox, y + oy, weight, small); + } + } + } + } +} + +fn render_sample(material: &GlassMaterial, sample: &Sample) -> Rgba { + let mut out = sample.backdrop.clone(); + for_each_glass_pixel(material, sample, false, |glass, style, x, y, _, _| { + let under = sample.backdrop.get(x, y); + let under = + model::under_with_hairline(glass, [under[0] / 255., under[1] / 255., under[2] / 255.]); + let color = if glass.coverage > 0. { + model::finish(style, glass) + } else { + under + }; + let mix = + |i: usize| (under[i] + (color[i].clamp(0., 1.) - under[i]) * glass.coverage) * 255.; + out.set(x, y, [mix(0), mix(1), mix(2), 255.]); + }); + out +} + +/// Normal equations of the colour transform over every covered pixel: +/// `coverage * scale * features . poly = target - (1 - coverage) * under - +/// coverage * glow`, per channel, plus what the residual needs. With size +/// adaptation the features come twice, weighted by how much of the large- +/// and the small-pane transform the pixel's pane uses, so both are solved +/// together. +struct Normal { + terms: usize, + ata: Vec, + atb: Vec<[f64; 3]>, + btb: [f64; 3], + count: usize, + /// Squared error of hairline-only pixels, which the transform cannot + /// change. + fixed_sse: f64, + fixed_count: usize, +} + +impl Normal { + fn new(terms: usize) -> Self { + Self { + terms, + ata: vec![0.; terms * terms], + atb: vec![[0.; 3]; terms], + btb: [0.; 3], + count: 0, + fixed_sse: 0., + fixed_count: 0, + } + } + + fn add(&mut self, other: &Self) { + for (a, b) in self.ata.iter_mut().zip(&other.ata) { + *a += b; + } + for (a, b) in self.atb.iter_mut().zip(&other.atb) { + for c in 0..3 { + a[c] += b[c]; + } + } + for c in 0..3 { + self.btb[c] += other.btb[c]; + } + self.count += other.count; + self.fixed_sse += other.fixed_sse; + self.fixed_count += other.fixed_count; + } + + /// Least-squares coefficients, `terms` per channel, and the mean + /// squared error per channel value on the 0-255 scale. + fn solve(&self) -> (Vec<[f32; 3]>, f64) { + let n = self.terms; + let mut a = self.ata.clone(); + for i in 0..n { + a[i * n + i] += 1e-6; + } + let mut coefficients = vec![[0f32; 3]; n]; + let mut sse = 0.; + for c in 0..3 { + let b: Vec = self.atb.iter().map(|row| row[c]).collect(); + let x = solve_linear(a.clone(), b.clone(), n); + let mut xax = 0.; + let mut xb = 0.; + for i in 0..n { + coefficients[i][c] = x[i] as f32; + xb += x[i] * b[i]; + for j in 0..n { + xax += x[i] * self.ata[i * n + j] * x[j]; + } + } + sse += self.btb[c] - 2. * xb + xax; + } + let total = sse.max(0.) + self.fixed_sse; + let values = 3 * (self.count + self.fixed_count).max(1); + (coefficients, total / values as f64 * 255. * 255.) + } +} + +fn solve_linear(mut m: Vec, mut v: Vec, n: usize) -> Vec { + for col in 0..n { + let pivot = (col..n) + .max_by(|a, b| m[*a * n + col].abs().total_cmp(&m[*b * n + col].abs())) + .unwrap(); + for k in 0..n { + m.swap(col * n + k, pivot * n + k); + } + v.swap(col, pivot); + for row in 0..n { + if row != col && m[col * n + col] != 0. { + let factor = m[row * n + col] / m[col * n + col]; + for k in 0..n { + m[row * n + k] -= factor * m[col * n + k]; + } + v[row] -= factor * v[col]; + } + } + } + (0..n) + .map(|i| { + if m[i * n + i] == 0. { + 0. + } else { + v[i] / m[i * n + i] + } + }) + .collect() +} + +fn normal_equations(material: &GlassMaterial, samples: &[Sample]) -> Normal { + let blocks = if material.adapt.is_some() { 2 } else { 1 }; + let terms = POLY_TERMS * blocks; + std::thread::scope(|scope| { + let handles: Vec<_> = samples + .iter() + .map(|sample| { + scope.spawn(move || { + let mut normal = Normal::new(terms); + // Flat backdrops pin the grey axis, where any error shows + // most; a handful of colourful scenes would otherwise + // outvote them. + let neutral = ["white-", "black-", "gray-"] + .iter() + .any(|prefix| sample.name.starts_with(prefix)); + let scene_weight = if neutral { neutral_weight() } else { 1. } + * if sample.name.starts_with("grey") { + sweep_weight() + } else { + 1. + }; + let mut row = vec![0f64; terms]; + for_each_glass_pixel( + material, + sample, + true, + |glass, _, x, y, weight, small| { + let weight = weight * scene_weight; + let under = sample.backdrop.get(x, y); + let under = model::under_with_hairline( + glass, + [under[0] / 255., under[1] / 255., under[2] / 255.], + ); + let target = sample.apple.get(x, y); + if glass.coverage <= 0. { + for c in 0..3 { + let error = under[c] as f64 - target[c] as f64 / 255.; + normal.fixed_sse += weight * error * error; + } + normal.fixed_count += weight as usize; + return; + } + let features = model::poly_features(glass.source); + let scale = (glass.coverage * glass.scale) as f64; + for i in 0..POLY_TERMS { + let feature = features[i] as f64 * scale; + if blocks == 1 { + row[i] = feature; + } else { + row[i] = feature * (1. - small as f64); + row[POLY_TERMS + i] = feature * small as f64; + } + } + for i in 0..terms { + if row[i] == 0. { + continue; + } + for j in i..terms { + normal.ata[i * terms + j] += weight * row[i] * row[j]; + } + } + let coverage = glass.coverage as f64; + for c in 0..3 { + let b = target[c] as f64 / 255. + - (1. - coverage) * under[c] as f64 + - coverage * glass.glow as f64; + for i in 0..terms { + normal.atb[i][c] += weight * row[i] * b; + } + normal.btb[c] += weight * b * b; + } + normal.count += weight as usize; + }, + ); + for i in 0..terms { + for j in 0..i { + normal.ata[i * terms + j] = normal.ata[j * terms + i]; + } + } + normal + }) + }) + .collect(); + let mut total = Normal::new(terms); + for handle in handles { + total.add(&handle.join().unwrap()); + } + total + }) +} + +fn evaluate(material: &GlassMaterial, samples: &[Sample]) -> (f64, GlassMaterial) { + let (coefficients, loss) = normal_equations(material, samples).solve(); + let mut material = *material; + for c in 0..3 { + for i in 0..POLY_TERMS { + material.poly[c * POLY_TERMS + i] = coefficients[i][c]; + if let Some(adapt) = &mut material.adapt { + adapt.poly[c * POLY_TERMS + i] = coefficients[POLY_TERMS + i][c]; + } + } + } + (loss, material) +} + +/// Fit everything but the blur law, which stays fixed so each backdrop is +/// blurred once. +fn fit_at_law(samples: &mut [Sample], start: GlassMaterial) -> (f64, GlassMaterial) { + prepare(samples, &start); + let samples: &[Sample] = samples; + let objective = |values: &[f32]| evaluate(&start.with_shape(values), samples).0; + // Blur is searched outside, the lens is measured: only lighting and the + // edge line move here. + let scales = [ + 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.04, 0.04, 0.5, 0.8, 0.04, 3., 0.08, 0.05, 0.08, 0.05, + ]; + let mut point = start.shape_vec().to_vec(); + let mut value = objective(&point); + for _ in 0..4 { + let (next_value, next) = nelder_mead(&objective, &point, &scales, 160); + if next_value >= value - 1e-3 { + break; + } + value = next_value; + point = next; + } + evaluate(&start.with_shape(&point), samples) +} + +fn region_mask(shapes: &[Shape], image: &Rgba) -> Vec { + (0..image.width * image.height) + .map(|index| { + let (x, y) = ( + (index % image.width) as f32 + 0.5, + (index / image.width) as f32 + 0.5, + ); + shapes.iter().any(|shape| shape.sdf(x, y) < 1.5) + }) + .collect() +} + +fn nelder_mead( + objective: &impl Fn(&[f32]) -> f64, + start: &[f32], + scales: &[f32], + iterations: usize, +) -> (f64, Vec) { + let dims: Vec = (0..start.len()).filter(|i| scales[*i] != 0.).collect(); + let mut simplex: Vec<(f64, Vec)> = vec![(objective(start), start.to_vec())]; + for &dim in &dims { + let mut vertex = start.to_vec(); + vertex[dim] += scales[dim]; + simplex.push((objective(&vertex), vertex)); + } + for _ in 0..iterations { + simplex.sort_by(|a, b| a.0.total_cmp(&b.0)); + let worst = simplex.len() - 1; + let centroid: Vec = (0..start.len()) + .map(|i| simplex[..worst].iter().map(|(_, v)| v[i]).sum::() / worst as f32) + .collect(); + let along = |t: f32| -> Vec { + centroid + .iter() + .zip(&simplex[worst].1) + .map(|(c, w)| c + t * (c - w)) + .collect() + }; + let reflected = along(1.); + let reflected_value = objective(&reflected); + if reflected_value < simplex[0].0 { + let expanded = along(2.); + let expanded_value = objective(&expanded); + simplex[worst] = if expanded_value < reflected_value { + (expanded_value, expanded) + } else { + (reflected_value, reflected) + }; + } else if reflected_value < simplex[worst - 1].0 { + simplex[worst] = (reflected_value, reflected); + } else { + let contracted = along(-0.5); + let contracted_value = objective(&contracted); + if contracted_value < simplex[worst].0 { + simplex[worst] = (contracted_value, contracted); + } else { + let best = simplex[0].1.clone(); + for vertex in simplex.iter_mut().skip(1) { + let shrunk: Vec = best + .iter() + .zip(&vertex.1) + .map(|(b, v)| b + 0.5 * (v - b)) + .collect(); + *vertex = (objective(&shrunk), shrunk); + } + } + } + } + simplex.sort_by(|a, b| a.0.total_cmp(&b.0)); + simplex.swap_remove(0) +} + +fn side_by_side(ours: &Rgba, apple: &Rgba, path: &Path) { + let mut sheet = Rgba::solid(ours.width * 2, ours.height, [255.; 4]); + for y in 0..ours.height { + for x in 0..ours.width { + sheet.set(x, y, apple.get(x, y)); + sheet.set(x + ours.width, y, ours.get(x, y)); + } + } + sheet.save(path).unwrap(); +} diff --git a/apps/desktop-gpui/glass/src/lib.rs b/apps/desktop-gpui/glass/src/lib.rs new file mode 100644 index 00000000000..bb20913b7e9 --- /dev/null +++ b/apps/desktop-gpui/glass/src/lib.rs @@ -0,0 +1,145 @@ +//! Cap Glass: the glass material's math, shared by the GPU shaders (Metal, +//! HLSL, WGSL in the gpui patch) and the CPU reference renderer that the +//! comparison harness fits against Apple's own glass. + +pub mod metrics; +pub mod model; +pub mod scenes; + +use std::path::Path; + +/// The fitted materials, keyed `regular-light`, `regular-dark`, +/// `clear-light` and `clear-dark` (see `glass-fit`). +pub const PRESETS_JSON: &str = include_str!("../presets.json"); + +/// An RGBA image with 0-255 float channels, non-premultiplied. +#[derive(Clone)] +pub struct Rgba { + pub width: usize, + pub height: usize, + pub pixels: Vec, +} + +impl Rgba { + pub fn solid(width: usize, height: usize, color: [f32; 4]) -> Self { + Self { + width, + height, + pixels: color.repeat(width * height), + } + } + + pub fn load(path: &Path) -> image::ImageResult { + let image = image::open(path)?.to_rgba8(); + Ok(Self { + width: image.width() as usize, + height: image.height() as usize, + pixels: image.as_raw().iter().map(|value| *value as f32).collect(), + }) + } + + pub fn save(&self, path: &Path) -> image::ImageResult<()> { + let bytes: Vec = self + .pixels + .iter() + .map(|value| value.round().clamp(0., 255.) as u8) + .collect(); + image::RgbaImage::from_raw(self.width as u32, self.height as u32, bytes) + .expect("pixel buffer matches its size") + .save(path) + } + + pub fn get(&self, x: usize, y: usize) -> [f32; 4] { + let index = (y * self.width + x) * 4; + [ + self.pixels[index], + self.pixels[index + 1], + self.pixels[index + 2], + self.pixels[index + 3], + ] + } + + pub fn set(&mut self, x: usize, y: usize, color: [f32; 4]) { + let index = (y * self.width + x) * 4; + self.pixels[index..index + 4].copy_from_slice(&color); + } + + /// Bilinear sample with clamped edges, in pixel coordinates (pixel + /// centres at +0.5). + pub fn sample(&self, x: f32, y: f32) -> [f32; 4] { + let fx = (x - 0.5).clamp(0., (self.width - 1) as f32); + let fy = (y - 0.5).clamp(0., (self.height - 1) as f32); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.width - 1), (y0 + 1).min(self.height - 1)); + let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); + let (a, b, c, d) = (self.get(x0, y0), self.get(x1, y0), self.get(x0, y1), self.get(x1, y1)); + std::array::from_fn(|i| { + let top = a[i] + (b[i] - a[i]) * tx; + let bottom = c[i] + (d[i] - c[i]) * tx; + top + (bottom - top) * ty + }) + } + + pub fn blurred(&self, sigma: f32) -> Self { + if sigma < 0.5 { + return self.clone(); + } + let radius = (sigma * 3.).ceil() as usize; + let kernel = metrics::gaussian_kernel(sigma as f64, radius); + let mut out = self.clone(); + for channel in 0..4 { + let values: Vec = self + .pixels + .iter() + .skip(channel) + .step_by(4) + .map(|value| *value as f64) + .collect(); + let blurred = metrics::separable_blur(&values, self.width, self.height, &kernel); + for (index, value) in blurred.into_iter().enumerate() { + out.pixels[index * 4 + channel] = value as f32; + } + } + out + } +} + +/// A rounded rectangle in pixels, top-left origin. +#[derive(Clone, Copy, Debug, serde::Deserialize, serde::Serialize)] +pub struct Shape { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, + pub radius: f32, +} + +impl Shape { + /// Signed distance to the rounded rectangle's edge, negative inside. + pub fn sdf(&self, px: f32, py: f32) -> f32 { + let radius = self.radius.min(self.width / 2.).min(self.height / 2.); + let cx = px - (self.x + self.width / 2.); + let cy = py - (self.y + self.height / 2.); + let qx = cx.abs() - (self.width / 2. - radius); + let qy = cy.abs() - (self.height / 2. - radius); + let outside = (qx.max(0.).powi(2) + qy.max(0.).powi(2)).sqrt(); + outside + qx.max(qy).min(0.) - radius + } + + pub fn normal(&self, px: f32, py: f32) -> (f32, f32) { + let e = 0.5; + let dx = self.sdf(px + e, py) - self.sdf(px - e, py); + let dy = self.sdf(px, py + e) - self.sdf(px, py - e); + let length = (dx * dx + dy * dy).sqrt().max(1e-6); + (dx / length, dy / length) + } + + pub fn mask(&self, width: usize, height: usize, inset: f32) -> Vec { + (0..width * height) + .map(|index| { + let (x, y) = ((index % width) as f32 + 0.5, (index / width) as f32 + 0.5); + self.sdf(x, y) < -inset + }) + .collect() + } +} diff --git a/apps/desktop-gpui/glass/src/metrics.rs b/apps/desktop-gpui/glass/src/metrics.rs new file mode 100644 index 00000000000..45cb04b4d2e --- /dev/null +++ b/apps/desktop-gpui/glass/src/metrics.rs @@ -0,0 +1,192 @@ +use crate::Rgba; + +/// Mean SSIM over the masked pixels, computed on Rec. 709 luma with the usual +/// 11x11, sigma 1.5 gaussian window. +pub fn ssim(a: &Rgba, b: &Rgba, mask: &[bool]) -> f64 { + assert_eq!((a.width, a.height), (b.width, b.height)); + let (width, height) = (a.width, a.height); + let luma = |image: &Rgba| -> Vec { + image + .pixels + .chunks_exact(4) + .map(|px| 0.2126 * px[0] as f64 + 0.7152 * px[1] as f64 + 0.0722 * px[2] as f64) + .collect() + }; + let (x, y) = (luma(a), luma(b)); + let kernel = gaussian_kernel(1.5, 5); + let blur = |values: &[f64]| separable_blur(values, width, height, &kernel); + let xy: Vec = x.iter().zip(&y).map(|(x, y)| x * y).collect(); + let xx: Vec = x.iter().map(|x| x * x).collect(); + let yy: Vec = y.iter().map(|y| y * y).collect(); + let (mu_x, mu_y) = (blur(&x), blur(&y)); + let (e_xx, e_yy, e_xy) = (blur(&xx), blur(&yy), blur(&xy)); + let (c1, c2) = ((0.01f64 * 255.).powi(2), (0.03f64 * 255.).powi(2)); + let mut total = 0.; + let mut count = 0usize; + for index in 0..width * height { + if !mask[index] { + continue; + } + let (mx, my) = (mu_x[index], mu_y[index]); + let vx = e_xx[index] - mx * mx; + let vy = e_yy[index] - my * my; + let cov = e_xy[index] - mx * my; + total += ((2. * mx * my + c1) * (2. * cov + c2)) / ((mx * mx + my * my + c1) * (vx + vy + c2)); + count += 1; + } + if count == 0 { 1. } else { total / count as f64 } +} + +/// CIEDE2000 colour difference per masked pixel, returned as (mean, p95). +pub fn delta_e(a: &Rgba, b: &Rgba, mask: &[bool]) -> (f64, f64) { + let mut values: Vec = a + .pixels + .chunks_exact(4) + .zip(b.pixels.chunks_exact(4)) + .zip(mask) + .filter(|(_, keep)| **keep) + .map(|((p, q), _)| ciede2000(srgb_to_lab(p), srgb_to_lab(q))) + .collect(); + if values.is_empty() { + return (0., 0.); + } + values.sort_by(f64::total_cmp); + let mean = values.iter().sum::() / values.len() as f64; + let p95 = values[((values.len() - 1) as f64 * 0.95).round() as usize]; + (mean, p95) +} + +pub fn gaussian_kernel(sigma: f64, radius: usize) -> Vec { + let mut kernel: Vec = (0..=2 * radius) + .map(|i| { + let x = i as f64 - radius as f64; + (-(x * x) / (2. * sigma * sigma)).exp() + }) + .collect(); + let sum: f64 = kernel.iter().sum(); + kernel.iter_mut().for_each(|value| *value /= sum); + kernel +} + +pub fn separable_blur(values: &[f64], width: usize, height: usize, kernel: &[f64]) -> Vec { + let radius = kernel.len() / 2; + let mut horizontal = vec![0.; values.len()]; + for row in 0..height { + for col in 0..width { + let mut sum = 0.; + for (k, weight) in kernel.iter().enumerate() { + let x = (col + k).saturating_sub(radius).min(width - 1); + sum += weight * values[row * width + x]; + } + horizontal[row * width + col] = sum; + } + } + let mut out = vec![0.; values.len()]; + for row in 0..height { + for col in 0..width { + let mut sum = 0.; + for (k, weight) in kernel.iter().enumerate() { + let y = (row + k).saturating_sub(radius).min(height - 1); + sum += weight * horizontal[y * width + col]; + } + out[row * width + col] = sum; + } + } + out +} + +fn srgb_to_lab(px: &[f32]) -> [f64; 3] { + let linear = |c: f32| { + let c = (c as f64 / 255.).clamp(0., 1.); + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } + }; + let (r, g, b) = (linear(px[0]), linear(px[1]), linear(px[2])); + let x = (0.4124564 * r + 0.3575761 * g + 0.1804375 * b) / 0.95047; + let y = 0.2126729 * r + 0.7151522 * g + 0.0721750 * b; + let z = (0.0193339 * r + 0.1191920 * g + 0.9503041 * b) / 1.08883; + let f = |t: f64| { + if t > 216. / 24389. { t.cbrt() } else { (24389. / 27. * t + 16.) / 116. } + }; + let (fx, fy, fz) = (f(x), f(y), f(z)); + [116. * fy - 16., 500. * (fx - fy), 200. * (fy - fz)] +} + +fn ciede2000(lab1: [f64; 3], lab2: [f64; 3]) -> f64 { + let [l1, a1, b1] = lab1; + let [l2, a2, b2] = lab2; + let c1 = (a1 * a1 + b1 * b1).sqrt(); + let c2 = (a2 * a2 + b2 * b2).sqrt(); + let c_bar = (c1 + c2) / 2.; + let g = 0.5 * (1. - (c_bar.powi(7) / (c_bar.powi(7) + 25f64.powi(7))).sqrt()); + let (a1p, a2p) = ((1. + g) * a1, (1. + g) * a2); + let (c1p, c2p) = ((a1p * a1p + b1 * b1).sqrt(), (a2p * a2p + b2 * b2).sqrt()); + let hue = |b: f64, a: f64| { + if a == 0. && b == 0. { + 0. + } else { + let h = b.atan2(a).to_degrees(); + if h < 0. { h + 360. } else { h } + } + }; + let (h1p, h2p) = (hue(b1, a1p), hue(b2, a2p)); + let dl = l2 - l1; + let dc = c2p - c1p; + let dh_angle = if c1p * c2p == 0. { + 0. + } else if (h2p - h1p).abs() <= 180. { + h2p - h1p + } else if h2p - h1p > 180. { + h2p - h1p - 360. + } else { + h2p - h1p + 360. + }; + let dh = 2. * (c1p * c2p).sqrt() * (dh_angle.to_radians() / 2.).sin(); + let l_bar = (l1 + l2) / 2.; + let cp_bar = (c1p + c2p) / 2.; + let hp_bar = if c1p * c2p == 0. { + h1p + h2p + } else if (h1p - h2p).abs() <= 180. { + (h1p + h2p) / 2. + } else if h1p + h2p < 360. { + (h1p + h2p + 360.) / 2. + } else { + (h1p + h2p - 360.) / 2. + }; + let t = 1. - 0.17 * (hp_bar - 30.).to_radians().cos() + + 0.24 * (2. * hp_bar).to_radians().cos() + + 0.32 * (3. * hp_bar + 6.).to_radians().cos() + - 0.20 * (4. * hp_bar - 63.).to_radians().cos(); + let d_theta = 30. * (-((hp_bar - 275.) / 25.).powi(2)).exp(); + let rc = 2. * (cp_bar.powi(7) / (cp_bar.powi(7) + 25f64.powi(7))).sqrt(); + let sl = 1. + (0.015 * (l_bar - 50.).powi(2)) / (20. + (l_bar - 50.).powi(2)).sqrt(); + let sc = 1. + 0.045 * cp_bar; + let sh = 1. + 0.015 * cp_bar * t; + let rt = -(2. * d_theta).to_radians().sin() * rc; + ((dl / sl).powi(2) + (dc / sc).powi(2) + (dh / sh).powi(2) + rt * (dc / sc) * (dh / sh)).sqrt() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ciede2000_matches_published_pairs() { + let pairs = [ + ([50.0, 2.6772, -79.7751], [50.0, 0.0, -82.7485], 2.0425), + ([50.0, 3.1571, -77.2803], [50.0, 0.0, -82.7485], 2.8615), + ([50.0, 2.5, 0.0], [73.0, 25.0, -18.0], 27.1492), + ([60.2574, -34.0099, 36.2677], [60.4626, -34.1751, 39.4387], 1.2644), + ]; + for (a, b, expected) in pairs { + assert!((ciede2000(a, b) - expected).abs() < 1e-3, "{a:?} {b:?}"); + } + } + + #[test] + fn identical_images_score_perfectly() { + let image = Rgba::solid(16, 16, [120., 40., 200., 255.]); + let mask = vec![true; 256]; + assert!((ssim(&image, &image, &mask) - 1.).abs() < 1e-9); + assert_eq!(delta_e(&image, &image, &mask), (0., 0.)); + } +} diff --git a/apps/desktop-gpui/glass/src/model.rs b/apps/desktop-gpui/glass/src/model.rs new file mode 100644 index 00000000000..4af498f9635 --- /dev/null +++ b/apps/desktop-gpui/glass/src/model.rs @@ -0,0 +1,484 @@ +use crate::{Rgba, Shape}; + +/// Terms of the colour transform, per output channel: the full cubic in +/// `r, g, b` of the blurred backdrop (0-1 sRGB), constant last. The cubes +/// follow Apple's tone curve along the grey axis; the mixed cubic terms let +/// that curve act on brightness without bending hue, which Apple's Regular +/// glass needs over saturated content (it lifts a light blue's blue channel +/// past what white comes out as). +pub const POLY_TERMS: usize = 20; + +pub fn poly_features(c: [f32; 3]) -> [f32; POLY_TERMS] { + let [r, g, b] = c; + [ + r, + g, + b, + r * r, + g * g, + b * b, + r * g, + g * b, + r * b, + r * r * r, + g * g * g, + b * b * b, + r * r * g, + r * r * b, + g * g * r, + g * g * b, + b * b * r, + b * b * g, + r * g * b, + 1., + ] +} + +/// A glass material, fitted against Apple's glass. Blur and lensing scale +/// with the pane's shorter side, the way Apple's do; [`GlassMaterial::resolve`] +/// turns them into the absolute [`PaneStyle`] the renderers draw with. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct GlassMaterial { + /// Blur sigma: `min(blur_max, blur_k * shorter side)`. + pub blur_k: f32, + pub blur_max: f32, + /// Decay length of the lensing rim: + /// `min(rim_max, rim_base + rim_k * shorter side)`. + pub rim_base: f32, + pub rim_k: f32, + pub rim_max: f32, + /// How far the edge pulls light in from inside the pane: + /// `min(bend_max, bend_base + bend_k * shorter side)`. Apple's grows with + /// the pane up to a cap, independently of the decay length. + pub bend_base: f32, + pub bend_k: f32, + pub bend_max: f32, + /// Shape of the falloff: the bend at depth `d` is + /// `exp(-(d / decay)^lens_curve)` of the edge's, so 1 is the + /// exponential falloff Apple's captures follow. + pub lens_curve: f32, + /// Second-order colour transform of the blurred backdrop: for each output + /// channel, coefficients of [`poly_features`]. It carries the frost, the + /// saturation, the hue shift and the brightness compression Apple's + /// materials apply. + #[serde(with = "serde_poly")] + pub poly: [f32; 3 * POLY_TERMS], + /// How opaque the glass is over a transparent backdrop. + pub opacity: f32, + /// Edge line: scales the colour by `1 + line + line_lit * facing` at the + /// edge, decaying inwards over `line_width`; `facing` is how much the + /// edge faces `light_angle` (radians, y down). + pub line: f32, + pub line_lit: f32, + pub line_width: f32, + pub light_angle: f32, + /// A soft inner glow from the edge. + pub glow: f32, + pub glow_width: f32, + /// The hairline Apple draws in the pixel just outside the edge: + /// `clamp(min(under, a * under + b), 0, 1)`, with `a` and `b` blended + /// between vertical edges (`[a, b]` first) and horizontal ones by the + /// edge normal. + pub hairline: [f32; 4], + /// Size adaptation: Regular glass in dark mode lifts small panes over + /// bright content. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapt: Option, + /// How a tint colours this material. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tint: Option, +} + +/// Rec. 709 luma of a gamma-encoded colour: the weights Apple's tint mixes +/// with, which fit its captures better than Rec. 601 or linear light. +pub const LUMA: [f32; 3] = [0.2126, 0.7152, 0.0722]; + +/// How a tint (an sRGB colour and an alpha) changes the finished glass +/// colour `u`, measured from Apple's tinted glass (`sweep tints` and +/// `sweep levels`). Either way it comes down to a colour matrix, +/// `u * scale + luma(u) * luma + offset` per channel: see [`TintMatrix`]. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TintLaw { + /// Regular glass ignores the tint's colour: it mixes `desaturate * + /// alpha` of the way towards the luma and shifts by `shift * alpha`. + Grey { desaturate: f32, shift: f32 }, + /// Clear glass alpha-blends a tint layer in sRGB. The layer is the tint + /// itself over white and a darker, more saturated colour of the same hue + /// over black, following the luma in between. Over black, the tint's + /// brightest and darkest channels land on `high` and `low`, looked up by + /// the tint's HSL lightness (rows, [`TINT_LIGHTNESS`]) and saturation + /// (columns, [`TINT_SATURATION`]); the other channel keeps its place + /// between them. Values below 0 are kept: they clamp per channel. + Layer { + high: [[f32; 5]; 12], + low: [[f32; 5]; 12], + }, +} + +/// The HSL grid Clear's tint table is measured on (`sweep hsl`). +pub const TINT_LIGHTNESS: [f32; 12] = [0., 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 1.]; +pub const TINT_SATURATION: [f32; 5] = [0., 0.25, 0.5, 0.75, 1.]; + +/// Bilinear lookup in a table over [`TINT_LIGHTNESS`] x [`TINT_SATURATION`]. +fn tint_lookup(table: &[[f32; 5]; 12], lightness: f32, saturation: f32) -> f32 { + fn cell(axis: &[f32], value: f32) -> (usize, f32) { + let value = value.clamp(axis[0], axis[axis.len() - 1]); + let index = axis + .windows(2) + .position(|pair| value <= pair[1]) + .unwrap_or(axis.len() - 2); + ( + index, + (value - axis[index]) / (axis[index + 1] - axis[index]), + ) + } + let (row, along_l) = cell(&TINT_LIGHTNESS, lightness); + let (column, along_s) = cell(&TINT_SATURATION, saturation); + let mix = |a: f32, b: f32, t: f32| a + (b - a) * t; + mix( + mix(table[row][column], table[row][column + 1], along_s), + mix(table[row + 1][column], table[row + 1][column + 1], along_s), + along_l, + ) +} + +/// A tint resolved to what the renderers apply to the finished colour. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct TintMatrix { + pub scale: [f32; 3], + pub luma: [f32; 3], + pub offset: [f32; 3], +} + +impl TintMatrix { + pub const NONE: Self = Self { + scale: [1.; 3], + luma: [0.; 3], + offset: [0.; 3], + }; + + pub fn apply(&self, u: [f32; 3]) -> [f32; 3] { + let y = u[0] * LUMA[0] + u[1] * LUMA[1] + u[2] * LUMA[2]; + std::array::from_fn(|c| u[c] * self.scale[c] + y * self.luma[c] + self.offset[c]) + } +} + +impl TintLaw { + /// `color` is sRGB 0-1, `alpha` 0-1. + pub fn matrix(&self, color: [f32; 3], alpha: f32) -> TintMatrix { + let alpha = alpha.clamp(0., 1.); + match *self { + TintLaw::Grey { desaturate, shift } => { + let mix = desaturate * alpha; + TintMatrix { + scale: [1. - mix; 3], + luma: [mix; 3], + offset: [shift * alpha; 3], + } + } + TintLaw::Layer { high, low } => { + let t = color.map(|c| c.clamp(0., 1.)); + let (brightest, darkest) = (t[0].max(t[1]).max(t[2]), t[0].min(t[1]).min(t[2])); + let lightness = (brightest + darkest) / 2.; + let spread = 1. - (2. * lightness - 1.).abs(); + let saturation = if spread > 1e-6 { + (brightest - darkest) / spread + } else { + 0. + }; + let (to_high, to_low) = ( + tint_lookup(&high, lightness, saturation), + tint_lookup(&low, lightness, saturation), + ); + let over_black = t.map(|c| { + let place = if brightest - darkest > 1e-6 { + (c - darkest) / (brightest - darkest) + } else { + 0.5 + }; + (to_low + (to_high - to_low) * place).clamp(0., 1.) + }); + TintMatrix { + scale: [1. - alpha; 3], + luma: std::array::from_fn(|c| alpha * (t[c] - over_black[c])), + offset: over_black.map(|n| alpha * n), + } + } + } + } +} + +/// A second colour transform for small panes. Below `small` points on the +/// shorter side a pane uses `poly`, above `large` the material's own, and +/// in between a linear blend of the two. Apple's captures show no +/// dependence on the backdrop beyond the transform itself, and no +/// hysteresis, so the blend is fixed per pane size. +#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub struct Adapt { + pub small: f32, + pub large: f32, + #[serde(with = "serde_poly")] + pub poly: [f32; 3 * POLY_TERMS], +} + +impl Adapt { + /// How much of the small-pane transform a pane uses. + pub fn weight(&self, shorter: f32) -> f32 { + ((self.large - shorter) / (self.large - self.small).max(1e-3)).clamp(0., 1.) + } +} + +mod serde_poly { + use super::POLY_TERMS; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(poly: &[f32; 3 * POLY_TERMS], s: S) -> Result { + s.collect_seq(poly.iter()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[f32; 3 * POLY_TERMS], D::Error> { + let values = Vec::::deserialize(d)?; + values + .try_into() + .map_err(|_| serde::de::Error::custom("poly needs 3 x POLY_TERMS coefficients")) + } +} + +/// One pane's resolved style, in logical pixels. Mirrored field for field by +/// `GlassStyle` in the gpui patch's shaders (which take device pixels). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PaneStyle { + pub blur: f32, + pub lens_rim: f32, + pub lens_curve: f32, + pub rim_bend: f32, + pub poly: [f32; 3 * POLY_TERMS], + pub opacity: f32, + pub line: f32, + pub line_lit: f32, + pub line_width: f32, + pub light: [f32; 2], + pub glow: f32, + pub glow_width: f32, + pub hairline: [f32; 4], + /// Tint on the finished colour; [`TintMatrix::NONE`] when untinted. + pub tint: TintMatrix, +} + +impl PaneStyle { + /// The same style for a window drawing `scale` device pixels per + /// logical pixel. + pub fn scaled(&self, scale: f32) -> Self { + Self { + blur: self.blur * scale, + lens_rim: self.lens_rim * scale, + rim_bend: self.rim_bend * scale, + line_width: self.line_width * scale, + glow_width: self.glow_width * scale, + ..*self + } + } +} + +impl GlassMaterial { + /// Parameters the optimiser searches; the colour transform is solved. + pub const SHAPE_FIELDS: usize = 19; + + /// The colour transform a pane of this shorter side uses. + pub fn poly_for(&self, shorter: f32) -> [f32; 3 * POLY_TERMS] { + match &self.adapt { + Some(adapt) => { + let weight = adapt.weight(shorter); + std::array::from_fn(|i| self.poly[i] + (adapt.poly[i] - self.poly[i]) * weight) + } + None => self.poly, + } + } + + pub fn resolve(&self, width: f32, height: f32) -> PaneStyle { + let shorter = width.min(height).max(1.); + let lens_rim = (self.rim_base + self.rim_k * shorter).clamp(0.5, self.rim_max.max(0.5)); + let bend = (self.bend_base + self.bend_k * shorter).clamp(0., self.bend_max.max(0.)); + PaneStyle { + blur: (self.blur_k * shorter).clamp(0., self.blur_max.max(0.)), + lens_rim, + lens_curve: self.lens_curve, + rim_bend: -bend, + poly: self.poly_for(shorter), + opacity: self.opacity, + line: self.line, + line_lit: self.line_lit, + line_width: self.line_width, + light: [self.light_angle.cos(), self.light_angle.sin()], + glow: self.glow, + glow_width: self.glow_width, + hairline: self.hairline, + tint: TintMatrix::NONE, + } + } + + /// The tint `color` (sRGB 0-1) at `alpha` on this material, or no tint + /// for a material without a measured tint law. + pub fn tint_matrix(&self, color: [f32; 3], alpha: f32) -> TintMatrix { + self.tint + .map_or(TintMatrix::NONE, |law| law.matrix(color, alpha)) + } + + pub fn shape_vec(&self) -> [f32; Self::SHAPE_FIELDS] { + [ + self.blur_k, + self.blur_max, + self.rim_base, + self.rim_k, + self.rim_max, + self.bend_base, + self.bend_k, + self.bend_max, + self.lens_curve, + self.line, + self.line_lit, + self.line_width, + self.light_angle, + self.glow, + self.glow_width, + self.hairline[0], + self.hairline[1], + self.hairline[2], + self.hairline[3], + ] + } + + pub fn with_shape(&self, values: &[f32]) -> Self { + Self { + blur_k: values[0].max(0.), + blur_max: values[1].max(0.), + rim_base: values[2].max(0.), + rim_k: values[3].max(0.), + rim_max: values[4].max(0.5), + bend_base: values[5], + bend_k: values[6], + bend_max: values[7].max(0.), + lens_curve: values[8].clamp(0.2, 6.), + line: values[9], + line_lit: values[10], + line_width: values[11].max(0.3), + light_angle: values[12], + glow: values[13], + glow_width: values[14].max(0.5), + hairline: [values[15], values[16], values[17], values[18]], + ..*self + } + } +} + +/// The blurred backdrop colour a glass pixel samples (0-1), before the +/// colour transform, plus everything applied around it. +pub struct GlassSample { + pub source: [f32; 3], + /// How much of the pixel the glass covers. + pub coverage: f32, + /// How much of the pixel the hairline ring outside the edge covers. + pub ring: f32, + /// The hairline's `a` and `b` at this point of the edge. + pub hairline: [f32; 2], + /// `1 + line term`. + pub scale: f32, + pub glow: f32, +} + +pub fn sample(style: &PaneStyle, shape: &Shape, blurred: &Rgba, px: f32, py: f32) -> GlassSample { + let d = shape.sdf(px, py); + let coverage = (0.5 - d).clamp(0., 1.); + let ring = (1.5 - d).clamp(0., 1.) - coverage; + let (nx, ny) = shape.normal(px, py); + let vertical = nx * nx; + let hairline = [ + style.hairline[0] * vertical + style.hairline[2] * (1. - vertical), + style.hairline[1] * vertical + style.hairline[3] * (1. - vertical), + ]; + if coverage <= 0. { + return GlassSample { + source: [0.; 3], + coverage: 0., + ring, + hairline, + scale: 1., + glow: 0., + }; + } + let edge = (-d).max(0.); + let rim = (-(edge / style.lens_rim).powf(style.lens_curve)).exp(); + let value = blurred.sample( + px + nx * style.rim_bend * rim, + py + ny * style.rim_bend * rim, + ); + let facing = nx * style.light[0] + ny * style.light[1]; + GlassSample { + source: [value[0] / 255., value[1] / 255., value[2] / 255.], + coverage, + ring, + hairline, + scale: 1. + (-edge / style.line_width).exp() * (style.line + style.line_lit * facing), + glow: (-edge / style.glow_width).exp() * style.glow, + } +} + +/// The backdrop under a glass pixel once the hairline is drawn on it (0-1). +pub fn under_with_hairline(sample: &GlassSample, under: [f32; 3]) -> [f32; 3] { + std::array::from_fn(|i| { + let line = under[i] + .min(sample.hairline[0] * under[i] + sample.hairline[1]) + .clamp(0., 1.); + under[i] + (line - under[i]) * sample.ring + }) +} + +/// Apply the colour transform, lighting and tint: 0-1 sRGB out, unclamped. +pub fn finish(style: &PaneStyle, sample: &GlassSample) -> [f32; 3] { + let features = poly_features(sample.source); + let untinted: [f32; 3] = std::array::from_fn(|channel| { + let coefficients = &style.poly[channel * POLY_TERMS..(channel + 1) * POLY_TERMS]; + let value: f32 = coefficients.iter().zip(features).map(|(c, f)| c * f).sum(); + (value * sample.scale + sample.glow).clamp(0., 1.) + }); + style.tint.apply(untinted) +} + +/// Composite one pane over `out`, sampling `blurred` (the backdrop blurred +/// by the pane's own blur). +pub fn composite(style: &PaneStyle, shape: &Shape, blurred: &Rgba, out: &mut Rgba) { + let x0 = (shape.x - 3.).floor().max(0.) as usize; + let y0 = (shape.y - 3.).floor().max(0.) as usize; + let x1 = ((shape.x + shape.width + 3.).ceil() as usize).min(out.width); + let y1 = ((shape.y + shape.height + 3.).ceil() as usize).min(out.height); + for y in y0..y1 { + for x in x0..x1 { + let glass = sample(style, shape, blurred, x as f32 + 0.5, y as f32 + 0.5); + if glass.coverage <= 0. && glass.ring <= 0. { + continue; + } + let under = out.get(x, y); + let under = + under_with_hairline(&glass, [under[0] / 255., under[1] / 255., under[2] / 255.]); + let color = if glass.coverage > 0. { + finish(style, &glass) + } else { + under + }; + let mix = + |i: usize| (under[i] + (color[i].clamp(0., 1.) - under[i]) * glass.coverage) * 255.; + out.set(x, y, [mix(0), mix(1), mix(2), 255.]); + } + } +} + +/// Composite every pane of `material` over `backdrop`. +pub fn render(material: &GlassMaterial, shapes: &[Shape], backdrop: &Rgba) -> Rgba { + let mut out = backdrop.clone(); + for shape in shapes { + let style = material.resolve(shape.width, shape.height); + composite(&style, shape, &backdrop.blurred(style.blur), &mut out); + } + out +} diff --git a/apps/desktop-gpui/glass/src/scenes.rs b/apps/desktop-gpui/glass/src/scenes.rs new file mode 100644 index 00000000000..d88a2ef725e --- /dev/null +++ b/apps/desktop-gpui/glass/src/scenes.rs @@ -0,0 +1,117 @@ +use crate::{Rgba, Shape}; + +/// Logical size of every scene; the probe window and the reference capture +/// share it. +pub const CANVAS: (f32, f32) = (960., 600.); + +/// The panes the probe and the reference capture lay over each scene, in +/// logical pixels with a top-left origin. +pub const SHAPES: [Shape; 5] = [ + Shape { + x: 60., + y: 60., + width: 320., + height: 120., + radius: 60., + }, + Shape { + x: 460., + y: 60., + width: 420., + height: 240., + radius: 24., + }, + Shape { + x: 60., + y: 260., + width: 300., + height: 48., + radius: 24., + }, + Shape { + x: 120., + y: 380., + width: 96., + height: 96., + radius: 48., + }, + Shape { + x: 460., + y: 380., + width: 360., + height: 160., + radius: 16., + }, +]; + +/// Backdrops that render identically everywhere (no text, so no fonts). +pub const NAMES: [&str; 9] = [ + "white", "black", "gray", "stripes", "grid", "checker", "hues", "blobs", "split", +]; + +/// Render `name` at `scale` device pixels per logical pixel. +pub fn render(name: &str, scale: f32) -> Option { + let (width, height) = ((CANVAS.0 * scale).round() as usize, (CANVAS.1 * scale).round() as usize); + let mut image = Rgba::solid(width, height, [255.; 4]); + for y in 0..height { + for x in 0..width { + let (lx, ly) = ((x as f32 + 0.5) / scale, (y as f32 + 0.5) / scale); + image.set(x, y, pixel(name, lx, ly)?); + } + } + Some(image) +} + +fn pixel(name: &str, x: f32, y: f32) -> Option<[f32; 4]> { + let gray = |value: f32| [value, value, value, 255.]; + Some(match name { + "white" => gray(255.), + "black" => gray(0.), + "gray" => gray(128.), + "stripes" => gray(if (x / 8.).floor() as i64 % 2 == 0 { 0. } else { 255. }), + "grid" => { + let on_line = |v: f32| (v + 1.).rem_euclid(24.) < 2.; + if on_line(x) || on_line(y) { + [26., 77., 230., 255.] + } else { + [245., 245., 247., 255.] + } + } + "checker" => { + if ((x / 32.).floor() as i64 + (y / 32.).floor() as i64) % 2 == 0 { + [242., 82., 51., 255.] + } else { + [31., 140., 242., 255.] + } + } + "hues" => { + let hue = (x / CANVAS.0).clamp(0., 1.) * 6.; + let f = |n: f32| { + let k = (n + hue) % 6.; + 242. * (1. - 0.85 * (k.min(4. - k).clamp(0., 1.))) + }; + [f(5.), f(3.), f(1.), 255.] + } + "blobs" => { + let mut color = [250., 240., 224.]; + for (fx, fy, radius, blob) in [ + (0.2, 0.7, 260., [255., 89., 115.]), + (0.7, 0.75, 300., [64., 140., 255.]), + (0.5, 0.2, 280., [51., 217., 140.]), + (0.9, 0.2, 220., [255., 204., 51.]), + ] { + let d = ((x - fx * CANVAS.0).powi(2) + (y - fy * CANVAS.1).powi(2)).sqrt(); + let t = (1. - d / radius).clamp(0., 1.); + for i in 0..3 { + color[i] += (blob[i] - color[i]) * t; + } + } + [color[0], color[1], color[2], 255.] + } + "split" => { + let white = y < CANVAS.1 / 2. || x < 170. || x >= 670.; + gray(if white { 255. } else { 0. }) + } + _ => return None, + }) +} diff --git a/apps/desktop-gpui/glass/tests/presets.rs b/apps/desktop-gpui/glass/tests/presets.rs new file mode 100644 index 00000000000..c761851ad87 --- /dev/null +++ b/apps/desktop-gpui/glass/tests/presets.rs @@ -0,0 +1,219 @@ +use cap_glass::{ + PRESETS_JSON, Rgba, Shape, + model::{self, GlassMaterial}, + scenes, +}; + +fn presets() -> serde_json::Value { + serde_json::from_str(PRESETS_JSON).unwrap() +} + +fn material(name: &str) -> GlassMaterial { + serde_json::from_value(presets()[name].clone()).unwrap() +} + +fn centre_over(material: &GlassMaterial, gray: f32) -> f32 { + let backdrop = Rgba::solid(960, 600, [gray, gray, gray, 255.]); + let shape = scenes::SHAPES[1]; + let out = model::render(material, &[shape], &backdrop); + out.get( + (shape.x + shape.width / 2.) as usize, + (shape.y + shape.height / 2.) as usize, + )[0] +} + +/// What Apple's glass turns a flat black, mid-grey and white backdrop into, +/// read off the reference captures (macOS 27, Apple M4 Max). +#[test] +fn presets_match_apple_over_flat_backdrops() { + for (name, black, gray, white) in [ + ("regular-light", 133., 188., 240.), + ("regular-dark", 20., 110., 114.), + ("clear-light", 71., 158., 244.), + ("clear-dark", 25., 116., 207.), + ] { + let material = material(name); + for (backdrop, expected) in [(0., black), (128., gray), (255., white)] { + let got = centre_over(&material, backdrop); + eprintln!("{name} {backdrop} -> {got:.1} (apple {expected})"); + assert!( + (got - expected).abs() <= 3., + "{name} over {backdrop}: {got} vs Apple's {expected}" + ); + } + } +} + +#[test] +fn every_preset_is_complete() { + let all = presets(); + let names: Vec<&str> = all + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!( + names, + ["clear-dark", "clear-light", "regular-dark", "regular-light"] + ); + for name in names { + let material = material(name); + let style = material.resolve(420., 240.); + assert!(style.blur > 0. && style.lens_rim > 0., "{name}"); + } +} + +#[test] +fn blur_and_lensing_scale_with_the_shorter_side_up_to_their_caps() { + let material = material("clear-light"); + let small = material.resolve(300., 48.); + let large = material.resolve(900., 600.); + assert!((small.blur - material.blur_k * 48.).abs() < 1e-4); + assert_eq!(large.blur, material.blur_max); + assert!(small.lens_rim < large.lens_rim); + let doubled = small.scaled(2.); + assert_eq!(doubled.blur, small.blur * 2.); + assert_eq!(doubled.lens_rim, small.lens_rim * 2.); + assert_eq!(doubled.rim_bend, small.rim_bend * 2.); +} + +#[test] +fn the_rounded_rect_distance_field_is_signed_and_clamps_its_radius() { + let shape = Shape { + x: 0., + y: 0., + width: 100., + height: 40., + radius: 80., + }; + assert!((shape.sdf(50., 20.) + 20.).abs() < 1e-4); + assert!((shape.sdf(50., -5.) - 5.).abs() < 1e-4); + assert!((shape.sdf(0., 20.)).abs() < 1e-4); + assert!( + shape.sdf(1., 1.) > 0., + "a corner beyond the clamped radius is outside" + ); + let (nx, ny) = shape.normal(50., 1.); + assert!(nx.abs() < 1e-3 && (ny + 1.).abs() < 1e-3); +} + +#[test] +fn scenes_render_deterministically_at_any_scale() { + for name in scenes::NAMES { + let one = scenes::render(name, 1.).unwrap(); + assert_eq!((one.width, one.height), (960, 600)); + let two = scenes::render(name, 2.).unwrap(); + assert_eq!((two.width, two.height), (1920, 1200)); + assert_eq!(one.pixels, scenes::render(name, 1.).unwrap().pixels); + } + assert!(scenes::render("nope", 1.).is_none()); +} + +#[test] +fn glass_leaves_pixels_beyond_its_hairline_alone() { + let material = material("regular-light"); + let backdrop = scenes::render("checker", 1.).unwrap(); + let shape = scenes::SHAPES[1]; + let out = model::render(&material, &[shape], &backdrop); + for (x, y) in [(455usize, 180usize), (885, 180), (670, 55), (670, 305)] { + assert_eq!(out.get(x, y), backdrop.get(x, y), "({x}, {y})"); + } +} + +fn centre_of( + material: &GlassMaterial, + gray: f32, + shape: Shape, + tint: Option<([f32; 3], f32)>, +) -> [f32; 3] { + let backdrop = Rgba::solid(960, 600, [gray, gray, gray, 255.]); + let mut style = material.resolve(shape.width, shape.height); + if let Some((color, alpha)) = tint { + style.tint = material.tint_matrix(color, alpha); + } + let mut out = backdrop.clone(); + model::composite(&style, &shape, &backdrop.blurred(style.blur), &mut out); + let pixel = out.get( + (shape.x + shape.width / 2.) as usize, + (shape.y + shape.height / 2.) as usize, + ); + [pixel[0], pixel[1], pixel[2]] +} + +fn pane(side: f32) -> Shape { + Shape { + x: 100., + y: 100., + width: 400., + height: side, + radius: side / 2., + } +} + +/// Regular dark glass lifts small panes over bright content: over white, +/// Apple's reads 180 up to 64 pt tall, 154 at 80 pt, 127 at 96 pt and 114 +/// from 128 pt (`sweep`, macOS 27.0.1). +#[test] +fn regular_dark_adapts_to_pane_size() { + let material = material("regular-dark"); + for (side, apple) in [ + (44., 180.), + (64., 180.), + (80., 154.), + (96., 127.), + (128., 114.), + (240., 114.), + ] { + let got = centre_of(&material, 255., pane(side), None)[0]; + eprintln!("regular-dark {side} pt over white -> {got:.1} (apple {apple})"); + assert!( + (got - apple).abs() <= 3., + "{side} pt: {got} vs Apple's {apple}" + ); + } +} + +/// Apple's tints over mid grey, read off the `sweep` captures: Regular +/// ignores the hue and shifts grey, Clear blends the colour in. +#[test] +fn tints_match_apple_over_mid_grey() { + let blue = ([0., 0.533, 1.], 0.6); + let red = ([1., 0.22, 0.235], 0.6); + for (name, tint, apple) in [ + ("regular-light", blue, [172., 172., 172.]), + ("regular-dark", blue, [126., 125., 126.]), + ("regular-light", red, [172., 172., 172.]), + ("clear-light", blue, [63., 130., 193.]), + ("clear-dark", blue, [46., 107., 166.]), + ] { + let got = centre_of(&material(name), 128., pane(240.), Some(tint)); + eprintln!("{name} {tint:?} over 128 -> {got:?} (apple {apple:?})"); + for c in 0..3 { + assert!( + (got[c] - apple[c]).abs() <= 3., + "{name} channel {c}: {got:?} vs Apple's {apple:?}" + ); + } + } +} + +/// Apple's refraction at the edge grows with the pane up to a cap: about +/// 0.52 px per point for Clear up to 45 px, 11 + 0.08 px per point for +/// Regular up to 26 px (`sweep lens`). +#[test] +fn lens_amplitude_follows_apple() { + for (name, side, apple) in [ + ("clear-light", 20., 10.9), + ("clear-light", 44., 23.5), + ("clear-light", 270., 44.1), + ("regular-light", 44., 14.3), + ("regular-light", 270., 25.6), + ] { + let got = -material(name).resolve(400., side).rim_bend; + assert!( + (got - apple).abs() <= 2., + "{name} {side} pt: {got} vs Apple's {apple}" + ); + } +} diff --git a/apps/desktop-gpui/patches/zed-glass.patch b/apps/desktop-gpui/patches/zed-glass.patch new file mode 100644 index 00000000000..340be7e9d2b --- /dev/null +++ b/apps/desktop-gpui/patches/zed-glass.patch @@ -0,0 +1,2158 @@ +diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs +index 6cc3562..4c1f558 100644 +--- a/crates/gpui/src/scene.rs ++++ b/crates/gpui/src/scene.rs +@@ -52,7 +52,7 @@ pub struct Scene { + pub surfaces: Vec, + /// Backdrop-blur regions — deliberately OUTSIDE the primitive batch + /// stream: the renderer breaks its render pass at each blur's order to +- /// snapshot the framebuffer (macOS Metal; other renderers ignore them). ++ /// snapshot the framebuffer. + pub backdrop_blurs: Vec, + } + +@@ -613,8 +613,8 @@ impl From for Primitive { + + /// A within-window backdrop blur region: the renderer snapshots everything + /// painted below this order and paints it back gaussian-blurred inside the +-/// rounded bounds (frosted-glass popovers). macOS Metal only — see +-/// [`crate::Window::paint_backdrop_blur`]. ++/// rounded bounds, optionally shaded as glass. See ++/// [`crate::Window::paint_backdrop_blur`] and [`crate::Window::paint_glass`]. + #[derive(Debug, Copy, Clone)] + #[repr(C)] + #[expect(missing_docs)] +@@ -624,6 +624,87 @@ pub struct BackdropBlur { + pub bounds: Bounds, + pub content_mask: ContentMask, + pub corner_radii: Corners, ++ pub glass: GlassStyle, ++} ++ ++/// The glass material a backdrop blur is shaded with, resolved for one ++/// pane. Lengths are device pixels; colours are 0-1 in the framebuffer's ++/// (gamma-encoded) space. All zeros (`enabled == 0`) is a plain blur. ++/// ++/// Per pixel: the blurred backdrop is sampled along the edge normal by ++/// `rim_bend` within the `lens_rim`-wide rim (falling off with ++/// `lens_curve`) and transformed by a cubic polynomial: each output channel ++/// `c` is `poly[c * 13..]` dotted with `r, g, b, r², g², b², rg, gb, rb, r³, ++/// g³, b³, 1`. An edge line then scales the colour by `1 + line + line_lit` ++/// times how much the edge faces `light_x`/`light_y` (decaying over ++/// `line_width`), and an inner glow adds `glow` (decaying over ++/// `glow_width`). Over a transparent backdrop the glass is `opacity` opaque. ++/// The pixel ring just outside the edge gets a hairline, ++/// `clamp(min(under, a * under + b), 0, 1)`, with `a` and `b` blended from ++/// `hairline[0..2]` on vertical edges to `hairline[2..4]` on horizontal ones. ++#[derive(Debug, Copy, Clone, PartialEq)] ++#[repr(C)] ++#[expect(missing_docs)] ++pub struct GlassStyle { ++ pub enabled: u32, ++ pub lens_rim: f32, ++ pub lens_curve: f32, ++ pub rim_bend: f32, ++ pub poly: [f32; 60], ++ pub opacity: f32, ++ pub line: f32, ++ pub line_lit: f32, ++ pub line_width: f32, ++ pub light_x: f32, ++ pub light_y: f32, ++ pub glow: f32, ++ pub glow_width: f32, ++ pub hairline: [f32; 4], ++ /// Tint, a colour matrix on the finished glass colour: `color * ++ /// tint_scale + luma(color) * tint_luma + tint_offset` per channel, with ++ /// Rec. 709 luma of the gamma-encoded colour. Untinted glass has a scale ++ /// of 1 and nothing else. The struct stays a multiple of 8 bytes, which ++ /// WGSL's storage layout requires of `BackdropBlur`. ++ pub tint_scale: [f32; 3], ++ pub tint_luma: [f32; 3], ++ pub tint_offset: [f32; 3], ++ pub pad: f32, ++} ++ ++impl Default for GlassStyle { ++ fn default() -> Self { ++ Self { ++ enabled: 0, ++ lens_rim: 0., ++ lens_curve: 0., ++ rim_bend: 0., ++ poly: [0.; 60], ++ opacity: 0., ++ line: 0., ++ line_lit: 0., ++ line_width: 0., ++ light_x: 0., ++ light_y: 0., ++ glow: 0., ++ glow_width: 0., ++ hairline: [0.; 4], ++ tint_scale: [1.; 3], ++ tint_luma: [0.; 3], ++ tint_offset: [0.; 3], ++ pad: 0., ++ } ++ } ++} ++ ++impl GlassStyle { ++ /// How far the shader may sample outside the pane's bounds. ++ pub fn reach(&self) -> f32 { ++ if self.enabled == 0 { ++ 0. ++ } else { ++ self.rim_bend.abs() + 2. ++ } ++ } + } + + #[derive(Debug, Copy, Clone)] +diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs +index 4299e15..82cdc92 100644 +--- a/crates/gpui/src/window.rs ++++ b/crates/gpui/src/window.rs +@@ -13,7 +13,7 @@ use crate::{ + PolychromeSprite, + Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, +- SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, BackdropBlur, ScaledPixels, Scene, Shadow, SharedString, Size, ++ SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, BackdropBlur, GlassStyle, ScaledPixels, Scene, Shadow, SharedString, Size, + StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, + TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, +@@ -4186,10 +4186,29 @@ impl Window { + bounds: Bounds, + corner_radii: Corners, + blur_radius: Pixels, ++ ) { ++ self.paint_glass(bounds, corner_radii, blur_radius, GlassStyle::default()); ++ } ++ ++ /// Paint a glass pane: [`Self::paint_backdrop_blur`] shaded with ++ /// `glass`, whose lengths are logical pixels here and scaled to device ++ /// pixels for the renderer. ++ pub fn paint_glass( ++ &mut self, ++ bounds: Bounds, ++ corner_radii: Corners, ++ blur_radius: Pixels, ++ glass: GlassStyle, + ) { + self.invalidator.debug_assert_paint(); + let scale_factor = self.scale_factor(); + let content_mask = self.content_mask().scale(scale_factor); ++ // One layer gives the splitter and the blur the same order, so the ++ // renderer paints the blur before the splitter's batch even when ++ // nothing else is painted above it. The extra 2px covers glass's ++ // hairline. ++ let layer_bounds = bounds.scale(scale_factor).dilate(ScaledPixels(2.)); ++ self.next_frame.scene.push_layer(layer_bounds); + // Invisible splitter primitive: forces a batch boundary at this order + // so the renderer can break its render pass exactly here. + self.next_frame.scene.insert_primitive(Shadow { +@@ -4210,7 +4229,15 @@ impl Window { + bounds: bounds.scale(scale_factor), + content_mask, + corner_radii: corner_radii.scale(scale_factor), ++ glass: GlassStyle { ++ lens_rim: glass.lens_rim * scale_factor, ++ rim_bend: glass.rim_bend * scale_factor, ++ line_width: glass.line_width * scale_factor, ++ glow_width: glass.glow_width * scale_factor, ++ ..glass ++ }, + }); ++ self.next_frame.scene.pop_layer(); + } + + /// Paint one or more quads into the scene for the next frame at the current stacking context. +diff --git a/crates/gpui_macos/build.rs b/crates/gpui_macos/build.rs +index 70e9a0b..79415df 100644 +--- a/crates/gpui_macos/build.rs ++++ b/crates/gpui_macos/build.rs +@@ -52,6 +52,7 @@ mod macos_build { + "Shadow".into(), + "BackdropBlurInputIndex".into(), + "BackdropBlur".into(), ++ "GlassStyle".into(), + "QuadInputIndex".into(), + "Underline".into(), + "UnderlineInputIndex".into(), +diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs +index 4be15aa..a4f34d0 100644 +--- a/crates/gpui_macos/src/metal_renderer.rs ++++ b/crates/gpui_macos/src/metal_renderer.rs +@@ -979,7 +979,7 @@ impl MetalRenderer { + // so snapshot just that padded region instead of the whole + // drawable (popovers are small; this is a 10-20x reduction). + let sigma = blur.blur_radius.0.max(1.0); +- let padding = (sigma * 3.0).ceil() + 2.0; ++ let padding = (sigma * 3.0 + blur.glass.reach()).ceil() + 2.0; + let visible = blur.bounds.intersect(&blur.content_mask.bounds); + let drawable_width = texture.width() as i64; + let drawable_height = texture.height() as i64; +@@ -1064,6 +1064,7 @@ impl MetalRenderer { + &mut instance_offset, + viewport_size, + &blurred, ++ &scratch, + command_encoder, + ) { + command_encoder.end_encoding(); +@@ -1425,6 +1426,7 @@ impl MetalRenderer { + instance_offset: &mut usize, + viewport_size: Size, + source_texture: &metal::TextureRef, ++ sharp_texture: &metal::TextureRef, + command_encoder: &metal::RenderCommandEncoderRef, + ) -> bool { + if blurs.is_empty() { +@@ -1462,6 +1464,10 @@ impl MetalRenderer { + BackdropBlurInputIndex::SourceTexture as u64, + Some(source_texture), + ); ++ command_encoder.set_fragment_texture( ++ BackdropBlurInputIndex::SharpTexture as u64, ++ Some(sharp_texture), ++ ); + + let blur_bytes_len = mem::size_of_val(blurs); + let buffer_contents = +@@ -2397,6 +2403,8 @@ enum BackdropBlurInputIndex { + /// Drawable rect the snapshot covers (x, y, w, h in device px) — the + /// fragment shader maps frag position → snapshot UV through it. + SourceRect = 4, ++ /// The unblurred snapshot glass antialiases its edge against. ++ SharpTexture = 5, + } + + #[repr(C)] +@@ -2568,7 +2576,7 @@ mod renderer_hardening_tests { + #[cfg(test)] + mod backdrop_blur_tests { + use super::*; +- use gpui::{Corners, Edges, Hsla, solid_background, transparent_black}; ++ use gpui::{Corners, Edges, GlassStyle, Hsla, solid_background, transparent_black}; + use image::RgbaImage; + + const VIEW_W: i32 = 640; +@@ -2645,6 +2653,7 @@ mod backdrop_blur_tests { + bounds: blur_bounds, + content_mask: ContentMask { bounds: viewport() }, + corner_radii: Corners::default(), ++ glass: Default::default(), + }); + scene.pop_layer(); + } +@@ -2741,6 +2750,249 @@ mod backdrop_blur_tests { + ); + } + ++ fn push_stripes(scene: &mut Scene) { ++ let mut x = 0.; ++ let mut dark = true; ++ while x < VIEW_W as f32 { ++ push_quad( ++ scene, ++ bounds(x, 0., 8., VIEW_H as f32), ++ if dark { 0.05 } else { 0.95 }, ++ ); ++ x += 8.; ++ dark = !dark; ++ } ++ } ++ ++ fn test_glass() -> GlassStyle { ++ let mut poly = [0.; 60]; ++ poly[..20].copy_from_slice(&[ ++ 0.6, 0.15, 0.05, -0.2, 0.1, 0.0, 0.05, 0.0, 0.02, 0.1, 0.0, -0.02, 0.04, -0.03, ++ 0.0, 0.02, 0.0, 0.0, 0.05, 0.22, ++ ]); ++ poly[20..40].copy_from_slice(&[ ++ 0.05, 0.5, 0.1, 0.0, -0.15, 0.05, 0.0, 0.04, 0.0, 0.0, 0.08, 0.0, 0.0, 0.03, -0.04, ++ 0.0, 0.02, 0.0, -0.03, 0.2, ++ ]); ++ poly[40..].copy_from_slice(&[ ++ 0.12, 0.05, 0.55, 0.0, 0.0, -0.1, 0.02, 0.0, 0.03, -0.03, 0.0, 0.06, 0.0, 0.0, ++ 0.03, 0.0, -0.05, 0.04, 0.02, 0.18, ++ ]); ++ GlassStyle { ++ enabled: 1, ++ lens_rim: 28., ++ lens_curve: 0.4, ++ rim_bend: -40., ++ poly, ++ opacity: 0.6, ++ line: -0.1, ++ line_lit: 0.05, ++ line_width: 1., ++ light_x: -0.7, ++ light_y: -0.7, ++ glow: 0.04, ++ glow_width: 6., ++ hairline: [0.86, -0.12, 0.92, -0.08], ++ tint_scale: [0.4, 0.4, 0.4], ++ tint_luma: [0.05, 0.12, 0.25], ++ tint_offset: [0.02, 0.15, 0.3], ++ pad: 0., ++ } ++ } ++ ++ fn gaussian_blur(image: &RgbaImage, sigma: f32) -> Vec<[f32; 4]> { ++ let (w, h) = (image.width() as i32, image.height() as i32); ++ let radius = (sigma * 3.).ceil() as i32; ++ let weights: Vec = (-radius..=radius) ++ .map(|i| (-(i * i) as f32 / (2. * sigma * sigma)).exp()) ++ .collect(); ++ let total: f32 = weights.iter().sum(); ++ let pass = |src: &dyn Fn(i32, i32) -> [f32; 4], horizontal: bool| -> Vec<[f32; 4]> { ++ let mut out = vec![[0.; 4]; (w * h) as usize]; ++ for y in 0..h { ++ for x in 0..w { ++ let mut acc = [0.; 4]; ++ for (k, weight) in weights.iter().enumerate() { ++ let offset = k as i32 - radius; ++ let (sx, sy) = if horizontal { ++ ((x + offset).clamp(0, w - 1), y) ++ } else { ++ (x, (y + offset).clamp(0, h - 1)) ++ }; ++ let px = src(sx, sy); ++ for c in 0..4 { ++ acc[c] += px[c] * weight / total; ++ } ++ } ++ out[(y * w + x) as usize] = acc; ++ } ++ } ++ out ++ }; ++ let first = pass( ++ &|x, y| { ++ let p = image.get_pixel(x as u32, y as u32); ++ [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32] ++ }, ++ true, ++ ); ++ pass(&|x, y| first[(y * w + x) as usize], false) ++ } ++ ++ /// The glass shader against the same math on the CPU: the snapshot is ++ /// gaussian-blurred, sampled along the edge normal in the rim, then ++ /// colour-transformed, compressed and lit. ++ #[test] ++ fn glass_matches_the_cpu_model() { ++ if metal::Device::system_default().is_none() { ++ return; ++ } ++ let pane = bounds(200., 140., 240., 160.); ++ let radius = 30.; ++ let sigma = 12.; ++ let glass = test_glass(); ++ ++ let mut base = Scene::default(); ++ push_stripes(&mut base); ++ let backdrop = render(&mut base); ++ ++ let mut scene = Scene::default(); ++ push_stripes(&mut scene); ++ scene.push_layer(pane); ++ scene.insert_primitive(Shadow { ++ order: 0, ++ blur_radius: ScaledPixels(0.), ++ bounds: pane, ++ corner_radii: Corners::all(ScaledPixels(radius)), ++ content_mask: ContentMask { bounds: viewport() }, ++ color: transparent_black(), ++ element_bounds: pane, ++ element_corner_radii: Corners::all(ScaledPixels(radius)), ++ inset: 0, ++ pad: 0, ++ }); ++ scene.insert_backdrop_blur(BackdropBlur { ++ order: 0, ++ blur_radius: ScaledPixels(sigma), ++ bounds: pane, ++ content_mask: ContentMask { bounds: viewport() }, ++ corner_radii: Corners::all(ScaledPixels(radius)), ++ glass, ++ }); ++ scene.pop_layer(); ++ let rendered = render(&mut scene); ++ ++ let blurred = gaussian_blur(&backdrop, sigma); ++ let width = VIEW_W; ++ let sample = |x: f32, y: f32| -> [f32; 4] { ++ let fx = (x - 0.5).clamp(0., (VIEW_W - 1) as f32); ++ let fy = (y - 0.5).clamp(0., (VIEW_H - 1) as f32); ++ let (x0, y0) = (fx.floor() as i32, fy.floor() as i32); ++ let (x1, y1) = ((x0 + 1).min(VIEW_W - 1), (y0 + 1).min(VIEW_H - 1)); ++ let (tx, ty) = (fx - x0 as f32, fy - y0 as f32); ++ let at = |x: i32, y: i32| blurred[(y * width + x) as usize]; ++ let (a, b, c, d) = (at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)); ++ std::array::from_fn(|i| { ++ let top = a[i] + (b[i] - a[i]) * tx; ++ top + (c[i] + (d[i] - c[i]) * tx - top) * ty ++ }) ++ }; ++ let sdf = |x: f32, y: f32| -> f32 { ++ let half = (pane.size.width.0 / 2., pane.size.height.0 / 2.); ++ let qx = (x - (pane.origin.x.0 + half.0)).abs() - (half.0 - radius); ++ let qy = (y - (pane.origin.y.0 + half.1)).abs() - (half.1 - radius); ++ (qx.max(0.).powi(2) + qy.max(0.).powi(2)).sqrt() + qx.max(qy).min(0.) - radius ++ }; ++ ++ let mut errors = Vec::new(); ++ for y in (pane.origin.y.0 as i32 - 2)..(pane.origin.y.0 + pane.size.height.0) as i32 + 2 { ++ for x in (pane.origin.x.0 as i32 - 2)..(pane.origin.x.0 + pane.size.width.0) as i32 + 2 ++ { ++ let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); ++ let d = sdf(px, py); ++ let coverage = (0.5 - d).clamp(0., 1.); ++ let ring = (1.5 - d).clamp(0., 1.) - coverage; ++ let (nx, ny) = { ++ let dx = sdf(px + 0.5, py) - sdf(px - 0.5, py); ++ let dy = sdf(px, py + 0.5) - sdf(px, py - 0.5); ++ let len = (dx * dx + dy * dy).sqrt().max(1e-6); ++ (dx / len, dy / len) ++ }; ++ let vertical = nx * nx; ++ let hair_a = glass.hairline[2] + (glass.hairline[0] - glass.hairline[2]) * vertical; ++ let hair_b = glass.hairline[3] + (glass.hairline[1] - glass.hairline[3]) * vertical; ++ let raw = backdrop.get_pixel(x as u32, y as u32); ++ let under: [f32; 3] = std::array::from_fn(|i| { ++ let value = raw[i] as f32 / 255.; ++ let line = value.min(hair_a * value + hair_b).clamp(0., 1.); ++ (value + (line - value) * ring) * 255. ++ }); ++ let expected: [f32; 3] = if coverage <= 0. { ++ under ++ } else { ++ let edge = (-d).max(0.); ++ let rim = (-(edge / glass.lens_rim).powf(glass.lens_curve)).exp(); ++ let s = sample( ++ px + nx * glass.rim_bend * rim, ++ py + ny * glass.rim_bend * rim, ++ ); ++ let c = [s[0] / 255., s[1] / 255., s[2] / 255.]; ++ let features = [ ++ c[0], ++ c[1], ++ c[2], ++ c[0] * c[0], ++ c[1] * c[1], ++ c[2] * c[2], ++ c[0] * c[1], ++ c[1] * c[2], ++ c[0] * c[2], ++ c[0] * c[0] * c[0], ++ c[1] * c[1] * c[1], ++ c[2] * c[2] * c[2], ++ c[0] * c[0] * c[1], ++ c[0] * c[0] * c[2], ++ c[1] * c[1] * c[0], ++ c[1] * c[1] * c[2], ++ c[2] * c[2] * c[0], ++ c[2] * c[2] * c[1], ++ c[0] * c[1] * c[2], ++ 1., ++ ]; ++ let facing = nx * glass.light_x + ny * glass.light_y; ++ let line = ++ (-edge / glass.line_width).exp() * (glass.line + glass.line_lit * facing); ++ let glow = (-edge / glass.glow_width).exp() * glass.glow; ++ let shaded: [f32; 3] = std::array::from_fn(|i| { ++ let value: f32 = ++ (0..20).map(|t| glass.poly[i * 20 + t] * features[t]).sum(); ++ (value * (1. + line) + glow).clamp(0., 1.) ++ }); ++ let luma = shaded[0] * 0.2126 + shaded[1] * 0.7152 + shaded[2] * 0.0722; ++ std::array::from_fn(|i| { ++ let value = (shaded[i] * glass.tint_scale[i] ++ + luma * glass.tint_luma[i] ++ + glass.tint_offset[i]) ++ .clamp(0., 1.) ++ * 255.; ++ under[i] + (value - under[i]) * coverage ++ }) ++ }; ++ let got = rendered.get_pixel(x as u32, y as u32); ++ for c in 0..3 { ++ errors.push((got[c] as f32 - expected[c]).abs()); ++ } ++ } ++ } ++ errors.sort_by(f32::total_cmp); ++ let mean = errors.iter().sum::() / errors.len() as f32; ++ let p99 = errors[errors.len() * 99 / 100]; ++ assert!( ++ mean < 1.5 && p99 < 8., ++ "glass vs CPU model: mean {mean:.2}, p99 {p99:.2}" ++ ); ++ } ++ + #[test] + fn two_blurs_of_different_sizes_in_one_frame() { + if metal::Device::system_default().is_none() { +diff --git a/crates/gpui_macos/src/shaders.metal b/crates/gpui_macos/src/shaders.metal +index e087c0d..1a5848c 100644 +--- a/crates/gpui_macos/src/shaders.metal ++++ b/crates/gpui_macos/src/shaders.metal +@@ -1354,9 +1354,17 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( + [[buffer(BackdropBlurInputIndex_ViewportSize)]]) { + float2 unit_vertex = unit_vertices[unit_vertex_id]; + BackdropBlur blur = blurs[blur_id]; ++ // Glass draws a hairline in the pixel ring just outside its edge. ++ Bounds_ScaledPixels bounds = blur.bounds; ++ if (blur.glass.enabled != 0) { ++ bounds.origin.x -= 2.; ++ bounds.origin.y -= 2.; ++ bounds.size.width += 4.; ++ bounds.size.height += 4.; ++ } + float4 device_position = +- to_device_position(unit_vertex, blur.bounds, viewport_size); +- float4 clip_distance = distance_from_clip_rect(unit_vertex, blur.bounds, ++ to_device_position(unit_vertex, bounds, viewport_size); ++ float4 clip_distance = distance_from_clip_rect(unit_vertex, bounds, + blur.content_mask.bounds); + return BackdropBlurVertexOutput{ + device_position, +@@ -1364,26 +1372,88 @@ vertex BackdropBlurVertexOutput backdrop_blur_vertex( + {clip_distance.x, clip_distance.y, clip_distance.z, clip_distance.w}}; + } + ++float3 glass_shade(GlassStyle glass, float3 c, float edge, float2 normal) { ++ // The full cubic in r, g and b; `cap_glass::model::poly_features`. ++ float features[20] = {c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ c.r * c.g, c.g * c.b, c.r * c.b, c.r * c.r * c.r, ++ c.g * c.g * c.g, c.b * c.b * c.b, c.r * c.r * c.g, ++ c.r * c.r * c.b, c.g * c.g * c.r, c.g * c.g * c.b, ++ c.b * c.b * c.r, c.b * c.b * c.g, c.r * c.g * c.b, 1.}; ++ float3 color = float3(0.); ++ for (int term = 0; term < 20; term++) { ++ color += features[term] * ++ float3(glass.poly[term], glass.poly[20 + term], glass.poly[40 + term]); ++ } ++ float facing = dot(normal, float2(glass.light_x, glass.light_y)); ++ float line = exp(-edge / glass.line_width) * (glass.line + glass.line_lit * facing); ++ float glow = exp(-edge / glass.glow_width) * glass.glow; ++ return color * (1. + line) + glow; ++} ++ + fragment float4 backdrop_blur_fragment( + BackdropBlurFragmentInput input [[stage_in]], + constant BackdropBlur *blurs [[buffer(BackdropBlurInputIndex_Blurs)]], + constant float4 &source_rect [[buffer(BackdropBlurInputIndex_SourceRect)]], + texture2d source_texture +- [[texture(BackdropBlurInputIndex_SourceTexture)]]) { ++ [[texture(BackdropBlurInputIndex_SourceTexture)]], ++ texture2d sharp_texture ++ [[texture(BackdropBlurInputIndex_SharpTexture)]]) { + constexpr sampler source_sampler(coord::normalized, address::clamp_to_edge, + filter::linear); + BackdropBlur blur = blurs[input.blur_id]; ++ float2 position = input.position.xy; ++ float distance = quad_sdf(position, blur.bounds, blur.corner_radii); + +- // Rounded-rect clip: blending is disabled on this pipeline (the blur +- // REPLACES the region), so fragments outside must discard, not return 0. +- float distance = quad_sdf(input.position.xy, blur.bounds, blur.corner_radii); +- if (distance > 0.) { +- discard_fragment(); ++ // The snapshot was gaussian-blurred on the GPU (MPSImageGaussianBlur) ++ // before this pass. It covers only source_rect (x, y, w, h) of the ++ // drawable, not the whole viewport. ++ if (blur.glass.enabled == 0) { ++ // Blending is disabled on this pipeline (the blur REPLACES the region), ++ // so fragments outside must discard, not return 0. ++ if (distance > 0.) { ++ discard_fragment(); ++ } ++ float2 uv = (position - source_rect.xy) / source_rect.zw; ++ return source_texture.sample(source_sampler, uv); + } + +- // The snapshot was gaussian-blurred on the GPU (MPSImageGaussianBlur) +- // before this pass — one clean sample. The snapshot covers only +- // source_rect (x, y, w, h) of the drawable, not the whole viewport. +- float2 uv = (input.position.xy - source_rect.xy) / source_rect.zw; +- return source_texture.sample(source_sampler, uv); ++ // Glass antialiases its own edge against the unblurred snapshot, and ++ // draws its hairline in the ring just outside the edge. ++ float coverage = saturate(0.5 - distance); ++ float ring = saturate(1.5 - distance) - coverage; ++ if (coverage <= 0. && ring <= 0.) { ++ discard_fragment(); ++ } ++ float2 normal = float2( ++ quad_sdf(position + float2(0.5, 0.), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0.5, 0.), blur.bounds, blur.corner_radii), ++ quad_sdf(position + float2(0., 0.5), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0., 0.5), blur.bounds, blur.corner_radii)); ++ normal /= max(length(normal), 1e-6); ++ float vertical = normal.x * normal.x; ++ float hair_a = mix(blur.glass.hairline[2], blur.glass.hairline[0], vertical); ++ float hair_b = mix(blur.glass.hairline[3], blur.glass.hairline[1], vertical); ++ float4 under = sharp_texture.sample( ++ source_sampler, (position - source_rect.xy) / source_rect.zw); ++ float3 hairline = saturate(min(under.rgb, hair_a * under.rgb + hair_b)); ++ under.rgb = mix(under.rgb, hairline, ring); ++ if (coverage <= 0.) { ++ return under; ++ } ++ float edge = max(-distance, 0.); ++ float rim = exp(-pow(edge / blur.glass.lens_rim, blur.glass.lens_curve)); ++ float2 sample_at = position + normal * blur.glass.rim_bend * rim; ++ float4 backdrop = source_texture.sample( ++ source_sampler, (sample_at - source_rect.xy) / source_rect.zw); ++ float3 straight = backdrop.a > 0. ? backdrop.rgb / backdrop.a : float3(0.); ++ float alpha = mix(backdrop.a, 1., blur.glass.opacity); ++ float3 glass = saturate(glass_shade(blur.glass, straight, edge, normal)); ++ float luma = dot(glass, float3(0.2126, 0.7152, 0.0722)); ++ glass = saturate(glass * float3(blur.glass.tint_scale[0], blur.glass.tint_scale[1], ++ blur.glass.tint_scale[2]) + ++ luma * float3(blur.glass.tint_luma[0], blur.glass.tint_luma[1], ++ blur.glass.tint_luma[2]) + ++ float3(blur.glass.tint_offset[0], blur.glass.tint_offset[1], ++ blur.glass.tint_offset[2])); ++ return mix(under, float4(glass * alpha, alpha), coverage); + } +diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl +index 011be71..2b82b2b 100644 +--- a/crates/gpui_wgpu/src/shaders.wgsl ++++ b/crates/gpui_wgpu/src/shaders.wgsl +@@ -1399,3 +1399,204 @@ fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { + + return ycbcr_to_RGB * y_cb_cr; + } ++ ++// --- backdrop blur and glass --- // ++ ++struct GlassStyle { ++ enabled: u32, ++ lens_rim: f32, ++ lens_curve: f32, ++ rim_bend: f32, ++ poly: array, ++ opacity: f32, ++ line_strength: f32, ++ line_lit: f32, ++ line_width: f32, ++ light_x: f32, ++ light_y: f32, ++ glow: f32, ++ glow_width: f32, ++ hairline: array, ++ tint_scale: array, ++ tint_luma: array, ++ tint_offset: array, ++ pad: f32, ++} ++ ++struct BackdropBlur { ++ order: u32, ++ blur_radius: f32, ++ bounds: Bounds, ++ content_mask: Bounds, ++ corner_radii: Corners, ++ glass: GlassStyle, ++} ++ ++// For the downsample and blur passes `source_rect` is the texel rect of the ++// source texture being read and `params` is (direction x, direction y, ++// sigma in source texels or the downsample factor, unused). For the glass ++// pass `source_rect` is the drawable rect that was blurred, `params.xy` how ++// far it was scaled down on each axis (the blurred texture holds it from ++// texel 0), and `sharp_texture_size` the size of the drawable-sized ++// unblurred copy. ++struct BackdropParams { ++ source_rect: vec4, ++ params: vec4, ++ source_texture_size: vec2, ++ sharp_texture_size: vec2, ++} ++ ++@group(1) @binding(0) var backdrop_params: BackdropParams; ++@group(1) @binding(1) var t_backdrop: texture_2d; ++@group(1) @binding(2) var s_backdrop: sampler; ++@group(1) @binding(3) var b_backdrop_blurs: array; ++@group(1) @binding(4) var t_backdrop_sharp: texture_2d; ++ ++struct BackdropPassVarying { ++ @builtin(position) position: vec4, ++ @location(0) uv: vec2, ++} ++ ++@vertex ++fn vs_backdrop_pass(@builtin(vertex_index) vertex_id: u32) -> BackdropPassVarying { ++ let uv = vec2(f32((vertex_id << 1u) & 2u), f32(vertex_id & 2u)); ++ var out = BackdropPassVarying(); ++ out.position = vec4(uv * vec2(2.0, -2.0) + vec2(-1.0, 1.0), 0.0, 1.0); ++ out.uv = uv; ++ return out; ++} ++ ++fn backdrop_source_uv(uv: vec2) -> vec2 { ++ let rect = backdrop_params.source_rect; ++ return (rect.xy + uv * rect.zw) / backdrop_params.source_texture_size; ++} ++ ++@fragment ++fn fs_backdrop_downsample(input: BackdropPassVarying) -> @location(0) vec4 { ++ let rect = backdrop_params.source_rect; ++ let texel = 1.0 / backdrop_params.source_texture_size; ++ let center = backdrop_source_uv(input.uv); ++ let quarter = texel * backdrop_params.params.z * 0.25; ++ let lo = (rect.xy + 0.5) * texel; ++ let hi = (rect.xy + rect.zw - 0.5) * texel; ++ return 0.25 * ( ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(-quarter.x, -quarter.y), lo, hi), 0.0) + ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(quarter.x, -quarter.y), lo, hi), 0.0) + ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(-quarter.x, quarter.y), lo, hi), 0.0) + ++ textureSampleLevel(t_backdrop, s_backdrop, clamp(center + vec2(quarter.x, quarter.y), lo, hi), 0.0)); ++} ++ ++@fragment ++fn fs_backdrop_blur_pass(input: BackdropPassVarying) -> @location(0) vec4 { ++ let rect = backdrop_params.source_rect; ++ let sigma = max(backdrop_params.params.z, 0.5); ++ let radius = i32(ceil(sigma * 3.0)); ++ let step = backdrop_params.params.xy / backdrop_params.source_texture_size; ++ let uv = backdrop_source_uv(input.uv); ++ let lo = (rect.xy + 0.5) / backdrop_params.source_texture_size; ++ let hi = (rect.xy + rect.zw - 0.5) / backdrop_params.source_texture_size; ++ var sum = vec4(0.0); ++ var total = 0.0; ++ for (var i = -radius; i <= radius; i++) { ++ let weight = exp(-f32(i * i) / (2.0 * sigma * sigma)); ++ sum += weight * textureSampleLevel(t_backdrop, s_backdrop, clamp(uv + step * f32(i), lo, hi), 0.0); ++ total += weight; ++ } ++ return sum / total; ++} ++ ++struct BackdropGlassVarying { ++ @builtin(position) position: vec4, ++ @location(0) @interpolate(flat) blur_id: u32, ++ @location(1) clip_distances: vec4, ++} ++ ++@vertex ++fn vs_backdrop_glass(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> BackdropGlassVarying { ++ let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); ++ let blur = b_backdrop_blurs[instance_id]; ++ // Glass draws a hairline in the pixel ring just outside its edge. ++ var bounds = blur.bounds; ++ if (blur.glass.enabled != 0u) { ++ bounds.origin -= vec2(2.0); ++ bounds.size += vec2(4.0); ++ } ++ var out = BackdropGlassVarying(); ++ out.position = to_device_position(unit_vertex, bounds); ++ out.blur_id = instance_id; ++ out.clip_distances = distance_from_clip_rect(unit_vertex, bounds, blur.content_mask); ++ return out; ++} ++ ++// Clamped to the blurred region: texels beyond it hold an older pass. ++fn backdrop_blurred_uv(position: vec2) -> vec2 { ++ let rect = backdrop_params.source_rect; ++ let scale = backdrop_params.params.xy; ++ let texel = clamp((position - rect.xy) / scale, vec2(0.5), rect.zw / scale - 0.5); ++ return texel / backdrop_params.source_texture_size; ++} ++ ++@fragment ++fn fs_backdrop_glass(input: BackdropGlassVarying) -> @location(0) vec4 { ++ if (any(input.clip_distances < vec4(0.0))) { ++ discard; ++ } ++ let blur = b_backdrop_blurs[input.blur_id]; ++ let position = input.position.xy; ++ let distance = quad_sdf(position, blur.bounds, blur.corner_radii); ++ ++ if (blur.glass.enabled == 0u) { ++ if (distance > 0.0) { ++ discard; ++ } ++ return textureSampleLevel(t_backdrop, s_backdrop, backdrop_blurred_uv(position), 0.0); ++ } ++ ++ let coverage = saturate(0.5 - distance); ++ let ring = saturate(1.5 - distance) - coverage; ++ if (coverage <= 0.0 && ring <= 0.0) { ++ discard; ++ } ++ let glass = blur.glass; ++ let edge = max(-distance, 0.0); ++ var normal = vec2( ++ quad_sdf(position + vec2(0.5, 0.0), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - vec2(0.5, 0.0), blur.bounds, blur.corner_radii), ++ quad_sdf(position + vec2(0.0, 0.5), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - vec2(0.0, 0.5), blur.bounds, blur.corner_radii)); ++ normal = normal / max(length(normal), 1e-6); ++ let vertical = normal.x * normal.x; ++ let hair_a = mix(glass.hairline[2], glass.hairline[0], vertical); ++ let hair_b = mix(glass.hairline[3], glass.hairline[1], vertical); ++ var under = textureSampleLevel(t_backdrop_sharp, s_backdrop, position / backdrop_params.sharp_texture_size, 0.0); ++ let hair_color = saturate(min(under.rgb, hair_a * under.rgb + vec3(hair_b))); ++ under = vec4(mix(under.rgb, hair_color, ring), under.a); ++ if (coverage <= 0.0) { ++ return under; ++ } ++ let rim = exp(-pow(edge / glass.lens_rim, glass.lens_curve)); ++ let sample_at = position + normal * glass.rim_bend * rim; ++ let backdrop = textureSampleLevel(t_backdrop, s_backdrop, backdrop_blurred_uv(sample_at), 0.0); ++ var color = select(vec3(0.0), backdrop.rgb / backdrop.a, backdrop.a > 0.0); ++ let alpha = mix(backdrop.a, 1.0, glass.opacity); ++ ++ let c = color; ++ let features = array(c.r, c.g, c.b, c.r * c.r, c.g * c.g, c.b * c.b, ++ c.r * c.g, c.g * c.b, c.r * c.b, c.r * c.r * c.r, c.g * c.g * c.g, c.b * c.b * c.b, ++ c.r * c.r * c.g, c.r * c.r * c.b, c.g * c.g * c.r, c.g * c.g * c.b, ++ c.b * c.b * c.r, c.b * c.b * c.g, c.r * c.g * c.b, 1.0); ++ color = vec3(0.0); ++ for (var term = 0; term < 20; term++) { ++ color += features[term] * vec3(glass.poly[term], glass.poly[20 + term], glass.poly[40 + term]); ++ } ++ let facing = dot(normal, vec2(glass.light_x, glass.light_y)); ++ let line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); ++ let glow = exp(-edge / glass.glow_width) * glass.glow; ++ color = saturate(color * (1.0 + line_term) + vec3(glow)); ++ let luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); ++ color = saturate(color * vec3(glass.tint_scale[0], glass.tint_scale[1], glass.tint_scale[2]) ++ + luma * vec3(glass.tint_luma[0], glass.tint_luma[1], glass.tint_luma[2]) ++ + vec3(glass.tint_offset[0], glass.tint_offset[1], glass.tint_offset[2])); ++ ++ return mix(under, vec4(color * alpha, alpha), coverage); ++} +diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs +index 2bfbc4e..72ce028 100644 +--- a/crates/gpui_wgpu/src/wgpu_renderer.rs ++++ b/crates/gpui_wgpu/src/wgpu_renderer.rs +@@ -2,7 +2,8 @@ use crate::surface_frame::SurfaceFrame; + use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; + use bytemuck::{Pod, Zeroable}; + use gpui::{ +- AtlasTextureId, AtlasTextureKind, Background, Bounds, DevicePixels, GpuSpecs, MonochromeSprite, ++ AtlasTextureId, AtlasTextureKind, BackdropBlur, Background, Bounds, DevicePixels, DrawOrder, ++ GpuSpecs, MonochromeSprite, + Path, Point, PolychromeSprite, PrimitiveBatch, Quad, ScaledPixels, Scene, Shadow, Size, + SubpixelSprite, Underline, get_gamma_correction_ratios, + }; +@@ -93,6 +94,9 @@ struct WgpuPipelines { + poly_sprites: wgpu::RenderPipeline, + #[allow(dead_code)] + surfaces: wgpu::RenderPipeline, ++ backdrop_downsample: wgpu::RenderPipeline, ++ backdrop_blur_pass: wgpu::RenderPipeline, ++ backdrop_glass: wgpu::RenderPipeline, + } + + struct WgpuBindGroupLayouts { +@@ -100,8 +104,34 @@ struct WgpuBindGroupLayouts { + instances: wgpu::BindGroupLayout, + instances_with_texture: wgpu::BindGroupLayout, + surfaces: wgpu::BindGroupLayout, ++ backdrop_pass: wgpu::BindGroupLayout, ++ backdrop_glass: wgpu::BindGroupLayout, + } + ++/// Surface-sized textures for backdrop blurs: `sharp` holds the snapshot at ++/// the region's own position; `ping` and `pong` hold it downsampled and ++/// blurred from texel 0. ++struct BackdropTargets { ++ width: u32, ++ height: u32, ++ sharp: wgpu::Texture, ++ sharp_view: wgpu::TextureView, ++ ping_view: wgpu::TextureView, ++ pong_view: wgpu::TextureView, ++} ++ ++#[repr(C)] ++#[derive(Clone, Copy, Pod, Zeroable)] ++struct BackdropParams { ++ source_rect: [f32; 4], ++ params: [f32; 4], ++ source_texture_size: [f32; 2], ++ sharp_texture_size: [f32; 2], ++} ++ ++/// Frames without a backdrop blur before its textures are dropped. ++const BACKDROP_RELEASE_AFTER_FRAMES: u32 = 120; ++ + /// Shared GPU context reference, used to coordinate device recovery across multiple windows. + pub type GpuContext = Rc>>; + +@@ -121,6 +151,8 @@ struct WgpuResources { + path_intermediate_view: Option, + path_msaa_texture: Option, + path_msaa_view: Option, ++ backdrop_sampler: wgpu::Sampler, ++ backdrop_targets: Option, + } + + impl WgpuResources { +@@ -129,6 +161,7 @@ impl WgpuResources { + self.path_intermediate_view = None; + self.path_msaa_texture = None; + self.path_msaa_view = None; ++ self.backdrop_targets = None; + } + } + +@@ -159,6 +192,9 @@ pub struct WgpuRenderer { + device_lost: std::sync::Arc, + surface_configured: bool, + needs_redraw: bool, ++ /// Whether the surface can be copied from, which backdrop blurs need. ++ backdrop_supported: bool, ++ blur_free_frames: u32, + } + + impl WgpuRenderer { +@@ -331,8 +367,13 @@ impl WgpuRenderer { + ); + } + ++ let backdrop_supported = surface_caps.usages.contains(wgpu::TextureUsages::COPY_SRC); + let surface_config = wgpu::SurfaceConfiguration { +- usage: wgpu::TextureUsages::RENDER_ATTACHMENT, ++ usage: if backdrop_supported { ++ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC ++ } else { ++ wgpu::TextureUsages::RENDER_ATTACHMENT ++ }, + format: surface_format, + width: clamped_width.max(1), + height: clamped_height.max(1), +@@ -368,6 +409,12 @@ impl WgpuRenderer { + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); ++ let backdrop_sampler = device.create_sampler(&wgpu::SamplerDescriptor { ++ label: Some("backdrop_sampler"), ++ mag_filter: wgpu::FilterMode::Linear, ++ min_filter: wgpu::FilterMode::Linear, ++ ..Default::default() ++ }); + + let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + let globals_size = std::mem::size_of::() as u64; +@@ -464,6 +511,8 @@ impl WgpuRenderer { + path_intermediate_view: None, + path_msaa_texture: None, + path_msaa_view: None, ++ backdrop_sampler, ++ backdrop_targets: None, + }; + + Ok(Self { +@@ -489,6 +538,8 @@ impl WgpuRenderer { + device_lost: context.device_lost_flag(), + surface_configured: true, + needs_redraw: false, ++ backdrop_supported, ++ blur_free_frames: 0, + }) + } + +@@ -608,11 +659,44 @@ impl WgpuRenderer { + ], + }); + ++ let texture_entry = |binding: u32| wgpu::BindGroupLayoutEntry { ++ binding, ++ visibility: wgpu::ShaderStages::FRAGMENT, ++ ty: wgpu::BindingType::Texture { ++ sample_type: wgpu::TextureSampleType::Float { filterable: true }, ++ view_dimension: wgpu::TextureViewDimension::D2, ++ multisampled: false, ++ }, ++ count: None, ++ }; ++ let sampler_entry = wgpu::BindGroupLayoutEntry { ++ binding: 2, ++ visibility: wgpu::ShaderStages::FRAGMENT, ++ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), ++ count: None, ++ }; ++ let backdrop_pass = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("backdrop_pass_layout"), ++ entries: &[storage_buffer_entry(0), texture_entry(1), sampler_entry], ++ }); ++ let backdrop_glass = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { ++ label: Some("backdrop_glass_layout"), ++ entries: &[ ++ storage_buffer_entry(0), ++ texture_entry(1), ++ sampler_entry, ++ storage_buffer_entry(3), ++ texture_entry(4), ++ ], ++ }); ++ + WgpuBindGroupLayouts { + globals, + instances, + instances_with_texture, + surfaces, ++ backdrop_pass, ++ backdrop_glass, + } + } + +@@ -878,6 +962,46 @@ impl WgpuRenderer { + &shader_module, + ); + ++ // The backdrop passes write their final colour, coverage included. ++ let replace_target = [Some(wgpu::ColorTargetState { ++ format: surface_format, ++ blend: None, ++ write_mask: wgpu::ColorWrites::ALL, ++ })]; ++ let backdrop_downsample = create_pipeline( ++ "backdrop_downsample", ++ "vs_backdrop_pass", ++ "fs_backdrop_downsample", ++ &layouts.globals, ++ &layouts.backdrop_pass, ++ wgpu::PrimitiveTopology::TriangleList, ++ &replace_target, ++ 1, ++ &shader_module, ++ ); ++ let backdrop_blur_pass = create_pipeline( ++ "backdrop_blur_pass", ++ "vs_backdrop_pass", ++ "fs_backdrop_blur_pass", ++ &layouts.globals, ++ &layouts.backdrop_pass, ++ wgpu::PrimitiveTopology::TriangleList, ++ &replace_target, ++ 1, ++ &shader_module, ++ ); ++ let backdrop_glass = create_pipeline( ++ "backdrop_glass", ++ "vs_backdrop_glass", ++ "fs_backdrop_glass", ++ &layouts.globals, ++ &layouts.backdrop_glass, ++ wgpu::PrimitiveTopology::TriangleStrip, ++ &replace_target, ++ 1, ++ &shader_module, ++ ); ++ + WgpuPipelines { + quads, + shadows, +@@ -888,6 +1012,9 @@ impl WgpuRenderer { + subpixel_sprites, + poly_sprites, + surfaces, ++ backdrop_downsample, ++ backdrop_blur_pass, ++ backdrop_glass, + } + } + +@@ -1148,6 +1275,17 @@ impl WgpuRenderer { + + // Now that we know the surface is healthy, ensure intermediate textures exist + self.ensure_intermediate_textures(); ++ if scene.backdrop_blurs.is_empty() { ++ self.blur_free_frames = self.blur_free_frames.saturating_add(1); ++ if self.blur_free_frames >= BACKDROP_RELEASE_AFTER_FRAMES { ++ self.resources_mut().backdrop_targets = None; ++ } ++ } else { ++ self.blur_free_frames = 0; ++ if self.backdrop_supported { ++ self.ensure_backdrop_targets(); ++ } ++ } + + let frame_view = frame + .texture() +@@ -1227,7 +1365,28 @@ impl WgpuRenderer { + ..Default::default() + }); + +- for batch in scene.batches() { ++ let mut pending_blurs = scene.backdrop_blurs.iter().peekable(); ++ 'batches: for batch in scene.batches() { ++ // Backdrop blurs interleave by draw order outside the ++ // batch stream: everything painted so far is copied, ++ // blurred and painted back before the batch above it. ++ while let Some(blur) = pending_blurs ++ .next_if(|blur| blur.order <= batch_first_order(scene, &batch)) ++ { ++ drop(pass); ++ let ok = self.draw_backdrop_blur( ++ &mut encoder, ++ frame.texture(), ++ &frame_view, ++ blur, ++ &mut instance_offset, ++ ); ++ pass = Self::continue_main_pass(&mut encoder, &frame_view); ++ if !ok { ++ overflow = true; ++ break 'batches; ++ } ++ } + let ok = match batch { + PrimitiveBatch::Quads(range) => { + self.draw_quads(&scene.quads[range], &mut instance_offset, &mut pass) +@@ -1313,6 +1472,21 @@ impl WgpuRenderer { + break; + } + } ++ if !overflow { ++ drop(pass); ++ for blur in pending_blurs { ++ if !self.draw_backdrop_blur( ++ &mut encoder, ++ frame.texture(), ++ &frame_view, ++ blur, ++ &mut instance_offset, ++ ) { ++ overflow = true; ++ break; ++ } ++ } ++ } + } + + if overflow { +@@ -1365,6 +1539,279 @@ impl WgpuRenderer { + ) + } + ++ fn continue_main_pass<'a>( ++ encoder: &'a mut wgpu::CommandEncoder, ++ frame_view: &'a wgpu::TextureView, ++ ) -> wgpu::RenderPass<'a> { ++ encoder.begin_render_pass(&wgpu::RenderPassDescriptor { ++ label: Some("main_pass_continued"), ++ color_attachments: &[Some(wgpu::RenderPassColorAttachment { ++ view: frame_view, ++ resolve_target: None, ++ ops: wgpu::Operations { ++ load: wgpu::LoadOp::Load, ++ store: wgpu::StoreOp::Store, ++ }, ++ depth_slice: None, ++ })], ++ depth_stencil_attachment: None, ++ ..Default::default() ++ }) ++ } ++ ++ fn ensure_backdrop_targets(&mut self) { ++ let (width, height) = (self.surface_config.width, self.surface_config.height); ++ let format = self.surface_config.format; ++ let resources = self.resources_mut(); ++ if resources ++ .backdrop_targets ++ .as_ref() ++ .is_some_and(|targets| targets.width == width && targets.height == height) ++ { ++ return; ++ } ++ let texture = |label: &str, usage: wgpu::TextureUsages| { ++ resources.device.create_texture(&wgpu::TextureDescriptor { ++ label: Some(label), ++ size: wgpu::Extent3d { ++ width, ++ height, ++ depth_or_array_layers: 1, ++ }, ++ mip_level_count: 1, ++ sample_count: 1, ++ dimension: wgpu::TextureDimension::D2, ++ format, ++ usage, ++ view_formats: &[], ++ }) ++ }; ++ let target_usage = ++ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; ++ let sharp = texture( ++ "backdrop_sharp", ++ wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING, ++ ); ++ let view = |texture: &wgpu::Texture| texture.create_view(&Default::default()); ++ let targets = BackdropTargets { ++ width, ++ height, ++ sharp_view: view(&sharp), ++ ping_view: view(&texture("backdrop_ping", target_usage)), ++ pong_view: view(&texture("backdrop_pong", target_usage)), ++ sharp, ++ }; ++ resources.backdrop_targets = Some(targets); ++ } ++ ++ /// Copy the region under `blur` (plus room for the gaussian tail and any ++ /// glass lensing), downsample and blur it, then paint it back. Returns ++ /// false only when the instance buffer is full. Downsampling keeps every ++ /// blur under ~13 taps per side: a gaussian that wide is ++ /// indistinguishable from one taken at full resolution. ++ fn draw_backdrop_blur( ++ &self, ++ encoder: &mut wgpu::CommandEncoder, ++ frame_texture: &wgpu::Texture, ++ frame_view: &wgpu::TextureView, ++ blur: &BackdropBlur, ++ instance_offset: &mut u64, ++ ) -> bool { ++ let resources = self.resources(); ++ let Some(targets) = resources.backdrop_targets.as_ref() else { ++ return true; ++ }; ++ let sigma = blur.blur_radius.0.max(1.0); ++ let padding = (sigma * 3.0 + blur.glass.reach()).ceil() + 2.0; ++ let visible = blur.bounds.intersect(&blur.content_mask.bounds); ++ let (width, height) = (targets.width as i64, targets.height as i64); ++ let x0 = ((visible.origin.x.0 - padding).floor() as i64).max(0); ++ let y0 = ((visible.origin.y.0 - padding).floor() as i64).max(0); ++ let x1 = ((visible.origin.x.0 + visible.size.width.0 + padding).ceil() as i64).min(width); ++ let y1 = ((visible.origin.y.0 + visible.size.height.0 + padding).ceil() as i64).min(height); ++ if x1 <= x0 || y1 <= y0 { ++ return true; ++ } ++ let factor = (sigma / 4.).floor().clamp(1., 8.); ++ let region = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; ++ let reduced = [(region[2] / factor).ceil(), (region[3] / factor).ceil()]; ++ // The downsample stretches the region across `reduced` texels, so a ++ // side that `factor` does not divide shrinks by a little less. ++ let scale = [region[2] / reduced[0], region[3] / reduced[1]]; ++ let full = [targets.width as f32, targets.height as f32]; ++ ++ encoder.copy_texture_to_texture( ++ wgpu::TexelCopyTextureInfo { ++ texture: frame_texture, ++ mip_level: 0, ++ origin: wgpu::Origin3d { ++ x: x0 as u32, ++ y: y0 as u32, ++ z: 0, ++ }, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ wgpu::TexelCopyTextureInfo { ++ texture: &targets.sharp, ++ mip_level: 0, ++ origin: wgpu::Origin3d { ++ x: x0 as u32, ++ y: y0 as u32, ++ z: 0, ++ }, ++ aspect: wgpu::TextureAspect::All, ++ }, ++ wgpu::Extent3d { ++ width: (x1 - x0) as u32, ++ height: (y1 - y0) as u32, ++ depth_or_array_layers: 1, ++ }, ++ ); ++ ++ let mut run_pass = |pipeline: &wgpu::RenderPipeline, ++ source: &wgpu::TextureView, ++ target: &wgpu::TextureView, ++ params: BackdropParams| ++ -> bool { ++ let Some((offset, size)) = ++ self.write_to_instance_buffer(instance_offset, bytemuck::bytes_of(¶ms)) ++ else { ++ return false; ++ }; ++ let bind_group = resources.device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: None, ++ layout: &resources.bind_group_layouts.backdrop_pass, ++ entries: &[ ++ wgpu::BindGroupEntry { ++ binding: 0, ++ resource: self.instance_binding(offset, size), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 1, ++ resource: wgpu::BindingResource::TextureView(source), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 2, ++ resource: wgpu::BindingResource::Sampler(&resources.backdrop_sampler), ++ }, ++ ], ++ }); ++ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { ++ label: Some("backdrop_pass"), ++ color_attachments: &[Some(wgpu::RenderPassColorAttachment { ++ view: target, ++ resolve_target: None, ++ ops: wgpu::Operations { ++ load: wgpu::LoadOp::Load, ++ store: wgpu::StoreOp::Store, ++ }, ++ depth_slice: None, ++ })], ++ depth_stencil_attachment: None, ++ ..Default::default() ++ }); ++ pass.set_viewport(0., 0., reduced[0], reduced[1], 0., 1.); ++ pass.set_pipeline(pipeline); ++ pass.set_bind_group(0, &resources.globals_bind_group, &[]); ++ pass.set_bind_group(1, &bind_group, &[]); ++ pass.draw(0..3, 0..1); ++ true ++ }; ++ ++ let pipelines = &resources.pipelines; ++ let reduced_rect = [0., 0., reduced[0], reduced[1]]; ++ let (blur_source, blur_source_rect) = if factor > 1. { ++ if !run_pass( ++ &pipelines.backdrop_downsample, ++ &targets.sharp_view, ++ &targets.pong_view, ++ BackdropParams { ++ source_rect: region, ++ params: [0., 0., factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ ) { ++ return false; ++ } ++ (&targets.pong_view, reduced_rect) ++ } else { ++ (&targets.sharp_view, region) ++ }; ++ if !run_pass( ++ &pipelines.backdrop_blur_pass, ++ blur_source, ++ &targets.ping_view, ++ BackdropParams { ++ source_rect: blur_source_rect, ++ params: [1., 0., sigma / scale[0], 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ ) || !run_pass( ++ &pipelines.backdrop_blur_pass, ++ &targets.ping_view, ++ &targets.pong_view, ++ BackdropParams { ++ source_rect: reduced_rect, ++ params: [0., 1., sigma / scale[1], 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ ) { ++ return false; ++ } ++ ++ let params = BackdropParams { ++ source_rect: region, ++ params: [scale[0], scale[1], 0., 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }; ++ let Some((params_offset, params_size)) = ++ self.write_to_instance_buffer(instance_offset, bytemuck::bytes_of(¶ms)) ++ else { ++ return false; ++ }; ++ let blur_bytes = unsafe { Self::instance_bytes(std::slice::from_ref(blur)) }; ++ let Some((blur_offset, blur_size)) = self.write_to_instance_buffer(instance_offset, blur_bytes) ++ else { ++ return false; ++ }; ++ let bind_group = resources.device.create_bind_group(&wgpu::BindGroupDescriptor { ++ label: None, ++ layout: &resources.bind_group_layouts.backdrop_glass, ++ entries: &[ ++ wgpu::BindGroupEntry { ++ binding: 0, ++ resource: self.instance_binding(params_offset, params_size), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 1, ++ resource: wgpu::BindingResource::TextureView(&targets.pong_view), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 2, ++ resource: wgpu::BindingResource::Sampler(&resources.backdrop_sampler), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 3, ++ resource: self.instance_binding(blur_offset, blur_size), ++ }, ++ wgpu::BindGroupEntry { ++ binding: 4, ++ resource: wgpu::BindingResource::TextureView(&targets.sharp_view), ++ }, ++ ], ++ }); ++ let mut pass = Self::continue_main_pass(encoder, frame_view); ++ pass.set_pipeline(&pipelines.backdrop_glass); ++ pass.set_bind_group(0, &resources.globals_bind_group, &[]); ++ pass.set_bind_group(1, &bind_group, &[]); ++ pass.draw(0..4, 0..1); ++ true ++ } ++ + fn draw_shadows( + &self, + shadows: &[Shadow], +@@ -1937,3 +2384,22 @@ impl RenderingParameters { + } + } + } ++ ++/// The draw order of a batch's first primitive -- where a backdrop blur ++/// whose order is not above it must be painted first. ++fn batch_first_order(scene: &Scene, batch: &PrimitiveBatch) -> DrawOrder { ++ match batch { ++ PrimitiveBatch::Shadows(range) => scene.shadows[range.start].order, ++ PrimitiveBatch::Quads(range) => scene.quads[range.start].order, ++ PrimitiveBatch::Paths(range) => scene.paths[range.start].order, ++ PrimitiveBatch::Underlines(range) => scene.underlines[range.start].order, ++ PrimitiveBatch::MonochromeSprites { range, .. } => { ++ scene.monochrome_sprites[range.start].order ++ } ++ PrimitiveBatch::SubpixelSprites { range, .. } => scene.subpixel_sprites[range.start].order, ++ PrimitiveBatch::PolychromeSprites { range, .. } => { ++ scene.polychrome_sprites[range.start].order ++ } ++ PrimitiveBatch::Surfaces(range) => scene.surfaces[range.start].order, ++ } ++} +diff --git a/crates/gpui_windows/build.rs b/crates/gpui_windows/build.rs +index 1db9571..0a6b4a0 100644 +--- a/crates/gpui_windows/build.rs ++++ b/crates/gpui_windows/build.rs +@@ -38,6 +38,9 @@ mod shader_compilation { + "monochrome_sprite", + "subpixel_sprite", + "polychrome_sprite", ++ "backdrop_downsample", ++ "backdrop_blur_pass", ++ "backdrop_glass", + ]; + + let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir); +diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs +index e4b605b..89f3cbb 100644 +--- a/crates/gpui_windows/src/directx_renderer.rs ++++ b/crates/gpui_windows/src/directx_renderer.rs +@@ -53,6 +53,34 @@ pub(crate) struct DirectXRenderer { + /// In that case we want to discard the first frame that we draw as we got reset in the middle of a frame + /// meaning we lost all the allocated gpu textures and scene resources. + skip_draws: bool, ++ ++ /// Snapshot and blur targets for backdrop blurs, dropped again after ++ /// [`BACKDROP_RELEASE_AFTER_FRAMES`] frames without one. ++ backdrop_textures: Option, ++ blur_free_frames: u32, ++} ++ ++const BACKDROP_RELEASE_AFTER_FRAMES: u32 = 120; ++ ++/// Drawable-sized textures for backdrop blurs: `sharp` holds the snapshot ++/// at the region's own position; `ping` and `pong` hold it downsampled and ++/// blurred from texel 0. ++struct BackdropTextures { ++ width: u32, ++ height: u32, ++ sharp: ID3D11Texture2D, ++ sharp_view: Option, ++ ping: (Option, Option), ++ pong: (Option, Option), ++} ++ ++#[derive(Debug, Default, Clone, Copy)] ++#[repr(C)] ++struct BackdropParams { ++ source_rect: [f32; 4], ++ params: [f32; 4], ++ source_texture_size: [f32; 2], ++ sharp_texture_size: [f32; 2], + } + + /// Direct3D objects +@@ -91,11 +119,16 @@ struct DirectXRenderPipelines { + mono_sprites: PipelineState, + subpixel_sprites: PipelineState, + poly_sprites: PipelineState, ++ backdrop_glass: PipelineState, ++ backdrop_downsample: (ID3D11VertexShader, ID3D11PixelShader), ++ backdrop_blur_pass: (ID3D11VertexShader, ID3D11PixelShader), + } + + struct DirectXGlobalElements { + global_params_buffer: Option, + sampler: Option, ++ backdrop_params_buffer: Option, ++ backdrop_sampler: Option, + } + + struct Annotation<'a>(&'a ID3DUserDefinedAnnotation); +@@ -192,6 +225,8 @@ impl DirectXRenderer { + width: 1, + height: 1, + skip_draws: false, ++ backdrop_textures: None, ++ blur_free_frames: 0, + }) + } + +@@ -314,6 +349,7 @@ impl DirectXRenderer { + self.resources = Some(resources); + self.globals = globals; + self.pipelines = pipelines; ++ self.backdrop_textures = None; + self.direct_composition = direct_composition; + self.skip_draws = true; + Ok(()) +@@ -336,12 +372,30 @@ impl DirectXRenderer { + + self.upload_scene_buffers(scene)?; + ++ if scene.backdrop_blurs.is_empty() { ++ self.blur_free_frames = self.blur_free_frames.saturating_add(1); ++ if self.blur_free_frames >= BACKDROP_RELEASE_AFTER_FRAMES { ++ self.backdrop_textures = None; ++ } ++ } else { ++ self.blur_free_frames = 0; ++ } ++ + let annotation = self + .devices + .as_ref() + .and_then(|devices| devices.annotation.clone()) + .filter(|annotation| unsafe { annotation.GetStatus().as_bool() }); ++ let mut pending_blurs = scene.backdrop_blurs.iter().peekable(); + for batch in scene.batches() { ++ // Backdrop blurs interleave by draw order outside the batch ++ // stream: everything painted so far is snapshotted, blurred and ++ // painted back before the batch drawn above it. ++ while let Some(blur) = ++ pending_blurs.next_if(|blur| blur.order <= batch_first_order(scene, &batch)) ++ { ++ self.draw_backdrop_blur(blur)?; ++ } + let _annotation = annotation + .as_ref() + .map(|annotation| Annotation::new(annotation, HSTRING::from(batch.label()))); +@@ -380,9 +434,225 @@ impl DirectXRenderer { + ) + })?; + } ++ for blur in pending_blurs { ++ self.draw_backdrop_blur(blur)?; ++ } + self.present() + } + ++ fn ensure_backdrop_textures(&mut self) -> Result<()> { ++ let (width, height) = (self.width, self.height); ++ if self ++ .backdrop_textures ++ .as_ref() ++ .is_some_and(|textures| textures.width == width && textures.height == height) ++ { ++ return Ok(()); ++ } ++ let device = &self.devices.as_ref().context("devices missing")?.device; ++ let texture = |bind: u32| -> Result { ++ let desc = D3D11_TEXTURE2D_DESC { ++ Width: width, ++ Height: height, ++ MipLevels: 1, ++ ArraySize: 1, ++ Format: RENDER_TARGET_FORMAT, ++ SampleDesc: DXGI_SAMPLE_DESC { ++ Count: 1, ++ Quality: 0, ++ }, ++ Usage: D3D11_USAGE_DEFAULT, ++ BindFlags: bind, ++ CPUAccessFlags: 0, ++ MiscFlags: 0, ++ }; ++ let mut output = None; ++ unsafe { device.CreateTexture2D(&desc, None, Some(&mut output))? }; ++ Ok(output.unwrap()) ++ }; ++ let target = || -> Result<(Option, Option)> { ++ let texture = texture((D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32)?; ++ let mut target_view = None; ++ let mut shader_view = None; ++ unsafe { ++ device.CreateRenderTargetView(&texture, None, Some(&mut target_view))?; ++ device.CreateShaderResourceView(&texture, None, Some(&mut shader_view))?; ++ } ++ Ok((target_view, shader_view)) ++ }; ++ let sharp = texture(D3D11_BIND_SHADER_RESOURCE.0 as u32)?; ++ let mut sharp_view = None; ++ unsafe { device.CreateShaderResourceView(&sharp, None, Some(&mut sharp_view))? }; ++ self.backdrop_textures = Some(BackdropTextures { ++ width, ++ height, ++ sharp, ++ sharp_view, ++ ping: target()?, ++ pong: target()?, ++ }); ++ Ok(()) ++ } ++ ++ /// Snapshot the region under `blur` (plus room for the gaussian tail and ++ /// any glass lensing), downsample and blur it, then paint it back. ++ /// Downsampling keeps every blur under ~13 taps per side: a gaussian ++ /// that wide is indistinguishable from one taken at full resolution. ++ fn draw_backdrop_blur(&mut self, blur: &BackdropBlur) -> Result<()> { ++ let sigma = blur.blur_radius.0.max(1.0); ++ let padding = (sigma * 3.0 + blur.glass.reach()).ceil() + 2.0; ++ let visible = blur.bounds.intersect(&blur.content_mask.bounds); ++ let (width, height) = (self.width as i64, self.height as i64); ++ let x0 = ((visible.origin.x.0 - padding).floor() as i64).max(0); ++ let y0 = ((visible.origin.y.0 - padding).floor() as i64).max(0); ++ let x1 = ((visible.origin.x.0 + visible.size.width.0 + padding).ceil() as i64).min(width); ++ let y1 = ((visible.origin.y.0 + visible.size.height.0 + padding).ceil() as i64).min(height); ++ if x1 <= x0 || y1 <= y0 { ++ return Ok(()); ++ } ++ self.ensure_backdrop_textures()?; ++ let factor = (sigma / 4.).floor().clamp(1., 8.); ++ let region = [x0 as f32, y0 as f32, (x1 - x0) as f32, (y1 - y0) as f32]; ++ let reduced = [(region[2] / factor).ceil(), (region[3] / factor).ceil()]; ++ // The downsample stretches the region across `reduced` texels, so a ++ // side that `factor` does not divide shrinks by a little less. ++ let scale = [region[2] / reduced[0], region[3] / reduced[1]]; ++ let full = [self.width as f32, self.height as f32]; ++ ++ let devices = self.devices.as_ref().context("devices missing")?; ++ let context = &devices.device_context; ++ let resources = self.resources.as_ref().context("resources missing")?; ++ let render_target = resources.render_target.as_ref().context("render target missing")?; ++ let textures = self.backdrop_textures.as_ref().context("backdrop textures missing")?; ++ let params_buffer = &self.globals.backdrop_params_buffer; ++ let sampler = slice::from_ref(&self.globals.backdrop_sampler); ++ let no_views: [Option; 3] = [None, None, None]; ++ ++ let set_params = |params: BackdropParams| -> Result<()> { ++ if let Some(buffer) = params_buffer { ++ update_buffer(context, buffer, slice::from_ref(¶ms))?; ++ } ++ Ok(()) ++ }; ++ let reduced_viewport = D3D11_VIEWPORT { ++ TopLeftX: 0., ++ TopLeftY: 0., ++ Width: reduced[0], ++ Height: reduced[1], ++ MinDepth: 0., ++ MaxDepth: 1., ++ }; ++ let run_pass = |shaders: &(ID3D11VertexShader, ID3D11PixelShader), ++ source: &Option, ++ target: &Option, ++ params: BackdropParams| ++ -> Result<()> { ++ set_params(params)?; ++ unsafe { ++ context.PSSetShaderResources(1, Some(&no_views)); ++ context.OMSetRenderTargets(Some(slice::from_ref(target)), None); ++ context.RSSetViewports(Some(slice::from_ref(&reduced_viewport))); ++ context.IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST); ++ context.VSSetShader(&shaders.0, None); ++ context.PSSetShader(&shaders.1, None); ++ context.PSSetConstantBuffers(1, Some(slice::from_ref(params_buffer))); ++ context.PSSetSamplers(1, Some(sampler)); ++ context.PSSetShaderResources(2, Some(slice::from_ref(source))); ++ context.OMSetBlendState(&self.pipelines.backdrop_glass.blend_state, None, 0xFFFFFFFF); ++ context.Draw(3, 0); ++ context.PSSetShaderResources(2, Some(&[None])); ++ } ++ Ok(()) ++ }; ++ ++ unsafe { ++ context.CopySubresourceRegion( ++ &textures.sharp, ++ 0, ++ x0 as u32, ++ y0 as u32, ++ 0, ++ render_target, ++ 0, ++ Some(&D3D11_BOX { ++ left: x0 as u32, ++ top: y0 as u32, ++ front: 0, ++ right: x1 as u32, ++ bottom: y1 as u32, ++ back: 1, ++ }), ++ ); ++ } ++ let reduced_rect = [0., 0., reduced[0], reduced[1]]; ++ let blur_source = if factor > 1. { ++ run_pass( ++ &self.pipelines.backdrop_downsample, ++ &textures.sharp_view, ++ &textures.pong.0, ++ BackdropParams { ++ source_rect: region, ++ params: [0., 0., factor, 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ )?; ++ (&textures.pong.1, reduced_rect) ++ } else { ++ (&textures.sharp_view, region) ++ }; ++ run_pass( ++ &self.pipelines.backdrop_blur_pass, ++ blur_source.0, ++ &textures.ping.0, ++ BackdropParams { ++ source_rect: blur_source.1, ++ params: [1., 0., sigma / scale[0], 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ )?; ++ run_pass( ++ &self.pipelines.backdrop_blur_pass, ++ &textures.ping.1, ++ &textures.pong.0, ++ BackdropParams { ++ source_rect: reduced_rect, ++ params: [0., 1., sigma / scale[1], 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ }, ++ )?; ++ ++ set_params(BackdropParams { ++ source_rect: region, ++ params: [scale[0], scale[1], 0., 0.], ++ source_texture_size: full, ++ sharp_texture_size: full, ++ })?; ++ self.pipelines ++ .backdrop_glass ++ .update_buffer(&devices.device, context, slice::from_ref(blur))?; ++ unsafe { ++ context.OMSetRenderTargets(Some(slice::from_ref(&resources.render_target_view)), None); ++ context.PSSetConstantBuffers(1, Some(slice::from_ref(params_buffer))); ++ context.PSSetSamplers(1, Some(sampler)); ++ context.PSSetShaderResources(2, Some(&[textures.pong.1.clone(), textures.sharp_view.clone()])); ++ } ++ self.pipelines.backdrop_glass.draw( ++ context, ++ slice::from_ref(&resources.viewport), ++ slice::from_ref(&self.globals.global_params_buffer), ++ D3D_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP, ++ 4, ++ 1, ++ )?; ++ unsafe { ++ context.PSSetShaderResources(2, Some(&[None, None])); ++ } ++ Ok(()) ++ } ++ + pub(crate) fn resize(&mut self, new_size: Size) -> Result<()> { + let width = new_size.width.0.max(1) as u32; + let height = new_size.height.0.max(1) as u32; +@@ -917,6 +1187,22 @@ impl DirectXRenderPipelines { + create_blend_state(device)?, + )?; + ++ let backdrop_glass = PipelineState::new( ++ device, ++ "backdrop_glass_pipeline", ++ ShaderModule::BackdropGlass, ++ 4, ++ create_replace_blend_state(device)?, ++ )?; ++ let pass_shaders = |module: ShaderModule| -> Result<(ID3D11VertexShader, ID3D11PixelShader)> { ++ let vertex = RawShaderBytes::new(module, ShaderTarget::Vertex)?; ++ let fragment = RawShaderBytes::new(module, ShaderTarget::Fragment)?; ++ Ok(( ++ create_vertex_shader(device, vertex.as_bytes())?, ++ create_fragment_shader(device, fragment.as_bytes())?, ++ )) ++ }; ++ + Ok(Self { + shadow_pipeline, + quad_pipeline, +@@ -926,6 +1212,9 @@ impl DirectXRenderPipelines { + mono_sprites, + subpixel_sprites, + poly_sprites, ++ backdrop_glass, ++ backdrop_downsample: pass_shaders(ShaderModule::BackdropDownsample)?, ++ backdrop_blur_pass: pass_shaders(ShaderModule::BackdropBlurPass)?, + }) + } + } +@@ -986,9 +1275,41 @@ impl DirectXGlobalElements { + output + }; + ++ let backdrop_params_buffer = unsafe { ++ let desc = D3D11_BUFFER_DESC { ++ ByteWidth: std::mem::size_of::() as u32, ++ Usage: D3D11_USAGE_DYNAMIC, ++ BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, ++ CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, ++ ..Default::default() ++ }; ++ let mut buffer = None; ++ device.CreateBuffer(&desc, None, Some(&mut buffer))?; ++ buffer ++ }; ++ let backdrop_sampler = unsafe { ++ let desc = D3D11_SAMPLER_DESC { ++ Filter: D3D11_FILTER_MIN_MAG_MIP_LINEAR, ++ AddressU: D3D11_TEXTURE_ADDRESS_CLAMP, ++ AddressV: D3D11_TEXTURE_ADDRESS_CLAMP, ++ AddressW: D3D11_TEXTURE_ADDRESS_CLAMP, ++ MipLODBias: 0.0, ++ MaxAnisotropy: 1, ++ ComparisonFunc: D3D11_COMPARISON_ALWAYS, ++ BorderColor: [0.0; 4], ++ MinLOD: 0.0, ++ MaxLOD: D3D11_FLOAT32_MAX, ++ }; ++ let mut output = None; ++ device.CreateSamplerState(&desc, Some(&mut output))?; ++ output ++ }; ++ + Ok(Self { + global_params_buffer, + sampler, ++ backdrop_params_buffer, ++ backdrop_sampler, + }) + } + } +@@ -1432,6 +1753,18 @@ fn create_blend_state(device: &ID3D11Device) -> Result { + } + } + ++/// The backdrop passes write their final colour, coverage already applied. ++fn create_replace_blend_state(device: &ID3D11Device) -> Result { ++ let mut desc = D3D11_BLEND_DESC::default(); ++ desc.RenderTarget[0].BlendEnable = false.into(); ++ desc.RenderTarget[0].RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL.0 as u8; ++ unsafe { ++ let mut state = None; ++ device.CreateBlendState(&desc, Some(&mut state))?; ++ Ok(state.unwrap()) ++ } ++} ++ + #[inline] + fn create_blend_state_for_subpixel_rendering(device: &ID3D11Device) -> Result { + let mut desc = D3D11_BLEND_DESC::default(); +@@ -1614,6 +1947,25 @@ fn report_live_objects(device: &ID3D11Device) -> Result<()> { + Ok(()) + } + ++/// The draw order of a batch's first primitive -- where a backdrop blur ++/// whose order is not above it must be painted first. ++fn batch_first_order(scene: &Scene, batch: &PrimitiveBatch) -> DrawOrder { ++ match batch { ++ PrimitiveBatch::Shadows(range) => scene.shadows[range.start].order, ++ PrimitiveBatch::Quads(range) => scene.quads[range.start].order, ++ PrimitiveBatch::Paths(range) => scene.paths[range.start].order, ++ PrimitiveBatch::Underlines(range) => scene.underlines[range.start].order, ++ PrimitiveBatch::MonochromeSprites { range, .. } => { ++ scene.monochrome_sprites[range.start].order ++ } ++ PrimitiveBatch::SubpixelSprites { range, .. } => scene.subpixel_sprites[range.start].order, ++ PrimitiveBatch::PolychromeSprites { range, .. } => { ++ scene.polychrome_sprites[range.start].order ++ } ++ PrimitiveBatch::Surfaces(range) => scene.surfaces[range.start].order, ++ } ++} ++ + const BUFFER_COUNT: usize = 3; + + pub(crate) mod shader_resources { +@@ -1639,6 +1991,9 @@ pub(crate) mod shader_resources { + SubpixelSprite, + PolychromeSprite, + EmojiRasterization, ++ BackdropDownsample, ++ BackdropBlurPass, ++ BackdropGlass, + } + + #[derive(Copy, Clone, Debug, Eq, PartialEq)] +@@ -1716,6 +2071,18 @@ pub(crate) mod shader_resources { + ShaderTarget::Vertex => EMOJI_RASTERIZATION_VERTEX_BYTES, + ShaderTarget::Fragment => EMOJI_RASTERIZATION_FRAGMENT_BYTES, + }, ++ ShaderModule::BackdropDownsample => match target { ++ ShaderTarget::Vertex => BACKDROP_DOWNSAMPLE_VERTEX_BYTES, ++ ShaderTarget::Fragment => BACKDROP_DOWNSAMPLE_FRAGMENT_BYTES, ++ }, ++ ShaderModule::BackdropBlurPass => match target { ++ ShaderTarget::Vertex => BACKDROP_BLUR_PASS_VERTEX_BYTES, ++ ShaderTarget::Fragment => BACKDROP_BLUR_PASS_FRAGMENT_BYTES, ++ }, ++ ShaderModule::BackdropGlass => match target { ++ ShaderTarget::Vertex => BACKDROP_GLASS_VERTEX_BYTES, ++ ShaderTarget::Fragment => BACKDROP_GLASS_FRAGMENT_BYTES, ++ }, + }; + Self { inner: bytes } + } +@@ -1803,6 +2170,9 @@ pub(crate) mod shader_resources { + ShaderModule::SubpixelSprite => "subpixel_sprite", + ShaderModule::PolychromeSprite => "polychrome_sprite", + ShaderModule::EmojiRasterization => "emoji_rasterization", ++ ShaderModule::BackdropDownsample => "backdrop_downsample", ++ ShaderModule::BackdropBlurPass => "backdrop_blur_pass", ++ ShaderModule::BackdropGlass => "backdrop_glass", + } + } + } +diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl +index 78b1ada..fa802f7 100644 +--- a/crates/gpui_windows/src/shaders.hlsl ++++ b/crates/gpui_windows/src/shaders.hlsl +@@ -1289,3 +1289,211 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe + color.a *= sprite.opacity * saturate(0.5 - distance) * edge_fade_alpha(input.position.xy, sprite.fade); + return color; + } ++ ++/* ++** ++** Backdrop blur and glass ++** ++*/ ++ ++// Mirrors gpui's `GlassStyle`; `line` is reserved in HLSL, hence ++// `line_strength` for the same field. ++struct GlassStyle { ++ uint enabled; ++ float lens_rim; ++ float lens_curve; ++ float rim_bend; ++ float poly[60]; ++ float opacity; ++ float line_strength; ++ float line_lit; ++ float line_width; ++ float light_x; ++ float light_y; ++ float glow; ++ float glow_width; ++ float hairline[4]; ++ float tint_scale[3]; ++ float tint_luma[3]; ++ float tint_offset[3]; ++ float pad; ++}; ++ ++struct BackdropBlur { ++ uint order; ++ float blur_radius; ++ Bounds bounds; ++ Bounds content_mask; ++ Corners corner_radii; ++ GlassStyle glass; ++}; ++ ++// One constant buffer serves every backdrop pass. For the downsample and ++// blur passes `source_rect` is the texel rect of the source texture being ++// read and `params` is (direction x, direction y, sigma in source texels or ++// the downsample factor, unused). For the glass pass `source_rect` is the ++// drawable rect that was blurred, `params.xy` how far it was scaled down on ++// each axis (the blurred texture holds it from texel 0), and ++// `sharp_texture_size` the size of the drawable-sized unblurred copy. ++cbuffer BackdropParams: register(b1) { ++ float4 source_rect; ++ float4 params; ++ float2 source_texture_size; ++ float2 sharp_texture_size; ++}; ++ ++Texture2D t_backdrop: register(t2); ++Texture2D t_backdrop_sharp: register(t3); ++SamplerState s_backdrop: register(s1); ++ ++struct BackdropPassOutput { ++ float4 position: SV_Position; ++ float2 uv: TEXCOORD0; ++}; ++ ++BackdropPassOutput backdrop_pass_vertex(uint vertex_id: SV_VertexID) { ++ float2 uv = float2((vertex_id << 1) & 2, vertex_id & 2); ++ BackdropPassOutput output; ++ output.position = float4(uv * float2(2., -2.) + float2(-1., 1.), 0., 1.); ++ output.uv = uv; ++ return output; ++} ++ ++BackdropPassOutput backdrop_downsample_vertex(uint vertex_id: SV_VertexID) { ++ return backdrop_pass_vertex(vertex_id); ++} ++ ++BackdropPassOutput backdrop_blur_pass_vertex(uint vertex_id: SV_VertexID) { ++ return backdrop_pass_vertex(vertex_id); ++} ++ ++float2 backdrop_source_uv(float2 uv) { ++ return (source_rect.xy + uv * source_rect.zw) / source_texture_size; ++} ++ ++float4 backdrop_downsample_fragment(BackdropPassOutput input): SV_Target { ++ float2 texel = 1. / source_texture_size; ++ float2 center = backdrop_source_uv(input.uv); ++ float2 quarter = texel * params.z * 0.25; ++ float2 lo = (source_rect.xy + 0.5) * texel; ++ float2 hi = (source_rect.xy + source_rect.zw - 0.5) * texel; ++ float4 sum = t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(-quarter.x, -quarter.y), lo, hi), 0); ++ sum += t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(quarter.x, -quarter.y), lo, hi), 0); ++ sum += t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(-quarter.x, quarter.y), lo, hi), 0); ++ sum += t_backdrop.SampleLevel(s_backdrop, clamp(center + float2(quarter.x, quarter.y), lo, hi), 0); ++ return 0.25 * sum; ++} ++ ++float4 backdrop_blur_pass_fragment(BackdropPassOutput input): SV_Target { ++ float sigma = max(params.z, 0.5); ++ int radius = (int)ceil(sigma * 3.); ++ float2 step = params.xy / source_texture_size; ++ float2 uv = backdrop_source_uv(input.uv); ++ float2 lo = (source_rect.xy + 0.5) / source_texture_size; ++ float2 hi = (source_rect.xy + source_rect.zw - 0.5) / source_texture_size; ++ float4 sum = 0.; ++ float total = 0.; ++ [loop] ++ for (int i = -radius; i <= radius; i++) { ++ float weight = exp(-(float)(i * i) / (2. * sigma * sigma)); ++ sum += weight * t_backdrop.SampleLevel(s_backdrop, clamp(uv + step * (float)i, lo, hi), 0); ++ total += weight; ++ } ++ return sum / total; ++} ++ ++StructuredBuffer backdrop_blurs: register(t1); ++ ++struct BackdropGlassVertexOutput { ++ float4 position: SV_Position; ++ nointerpolation uint blur_id: TEXCOORD0; ++ float4 clip_distance: SV_ClipDistance; ++}; ++ ++BackdropGlassVertexOutput backdrop_glass_vertex(uint vertex_id: SV_VertexID, uint blur_id: SV_InstanceID) { ++ float2 unit_vertex = float2(float(vertex_id & 1u), 0.5 * float(vertex_id & 2u)); ++ BackdropBlur blur = backdrop_blurs[blur_id]; ++ // Glass draws a hairline in the pixel ring just outside its edge. ++ Bounds bounds = blur.bounds; ++ if (blur.glass.enabled != 0u) { ++ bounds.origin -= 2.; ++ bounds.size += 4.; ++ } ++ BackdropGlassVertexOutput output; ++ output.position = to_device_position(unit_vertex, bounds); ++ output.blur_id = blur_id; ++ output.clip_distance = distance_from_clip_rect(unit_vertex, bounds, blur.content_mask); ++ return output; ++} ++ ++// Clamped to the blurred region: texels beyond it hold an older pass. ++float2 backdrop_blurred_uv(float2 position) { ++ float2 texel = (position - source_rect.xy) / params.xy; ++ texel = clamp(texel, 0.5, source_rect.zw / params.xy - 0.5); ++ return texel / source_texture_size; ++} ++ ++float4 backdrop_glass_fragment(BackdropGlassVertexOutput input): SV_Target { ++ BackdropBlur blur = backdrop_blurs[input.blur_id]; ++ float2 position = input.position.xy; ++ float distance = quad_sdf(position, blur.bounds, blur.corner_radii); ++ ++ if (blur.glass.enabled == 0u) { ++ if (distance > 0.) { ++ discard; ++ } ++ return t_backdrop.SampleLevel(s_backdrop, backdrop_blurred_uv(position), 0); ++ } ++ ++ float coverage = saturate(0.5 - distance); ++ float ring = saturate(1.5 - distance) - coverage; ++ if (coverage <= 0. && ring <= 0.) { ++ discard; ++ } ++ GlassStyle glass = blur.glass; ++ float edge = max(-distance, 0.); ++ float2 normal = float2( ++ quad_sdf(position + float2(0.5, 0.), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0.5, 0.), blur.bounds, blur.corner_radii), ++ quad_sdf(position + float2(0., 0.5), blur.bounds, blur.corner_radii) - ++ quad_sdf(position - float2(0., 0.5), blur.bounds, blur.corner_radii)); ++ normal /= max(length(normal), 1e-6); ++ float vertical = normal.x * normal.x; ++ float hair_a = lerp(glass.hairline[2], glass.hairline[0], vertical); ++ float hair_b = lerp(glass.hairline[3], glass.hairline[1], vertical); ++ float4 under = t_backdrop_sharp.SampleLevel(s_backdrop, position / sharp_texture_size, 0); ++ float3 hair_color = saturate(min(under.rgb, hair_a * under.rgb + hair_b)); ++ under.rgb = lerp(under.rgb, hair_color, ring); ++ if (coverage <= 0.) { ++ return under; ++ } ++ float rim = exp(-pow(edge / glass.lens_rim, glass.lens_curve)); ++ float2 sample_at = position + normal * glass.rim_bend * rim; ++ float4 backdrop = t_backdrop.SampleLevel(s_backdrop, backdrop_blurred_uv(sample_at), 0); ++ float3 color = backdrop.a > 0. ? backdrop.rgb / backdrop.a : float3(0., 0., 0.); ++ float alpha = lerp(backdrop.a, 1., glass.opacity); ++ ++ float features[20] = {color.r, color.g, color.b, color.r * color.r, color.g * color.g, ++ color.b * color.b, color.r * color.g, color.g * color.b, ++ color.r * color.b, color.r * color.r * color.r, ++ color.g * color.g * color.g, color.b * color.b * color.b, ++ color.r * color.r * color.g, color.r * color.r * color.b, ++ color.g * color.g * color.r, color.g * color.g * color.b, ++ color.b * color.b * color.r, color.b * color.b * color.g, ++ color.r * color.g * color.b, 1.}; ++ color = float3(0., 0., 0.); ++ [unroll] ++ for (int term = 0; term < 20; term++) { ++ color += features[term] * float3(glass.poly[term], glass.poly[20 + term], glass.poly[40 + term]); ++ } ++ float facing = dot(normal, float2(glass.light_x, glass.light_y)); ++ float line_term = exp(-edge / glass.line_width) * (glass.line_strength + glass.line_lit * facing); ++ float glow = exp(-edge / glass.glow_width) * glass.glow; ++ color = saturate(color * (1. + line_term) + glow); ++ float luma = dot(color, float3(0.2126, 0.7152, 0.0722)); ++ color = saturate(color * float3(glass.tint_scale[0], glass.tint_scale[1], glass.tint_scale[2]) ++ + luma * float3(glass.tint_luma[0], glass.tint_luma[1], glass.tint_luma[2]) ++ + float3(glass.tint_offset[0], glass.tint_offset[1], glass.tint_offset[2])); ++ ++ return lerp(under, float4(color * alpha, alpha), coverage); ++} diff --git a/apps/desktop-gpui/src/glass_probe.rs b/apps/desktop-gpui/src/glass_probe.rs new file mode 100644 index 00000000000..36789588572 --- /dev/null +++ b/apps/desktop-gpui/src/glass_probe.rs @@ -0,0 +1,516 @@ +use std::{path::PathBuf, sync::Arc, time::Duration}; + +use cap_glass::{Rgba, Shape, metrics, model, scenes}; +use cap_recording::sources::screen_capture::ScreenCaptureTarget; +use gpui::{ + App, AppContext as _, AsyncApp, Context, IntoElement, ParentElement, Render, RenderImage, + Styled, Window, WindowBackgroundAppearance, WindowBounds, WindowHandle, WindowKind, + WindowOptions, div, img, point, px, size, +}; + +use crate::ui::glass::{self, GlassKind}; + +const PRESETS: [(&str, GlassKind, bool); 4] = [ + ("regular-light", GlassKind::Regular, false), + ("regular-dark", GlassKind::Regular, true), + ("clear-light", GlassKind::Clear, false), + ("clear-dark", GlassKind::Clear, true), +]; + +/// `CAP_GLASS_PROBE_OUTPUT=`: draw every scene in `cap_glass::scenes` +/// with glass panes in each preset, capture the window, and score it against +/// the CPU model of the same material. Writes the captures, the model's +/// renders and `report.json`, whose `passed` is the CI verdict. +/// +/// With `CAP_GLASS_PROBE_REFERENCE=` it replays a corpus from +/// `scripts/glass-reference` instead: every case's backdrop, panes and tint, +/// scored against Apple's capture of the same case, so captures from each +/// OS line up with Apple's and with each other. `CAP_GLASS_PROBE_CASES` +/// limits it to cases whose names start with one of its comma-separated +/// prefixes. +pub fn run(cx: &mut App) { + let Some(output) = std::env::var_os("CAP_GLASS_PROBE_OUTPUT").map(PathBuf::from) else { + return; + }; + let reference = match std::env::var_os("CAP_GLASS_PROBE_REFERENCE").map(PathBuf::from) { + Some(dir) => match Reference::load(&dir) { + Ok(reference) => Some(Arc::new(reference)), + Err(error) => { + tracing::error!(%error, "glass probe: cannot read the reference corpus"); + return; + } + }, + None => None, + }; + if let Err(error) = std::fs::create_dir_all(&output) { + tracing::error!(%error, "glass probe: cannot create its output directory"); + return; + } + // An area capture reads whatever is on screen over the window. + crate::app_windows::hide_main_window(cx); + let handle = cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(gpui::Bounds { + origin: point(px(120.), px(120.)), + size: size(px(scenes::CANVAS.0), px(scenes::CANVAS.1)), + })), + titlebar: None, + kind: WindowKind::Normal, + focus: false, + show: true, + is_resizable: false, + window_background: WindowBackgroundAppearance::Opaque, + ..Default::default() + }, + |_, cx| cx.new(|_| ProbeView::default()), + ); + let handle = match handle { + Ok(handle) => handle, + Err(error) => { + tracing::error!("glass probe: window failed to open: {error:#}"); + return; + } + }; + cx.spawn(async move |cx| { + let report = match reference { + Some(reference) => probe_reference(handle, &reference, &output, cx).await, + None => probe(handle, &output, cx).await, + }; + if let Err(error) = std::fs::write(output.join("report.json"), report.to_string()) { + tracing::error!(%error, "glass probe: could not write its report"); + } + cx.update(|cx| cx.quit()); + }) + .detach(); +} + +struct ProbeView { + scene: Option>, + preset: Option<(GlassKind, bool)>, + shapes: Vec, + tint: Option, +} + +impl Default for ProbeView { + fn default() -> Self { + Self { + scene: None, + preset: None, + shapes: scenes::SHAPES.to_vec(), + tint: None, + } + } +} + +impl Render for ProbeView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let mut root = div().size_full().relative().bg(gpui::white()).children( + self.scene + .clone() + .map(|scene| img(scene).absolute().size_full()), + ); + if let Some((kind, dark)) = self.preset { + for shape in &self.shapes { + let pane = match self.tint { + Some(tint) => glass::pane_tinted(kind, dark, shape.radius, tint), + None => glass::pane(kind, dark, shape.radius), + }; + root = root.child( + pane.absolute() + .left(px(shape.x)) + .top(px(shape.y)) + .w(px(shape.width)) + .h(px(shape.height)), + ); + } + } + root + } +} + +async fn sleep(cx: &mut AsyncApp, millis: u64) { + cx.background_executor() + .timer(Duration::from_millis(millis)) + .await; +} + +async fn probe( + handle: WindowHandle, + output: &std::path::Path, + cx: &mut AsyncApp, +) -> serde_json::Value { + sleep(cx, 1500).await; + let scale = handle + .update(cx, |_, window, _| window.scale_factor()) + .unwrap_or(1.); + let Some(target) = handle + .update(cx, |_, window, _| window_target(window)) + .ok() + .flatten() + else { + return serde_json::json!({ "passed": false, "error": "no window id to capture" }); + }; + + let mut cases = Vec::new(); + for scene_name in scenes::NAMES { + let Some(scene) = scenes::render(scene_name, scale) else { + continue; + }; + let image = to_rgba_image(&scene); + let render_image = crate::library::rgba_to_render_image(image); + for (preset_name, kind, dark) in PRESETS { + handle + .update(cx, |view, window, cx| { + view.scene = Some(render_image.clone()); + view.preset = Some((kind, dark)); + cx.notify(); + window.refresh(); + }) + .ok(); + sleep(cx, 400).await; + let name = format!("{scene_name}-{preset_name}"); + let captured = { + let target = target.clone(); + gpui_tokio::Tokio::spawn(cx, async move { + cap_recording::screenshot::capture_screenshot(target).await + }) + .await + }; + let captured = match captured { + Ok(Ok(image)) => image.to_rgba8(), + Ok(Err(error)) => { + cases.push(serde_json::json!({ "name": name, "error": format!("{error:#}") })); + continue; + } + Err(error) => { + cases.push(serde_json::json!({ "name": name, "error": error.to_string() })); + continue; + } + }; + let captured = crop_to(&captured, scene.width as u32, scene.height as u32); + let captured = Rgba { + width: captured.width() as usize, + height: captured.height() as usize, + pixels: captured + .as_raw() + .iter() + .map(|value| *value as f32) + .collect(), + }; + let expected = expected_render(&scene, glass::material(kind, dark), scale); + let mask = glass_mask(scale, captured.width, captured.height); + let ssim = metrics::ssim(&captured, &expected, &mask); + let (delta_e, delta_e95) = metrics::delta_e(&captured, &expected, &mask); + let _ = captured.save(&output.join(format!("{name}.png"))); + let _ = expected.save(&output.join(format!("{name}-model.png"))); + cases.push(serde_json::json!({ + "name": name, + "ssim": ssim, + "deltaE": delta_e, + "deltaE95": delta_e95, + "passed": ssim >= 0.97 && delta_e <= 2.0, + })); + } + } + let passed = !cases.is_empty() + && cases + .iter() + .all(|case| case["passed"].as_bool() == Some(true)); + serde_json::json!({ "passed": passed, "scale": scale, "cases": cases }) +} + +/// A corpus written by `scripts/glass-reference` (`reference` or `sweep`). +struct Reference { + dir: PathBuf, + shapes: Vec, + cases: Vec, + tints: std::collections::HashMap, +} + +#[derive(serde::Deserialize)] +struct ReferenceManifest { + shapes: Vec, + cases: Vec, + #[serde(default)] + tints: std::collections::HashMap, +} + +#[derive(serde::Deserialize)] +struct ReferenceShape { + rect: [f32; 4], + radius: f32, +} + +#[derive(serde::Deserialize, Clone)] +struct ReferenceCase { + name: String, + scene: String, + style: String, + appearance: String, + tint: String, + #[serde(default)] + pass: String, +} + +impl Reference { + fn load(dir: &std::path::Path) -> anyhow::Result { + let manifest: ReferenceManifest = + serde_json::from_slice(&std::fs::read(dir.join("manifest.json"))?)?; + let prefixes: Vec = std::env::var("CAP_GLASS_PROBE_CASES") + .map(|list| list.split(',').map(str::to_owned).collect()) + .unwrap_or_default(); + Ok(Self { + dir: dir.to_owned(), + shapes: manifest + .shapes + .iter() + .map(|shape| Shape { + x: shape.rect[0], + y: shape.rect[1], + width: shape.rect[2], + height: shape.rect[3], + radius: shape.radius, + }) + .collect(), + cases: manifest + .cases + .into_iter() + // The sweep's falling pass repeats its rising one. + .filter(|case| case.pass != "down") + .filter(|case| { + prefixes.is_empty() + || prefixes.iter().any(|prefix| case.name.starts_with(prefix)) + }) + .collect(), + tints: manifest.tints, + }) + } + + /// `name` from the corpus at `width` x `height` device pixels. + fn image(&self, folder: &str, name: &str, width: u32, height: u32) -> Option { + let image = image::open(self.dir.join(folder).join(format!("{name}.png"))) + .ok()? + .to_rgba8(); + Some(if image.width() == width && image.height() == height { + image + } else { + image::imageops::resize(&image, width, height, image::imageops::FilterType::Triangle) + }) + } +} + +async fn probe_reference( + handle: WindowHandle, + reference: &Reference, + output: &std::path::Path, + cx: &mut AsyncApp, +) -> serde_json::Value { + sleep(cx, 1500).await; + let scale = handle + .update(cx, |_, window, _| window.scale_factor()) + .unwrap_or(1.); + let Some(target) = handle + .update(cx, |_, window, _| window_target(window)) + .ok() + .flatten() + else { + return serde_json::json!({ "passed": false, "error": "no window id to capture" }); + }; + let shapes = reference.shapes.clone(); + handle + .update(cx, |view, _, _| view.shapes = shapes.clone()) + .ok(); + let (width, height) = ( + (scenes::CANVAS.0 * scale).round() as u32, + (scenes::CANVAS.1 * scale).round() as u32, + ); + let mask: Vec = (0..(width * height) as usize) + .map(|index| { + let x = (index % width as usize) as f32 + 0.5; + let y = (index / width as usize) as f32 + 0.5; + shapes + .iter() + .any(|shape| scaled(shape, scale).sdf(x, y) < 1.5) + }) + .collect(); + let mut scene_cache: std::collections::HashMap> = Default::default(); + let mut cases = Vec::new(); + for case in &reference.cases { + let kind = match case.style.as_str() { + "clear" => GlassKind::Clear, + _ => GlassKind::Regular, + }; + let dark = case.appearance == "dark"; + let tint = reference + .tints + .get(&case.tint) + .map(|[r, g, b, a]| gpui::Rgba { + r: *r, + g: *g, + b: *b, + a: *a, + }); + let scene = match scene_cache.get(&case.scene) { + Some(scene) => scene.clone(), + None => { + let Some(image) = reference.image("scenes", &case.scene, width, height) else { + cases.push(serde_json::json!({ "name": case.name, "error": "missing scene" })); + continue; + }; + let scene = crate::library::rgba_to_render_image(image); + scene_cache.insert(case.scene.clone(), scene.clone()); + scene + } + }; + handle + .update(cx, |view, window, cx| { + view.scene = Some(scene); + view.preset = Some((kind, dark)); + view.tint = tint; + cx.notify(); + window.refresh(); + }) + .ok(); + sleep(cx, 400).await; + let captured = { + let target = target.clone(); + gpui_tokio::Tokio::spawn(cx, async move { + cap_recording::screenshot::capture_screenshot(target).await + }) + .await + }; + let captured = match captured { + Ok(Ok(image)) => crop_to(&image.to_rgba8(), width, height), + Ok(Err(error)) => { + cases.push(serde_json::json!({ "name": case.name, "error": format!("{error:#}") })); + continue; + } + Err(error) => { + cases.push(serde_json::json!({ "name": case.name, "error": error.to_string() })); + continue; + } + }; + let _ = captured.save(output.join(format!("{}.png", case.name))); + let Some(apple) = reference.image("apple", &case.name, width, height) else { + cases.push(serde_json::json!({ "name": case.name, "error": "missing Apple capture" })); + continue; + }; + let to_rgba = |image: &image::RgbaImage| Rgba { + width: image.width() as usize, + height: image.height() as usize, + pixels: image.as_raw().iter().map(|value| *value as f32).collect(), + }; + let (ours, apple) = (to_rgba(&captured), to_rgba(&apple)); + let ssim = metrics::ssim(&ours, &apple, &mask); + let (delta_e, delta_e95) = metrics::delta_e(&ours, &apple, &mask); + cases.push(serde_json::json!({ + "name": case.name, + "ssim": ssim, + "deltaE": delta_e, + "deltaE95": delta_e95, + "passed": ssim >= 0.98 && delta_e <= 1.0 && delta_e95 <= 3.0, + })); + } + let passed = !cases.is_empty() + && cases + .iter() + .all(|case| case["passed"].as_bool() == Some(true)); + serde_json::json!({ + "passed": passed, + "scale": scale, + "os": std::env::consts::OS, + "cases": cases, + }) +} + +fn to_rgba_image(image: &Rgba) -> image::RgbaImage { + let bytes = image + .pixels + .iter() + .map(|value| value.round().clamp(0., 255.) as u8) + .collect(); + image::RgbaImage::from_raw(image.width as u32, image.height as u32, bytes) + .expect("scene buffer matches its size") +} + +/// A window capture can carry a frame or shadow around the content; the +/// content is the centred `width` x `height`. +fn crop_to(image: &image::RgbaImage, width: u32, height: u32) -> image::RgbaImage { + if image.width() == width && image.height() == height { + return image.clone(); + } + let x = image.width().saturating_sub(width) / 2; + let y = image.height().saturating_sub(height) / 2; + image::imageops::crop_imm( + image, + x, + y, + width.min(image.width()), + height.min(image.height()), + ) + .to_image() +} + +fn scaled(shape: &Shape, scale: f32) -> Shape { + Shape { + x: shape.x * scale, + y: shape.y * scale, + width: shape.width * scale, + height: shape.height * scale, + radius: shape.radius * scale, + } +} + +fn expected_render(scene: &Rgba, material: &model::GlassMaterial, scale: f32) -> Rgba { + let mut out = scene.clone(); + for shape in scenes::SHAPES { + let style = material.resolve(shape.width, shape.height).scaled(scale); + let device = scaled(&shape, scale); + model::composite(&style, &device, &scene.blurred(style.blur), &mut out); + } + out +} + +fn glass_mask(scale: f32, width: usize, height: usize) -> Vec { + (0..width * height) + .map(|index| { + let (x, y) = ((index % width) as f32 + 0.5, (index / width) as f32 + 0.5); + scenes::SHAPES + .iter() + .any(|shape| scaled(shape, scale).sdf(x, y) < 1.5) + }) + .collect() +} + +#[cfg(target_os = "macos")] +fn window_target(window: &Window) -> Option { + let number = crate::platform::window_number(window)?; + Some(ScreenCaptureTarget::Window { + id: number.to_string().parse().ok()?, + }) +} + +/// Cap's own windows are not in its X11 window list, and hosted Windows +/// runners have no window capture, so the probe captures the window's area +/// of the display; it hides every other Cap window first. +#[cfg(not(target_os = "macos"))] +fn window_target(window: &Window) -> Option { + use scap_targets::bounds::{LogicalBounds, LogicalPosition, LogicalSize}; + + let display = scap_targets::Display::primary(); + let bounds = window.bounds(); + let origin = display.raw_handle().logical_bounds()?.position(); + Some(ScreenCaptureTarget::Area { + screen: display.id(), + bounds: LogicalBounds::new( + LogicalPosition::new( + f64::from(f32::from(bounds.origin.x)) - origin.x(), + f64::from(f32::from(bounds.origin.y)) - origin.y(), + ), + LogicalSize::new( + f64::from(f32::from(bounds.size.width)), + f64::from(f32::from(bounds.size.height)), + ), + ), + }) +} diff --git a/apps/desktop-gpui/src/library.rs b/apps/desktop-gpui/src/library.rs index 8c4906d58bf..aea11ee1b97 100644 --- a/apps/desktop-gpui/src/library.rs +++ b/apps/desktop-gpui/src/library.rs @@ -1427,6 +1427,13 @@ pub fn bundle_thumbnail_path(project_dir: &Path) -> PathBuf { mod tests { use super::*; + /// Wait long enough that the next file gets a later timestamp. Kernels + /// stamp files from a coarse clock (one 4 to 10 ms tick), so a shorter + /// sleep can leave two bundles tied and sorted in directory order. + fn next_timestamp() { + std::thread::sleep(std::time::Duration::from_millis(25)); + } + #[test] fn decode_jobs_stop_once_nobody_takes_the_results() { let (job_tx, job_rx) = flume::unbounded(); @@ -1678,7 +1685,7 @@ mod tests { // Twelve recordings, oldest first, so the newest nine are 3..=11. for i in 0..12 { write_studio_bundle(&recordings, &format!("rec-{i:02}"), 1); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); } let items = recent_media_in(std::slice::from_ref(&recordings), &screenshots); @@ -1707,9 +1714,9 @@ mod tests { std::fs::create_dir_all(&screenshots).unwrap(); write_studio_bundle(&recordings, "old-recording", 1); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_screenshot_bundle(&screenshots, "middle-screenshot"); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_studio_bundle(&recordings, "new-recording", 3); let items = recent_media_in(std::slice::from_ref(&recordings), &screenshots); @@ -1806,15 +1813,15 @@ mod tests { std::fs::create_dir_all(&active).unwrap(); std::fs::create_dir_all(&archived).unwrap(); write_studio_bundle(&active, "old-studio", 1); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_studio_bundle(&archived, "new-studio", 3); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_bundle( &active, "instant", r#"{"pretty_name":"Instant","sharing":null,"fps":30,"sample_rate":48000}"#, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); std::fs::create_dir_all(archived.join("missing-meta")).unwrap(); write_bundle(&active, "corrupt-meta", "{ not json "); std::fs::write(archived.join("loose.mp4"), b"").unwrap(); @@ -1887,16 +1894,16 @@ mod tests { std::fs::write(not_a_bundle.join("original.png"), b"png").unwrap(); std::fs::write(root.join("loose.cap"), b"").unwrap(); for i in 0..30 { - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let bundle = write_shot(&root, &format!("shot-{:02}", (i * 7) % 30)); std::fs::write(bundle.join("original.png"), b"png").unwrap(); } - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let tie_a = write_shot(&root, "tie-a"); std::fs::write(tie_a.join("original.png"), b"png").unwrap(); let tie_b = write_shot(&root, "tie-b"); std::fs::hard_link(tie_a.join("original.png"), tie_b.join("original.png")).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let corrupt = write_bundle(&root, "corrupt", "{ not json "); std::fs::write(corrupt.join("original.png"), b"png").unwrap(); @@ -2276,13 +2283,13 @@ mod tests { r#"{{"pretty_name":"Studio multi","sharing":{{"id":"abc","link":"https://cap.so/s/abc"}},"segments":[{segment},{segment},{segment}]}}"# ), ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_bundle( &recordings, "studio-single", r#"{"pretty_name":"Studio single","sharing":null,"display":{"path":"content/display.mp4","fps":30}}"#, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_bundle( &recordings, "studio-failed", @@ -2290,7 +2297,7 @@ mod tests { r#"{{"pretty_name":"Studio failed","sharing":null,"segments":[{segment}],"status":{{"status":"Failed","error":"encoder died"}}}}"# ), ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_bundle( &recordings, "studio-remux", @@ -2298,7 +2305,7 @@ mod tests { r#"{{"pretty_name":"Studio remux","sharing":null,"segments":[{segment}],"status":{{"status":"NeedsRemux"}}}}"# ), ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let instant_complete = write_bundle( &recordings, "instant-complete", @@ -2324,13 +2331,13 @@ mod tests { .unwrap(), ) .unwrap(); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_bundle( &recordings, "instant-progress", r#"{"pretty_name":"Instant in progress","sharing":null,"recording":true}"#, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_bundle( &recordings, "instant-failed", @@ -2552,7 +2559,7 @@ mod tests { "InProgress", true, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let complete_studio = write_incomplete_bundle( &recordings, "complete-studio", @@ -2560,11 +2567,11 @@ mod tests { "Complete", true, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); write_bundle(&recordings, "corrupt-meta", "{ not json "); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); std::fs::create_dir_all(recordings.join("missing-meta")).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let active = write_incomplete_bundle( &recordings, "active", @@ -2572,7 +2579,7 @@ mod tests { "InProgress", true, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let in_progress = write_incomplete_bundle( &recordings, "in-progress", @@ -2580,7 +2587,7 @@ mod tests { "InProgress", true, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let remux = write_incomplete_bundle( &recordings, "remux", @@ -2588,14 +2595,14 @@ mod tests { "NeedsRemux", true, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let instant = write_incomplete_instant_bundle( &recordings, "instant-incomplete", "Cap 2026-01-03 at 10.00.00", false, ); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let complete_instant = write_incomplete_instant_bundle( &recordings, "complete-instant", @@ -2609,7 +2616,7 @@ mod tests { r#"{"pretty_name":"Cap 2026-01-06 at 10.00.00","sharing":null,"fps":30,"sample_rate":48000}"#, ) .unwrap(); - std::thread::sleep(std::time::Duration::from_millis(6)); + next_timestamp(); let unrecoverable = write_incomplete_bundle( &recordings, "unrecoverable", diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 184144a4b23..3ede16b4231 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -69,6 +69,8 @@ mod permissions_ui; #[cfg(test)] mod picker_benchmark; #[cfg(debug_assertions)] +mod glass_probe; +#[cfg(debug_assertions)] mod picker_probe; #[cfg(debug_assertions)] mod picker_ui_benchmark; @@ -812,6 +814,8 @@ fn main() { picker_ui_benchmark::run(window_handle, cx); #[cfg(debug_assertions)] picker_probe::run(window_handle, cx); + #[cfg(debug_assertions)] + glass_probe::run(cx); if let Ok(auto) = std::env::var("CAP_GPUI_AUTO_RECORD") && let Some((mode, secs)) = parse_auto_record(&auto) { diff --git a/apps/desktop-gpui/src/main_window.rs b/apps/desktop-gpui/src/main_window.rs index 0238c1b158a..d20a3312529 100644 --- a/apps/desktop-gpui/src/main_window.rs +++ b/apps/desktop-gpui/src/main_window.rs @@ -1787,7 +1787,7 @@ impl MainWindow { this.scan_incomplete_recordings(window, cx, std::time::Duration::ZERO); if recover { if project_path.join("content/output.mp4").is_file() { - cx.reveal_path(&project_path.join("content/output.mp4")); + library::reveal_path(&project_path.join("content/output.mp4"), cx); } else { cx.defer(move |cx| app_windows::open_editor(project_path, cx)); } diff --git a/apps/desktop-gpui/src/ui/glass.rs b/apps/desktop-gpui/src/ui/glass.rs new file mode 100644 index 00000000000..6194a92b0a8 --- /dev/null +++ b/apps/desktop-gpui/src/ui/glass.rs @@ -0,0 +1,132 @@ +//! Cap Glass: panes that blur, lens and tint whatever is painted beneath +//! them, with materials fitted against Apple's own glass (`glass/`, and the +//! `glass-reference` capture that feeds it). +//! +//! A pane paints itself first and its children over it, so content sits on +//! the glass, never under it. The glass samples what is already in the +//! window: on a transparent window, paint the backdrop it should show (the +//! picker paints the captured desktop) before the pane. + +use std::sync::OnceLock; + +use cap_glass::model::{GlassMaterial, PaneStyle}; +use gpui::{Bounds, Corners, Div, ParentElement, Pixels, Styled, Window, canvas, div, px}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GlassKind { + /// Frosted: reads as a surface, for panels and toolbars. + Regular, + /// Barely frosted: for controls floating over busy content. + Clear, +} + +struct Presets { + regular_light: GlassMaterial, + regular_dark: GlassMaterial, + clear_light: GlassMaterial, + clear_dark: GlassMaterial, +} + +fn presets() -> &'static Presets { + static PRESETS: OnceLock = OnceLock::new(); + PRESETS.get_or_init(|| { + let all: serde_json::Value = + serde_json::from_str(cap_glass::PRESETS_JSON).expect("glass presets are valid JSON"); + let get = |name: &str| -> GlassMaterial { + serde_json::from_value(all[name].clone()).expect("glass preset is complete") + }; + Presets { + regular_light: get("regular-light"), + regular_dark: get("regular-dark"), + clear_light: get("clear-light"), + clear_dark: get("clear-dark"), + } + }) +} + +pub fn material(kind: GlassKind, dark: bool) -> &'static GlassMaterial { + let presets = presets(); + match (kind, dark) { + (GlassKind::Regular, false) => &presets.regular_light, + (GlassKind::Regular, true) => &presets.regular_dark, + (GlassKind::Clear, false) => &presets.clear_light, + (GlassKind::Clear, true) => &presets.clear_dark, + } +} + +pub fn gpui_style(style: &PaneStyle) -> gpui::GlassStyle { + gpui::GlassStyle { + enabled: 1, + lens_rim: style.lens_rim, + lens_curve: style.lens_curve, + rim_bend: style.rim_bend, + poly: style.poly, + opacity: style.opacity, + line: style.line, + line_lit: style.line_lit, + line_width: style.line_width, + light_x: style.light[0], + light_y: style.light[1], + glow: style.glow, + glow_width: style.glow_width, + hairline: style.hairline, + tint_scale: style.tint.scale, + tint_luma: style.tint.luma, + tint_offset: style.tint.offset, + pad: 0., + } +} + +/// A tint for a pane, sRGB with alpha, as SwiftUI's `.tint` takes it: +/// Regular glass reads only its alpha, Clear glass its colour too. +pub type Tint = gpui::Rgba; + +pub fn paint( + window: &mut Window, + bounds: Bounds, + radius: f32, + material: &GlassMaterial, + tint: Option, +) { + let (width, height) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + if width <= 0. || height <= 0. { + return; + } + let mut style = material.resolve(width, height); + if let Some(tint) = tint { + style.tint = material.tint_matrix([tint.r, tint.g, tint.b], tint.a); + } + let radius = radius.min(width / 2.).min(height / 2.); + window.paint_glass( + bounds, + Corners::all(px(radius)), + px(style.blur), + gpui_style(&style), + ); +} + +/// A rounded glass pane: a `Div` whose first child is the glass layer, so +/// everything added after it paints on the glass. `radius` is clamped to the +/// pane like CSS clamps `border-radius`. +pub fn pane(kind: GlassKind, dark: bool, radius: f32) -> Div { + pane_with(kind, dark, radius, None) +} + +/// [`pane`] with a tint. +pub fn pane_tinted(kind: GlassKind, dark: bool, radius: f32, tint: Tint) -> Div { + pane_with(kind, dark, radius, Some(tint)) +} + +fn pane_with(kind: GlassKind, dark: bool, radius: f32, tint: Option) -> Div { + let material = *material(kind, dark); + div().relative().rounded(px(radius)).child( + canvas( + |_, _, _| {}, + move |bounds, _, window, _| paint(window, bounds, radius, &material, tint), + ) + .absolute() + .top_0() + .left_0() + .size_full(), + ) +} diff --git a/apps/desktop-gpui/src/ui/mod.rs b/apps/desktop-gpui/src/ui/mod.rs index 4d3b32d9460..8473ee64ae7 100644 --- a/apps/desktop-gpui/src/ui/mod.rs +++ b/apps/desktop-gpui/src/ui/mod.rs @@ -45,6 +45,7 @@ mod collapsible; mod color_picker; mod editor_button; mod field; +pub mod glass; mod kbd; pub mod keyboard_nav; mod menu; diff --git a/crates/recording/src/sources/screen_capture/windows.rs b/crates/recording/src/sources/screen_capture/windows.rs index bf99efb640f..ef8a9dbd343 100644 --- a/crates/recording/src/sources/screen_capture/windows.rs +++ b/crates/recording/src/sources/screen_capture/windows.rs @@ -708,7 +708,16 @@ fn create_d3d_capturer( }, Some(params.d3d_device.clone()), ) - .map_err(|e| anyhow!("{e}")) + .map_err(|error| match &error { + scap_direct3d::NewCapturerError::CaptureSession(inner) + if inner.code() == windows::Win32::Foundation::E_ACCESSDENIED => + { + anyhow!( + "Windows denied Cap access to screen capture. Check that screen capture is allowed for desktop apps in Windows privacy settings and that no security software is blocking it ({error})" + ) + } + _ => anyhow!("{error}"), + }) } impl output_pipeline::VideoSource for VideoSource { @@ -808,6 +817,9 @@ impl output_pipeline::VideoSource for VideoSource { } Err(e) => { error!("Failed to create D3D capturer: {}", e); + // `start` reads why from here: its reply channel only + // says the thread is gone. + first_frame.complete(Err(format!("{e:#}"))); return Err(e); } }; @@ -1085,12 +1097,18 @@ impl output_pipeline::VideoSource for VideoSource { let first_frame = self.first_frame.take(); async move { tokio::time::timeout(Duration::from_secs(5), async move { - rx.await??; - wait_for_first_screen_frame( - first_frame.ok_or_else(|| anyhow!("Windows screen source already started"))?, - Duration::from_secs(5), - ) - .await + let first_frame = + first_frame.ok_or_else(|| anyhow!("Windows screen source already started"))?; + match rx.await { + Ok(started) => started?, + Err(_) => { + return Err(match first_frame.await { + Ok(Err(reason)) => anyhow!(reason), + _ => anyhow!("Windows screen capture stopped before it started"), + }); + } + } + wait_for_first_screen_frame(first_frame, Duration::from_secs(5)).await }) .await .map_err(|_| { diff --git a/scripts/glass-probe/run.sh b/scripts/glass-probe/run.sh new file mode 100755 index 00000000000..efa057d56fa --- /dev/null +++ b/scripts/glass-probe/run.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Runs cap-gpui's glass probe (src/glass_probe.rs) in a throwaway profile: +# every scene in cap_glass::scenes with glass panes in each preset, captured +# from the real window and scored against the CPU model. Fails unless every +# case passes. +# +# scripts/glass-probe/run.sh +set -euo pipefail + +APP=$1 +OUT=$2 +mkdir -p "$OUT/profile/recordings" "$OUT/profile/logs" "$OUT/captures" +cat > "$OUT/profile/store" <<'JSON' +{"general_settings":{"hasCompletedOnboarding":true,"hasCompletedStartup":true,"enableNotifications":false,"enableTelemetry":false,"recordingCountdown":0},"recording_start_safety":{"confirmBeforeRecordingWithoutMicrophone":false}} +JSON +report="$OUT/captures/report.json" +rm -f "$report" + +env CAP_GPUI_APP_DATA_DIR="$OUT/profile" \ + CAP_GPUI_RECORDINGS_DIR="$OUT/profile/recordings" \ + CAP_GPUI_LOG_DIR="$OUT/profile/logs" \ + CAP_GPUI_AUTO_NO_MIC=1 \ + CAP_GLASS_PROBE_OUTPUT="$OUT/captures" \ + RUST_LOG="${RUST_LOG:-cap_gpui=info}" \ + "$APP" > "$OUT/app.log" 2>&1 & +pid=$! + +for _ in $(seq 1 1200); do + [ -s "$report" ] && break + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 +done +kill "$pid" 2>/dev/null || true + +if [ ! -s "$report" ]; then + echo "glass probe wrote no report" >&2 + tail -n 80 "$OUT/app.log" >&2 || true + exit 1 +fi +# Windows images ship `python`, not `python3`. +PYTHON=$(command -v python3 || command -v python) +"$PYTHON" - "$report" <<'PY' +import json, sys +report = json.load(open(sys.argv[1])) +print(f"scale {report.get('scale')} passed {report['passed']}") +for case in report.get("cases", []): + if "error" in case: + print(f" {case['name']:28s} error: {case['error']}") + else: + mark = "ok " if case["passed"] else "BAD" + print(f" {mark} {case['name']:28s} ssim {case['ssim']:.4f} dE {case['deltaE']:.2f} dE95 {case['deltaE95']:.2f}") +sys.exit(0 if report["passed"] else 1) +PY diff --git a/scripts/glass-reference/reference.swift b/scripts/glass-reference/reference.swift new file mode 100644 index 00000000000..eb492fceb78 --- /dev/null +++ b/scripts/glass-reference/reference.swift @@ -0,0 +1,319 @@ +// Renders Apple's own glass (NSGlassEffectView, macOS 26+) over a fixed set +// of backdrops and captures each window, so Cap's glass renderer can be +// compared against it pixel for pixel. Writes /scenes/.png (the +// bare backdrop), /apple/.png and /manifest.json. +// +// swiftc -O reference.swift -o reference && ./reference +// +// Needs Screen Recording for the calling process (screencapture -l). +import AppKit +import Metal +import SwiftUI + +let canvas = NSSize(width: 960, height: 600) + +struct Shape { + let name: String + let rect: NSRect + let radius: CGFloat +} + +// Rects are in points with a top-left origin, matching the manifest. +let shapes: [Shape] = [ + Shape(name: "capsule", rect: NSRect(x: 60, y: 60, width: 320, height: 120), radius: 60), + Shape(name: "panel", rect: NSRect(x: 460, y: 60, width: 420, height: 240), radius: 24), + Shape(name: "toolbar", rect: NSRect(x: 60, y: 260, width: 300, height: 48), radius: 24), + Shape(name: "circle", rect: NSRect(x: 120, y: 380, width: 96, height: 96), radius: 48), + Shape(name: "card", rect: NSRect(x: 460, y: 380, width: 360, height: 160), radius: 16), +] + +func drawScene(_ name: String, in ctx: CGContext, size: NSSize) { + let rect = CGRect(origin: .zero, size: size) + func fill(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) { + ctx.setFillColor(CGColor(srgbRed: r, green: g, blue: b, alpha: 1)) + ctx.fill(rect) + } + func text(_ color: NSColor) { + let style = NSMutableParagraphStyle() + style.lineSpacing = 4 + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 15, weight: .regular), + .foregroundColor: color, + .paragraphStyle: style, + ] + let line = "The quick brown fox jumps over the lazy dog 0123456789 — Cap records, edits and shares. " + let body = String(repeating: line, count: 40) + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(cgContext: ctx, flipped: false) + (body as NSString).draw(in: rect.insetBy(dx: 24, dy: 24), withAttributes: attrs) + NSGraphicsContext.restoreGraphicsState() + } + switch name { + case "white": fill(1, 1, 1) + case "black": fill(0, 0, 0) + case "gray": fill(0.5, 0.5, 0.5) + case "text": + fill(1, 1, 1) + text(.black) + case "darktext": + fill(0.08, 0.08, 0.09) + text(.white) + case "stripes": + fill(1, 1, 1) + ctx.setFillColor(CGColor(srgbRed: 0, green: 0, blue: 0, alpha: 1)) + var x: CGFloat = 0 + while x < size.width { + ctx.fill(CGRect(x: x, y: 0, width: 8, height: size.height)) + x += 16 + } + case "grid": + fill(0.96, 0.96, 0.97) + ctx.setStrokeColor(CGColor(srgbRed: 0.1, green: 0.3, blue: 0.9, alpha: 1)) + ctx.setLineWidth(2) + var v: CGFloat = 0 + while v <= max(size.width, size.height) { + ctx.move(to: CGPoint(x: v, y: 0)); ctx.addLine(to: CGPoint(x: v, y: size.height)) + ctx.move(to: CGPoint(x: 0, y: v)); ctx.addLine(to: CGPoint(x: size.width, y: v)) + v += 24 + } + ctx.strokePath() + case "checker": + let cell: CGFloat = 32 + for row in 0.. CGColor in + NSColor(calibratedHue: CGFloat(i) / 6, saturation: 0.85, brightness: 0.95, alpha: 1).cgColor + } + let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: colors as CFArray, locations: nil)! + ctx.drawLinearGradient(gradient, start: .zero, end: CGPoint(x: size.width, y: 0), options: []) + case "blobs": + fill(0.98, 0.94, 0.88) + let blobs: [(CGFloat, CGFloat, CGFloat, CGColor)] = [ + (0.2, 0.3, 260, CGColor(srgbRed: 1.0, green: 0.35, blue: 0.45, alpha: 1)), + (0.7, 0.25, 300, CGColor(srgbRed: 0.25, green: 0.55, blue: 1.0, alpha: 1)), + (0.5, 0.8, 280, CGColor(srgbRed: 0.2, green: 0.85, blue: 0.55, alpha: 1)), + (0.9, 0.8, 220, CGColor(srgbRed: 1.0, green: 0.8, blue: 0.2, alpha: 1)), + ] + for (fx, fy, radius, color) in blobs { + let center = CGPoint(x: fx * size.width, y: fy * size.height) + let clear = color.copy(alpha: 0)! + let gradient = CGGradient(colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), colors: [color, clear] as CFArray, locations: [0, 1])! + ctx.drawRadialGradient(gradient, startCenter: center, startRadius: 0, endCenter: center, endRadius: radius, options: []) + } + case "uv": + // R and G encode the sampled point, so a capture reads back where + // each glass pixel's light came from. + let width = Int(size.width), height = Int(size.height) + for row in 0.. CGImage { + let width = Int(canvas.width * scale), height = Int(canvas.height * scale) + let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + ctx.scaleBy(x: scale, y: scale) + drawScene(name, in: ctx, size: canvas) + return ctx.makeImage()! +} + +func writePNG(_ image: CGImage, to url: URL) { + let rep = NSBitmapImageRep(cgImage: image) + try! rep.representation(using: .png, properties: [:])!.write(to: url) +} + +func capture(_ window: NSWindow, to url: URL) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/sbin/screencapture") + process.arguments = ["-x", "-o", "-l\(window.windowNumber)", url.path] + try! process.run() + process.waitUntilExit() + let convert = Process() + convert.executableURL = URL(fileURLWithPath: "/usr/bin/sips") + convert.arguments = ["--matchTo", "/System/Library/ColorSync/Profiles/sRGB Profile.icc", url.path] + convert.standardOutput = FileHandle.nullDevice + try! convert.run() + convert.waitUntilExit() +} + +struct ControlsPanel: View { + @State var on = true + @State var off = false + @State var level = 0.35 + @State var choice = 1 + var body: some View { + VStack(alignment: .leading, spacing: 28) { + HStack(spacing: 28) { + Toggle("", isOn: $on).toggleStyle(.switch).labelsHidden() + Toggle("", isOn: $off).toggleStyle(.switch).labelsHidden() + Toggle("", isOn: $on).toggleStyle(.switch).labelsHidden().controlSize(.large) + } + Slider(value: $level).frame(width: 320) + Picker("", selection: $choice) { + Text("Free").tag(0) + Text("16:9").tag(1) + Text("4:3").tag(2) + Text("1:1").tag(3) + } + .pickerStyle(.segmented).labelsHidden().frame(width: 320) + HStack(spacing: 20) { + Button("Glass") {}.buttonStyle(.glass) + Button("Prominent") {}.buttonStyle(.glassProminent) + Button("Large") {}.buttonStyle(.glass).controlSize(.large) + Button {} label: { Image(systemName: "xmark") }.buttonStyle(.glass).buttonBorderShape(.circle) + } + } + .padding(48) + .frame(width: 960, height: 600, alignment: .topLeading) + } +} + +func pump(_ seconds: TimeInterval) { + RunLoop.current.run(until: Date().addingTimeInterval(seconds)) +} + +let out = URL(fileURLWithPath: CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "glass-reference") +try FileManager.default.createDirectory(at: out.appendingPathComponent("scenes"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("apple"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("bare"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("controls"), withIntermediateDirectories: true) + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +app.finishLaunching() + +let window = NSWindow(contentRect: NSRect(origin: NSPoint(x: 80, y: 80), size: canvas), + styleMask: [.borderless], backing: .buffered, defer: false) +window.isOpaque = true +window.colorSpace = .sRGB +window.hasShadow = false +window.level = .floating +let root = FlippedView(frame: NSRect(origin: .zero, size: canvas)) +window.contentView = root +window.makeKeyAndOrderFront(nil) +app.activate(ignoringOtherApps: true) +pump(1.0) + +let scale = window.backingScaleFactor +var cases: [[String: Any]] = [] +let backdrop = NSImageView(frame: root.bounds) +backdrop.imageScaling = .scaleAxesIndependently +root.addSubview(backdrop) + +for scene in scenes { + let image = renderScene(scene, scale: scale) + writePNG(image, to: out.appendingPathComponent("scenes/\(scene).png")) + backdrop.image = NSImage(cgImage: image, size: canvas) + window.appearance = NSAppearance(named: .aqua) + pump(0.4) + capture(window, to: out.appendingPathComponent("bare/\(scene).png")) + for (styleName, style) in styles { + for (appearanceName, appearance) in appearances { + for (tintName, tint) in tints { + if tintName != "none" && !(scene == "white" || scene == "blobs" || scene == "black") { + continue + } + window.appearance = NSAppearance(named: appearance) + var views: [NSView] = [] + for shape in shapes { + let glass = NSGlassEffectView(frame: shape.rect) + glass.style = style + glass.cornerRadius = shape.radius + glass.tintColor = tint + root.addSubview(glass) + views.append(glass) + } + pump(0.6) + let name = "\(scene)-\(styleName)-\(appearanceName)-\(tintName)" + capture(window, to: out.appendingPathComponent("apple/\(name).png")) + cases.append([ + "name": name, "scene": scene, "style": styleName, + "appearance": appearanceName, "tint": tintName, + ]) + views.forEach { $0.removeFromSuperview() } + } + } + } +} + +var controlCases: [String] = [] +for scene in ["blobs", "white", "darktext"] { + backdrop.image = NSImage(cgImage: renderScene(scene, scale: scale), size: canvas) + for (appearanceName, appearance) in appearances { + window.appearance = NSAppearance(named: appearance) + let host = NSHostingView(rootView: ControlsPanel()) + host.frame = root.bounds + root.addSubview(host) + pump(0.8) + let name = "\(scene)-\(appearanceName)" + capture(window, to: out.appendingPathComponent("controls/\(name).png")) + controlCases.append(name) + host.removeFromSuperview() + } +} + +let manifest: [String: Any] = [ + "controls": controlCases, + "canvas": [canvas.width, canvas.height], + "scale": scale, + "os": ProcessInfo.processInfo.operatingSystemVersionString, + "reduceTransparency": NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency, + "increaseContrast": NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast, + "gpu": MTLCreateSystemDefaultDevice()?.name ?? "none", + "shapes": shapes.map { ["name": $0.name, "rect": [$0.rect.minX, $0.rect.minY, $0.rect.width, $0.rect.height], "radius": $0.radius] }, + "tints": ["blue": [0.0, 0.533, 1.0, 0.6]], + "cases": cases, +] +let data = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: out.appendingPathComponent("manifest.json")) +print("wrote \(cases.count) cases to \(out.path)") diff --git a/scripts/glass-reference/sweep.swift b/scripts/glass-reference/sweep.swift new file mode 100644 index 00000000000..15cea806a12 --- /dev/null +++ b/scripts/glass-reference/sweep.swift @@ -0,0 +1,267 @@ +// Sweeps Apple's glass (NSGlassEffectView) across backdrop brightness and +// pane size, so Cap's glass can match how Apple's adapts: small Regular +// panes lighten over bright content in dark mode, and tints are tone mapped +// against what is behind them. Writes /scenes/.png, +// /apple/.png and /manifest.json in the same format as +// reference.swift. +// +// swiftc -O sweep.swift -o sweep && ./sweep [lens | tints] +// +// `lens` swaps the greys for the UV ramp (red and green encode the sampled +// point), so the same shapes show how Apple's refraction scales with size. +// `tints` runs a grid of tint colours and alphas over three greys, to fit +// how a tint's colour and alpha set the tinted glass. `levels` runs Clear +// glass only, under tints spanning their brightest and darkest channel, +// which is what sets how a tint darkens over dark content. `hsl` runs Clear +// glass under a regular grid of HSL lightness and saturation at one hue: +// the tint keeps its hue, so this grid is the whole law. +// +// Each style and appearance keeps the same views for a rising and then a +// falling pass over the greys, so any hysteresis in the adaptation shows up +// as a difference between the `up` and `down` captures of a level. +import AppKit +import Metal + +let canvas = NSSize(width: 960, height: 600) + +struct Shape { + let name: String + let rect: NSRect + let radius: CGFloat +} + +var shapes: [Shape] = [] +do { + // Capsules of one width and rising height. + var y: CGFloat = 20 + for height in [20, 28, 36, 44, 52, 64, 80, 96] as [CGFloat] { + shapes.append(Shape(name: "capsule\(Int(height))", rect: NSRect(x: 30, y: y, width: 200, height: height), radius: height / 2)) + y += height + 18 + } + // Squares with a fixed corner radius. + y = 20 + for side in [24, 32, 48, 64, 96, 128] as [CGFloat] { + shapes.append(Shape(name: "square\(Int(side))", rect: NSRect(x: 270, y: y, width: side, height: side), radius: min(12, side / 2))) + y += side + 18 + } + // Bars of one height and rising width, then a large panel. + y = 20 + for width in [60, 120, 240, 420] as [CGFloat] { + shapes.append(Shape(name: "bar\(Int(width))", rect: NSRect(x: 460, y: y, width: width, height: 44), radius: 22)) + y += 44 + 18 + } + shapes.append(Shape(name: "panel", rect: NSRect(x: 460, y: 300, width: 460, height: 270), radius: 24)) +} + +let greys: [Int] = [0, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 255] +let styles: [(String, NSGlassEffectView.Style)] = [("regular", .regular), ("clear", .clear)] +let appearances: [(String, NSAppearance.Name)] = [("light", .aqua), ("dark", .darkAqua)] +let tints: [(String, NSColor?, [Double])] = [ + ("none", nil, []), + ("blue", NSColor.systemBlue.withAlphaComponent(0.6), [0.0, 0.533, 1.0, 0.6]), + ("red", NSColor.systemRed.withAlphaComponent(0.6), [1.0, 0.22, 0.235, 0.6]), + ("green", NSColor.systemGreen.withAlphaComponent(0.6), [0.204, 0.78, 0.349, 0.6]), + ("blue30", NSColor.systemBlue.withAlphaComponent(0.3), [0.0, 0.533, 1.0, 0.3]), + ("blue100", NSColor.systemBlue, [0.0, 0.533, 1.0, 1.0]), +] + +final class FlippedView: NSView { + override var isFlipped: Bool { true } +} + +func greyImage(_ level: Int, scale: CGFloat) -> CGImage { + let width = Int(canvas.width * scale), height = Int(canvas.height * scale) + let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + let v = CGFloat(level) / 255 + ctx.setFillColor(CGColor(srgbRed: v, green: v, blue: v, alpha: 1)) + ctx.fill(CGRect(x: 0, y: 0, width: width, height: height)) + return ctx.makeImage()! +} + +func uvImage(scale: CGFloat) -> CGImage { + let width = Int(canvas.width * scale), height = Int(canvas.height * scale) + let ctx = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, + space: CGColorSpace(name: CGColorSpace.sRGB)!, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)! + for row in 0.. 1 ? CommandLine.arguments[1] : "glass-sweep") +let lensMode = CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "lens" +let hslMode = CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "hsl" +let levelsMode = hslMode || (CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "levels") +let tintMode = levelsMode || (CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "tints") +let tintGrid: [(String, NSColor?, [Double])] = { + var grid: [(String, NSColor?, [Double])] = [("none", nil, [])] + func add(_ name: String, _ r: Double, _ g: Double, _ b: Double, _ a: Double) { + grid.append((name, NSColor(srgbRed: r, green: g, blue: b, alpha: a), [r, g, b, a])) + } + for i in 0..<12 { + let color = NSColor(calibratedHue: CGFloat(i) / 12, saturation: 1, brightness: 1, alpha: 1).usingColorSpace(.sRGB)! + add("hue\(i * 30)", Double(color.redComponent), Double(color.greenComponent), Double(color.blueComponent), 0.6) + } + add("white", 1, 1, 1, 0.6) + add("grey50", 0.5, 0.5, 0.5, 0.6) + add("black", 0, 0, 0, 0.6) + add("navy", 0.1, 0.15, 0.45, 0.6) + add("brown", 0.55, 0.35, 0.2, 0.6) + add("pastel", 0.95, 0.75, 0.85, 0.6) + for a in [0.1, 0.2, 0.45, 0.8, 1.0] { + add("orange\(Int(a * 100))", 1, 0.58, 0.0, a) + } + if levelsMode { + grid = [("none", nil, [])] + for high in [0.2, 0.4, 0.6, 0.8, 1.0] { + for fraction in [0.0, 0.25, 0.5, 0.75] { + let low = high * fraction + add("warm\(Int(high * 100))-\(Int(fraction * 100))", high, low + (high - low) * 0.3, low, 0.6) + add("cool\(Int(high * 100))-\(Int(fraction * 100))", low, low + (high - low) * 0.6, high, 0.6) + } + add("grey\(Int(high * 100))", high, high, high, 0.6) + } + } + if hslMode { + grid = [("none", nil, [])] + // HSL to sRGB at hue 210 degrees: red is the darkest channel, blue + // the brightest, green halfway between. + for lightness in [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 0.95, 1.0] { + for saturation in [0.0, 0.25, 0.5, 0.75, 1.0] { + let chroma = (1 - abs(2 * lightness - 1)) * saturation + let low = lightness - chroma / 2 + let high = lightness + chroma / 2 + add("l\(Int(lightness * 100))-s\(Int(saturation * 100))", low, (low + high) / 2, high, 0.6) + } + } + } + return grid +}() +try FileManager.default.createDirectory(at: out.appendingPathComponent("scenes"), withIntermediateDirectories: true) +try FileManager.default.createDirectory(at: out.appendingPathComponent("apple"), withIntermediateDirectories: true) + +let app = NSApplication.shared +app.setActivationPolicy(.regular) +app.finishLaunching() + +let window = NSWindow(contentRect: NSRect(origin: NSPoint(x: 80, y: 80), size: canvas), + styleMask: [.borderless], backing: .buffered, defer: false) +window.isOpaque = true +window.colorSpace = .sRGB +window.hasShadow = false +window.level = .floating +let root = FlippedView(frame: NSRect(origin: .zero, size: canvas)) +window.contentView = root +window.makeKeyAndOrderFront(nil) +app.activate(ignoringOtherApps: true) +pump(1.0) + +let scale = window.backingScaleFactor +let backdrop = NSImageView(frame: root.bounds) +backdrop.imageScaling = .scaleAxesIndependently +root.addSubview(backdrop) + +// Scene names by level; the lens mode has one scene, the UV ramp. +var images: [Int: NSImage] = [:] +var sceneNames: [Int: String] = [:] +let levels: [Int] = lensMode ? [-1] : tintMode ? [16, 128, 240] : greys +for level in levels { + let image = level < 0 ? uvImage(scale: scale) : greyImage(level, scale: scale) + let scene = level < 0 ? "uv" : String(format: "grey%03d", level) + writePNG(image, to: out.appendingPathComponent("scenes/\(scene).png")) + images[level] = NSImage(cgImage: image, size: canvas) + sceneNames[level] = scene +} + +var cases: [[String: Any]] = [] +let runTints = lensMode ? Array(tints.prefix(1)) : tintMode ? tintGrid : tints +for (tintName, tint, _) in runTints { + for (styleName, style) in styles { + for (appearanceName, appearance) in appearances { + // Clear's tint does not depend on the appearance, so the grid + // only needs Clear in light mode. + if tintMode && styleName == "clear" && appearanceName == "dark" { + continue + } + if levelsMode && styleName == "regular" { + continue + } + window.appearance = NSAppearance(named: appearance) + backdrop.image = images[levels[0]] + var views: [NSView] = [] + for shape in shapes { + let glass = NSGlassEffectView(frame: shape.rect) + glass.style = style + glass.cornerRadius = shape.radius + glass.tintColor = tint + root.addSubview(glass) + views.append(glass) + } + pump(1.0) + // Untinted runs go up and back down to expose hysteresis; tinted + // ones only go up. + let passes: [(String, [Int])] = tintName == "none" && !lensMode && !tintMode + ? [("up", levels), ("down", levels.reversed())] + : [("up", levels)] + for (pass, levels) in passes { + for level in levels { + backdrop.image = images[level] + pump(1.0) + let scene = sceneNames[level]! + let name = "\(scene)-\(styleName)-\(appearanceName)-\(tintName)-\(pass)" + capture(window, to: out.appendingPathComponent("apple/\(name).png")) + cases.append([ + "name": name, "scene": scene, "style": styleName, + "appearance": appearanceName, "tint": tintName, "pass": pass, + ]) + } + } + views.forEach { $0.removeFromSuperview() } + } + } +} + +let manifest: [String: Any] = [ + "canvas": [canvas.width, canvas.height], + "scale": scale, + "os": ProcessInfo.processInfo.operatingSystemVersionString, + "reduceTransparency": NSWorkspace.shared.accessibilityDisplayShouldReduceTransparency, + "increaseContrast": NSWorkspace.shared.accessibilityDisplayShouldIncreaseContrast, + "gpu": MTLCreateSystemDefaultDevice()?.name ?? "none", + "shapes": shapes.map { ["name": $0.name, "rect": [$0.rect.minX, $0.rect.minY, $0.rect.width, $0.rect.height], "radius": $0.radius] }, + "tints": Dictionary(uniqueKeysWithValues: runTints.filter { !$0.2.isEmpty }.map { ($0.0, $0.2) }), + "cases": cases, +] +let data = try JSONSerialization.data(withJSONObject: manifest, options: [.prettyPrinted, .sortedKeys]) +try data.write(to: out.appendingPathComponent("manifest.json")) +print("wrote \(cases.count) cases to \(out.path)") diff --git a/scripts/prepare-gpui-dependency.sh b/scripts/prepare-gpui-dependency.sh index bd26ac9bf2c..cce93449c1a 100755 --- a/scripts/prepare-gpui-dependency.sh +++ b/scripts/prepare-gpui-dependency.sh @@ -12,6 +12,7 @@ patch_paths=( "apps/desktop-gpui/patches/zed-wayland-visibility.patch" "apps/desktop-gpui/patches/zed-wayland-activation.patch" "apps/desktop-gpui/patches/zed-closed-window-events.patch" + "apps/desktop-gpui/patches/zed-glass.patch" ) patch_files=("${patch_paths[@]/#/$repo_root/}") base_revision="5d1f83d9f27a19bec1fb241dc33b42238af9cf8d" diff --git a/scripts/prepare-gpui-dependency.test.mjs b/scripts/prepare-gpui-dependency.test.mjs index 45a5b1eee0a..8b92595aaae 100644 --- a/scripts/prepare-gpui-dependency.test.mjs +++ b/scripts/prepare-gpui-dependency.test.mjs @@ -152,6 +152,8 @@ test("dependency verification handles overlapping patches without changing the c patch("zed-wayland-activation.patch"); writeFileSync(path.join(upstream, "closed.rs"), "quiet closed windows\n"); patch("zed-closed-window-events.patch"); + writeFileSync(path.join(upstream, "glass.rs"), "glass panes\n"); + patch("zed-glass.patch"); const script = path.join(cap, "scripts/prepare-gpui-dependency.sh"); writeFileSync( script,