diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..8d5ace99 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.tasque/events.jsonl merge=tasque-events diff --git a/.github/actions/install-linux-dependencies/action.yml b/.github/actions/install-linux-dependencies/action.yml new file mode 100644 index 00000000..ba6f801b --- /dev/null +++ b/.github/actions/install-linux-dependencies/action.yml @@ -0,0 +1,29 @@ +name: Install Linux dependencies +description: Install the native libraries required to build and test GPUI on Linux + +runs: + using: composite + steps: + - shell: bash + run: | + sudo apt-get update + sudo apt-get install -y \ + clang \ + pkg-config \ + libfontconfig-dev \ + libfreetype-dev \ + mesa-vulkan-drivers \ + libvulkan-dev \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libwayland-dev \ + libx11-xcb-dev \ + libxcb1-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxcb-randr0-dev \ + libxcb-xinput-dev \ + libxcb-cursor-dev \ + libxcb-sync-dev \ + libxcb-render0-dev \ + libxcb-present-dev diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1250e04d..7fe34bd4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,53 +6,117 @@ on: pull_request: branches: [main] +permissions: + contents: read + env: CARGO_TERM_COLOR: always - RUSTFLAGS: -D warnings jobs: - check-macos: - name: Check (macOS) - runs-on: macos-latest + quality: + name: Quality and tests + runs-on: ubuntu-latest + timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - run: cargo check -p gpui --all-targets + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + with: + toolchain: 1.95.0 + components: rustfmt, clippy + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + - name: Install Linux dependencies + uses: ./.github/actions/install-linux-dependencies + - run: cargo fmt --all -- --check + - run: cargo metadata --locked --format-version 1 > /dev/null + - run: cargo check --locked --workspace --all-targets + - run: cargo clippy --locked --workspace --all-targets --all-features -- --deny warnings + - run: cargo test --locked -p scheduler + - run: cargo test --locked -p gpui --features bench,profiler --lib + - name: Test release-only accessibility duplicate handling + run: cargo test --locked --release -p gpui --features test-support duplicate_id + - run: cargo test --locked -p gpui_wgpu --lib + - run: cargo check --locked -p gpui --examples + - run: cargo check --locked -p gpui --features bench --benches + - run: cargo check --locked -p gpui_wgpu --benches - test-macos: - name: Test (macOS) - runs-on: macos-latest + native-platform: + name: Native platform (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - run: cargo test -p gpui --features test-support + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + with: + toolchain: 1.95.0 + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + - name: Ensure Metal toolchain is available + if: runner.os == 'macOS' + run: | + if ! xcrun -f metal > /dev/null 2>&1; then + xcodebuild -downloadComponent MetalToolchain + fi + xcrun -f metal + - run: cargo check --locked --workspace --all-targets + - name: Test macOS platform + if: runner.os == 'macOS' + run: cargo test --locked -p gpui_macos --all-features --all-targets + - name: Test Windows platform + if: runner.os == 'Windows' + run: cargo test --locked -p gpui_windows --all-features --all-targets + - name: Compile Windows shaders in release mode + if: runner.os == 'Windows' + run: cargo check --locked -p gpui_windows --release - check-linux: - name: Check (Linux) + linux-backend: + name: Linux (${{ matrix.backend }}) + strategy: + fail-fast: false + matrix: + backend: [wayland, x11] runs-on: ubuntu-latest + timeout-minutes: 45 + env: + BACKEND: ${{ matrix.backend }} steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - libwayland-dev \ - libxkbcommon-dev \ - libx11-xcb-dev \ - libxcb-shape0-dev \ - libxcb-xfixes0-dev \ - libxcb-randr0-dev \ - libxcb-xinput-dev \ - libxcb-cursor-dev \ - libxcb-sync-dev \ - libxcb-render0-dev \ - libxcb-present-dev \ - libfontconfig-dev \ - libfreetype-dev \ - libvulkan-dev \ - clang - - run: cargo check -p gpui --all-targets + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + with: + toolchain: 1.95.0 + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + key: linux-${{ matrix.backend }} + - name: Install Linux dependencies + uses: ./.github/actions/install-linux-dependencies + - run: cargo check --locked -p gpui_platform --no-default-features --features "$BACKEND" --all-targets + - run: cargo test --locked -p gpui_linux --no-default-features --features "$BACKEND,test-support" --lib + + wasm: + name: WebAssembly + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: -C target-feature=+atomics,+bulk-memory,+mutable-globals + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@fa04a1451ff1842e2626ccb99004d0195b455a88 # master + with: + toolchain: nightly + components: rust-src + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 + with: + key: wasm + - run: cargo +nightly -Zbuild-std=std,panic_abort check --locked --target wasm32-unknown-unknown -p gpui_platform + - run: cargo +nightly -Zbuild-std=std,panic_abort check --locked --target wasm32-unknown-unknown -p gpui --example a11y --example view_example diff --git a/.tasque/.gitignore b/.tasque/.gitignore new file mode 100644 index 00000000..264dbaec --- /dev/null +++ b/.tasque/.gitignore @@ -0,0 +1,5 @@ +state.json +state.json.tmp* +.lock +snapshots/ +snapshots/*.tmp diff --git a/.tasque/config.json b/.tasque/config.json new file mode 100644 index 00000000..a813cc39 --- /dev/null +++ b/.tasque/config.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "snapshot_every": 200, + "sync_branch": "tsq-sync" +} diff --git a/.tasque/events.jsonl b/.tasque/events.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/Cargo.lock b/Cargo.lock index ba395656..c117823c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ "uuid", ] @@ -27,9 +27,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" dependencies = [ "accesskit", "hashbrown 0.16.1", @@ -37,9 +37,9 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "f950720ce064757a1b629caad3a408e8d2c63bb01f29b8a3ff8daa331053ffeb" dependencies = [ "accesskit", "hashbrown 0.16.1", @@ -55,7 +55,7 @@ dependencies = [ "accesskit_consumer 0.36.0", "hashbrown 0.16.1", "objc2 0.5.2", - "objc2-app-kit", + "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -79,12 +79,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.33.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "36e93ac7bf50b964f1cbb75f741629a4e950571baa1ef1274457ab5a80d9bcc2" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer 0.37.0", "hashbrown 0.16.1", "static_assertions", "windows 0.62.2", @@ -177,7 +177,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", - "anstyle-parse", + "anstyle-parse 0.2.7", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse 1.0.0", "anstyle-query", "anstyle-wincon", "colorchoice", @@ -200,6 +215,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + [[package]] name = "anstyle-query" version = "1.1.4" @@ -272,19 +296,16 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.12.1" +version = "0.13.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "618a409b91d5265798a99e3d1d0b226911605e581c4e7255e83c1e397b172bce" +checksum = "281e6645758940dee594495e28807a7672ce40f11ebf4df6c22c4fcd59e2689f" dependencies = [ - "async-fs", - "async-net", "enumflags2", "futures-channel", "futures-util", - "rand 0.9.2", + "getrandom 0.4.3", "serde", "serde_repr", - "url", "wayland-backend", "wayland-client", "wayland-protocols", @@ -399,9 +420,9 @@ dependencies = [ [[package]] name = "async-lock" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd03604047cee9b6ce9de9f70c6cd540a0520c813cbd49bae61f33ab80ed1dc" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ "event-listener 5.4.1", "event-listener-strategy", @@ -652,6 +673,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -698,30 +725,15 @@ dependencies = [ "syn", ] -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec 0.9.1", + "bit-vec", ] -[[package]] -name = "bit-vec" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" - [[package]] name = "bit-vec" version = "0.9.1" @@ -1068,6 +1080,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -1076,8 +1089,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream 1.0.0", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1155,17 +1182,6 @@ dependencies = [ "objc", ] -[[package]] -name = "codespan-reporting" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" -dependencies = [ - "serde", - "termcolor", - "unicode-width", -] - [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1181,6 +1197,7 @@ dependencies = [ name = "collections" version = "0.1.0" dependencies = [ + "gpui_util", "indexmap", "rustc-hash 2.1.1", ] @@ -1517,6 +1534,15 @@ dependencies = [ "itertools 0.10.5", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1564,7 +1590,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] @@ -1578,6 +1603,17 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.3", + "windows-sys 0.61.2", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1638,15 +1674,6 @@ dependencies = [ "dirs-sys 0.3.7", ] -[[package]] -name = "dirs" -version = "5.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" -dependencies = [ - "dirs-sys 0.4.1", -] - [[package]] name = "dirs" version = "6.0.0" @@ -1667,18 +1694,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", -] - [[package]] name = "dirs-sys" version = "0.5.0" @@ -1722,9 +1737,9 @@ dependencies = [ [[package]] name = "dlib" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ "libloading", ] @@ -1840,7 +1855,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" dependencies = [ - "anstream", + "anstream 0.6.21", "anstyle", "env_filter", "jiff", @@ -2116,7 +2131,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2382,11 +2397,22 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "gif" version = "0.13.3" @@ -2397,6 +2423,16 @@ dependencies = [ "weezl", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.32.3" @@ -2536,6 +2572,7 @@ dependencies = [ "core-graphics 0.24.0", "core-text", "core-video", + "criterion", "ctor", "derive_more", "embed-resource", @@ -2550,6 +2587,8 @@ dependencies = [ "gpui_shared_string", "gpui_util", "gpui_web", + "hdrhistogram", + "heapless", "http_client", "image", "inventory", @@ -2559,7 +2598,6 @@ dependencies = [ "mach2", "media", "metal", - "naga 25.0.1", "num_cpus", "objc", "objc2 0.6.3", @@ -2587,7 +2625,7 @@ dependencies = [ "smallvec", "spin 0.10.0", "stacksafe", - "strum 0.27.2", + "strum", "sum_tree", "taffy", "thiserror 2.0.17", @@ -2595,14 +2633,13 @@ dependencies = [ "unicode-general-category", "unicode-segmentation", "usvg", - "util", "util_macros", "uuid", "waker-fn", "wasm-bindgen", "web-time", "windows 0.61.3", - "zed-font-kit 0.14.1-zed (git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269)", + "zed-font-kit", "zed-scap", ] @@ -2624,8 +2661,10 @@ dependencies = [ "filedescriptor", "futures", "gpui", + "gpui_util", "gpui_wgpu", "http_client", + "image", "itertools 0.14.0", "libc", "log", @@ -2633,13 +2672,14 @@ dependencies = [ "open", "parking_lot", "pathfinder_geometry", + "pollster 0.4.0", "profiling", "raw-window-handle", "smallvec", "smol", - "strum 0.27.2", + "strum", "swash", - "util", + "url", "uuid", "wayland-backend", "wayland-client", @@ -2651,7 +2691,7 @@ dependencies = [ "x11-clipboard", "x11rb", "xkbcommon", - "zed-font-kit 0.14.1-zed (git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5)", + "zed-font-kit", "zed-scap", "zed-xim", ] @@ -2680,6 +2720,7 @@ dependencies = [ "foreign-types", "futures", "gpui", + "gpui_util", "image", "itertools 0.14.0", "libc", @@ -2688,15 +2729,17 @@ dependencies = [ "media", "metal", "objc", + "objc2 0.6.3", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "parking_lot", "pathfinder_geometry", "raw-window-handle", "semver", "smallvec", - "strum 0.27.2", - "util", + "strum", "uuid", - "zed-font-kit 0.14.1-zed (git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5)", + "zed-font-kit", ] [[package]] @@ -2737,8 +2780,8 @@ version = "0.1.0" dependencies = [ "anyhow", "gpui", + "gpui_util", "tokio", - "util", ] [[package]] @@ -2747,6 +2790,7 @@ version = "0.1.0" dependencies = [ "anyhow", "log", + "which", ] [[package]] @@ -2798,7 +2842,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "wgpu", - "zed-font-kit 0.14.1-zed (git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269)", + "zed-font-kit", ] [[package]] @@ -2809,9 +2853,11 @@ dependencies = [ "accesskit_windows", "anyhow", "collections", + "dunce", "etagere", "futures", "gpui", + "gpui_util", "image", "itertools 0.14.0", "log", @@ -2819,7 +2865,6 @@ dependencies = [ "rand 0.9.2", "raw-window-handle", "smallvec", - "util", "uuid", "windows 0.61.3", "windows-core 0.61.2", @@ -2913,6 +2958,20 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hdrhistogram" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +dependencies = [ + "base64 0.21.7", + "byteorder", + "crossbeam-channel", + "flate2", + "nom", + "num-traits", +] + [[package]] name = "heapless" version = "0.9.2" @@ -3253,7 +3312,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.13.3", "image-webp", "moxcms", "num-traits", @@ -3263,8 +3322,8 @@ dependencies = [ "rayon", "rgb", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.4.12", + "zune-jpeg 0.4.21", ] [[package]] @@ -3279,9 +3338,9 @@ dependencies = [ [[package]] name = "imagesize" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imgref" @@ -3518,12 +3577,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kurbo" -version = "0.11.3" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ "arrayvec", "euclid", + "polycool", "smallvec", ] @@ -3541,9 +3601,6 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -dependencies = [ - "spin 0.9.8", -] [[package]] name = "leak" @@ -3568,9 +3625,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -3946,39 +4003,16 @@ dependencies = [ [[package]] name = "naga" -version = "25.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b977c445f26e49757f9aca3631c3b8b836942cb278d69a92e7b80d3b24da632" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set 0.8.0", - "bitflags 2.11.0", - "cfg_aliases", - "codespan-reporting 0.12.0", - "half", - "hashbrown 0.15.5", - "hexf-parse", - "indexmap", - "log", - "num-traits", - "once_cell", - "rustc-hash 1.1.0", - "strum 0.26.3", - "thiserror 2.0.17", - "unicode-ident", -] - -[[package]] -name = "naga" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" -dependencies = [ - "arrayvec", - "bit-set 0.9.1", + "bit-set", "bitflags 2.11.0", "cfg-if", "cfg_aliases", - "codespan-reporting 0.13.1", + "codespan-reporting", "half", "hashbrown 0.16.1", "hexf-parse", @@ -4039,7 +4073,18 @@ dependencies = [ "cfg-if", "cfg_aliases", "libc", - "memoffset", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", ] [[package]] @@ -4102,16 +4147,16 @@ dependencies = [ [[package]] name = "num-bigint-dig" -version = "0.8.6" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" dependencies = [ - "lazy_static", "libm", "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "once_cell", + "rand 0.9.2", "serde", "smallvec", "zeroize", @@ -4250,6 +4295,18 @@ dependencies = [ "objc2-quartz-core 0.2.2", ] +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -4407,9 +4464,9 @@ checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" [[package]] name = "oo7" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3299dd401feaf1d45afd8fd1c0586f10fcfb22f244bb9afa942cec73503b89d" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ "aes", "ashpd", @@ -4423,15 +4480,15 @@ dependencies = [ "endi", "futures-lite 2.6.1", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.3", "hkdf", "hmac", "md-5", "num", "num-bigint-dig", "pbkdf2", - "rand 0.9.2", "serde", + "serde_bytes", "sha2", "subtle", "zbus", @@ -4751,6 +4808,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -4860,9 +4926,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -5004,9 +5070,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -5017,6 +5083,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -5318,11 +5390,11 @@ dependencies = [ [[package]] name = "resvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ - "gif", + "gif 0.14.2", "image-webp", "log", "pico-args", @@ -5330,7 +5402,7 @@ dependencies = [ "svgtypes", "tiny-skia", "usvg", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -5362,6 +5434,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rust-embed" version = "8.7.2" @@ -5713,6 +5794,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -6053,13 +6144,10 @@ dependencies = [ ] [[package]] -name = "strum" -version = "0.26.3" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" @@ -6067,20 +6155,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "rustversion", - "syn", + "strum_macros", ] [[package]] @@ -6201,9 +6276,9 @@ checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ "kurbo", "siphasher", @@ -6222,9 +6297,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.106" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -6437,7 +6512,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -6902,11 +6977,11 @@ dependencies = [ [[package]] name = "usvg" -version = "0.45.1" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -6914,7 +6989,7 @@ dependencies = [ "kurbo", "log", "pico-args", - "roxmltree", + "roxmltree 0.21.1", "rustybuzz", "simplecss", "siphasher", @@ -7227,9 +7302,9 @@ dependencies = [ [[package]] name = "wayland-backend" -version = "0.3.11" +version = "0.3.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673a33c33048a5ade91a6b139580fa174e19fb0d23f396dca9fa15f2e1e49b35" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", "downcast-rs", @@ -7313,9 +7388,9 @@ dependencies = [ [[package]] name = "wayland-sys" -version = "0.31.7" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34949b42822155826b41db8e5d0c1be3a2bd296c747577a43a3e6daefc296142" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" dependencies = [ "dlib", "log", @@ -7360,8 +7435,9 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", "bitflags 2.11.0", @@ -7372,7 +7448,7 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga 29.0.3", + "naga", "parking_lot", "portable-atomic", "profiling", @@ -7389,12 +7465,13 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set 0.9.1", - "bit-vec 0.9.1", + "bit-set", + "bit-vec", "bitflags 2.11.0", "bytemuck", "cfg_aliases", @@ -7402,7 +7479,7 @@ dependencies = [ "hashbrown 0.16.1", "indexmap", "log", - "naga 29.0.3", + "naga", "once_cell", "parking_lot", "portable-atomic", @@ -7421,37 +7498,41 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.9.1", + "bit-set", "bitflags 2.11.0", "block2 0.6.2", "bytemuck", @@ -7467,7 +7548,7 @@ dependencies = [ "libc", "libloading", "log", - "naga 29.0.3", + "naga", "ndk-sys", "objc2 0.6.3", "objc2-core-foundation", @@ -7498,17 +7579,19 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ - "naga 29.0.3", + "naga", "wgpu-types", ] [[package]] name = "wgpu-types" -version = "29.0.3" -source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ "bitflags 2.11.0", "bytemuck", @@ -7598,10 +7681,11 @@ dependencies = [ [[package]] name = "windows-capture" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +version = "1.4.3" +source = "git+https://github.com/zed-industries/windows-capture.git?rev=f0d6c1b6691db75461b732f6d5ff56eed002eeb9#f0d6c1b6691db75461b732f6d5ff56eed002eeb9" dependencies = [ + "clap", + "ctrlc", "parking_lot", "rayon", "thiserror 2.0.17", @@ -7848,15 +7932,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -7908,21 +7983,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -7980,12 +8040,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8004,12 +8058,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -8028,12 +8076,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -8064,12 +8106,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -8088,12 +8124,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -8112,12 +8142,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -8136,12 +8160,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8371,9 +8389,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.12.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b622b18155f7a93d1cd2dc8c01d2d6a44e08fb9ebb7b3f9e6ed101488bad6c91" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-executor", @@ -8389,15 +8407,16 @@ dependencies = [ "futures-core", "futures-lite 2.6.1", "hex", - "nix 0.30.1", + "libc", "ordered-stream", + "rustix 1.1.2", "serde", "serde_repr", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 0.7.13", + "winnow 1.0.3", "zbus_macros", "zbus_names", "zvariant", @@ -8429,9 +8448,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.12.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb94821ca8a87ca9c298b5d1cbd80e2a8b67115d99f6e4551ac49e42b6a314" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -8465,30 +8484,6 @@ dependencies = [ "zvariant", ] -[[package]] -name = "zed-font-kit" -version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5#110523127440aefb11ce0cf280ae7c5071337ec5" -dependencies = [ - "bitflags 2.11.0", - "byteorder", - "core-foundation 0.10.0", - "core-graphics 0.24.0", - "core-text", - "dirs 5.0.1", - "dwrote", - "float-ord", - "freetype-sys", - "lazy_static", - "libc", - "log", - "pathfinder_geometry", - "pathfinder_simd", - "walkdir", - "winapi", - "yeslogic-fontconfig-sys", -] - [[package]] name = "zed-font-kit" version = "0.14.1-zed" @@ -8518,7 +8513,7 @@ name = "zed-reqwest" version = "0.12.15-zed" source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -8729,6 +8724,12 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + [[package]] name = "zune-inflate" version = "0.2.54" @@ -8744,7 +8745,16 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core 0.5.1", ] [[package]] @@ -8756,7 +8766,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "url", + "serde_bytes", "winnow 1.0.3", "zvariant_derive", "zvariant_utils", diff --git a/Cargo.toml b/Cargo.toml index e2d38732..d93a84ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,9 +74,16 @@ ztracing_macro = { path = "crates/ztracing_macro" } accesskit = "0.24.0" accesskit_macos = "0.26.0" accesskit_unix = "0.21.0" -accesskit_windows = "0.32.1" +accesskit_windows = "0.33.1" anyhow = "1.0.86" -ashpd = { version = "0.12", default-features = false, features = ["async-std"] } +ashpd = { version = "0.13", default-features = false, features = [ + "async-io", + "notification", + "open_uri", + "file_chooser", + "settings", + "trash", +] } async-channel = "2.5.0" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-fs = "2.1" @@ -100,6 +107,7 @@ criterion = { version = "0.5", features = ["html_reports"] } ctor = "1.0.6" derive_more = "0.99.17" dirs = "4.0" +dunce = "1.0" env_logger = "0.11" futures = "0.3" futures-concurrency = "7.7.1" @@ -120,8 +128,42 @@ libc = "0.2" log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } mach2 = "0.5" metal = "0.29" -naga = { version = "25.0", features = ["wgsl-in"] } objc = "0.2" +objc2 = "0.6" +objc2-app-kit = { version = "0.3.2", default-features = false, features = [ + "NSButton", + "NSControl", + "NSGraphics", + "NSResponder", + "NSView", + "NSWindow", + "objc2-core-foundation", +] } +objc2-foundation = { version = "=0.3.2", default-features = false, features = [ + "NSArray", + "NSAttributedString", + "NSBundle", + "NSCoder", + "NSData", + "NSDate", + "NSDictionary", + "NSEnumerator", + "NSError", + "NSGeometry", + "NSNotification", + "NSNull", + "NSObjCRuntime", + "NSObject", + "NSProcessInfo", + "NSRange", + "NSRunLoop", + "NSString", + "NSURL", + "NSUndoManager", + "NSValue", + "objc2-core-foundation", + "std", +] } parking_lot = "0.12.1" percent-encoding = "2.3.2" postage = { version = "0.5", features = ["futures-traits"] } @@ -143,6 +185,12 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662 "socks", "stream", ], package = "zed-reqwest", version = "0.12.15-zed" } +resvg = { version = "0.46.0", default-features = false, features = [ + "text", + "system-fonts", + "memmap-fonts", + "raster-images", +] } rust-embed = { version = "8.4", features = ["include-exclude"] } rustc-hash = "2.1.0" rustls = { version = "0.23.26" } @@ -173,11 +221,12 @@ unicase = "2.6" unicode-general-category = "1.1.0" unicode-segmentation = "1.10" url = "2.2" +usvg = { version = "0.46.0", default-features = false } uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } walkdir = "2.5" wasm-bindgen = "0.2.113" web-time = "1.1.0" -wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } +wgpu = "29.0.4" which = "6.0.0" windows-core = "0.61" windows-numerics = "0.2" @@ -242,6 +291,7 @@ features = [ [patch.crates-io] async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } calloop = { git = "https://github.com/zed-industries/calloop" } +windows-capture = { git = "https://github.com/zed-industries/windows-capture.git", rev = "f0d6c1b6691db75461b732f6d5ff56eed002eeb9" } [profile.dev] split-debuginfo = "unpacked" diff --git a/README.md b/README.md index 06b635f9..3a77e450 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,11 @@ cargo build --example hello_world ## Upstream Sync -Last targeted sync with [zed-industries/zed](https://github.com/zed-industries/zed) at commit [`24f62484e9`](https://github.com/zed-industries/zed/commit/24f62484e936aa355c72f2009313bbe2898a9fd5) (2026-04-29). +GPUI changes have been selectively audited and synchronized through +[zed-industries/zed commit `2c4e44704c`](https://github.com/zed-industries/zed/commit/2c4e44704c37ee87e59ac84e3e17388178b28545) +(2026-07-09). This is a semantic fork sync, not a contiguous merge or a claim that the trees are +byte-identical. See [UPSTREAM.md](UPSTREAM.md) for imported areas, fork invariants, exclusions, +verification evidence, and the next-sync procedure. ## License diff --git a/UPSTREAM.md b/UPSTREAM.md new file mode 100644 index 00000000..135d6140 --- /dev/null +++ b/UPSTREAM.md @@ -0,0 +1,132 @@ +# Upstream synchronization + +This repository is a standalone, selective hard fork of GPUI from +[`zed-industries/zed`](https://github.com/zed-industries/zed). It preserves APIs and renderer and +platform behavior that do not exist upstream while periodically importing compatible GPUI work. + +## Current provenance + +- Historical README marker: `24f62484e936aa355c72f2009313bbe2898a9fd5` (2026-04-29). +- Current audited target: `2c4e44704c37ee87e59ac84e3e17388178b28545` (2026-07-09). +- Audited range: 1,493 Zed commits, including 117 commits touching GPUI-family code and 126 + GPUI-family files. +- Fork implementation range: commits after local baseline `510dedc3b44fcf80c791ab17e59530ae66e80558`. + +The historical marker was only a lower-bound provenance note. Earlier fork commits `b373e0b` and +`8164718` had already imported some later upstream work. Neither upstream SHA is a byte-identical +fork baseline or a suitable Git merge base for this fork; the current target is an audit cursor. +The 2026-07 sync compared behavior and paths, then imported or adapted changes in reviewable local +commits. It did not merge the Zed repository or copy its root manifests and lockfile. + +## Imported and reconciled areas + +| Area | Local commits | Upstream references and result | +| --- | --- | --- | +| Preservation contracts | `800c73a` | Locked overlay, rounded blur, element backdrop blur, retained-layer, accessibility, scheduler, and device-recovery behavior before importing changes. | +| Approved local pending fixes | `e8bceee` | Ported reviewed semantic fixes from the fork-local, previously unmerged `ed805ad`; this is not attributed to the audited upstream range. | +| Standalone foundation and dependencies | `6ad4ac9`, `6cc040c` | Rust 1.95 foundation, standalone assets, dependency updates, `gpui_util` boundary, and crates.io WGPU 29.0.4. | +| Runtime and scheduling | `e4fc606` | Reconciled `b14229f1a0`, `74798c68d5`, `ee571d3c69`, and `2c4e44704c`: embedded application handles, system wake, lazy test scheduling, and shutdown fixes while retaining fork panic and `Send`-only output contracts. | +| View model | `2186631` | Ported `74b5207744` View/ViewElement semantics and macro support while retaining fork accessibility and retained-layer invalidation. | +| Profiling and benchmarks | `bb2ea30`, `256ff6c` | Reconciled `aa6f03bedd`, `297c4a4d78`, `39f7849a0f`, and `48511e0b9c`; profiling remains default-off and benchmarks cover CPU scene construction, backdrop blur, and retained layers. | +| Accessibility | `38ee1a8` | Reconciled `777e16dd1f`, `83d4847462`, and follow-ups: current roles/properties, synthetic nodes, active descendants, root titles, and wasm example while retaining stable IDs, rollback, mutation-safe listeners, and main-thread macOS ownership. | +| Rendering and GPU resources | `92e3e78`, `a2ea0ed`, `1120c02` | Reconciled `34cd17ff5e`, `0d8a4d4292`, `f281770034`, `fbd911ed3e`, `f791aa57d7`, and `56009f39b`: padded GPU booleans, atlas reclamation, SVG caps, box-shadow builders, WGPU recovery, and presented-frame reporting. | +| Remaining platform-neutral GPUI | `075969c` through `7737056` | Reconciled color, text wrapping, tooltip lifecycle, list scrolling, flex APIs, animation parenting, drag/hover cleanup, TypeId collections, prompt labels, and bounds/mouse refresh through the audited target. | +| Popups and input regions | `e9e548d`, `2a8b7b2`, `a551412`, `66835b0`, `5d9a7a7`, `cd0a58f`, `434dd20` | Ported `546a16d64f`, `a29c0d41f4`, `35eaeb94a7`, and `737f55a1a1`: the core `AnchoredPopup`/typed-error contract, generic input regions, initial app IDs, IME candidate bounds, Wayland implementation, and concrete unsupported-platform rejections. Anchored popups remain distinct from overlays. | +| Linux | `66835b0` | Reconciled 26 audited Linux commits, including `923f315f26` presented-frame commits, Wayland popup lifecycle/grabs, `a29c0d41f4` input regions, `bda5ac3` clipboard timeout, `35eaeb94a7` app IDs, `7c3160b` activation tokens, `f4364d8` headless windows, and `961f4f2` X11 mouse refresh. | +| macOS | `7c107e9`, `5d9a7a7` | Reconciled traffic lights (`137eeea67b`), font fallback/PostScript fixes (`20a93f6195`, `61ad9ebfcd`, `486cf9ef3c`, `aa16a3bf9d`), and per-window cursor rectangles (`1eba1ca72e`, `96165ec626`, `a03729b6c0`) across both windows and panels. | +| Windows | `cd0a58f` | Reconciled `23c0080d1d`, `7194f987a7`, `5aa6e8a0b3`, `ee571d3c69`, `9ac117693b`, `5c0b33f72e`, `7d19e89988`, and related current behavior: composition safety, controls, credentials, wake, hit testing, priority, and device recovery while retaining fork HostBackdrop and overlays. | +| Web and examples | `434dd20` | Reconciled `546a16d64f`, `a03729b6c0`, and `74b5207744`: explicit popup degradation and the View/a11y examples while retaining standalone web cursor and font behavior. | +| Verification and CI | `2e589cf`, `a207892`, `0330be6`, `55a253c` | Added strict quality gates plus macOS, Windows, isolated Wayland/X11, and nightly wasm jobs. | + +## Dependency decisions + +- The standalone toolchain is Rust 1.95.0 with rustfmt, Clippy, rust-analyzer, rust-src, and wasm + targets recorded in `rust-toolchain.toml`. +- WGPU moved from the fork's git revision to crates.io `29.0.4`; custom backdrop-blur, retained-layer, + recovery, and COPY_SRC paths were revalidated against that release. +- The sync adopted `ashpd` 0.13, `wayland-backend` 0.3.15, `resvg`/`usvg` 0.46, + `accesskit_windows` 0.33.1, and `zed-font-kit` revision + `94b0f28166665e8fd2f53ff6d268a14955c82269` where the standalone extraction uses them. +- Dependency declarations were migrated individually and the fork lockfile was regenerated locally. + No upstream root manifest or lockfile was copied. + +## Fork invariants + +Future syncs must preserve these unless an explicit project decision changes them: + +- `OverlaySurfaceOptions`, `OverlayInputMode`, and `App::open_overlay_surface` are display-oriented + overlay contracts. They are not aliases for parent-relative `WindowKind::AnchoredPopup`. +- `WindowBackgroundAppearance::Blurred { corner_radius }` controls native compositor blur geometry. + A zero radius is rectangular; a positive radius clips native blur and must survive resize, scale, + and device recovery. +- `Styled::backdrop_blur` is an element effect that samples already-painted GPUI content. It is + separate from native window blur. +- Retained compositor layers, retained-layer exclusions around backdrop blur, transform-aware + hitboxes, easing bounds, public shadow painting, and inset shadows remain available. +- Overlay windows retain their platform-specific level, nonactivation, click-through, and + all-spaces behavior. On Windows, zero-radius blur remains acrylic and positive-radius blur uses + HostBackdrop. On macOS, overlays remain `NSPanel` instances. +- Fork accessibility keeps stable IDs, snapshot rollback, live-node sweeping, listener precedence + and mutation safety, semantic keyboard click behavior, and macOS main-thread adapter ownership. +- Dedicated scheduler output only needs `Send`; thread and child panics must remain observable and + must not stop later dedicated work. + +## Adaptations and exclusions + +- Native anchored popups are implemented on Wayland. X11, macOS, Windows, and web return the typed + `PopupNotSupportedError` so callers can choose an in-window fallback. +- Wayland's generic input region was adapted into persistent, explicit precedence state so it can + coexist with `OverlayInputMode`; copying upstream's transient setter would let later window + updates erase the fork's overlay policy. +- The upstream Metal headless renderer was not copied into the fork renderer because it does not + model the fork's retained-layer and backdrop-blur state. `current_headless_renderer` therefore + reports `None`, and the benchmark harness uses deterministic CPU scene construction. +- Upstream async fallback `952119de41` was rejected because the fork deliberately panics when an + `AsyncWindowContext` is bound to a closed window. That contract has focused tests. +- `f482f9e18c` and `2301e61d2a` depend on an unextracted proptest feature; `ccf4058b7a` is a + Zed-specific keymap test; the relevant GPUI hunk of `84b753cb51` is whitespace-only; + `2882636c06` was reverted upstream. +- Zed application features and changes outside GPUI and the standalone dependency graph are out of + scope. No Zed root `Cargo.toml`, `Cargo.lock`, workflow, or product assets are copied wholesale. +- Rounded native blur on pre-macOS 12 uses the legacy CGS path, which cannot apply the modern mask + API and therefore degrades to rectangular blur. Wayland compositors without the KDE blur protocol + and X11/web degrade to transparency rather than emulating native blur. + +## Verification for the 2026-07 target + +The completed local matrix passed: + +- `cargo fmt --all -- --check` +- `cargo metadata --locked --format-version 1` +- `cargo check --locked --workspace --all-targets` +- `cargo clippy --locked --workspace --all-targets --all-features -- --deny warnings` +- `cargo test --locked -p scheduler` (36 tests) +- `cargo test --locked -p gpui --features test-support` (253 unit and 3 integration tests) +- all GPUI examples, GPUI benches, and GPUI WGPU benches +- macOS platform tests (8 tests) +- isolated Linux no-default, Wayland, X11, and combined checks with warnings denied +- nightly wasm build-std checks for `gpui_platform`, `a11y`, and `view_example` + +Rounded native window blur and animated element backdrop-blur cards were visually confirmed on +macOS. The workflow is configured to run native Windows compilation/HLSL validation and repeat the +cross-platform matrix, but this branch has not been pushed and no remote CI result exists yet. +Live Wayland/X11 popup, input-region and compositor blur behavior; Windows +HostBackdrop/device-loss; macOS legacy-CGS blur, Retina/non-Retina resizing and retained/backdrop +Metal behavior; and multi-GPU recovery remain hardware/session-only validation risks. Stable wasm +is not the supported gate because `wasm_thread` requires nightly; CI uses nightly build-std with +atomics enabled. + +## Next sync procedure + +1. Clone or update Zed in `/tmp/zed`; never overwrite tracked fork files with an upstream tree. +2. Set `BASE=2c4e44704c37ee87e59ac84e3e17388178b28545` and the new audited `TARGET`. +3. Inventory `BASE..TARGET` for `crates/gpui*`, `crates/scheduler`, required extracted utilities, + shaders, examples, and platform manifests. Also compare the target trees semantically so an old + README marker cannot hide a pre-existing gap. +4. Classify every relevant change as already integrated, fork-modified, missing, competing, or + excluded. Record exact paths and upstream SHAs before editing. +5. Run the preservation tests first. Import compatible behavior in dependency-ordered, reviewable + commits; adapt conflicts around the invariants above instead of replacing whole files. +6. Run focused tests after each slice, then the full CI matrix and available native visual smokes. +7. Update the audited target here and in README only after the selective sync and independent review + are complete. Do not describe the result as a Git merge unless a real merge was performed. diff --git a/crates/collections/Cargo.toml b/crates/collections/Cargo.toml index aa3dd899..2a86e88d 100644 --- a/crates/collections/Cargo.toml +++ b/crates/collections/Cargo.toml @@ -19,3 +19,4 @@ test-support = [] [dependencies] indexmap.workspace = true rustc-hash.workspace = true +gpui_util.workspace = true diff --git a/crates/collections/src/collections.rs b/crates/collections/src/collections.rs index 8e6c334d..9a7f4942 100644 --- a/crates/collections/src/collections.rs +++ b/crates/collections/src/collections.rs @@ -2,10 +2,12 @@ pub type HashMap = FxHashMap; pub type HashSet = FxHashSet; pub type IndexMap = indexmap::IndexMap; pub type IndexSet = indexmap::IndexSet; +pub type TypeIdHashMap = + std::collections::HashMap; +pub type TypeIdHashSet = std::collections::HashSet; pub use indexmap::Equivalent; -pub use rustc_hash::FxHasher; -pub use rustc_hash::{FxHashMap, FxHashSet}; +pub use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet, FxHasher}; pub use std::collections::*; pub mod vecmap; diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 5359eaa2..28777d73 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -21,17 +21,18 @@ default = ["font-kit", "wayland", "x11", "windows-manifest"] test-support = [ "leak-detection", "collections/test-support", - "util/test-support", "http_client/test-support", "wayland", "x11", ] +bench = ["test-support", "dep:criterion", "dep:hdrhistogram"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] -wayland = ["bitflags"] +wayland = [] x11 = ["scap?/x11"] screen-capture = ["scap"] -windows-manifest = [] +windows-manifest = ["dep:embed-resource"] +profiler = [] [lib] path = "src/gpui.rs" @@ -43,9 +44,10 @@ anyhow.workspace = true async-task = "4.7" async-channel.workspace = true backtrace = { workspace = true, optional = true } -bitflags = { workspace = true, optional = true } +bitflags.workspace = true collections.workspace = true +criterion = { workspace = true, optional = true } ctor.workspace = true derive_more.workspace = true etagere = "0.2" @@ -54,6 +56,8 @@ futures-concurrency.workspace = true gpui_macros.workspace = true gpui_shared_string.workspace = true gpui_util.workspace = true +heapless.workspace = true +hdrhistogram = { workspace = true, optional = true } http_client.workspace = true image.workspace = true inventory.workspace = true @@ -72,13 +76,8 @@ refineable.workspace = true regex.workspace = true scheduler.workspace = true unicode-general-category.workspace = true -resvg = { version = "0.45.0", default-features = false, features = [ - "text", - "system-fonts", - "memmap-fonts", - "raster-images", -] } -usvg = { version = "0.45.0", default-features = false } +resvg.workspace = true +usvg.workspace = true ttf-parser = "0.25" util_macros.workspace = true schemars.workspace = true @@ -115,7 +114,7 @@ foreign-types = "0.5" log.workspace = true media.workspace = true objc.workspace = true -objc2 = { version = "0.6", optional = true } +objc2 = { workspace = true, optional = true } objc2-metal = { version = "0.3", optional = true } mach2.workspace = true #TODO: replace with "objc2" @@ -137,14 +136,15 @@ backtrace.workspace = true collections = { workspace = true, features = ["test-support"] } env_logger.workspace = true gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } -http_client = { workspace = true, features = ["test-support"] } lyon = { version = "1.0", features = ["extra"] } pretty_assertions.workspace = true rand.workspace = true -reqwest_client = { workspace = true, features = ["test-support"] } scheduler = { workspace = true, features = ["test-support"] } unicode-segmentation.workspace = true -util = { workspace = true, features = ["test-support"] } + +[target.'cfg(not(target_family = "wasm"))'.dev-dependencies] +http_client = { workspace = true, features = ["test-support"] } +reqwest_client = { workspace = true, features = ["test-support"] } [target.'cfg(target_family = "wasm")'.dependencies] getrandom = { version = "0.3.4", features = ["wasm_js"] } @@ -155,13 +155,12 @@ wasm-bindgen = { workspace = true } gpui_web.workspace = true -[target.'cfg(target_os = "windows")'.build-dependencies] -embed-resource = "3.0" +[build-dependencies] +embed-resource = { version = "3.0", optional = true } [target.'cfg(target_os = "macos")'.build-dependencies] bindgen = "0.71" cbindgen = { version = "0.28.0", default-features = false } -naga.workspace = true [[example]] @@ -231,3 +230,16 @@ path = "examples/grid_layout.rs" [[example]] name = "mouse_pressure" path = "examples/mouse_pressure.rs" + +[[example]] +name = "a11y" +path = "examples/a11y.rs" + +[[example]] +name = "view_example" +path = "examples/view_example/view_example_main.rs" + +[[bench]] +name = "scene_construction" +harness = false +required-features = ["bench"] diff --git a/crates/gpui/benches/scene_construction.rs b/crates/gpui/benches/scene_construction.rs new file mode 100644 index 00000000..1a0a709b --- /dev/null +++ b/crates/gpui/benches/scene_construction.rs @@ -0,0 +1,121 @@ +//! CPU benchmark for GPUI update, layout, paint, and scene construction. +//! +//! The default benchmark platform intentionally has no headless renderer, so +//! these measurements do not claim to include GPU encoding or submission. + +use std::fmt; + +use gpui::{ + BenchAppContext, Context, IntoElement, Render, RetainedLayerExt, Window, div, hsla, prelude::*, + px, rgb, +}; + +#[derive(Clone, Copy)] +enum SceneCase { + BackdropBlur, + RetainedWarm, + RetainedContentDirty, +} + +impl fmt::Display for SceneCase { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::BackdropBlur => "backdrop-blur", + Self::RetainedWarm => "retained-warm", + Self::RetainedContentDirty => "retained-content-dirty", + }) + } +} + +struct SceneBench { + case: SceneCase, + revision: u64, + opacity: f32, + phase: u64, +} + +impl Render for SceneBench { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + match self.case { + SceneCase::BackdropBlur => { + let background = if self.phase.is_multiple_of(2) { + rgb(0x1e293b) + } else { + rgb(0x334155) + }; + let mut root = div().relative().size_full().bg(background); + for index in 0..16 { + let column = index % 4; + let row = index / 4; + root = root.child( + div() + .absolute() + .left(px(32.0 + column as f32 * 112.0)) + .top(px(32.0 + row as f32 * 88.0)) + .w(px(144.0)) + .h(px(112.0)) + .rounded_xl() + .backdrop_blur(px(24.0)) + .bg(hsla(0.0, 0.0, 1.0, 0.24)), + ); + } + root.into_any_element() + } + SceneCase::RetainedWarm | SceneCase::RetainedContentDirty => div() + .size_full() + .bg(rgb(0x0f172a)) + .child( + div() + .size_full() + .bg(rgb(0x2563eb)) + .with_retained_layer("scene-benchmark-layer", self.revision) + .opacity(self.opacity), + ) + .into_any_element(), + } + } +} + +#[gpui::bench( + inputs = scene_cases(), + group = "scene-construction", + input_name = "mode", + sample_size = 20 +)] +fn scene_construction(case: &SceneCase, cx: &mut BenchAppContext) { + let case = *case; + let mut window = cx.add_empty_window(); + let view = window.update(|window, cx| { + window.replace_root(cx, |_, _| SceneBench { + case, + revision: 0, + opacity: 1.0, + phase: 0, + }) + }); + + cx.bench_renderer(view, move |view, _window, cx| { + view.phase = view.phase.wrapping_add(1); + match case { + SceneCase::BackdropBlur => {} + SceneCase::RetainedWarm => { + view.opacity = if view.opacity == 1.0 { 0.75 } else { 1.0 }; + } + SceneCase::RetainedContentDirty => { + view.revision = view.revision.wrapping_add(1); + } + } + cx.notify(); + }); +} + +fn scene_cases() -> [SceneCase; 3] { + [ + SceneCase::BackdropBlur, + SceneCase::RetainedWarm, + SceneCase::RetainedContentDirty, + ] +} + +gpui::bench_group!(benches, scene_construction); +gpui::bench_main!(benches); diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 53f78e3c..b1bfd219 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -1,14 +1,17 @@ #![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] -#![cfg_attr(not(target_os = "macos"), allow(unused))] fn main() { println!("cargo::rustc-check-cfg=cfg(gles)"); - #[cfg(all(target_os = "windows", feature = "windows-manifest"))] - embed_resource(); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + if target_os == "windows" { + #[cfg(feature = "windows-manifest")] + embed_resource(); + } } -#[cfg(all(target_os = "windows", feature = "windows-manifest"))] +#[cfg(feature = "windows-manifest")] fn embed_resource() { let manifest = std::path::Path::new("resources/windows/gpui.manifest.xml"); let rc_file = std::path::Path::new("resources/windows/gpui.rc"); diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs index 0ef6116c..efd525fd 100644 --- a/crates/gpui/examples/a11y.rs +++ b/crates/gpui/examples/a11y.rs @@ -1,3 +1,5 @@ +#![cfg_attr(target_family = "wasm", no_main)] + //! Accessibility (AccessKit) demo app. //! //! Run with: `cargo run -p gpui --example a11y`. @@ -184,7 +186,7 @@ impl Render for A11yDemo { } } -fn main() { +fn run_example() { application().run(|cx: &mut App| { cx.bind_keys([ KeyBinding::new("tab", Tab, None), @@ -206,3 +208,19 @@ fn main() { cx.activate(true); }); } + +#[cfg(not(target_family = "wasm"))] +fn main() { + env_logger::builder() + .filter_level(log::LevelFilter::Warn) + .filter_module("gpui", log::LevelFilter::Info) + .init(); + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index a9faf387..42b7da4e 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -3,7 +3,7 @@ use std::{fs, path::PathBuf}; use anyhow::Result; use gpui::{ App, AssetSource, Bounds, BoxShadow, ClickEvent, Context, SharedString, Task, Window, - WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, svg, + WindowBounds, WindowOptions, div, hsla, img, prelude::*, px, rgb, size, svg, }; use gpui_platform::application; @@ -112,13 +112,11 @@ impl Render for HelloWorld { .bg(gpui::blue()) .border_3() .border_color(gpui::black()) - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - blur_radius: px(1.0), - spread_radius: px(5.0), - offset: point(px(10.0), px(10.0)), - inset: false, - }]) + .shadow(vec![ + BoxShadow::new(px(10.0), px(10.0), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(1.0)) + .spread_radius(px(5.0)), + ]) .child(img("image/app-icon.png").size_8()) .child("Opacity Panel (Click to test)") .child( diff --git a/crates/gpui/examples/shadow.rs b/crates/gpui/examples/shadow.rs index f2afe7a1..edadd4e6 100644 --- a/crates/gpui/examples/shadow.rs +++ b/crates/gpui/examples/shadow.rs @@ -1,6 +1,6 @@ use gpui::{ App, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, WindowOptions, div, - hsla, point, prelude::*, px, relative, rgb, size, + hsla, prelude::*, px, relative, rgb, size, }; use gpui_platform::application; @@ -93,609 +93,495 @@ impl Render for Shadow { .size_full() .text_xs() .child(div().flex().flex_col().w_full().children(vec![ - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .flex_row() - .children(vec![ - example( - "Square", - Shadow::square() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded 4", - Shadow::rounded_small() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded 8", - Shadow::rounded_medium() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded 16", - Shadow::rounded_large() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Circle", - Shadow::base() - .shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .w_full() - .children(vec![ - example("None", Shadow::base()), - // 2Xsmall shadow - example("2X Small", Shadow::base().shadow_2xs()), - // Xsmall shadow - example("Extra Small", Shadow::base().shadow_xs()), - // Small shadow - example("Small", Shadow::base().shadow_sm()), - // Medium shadow - example("Medium", Shadow::base().shadow_md()), - // Large shadow - example("Large", Shadow::base().shadow_lg()), - example("Extra Large", Shadow::base().shadow_xl()), - example("2X Large", Shadow::base().shadow_2xl()), - ]), - // Horizontal list of increasing blur radii - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Blur 0", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(0.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 2", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 4", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(4.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 8", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Blur 16", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(16.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Horizontal list of increasing spread radii - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Spread 0", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Spread 2", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }]), - ), - example( - "Spread 4", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(4.), - inset: false, - }]), - ), - example( - "Spread 8", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(8.), - inset: false, - }]), - ), - example( - "Spread 16", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(16.), - inset: false, - }]), - ), - ]), - // Square spread examples - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Square Spread 0", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Spread 8", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(8.), - inset: false, - }]), - ), - example( - "Square Spread 16", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(16.), - inset: false, - }]), - ), - ]), - // Rounded large spread examples - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Rounded Large Spread 0", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Spread 8", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(8.), - inset: false, - }]), - ), - example( - "Rounded Large Spread 16", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(16.), - inset: false, - }]), - ), - ]), - // Directional shadows - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Left", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(-8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Right", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Top", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(-8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Bottom", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Square directional shadows - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Square Left", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(-8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Right", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Top", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(-8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Square Bottom", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Rounded large directional shadows - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Rounded Large Left", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(-8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Right", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(8.), px(0.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Top", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(-8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - example( - "Rounded Large Bottom", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.5, 0.5, 0.3), - offset: point(px(0.), px(8.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: false, - }]), - ), - ]), - // Multiple shadows for different shapes - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .children(vec![ - example( - "Circle Multiple", - Shadow::base().shadow(vec![ - BoxShadow { - color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red - offset: point(px(0.), px(-12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow - offset: point(px(12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green - offset: point(px(0.), px(12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue - offset: point(px(-12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - ]), - ), - example( - "Square Multiple", - Shadow::square().shadow(vec![ - BoxShadow { - color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red - offset: point(px(0.), px(-12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow - offset: point(px(12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green - offset: point(px(0.), px(12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue - offset: point(px(-12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - ]), - ), - example( - "Rounded Large Multiple", - Shadow::rounded_large().shadow(vec![ - BoxShadow { - color: hsla(0.0 / 360., 1.0, 0.5, 0.3), // Red - offset: point(px(0.), px(-12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(60.0 / 360., 1.0, 0.5, 0.3), // Yellow - offset: point(px(12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(120.0 / 360., 1.0, 0.5, 0.3), // Green - offset: point(px(0.), px(12.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - BoxShadow { - color: hsla(240.0 / 360., 1.0, 0.5, 0.3), // Blue - offset: point(px(-12.), px(0.)), - blur_radius: px(8.), - spread_radius: px(2.), - inset: false, - }, - ]), - ), - ]), - // Inset shadows (CSS `box-shadow: inset ...`). - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .w_full() - .children(vec![ - example( - "Inset basic", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(0.), px(0.)), - blur_radius: px(12.), - spread_radius: px(0.), - inset: true, - }]), - ), - example( - "Inset offset", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(6.), px(6.)), - blur_radius: px(8.), - spread_radius: px(0.), - inset: true, - }]), - ), - example( - "Inset spread", - Shadow::base().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(0.), px(0.)), - blur_radius: px(4.), - spread_radius: px(8.), - inset: true, - }]), - ), - example( - "Inset rounded", - Shadow::rounded_large().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.5), - offset: point(px(0.), px(4.)), - blur_radius: px(10.), - spread_radius: px(2.), - inset: true, - }]), - ), - example( - "Inset sharp", - Shadow::square().shadow(vec![BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.6), - offset: point(px(0.), px(0.)), - blur_radius: px(0.), - spread_radius: px(6.), - inset: true, - }]), - ), - ]), - // Combined: drop + inset shadows on the same element. - div() - .border_b_1() - .border_color(hsla(0.0, 0.0, 0.0, 1.0)) - .flex() - .w_full() - .children(vec![ - example( + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .flex_row() + .children(vec![ + example( + "Square", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded 4", + Shadow::rounded_small().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded 8", + Shadow::rounded_medium().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded 16", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Circle", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example("None", Shadow::base()), + // 2Xsmall shadow + example("2X Small", Shadow::base().shadow_2xs()), + // Xsmall shadow + example("Extra Small", Shadow::base().shadow_xs()), + // Small shadow + example("Small", Shadow::base().shadow_sm()), + // Medium shadow + example("Medium", Shadow::base().shadow_md()), + // Large shadow + example("Large", Shadow::base().shadow_lg()), + example("Extra Large", Shadow::base().shadow_xl()), + example("2X Large", Shadow::base().shadow_2xl()), + ]), + // Horizontal list of increasing blur radii + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Blur 0", + Shadow::base().shadow(vec![BoxShadow::new( + px(0.), + px(8.), + hsla(0.0, 0.0, 0.0, 0.3), + )]), + ), + example( + "Blur 2", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(2.)), + ]), + ), + example( + "Blur 4", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(4.)), + ]), + ), + example( + "Blur 8", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Blur 16", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(16.)), + ]), + ), + ]), + // Horizontal list of increasing spread radii + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Spread 0", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Spread 2", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + example( + "Spread 4", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(4.)), + ]), + ), + example( + "Spread 8", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(8.)), + ]), + ), + example( + "Spread 16", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(16.)), + ]), + ), + ]), + // Square spread examples + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Square Spread 0", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Spread 8", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(8.)), + ]), + ), + example( + "Square Spread 16", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(16.)), + ]), + ), + ]), + // Rounded large spread examples + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Rounded Large Spread 0", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Spread 8", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(8.)), + ]), + ), + example( + "Rounded Large Spread 16", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.3)) + .blur_radius(px(8.)) + .spread_radius(px(16.)), + ]), + ), + ]), + // Directional shadows + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Left", + Shadow::base().shadow(vec![ + BoxShadow::new(px(-8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Right", + Shadow::base().shadow(vec![ + BoxShadow::new(px(8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Top", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(-8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Bottom", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + // Square directional shadows + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Square Left", + Shadow::square().shadow(vec![ + BoxShadow::new(px(-8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Right", + Shadow::square().shadow(vec![ + BoxShadow::new(px(8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Top", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(-8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Square Bottom", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + // Rounded large directional shadows + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Rounded Large Left", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(-8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Right", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(8.), px(0.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Top", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(-8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + example( + "Rounded Large Bottom", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.5, 0.5, 0.3)) + .blur_radius(px(8.)), + ]), + ), + ]), + // Multiple shadows for different shapes + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .children(vec![ + example( + "Circle Multiple", + Shadow::base().shadow(vec![ + BoxShadow::new( + px(0.), + px(-12.), + hsla(0.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(12.), + px(0.), + hsla(60.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(0.), + px(12.), + hsla(120.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(-12.), + px(0.), + hsla(240.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + example( + "Square Multiple", + Shadow::square().shadow(vec![ + BoxShadow::new( + px(0.), + px(-12.), + hsla(0.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(12.), + px(0.), + hsla(60.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(0.), + px(12.), + hsla(120.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(-12.), + px(0.), + hsla(240.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + example( + "Rounded Large Multiple", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new( + px(0.), + px(-12.), + hsla(0.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(12.), + px(0.), + hsla(60.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(0.), + px(12.), + hsla(120.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + BoxShadow::new( + px(-12.), + px(0.), + hsla(240.0 / 360., 1.0, 0.5, 0.3), + ) + .blur_radius(px(8.)) + .spread_radius(px(2.)), + ]), + ), + ]), + // Inset shadows (CSS `box-shadow: inset ...`). + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![ + example( + "Inset basic", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(0.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(12.)) + .inset(), + ]), + ), + example( + "Inset offset", + Shadow::base().shadow(vec![ + BoxShadow::new(px(6.), px(6.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(8.)) + .inset(), + ]), + ), + example( + "Inset spread", + Shadow::base().shadow(vec![ + BoxShadow::new(px(0.), px(0.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(4.)) + .spread_radius(px(8.)) + .inset(), + ]), + ), + example( + "Inset rounded", + Shadow::rounded_large().shadow(vec![ + BoxShadow::new(px(0.), px(4.), hsla(0.0, 0.0, 0.0, 0.5)) + .blur_radius(px(10.)) + .spread_radius(px(2.)) + .inset(), + ]), + ), + example( + "Inset sharp", + Shadow::square().shadow(vec![ + BoxShadow::new(px(0.), px(0.), hsla(0.0, 0.0, 0.0, 0.6)) + .spread_radius(px(6.)) + .inset(), + ]), + ), + ]), + // Combined: drop + inset shadows on the same element. + div() + .border_b_1() + .border_color(hsla(0.0, 0.0, 0.0, 1.0)) + .flex() + .w_full() + .children(vec![example( "Drop + Inset", Shadow::rounded_medium().shadow(vec![ - BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.25), - offset: point(px(0.), px(8.)), - blur_radius: px(12.), - spread_radius: px(0.), - inset: false, - }, - BoxShadow { - color: hsla(0.0, 0.0, 0.0, 0.4), - offset: point(px(0.), px(2.)), - blur_radius: px(4.), - spread_radius: px(0.), - inset: true, - }, + BoxShadow::new(px(0.), px(8.), hsla(0.0, 0.0, 0.0, 0.25)) + .blur_radius(px(12.)), + BoxShadow::new(px(0.), px(2.), hsla(0.0, 0.0, 0.0, 0.4)) + .blur_radius(px(4.)) + .inset(), ]), - ), - ]), - ])) + )]), + ])) } } diff --git a/crates/gpui/examples/view_example/editor_text.rs b/crates/gpui/examples/view_example/editor_text.rs new file mode 100644 index 00000000..e01e7200 --- /dev/null +++ b/crates/gpui/examples/view_example/editor_text.rs @@ -0,0 +1,198 @@ +use gpui::{ + App, Bounds, Element, ElementInputHandler, Entity, IntoElement, LayoutId, PaintQuad, Pixels, + ShapedLine, SharedString, TextRun, Window, fill, hsla, point, px, relative, size, +}; + +use super::Editor; + +// --------------------------------------------------------------------------- +// EditorText — the specialized renderer: shapes the text and paints the cursor. +// --------------------------------------------------------------------------- + +pub(super) struct EditorText { + pub(super) editor: Entity, +} + +pub(super) struct EditorTextPrepaint { + lines: Vec, + cursor: Option, +} + +impl IntoElement for EditorText { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for EditorText { + type RequestLayoutState = (); + type PrepaintState = EditorTextPrepaint; + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let editor = self.editor.read(cx); + let content = editor.value.read(cx); + let line_count = content.split('\n').count().max(1); + let line_height = window.line_height(); + let mut style = gpui::Style::default(); + style.size.width = relative(1.).into(); + style.size.height = (line_height * line_count as f32).into(); + (window.request_layout(style, [], cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + let editor = self.editor.read(cx); + let content = editor.value.read(cx).clone(); + let cursor_offset = editor.cursor; + let cursor_visible = editor.cursor_visible; + let is_focused = editor.focus_handle.is_focused(window); + + let style = window.text_style(); + let text_color = style.color; + let font_size = style.font_size.to_pixels(window.rem_size()); + let line_height = window.line_height(); + + let is_placeholder = content.is_empty(); + + let lines: Vec = if is_placeholder { + let placeholder: SharedString = "Type here...".into(); + let run = TextRun { + len: placeholder.len(), + font: style.font(), + color: hsla(0., 0., 0.5, 0.5), + background_color: None, + underline: None, + strikethrough: None, + }; + vec![ + window + .text_system() + .shape_line(placeholder, font_size, &[run], None), + ] + } else { + content + .split('\n') + .map(|line_str| { + let text: SharedString = SharedString::from(line_str.to_string()); + let run = TextRun { + len: text.len(), + font: style.font(), + color: text_color, + background_color: None, + underline: None, + strikethrough: None, + }; + window + .text_system() + .shape_line(text, font_size, &[run], None) + }) + .collect() + }; + + let cursor = if is_focused && cursor_visible && !is_placeholder { + let (cursor_line, offset_in_line) = cursor_line_and_offset(&content, cursor_offset); + let cursor_line = cursor_line.min(lines.len().saturating_sub(1)); + let cursor_x = lines[cursor_line].x_for_index(offset_in_line); + Some(fill( + Bounds::new( + point( + bounds.left() + cursor_x, + bounds.top() + line_height * cursor_line as f32, + ), + size(px(1.5), line_height), + ), + text_color, + )) + } else if is_focused && cursor_visible && is_placeholder { + Some(fill( + Bounds::new( + point(bounds.left(), bounds.top()), + size(px(1.5), line_height), + ), + text_color, + )) + } else { + None + }; + + EditorTextPrepaint { lines, cursor } + } + + fn paint( + &mut self, + _id: Option<&gpui::GlobalElementId>, + _inspector_id: Option<&gpui::InspectorElementId>, + bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let focus_handle = self.editor.read(cx).focus_handle.clone(); + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, self.editor.clone()), + cx, + ); + + let line_height = window.line_height(); + for (i, line) in prepaint.lines.iter().enumerate() { + let origin = point(bounds.left(), bounds.top() + line_height * i as f32); + line.paint(origin, line_height, gpui::TextAlign::Left, None, window, cx) + .unwrap(); + } + + if let Some(cursor) = prepaint.cursor.take() { + window.paint_quad(cursor); + } + } +} + +fn cursor_line_and_offset(content: &str, cursor: usize) -> (usize, usize) { + let cursor = cursor.min(content.len()); + let mut line_index = 0; + let mut line_start = 0; + for (i, ch) in content.char_indices() { + if i >= cursor { + break; + } + if ch == '\n' { + line_index += 1; + line_start = i + 1; + } + } + (line_index, cursor - line_start) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_offset_past_content_clamps_to_the_last_line_end() { + assert_eq!(cursor_line_and_offset("a\nβ", usize::MAX), (1, "β".len())); + } +} diff --git a/crates/gpui/examples/view_example/example_editor.rs b/crates/gpui/examples/view_example/example_editor.rs new file mode 100644 index 00000000..a1e6aa4a --- /dev/null +++ b/crates/gpui/examples/view_example/example_editor.rs @@ -0,0 +1,438 @@ +//! `Editor` — the workhorse entity. It owns the cursor, blink, focus, keyboard +//! handling, and the specialized text-shaping renderer. The *text itself* lives +//! in a shared `Entity` it's handed at construction, so the value is +//! readable/writable from outside while the editing machinery stays in here. +//! +//! This is the piece that proves the point: a text input is genuinely +//! complicated, and `View` lets all of that complexity live in one entity that +//! anything can embed. + +use std::ops::Range; +use std::time::Duration; + +use gpui::{ + App, Bounds, Context, Entity, EntityInputHandler, FocusHandle, Focusable, InteractiveElement, + Pixels, Subscription, Task, UTF16Selection, Window, prelude::*, +}; +use unicode_segmentation::*; + +use crate::{Backspace, Delete, End, Home, Left, Right}; + +pub struct Editor { + pub value: Entity, + pub focus_handle: FocusHandle, + pub cursor: usize, + pub cursor_visible: bool, + selected_range: Range, + marked_range: Option>, + expected_value: Option, + _blink_task: Task<()>, + _subscriptions: Vec, +} + +impl Editor { + /// An editor that owns its own string internally, seeded with `text`. + /// Nothing to allocate or wire up at the call site. + pub fn new(text: impl Into, window: &mut Window, cx: &mut Context) -> Self { + let value = cx.new(|_| text.into()); + Self::over(value, window, cx) + } + + /// An editor over a string *you* own, so the value is shared in and out. + pub fn over(value: Entity, window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + + let focus_sub = cx.on_focus(&focus_handle, window, |this, _window, cx| { + this.start_blink(cx); + }); + let blur_sub = cx.on_blur(&focus_handle, window, |this, _window, cx| { + this.stop_blink(cx); + }); + + // The value is shared: anything can write it while we hold a cursor into + // it. Observe it so external writes (a) clamp the cursor back onto a grapheme + // boundary before the next IME round-trip can slice out of bounds, and + // (b) notify us, so an `editor.cached(..)` subtree re-renders — the cache + // is keyed on *our* notify, not the value's. + let value_sub = cx.observe(&value, |this, value, cx| { + let content = value.read(cx); + if this.expected_value.as_deref() == Some(content.as_str()) { + this.expected_value = None; + } else { + let cursor = floor_grapheme_boundary(content, this.cursor); + this.cursor = cursor; + this.selected_range = cursor..cursor; + this.marked_range = None; + } + cx.notify(); + }); + + Self { + value, + focus_handle, + cursor: 0, + cursor_visible: false, + selected_range: 0..0, + marked_range: None, + expected_value: None, + _blink_task: Task::ready(()), + _subscriptions: vec![focus_sub, blur_sub, value_sub], + } + } + + /// The current text. Read this from anywhere to get the value out. + pub fn text(&self, cx: &App) -> String { + self.value.read(cx).clone() + } + + fn start_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + fn stop_blink(&mut self, cx: &mut Context) { + self.cursor_visible = false; + self._blink_task = Task::ready(()); + cx.notify(); + } + + fn spawn_blink_task(cx: &mut Context) -> Task<()> { + cx.spawn(async move |this, cx| { + loop { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + let result = this.update(cx, |editor, cx| { + editor.cursor_visible = !editor.cursor_visible; + cx.notify(); + }); + if result.is_err() { + break; + } + } + }) + } + + fn reset_blink(&mut self, cx: &mut Context) { + self.cursor_visible = true; + self._blink_task = Self::spawn_blink_task(cx); + } + + pub fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + self.cursor = previous_boundary(&content, self.cursor); + } + self.selected_range = self.cursor..self.cursor; + self.marked_range = None; + self.reset_blink(cx); + cx.notify(); + } + + pub fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + self.cursor = next_boundary(&content, self.cursor); + } + self.selected_range = self.cursor..self.cursor; + self.marked_range = None; + self.reset_blink(cx); + cx.notify(); + } + + pub fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context) { + self.cursor = 0; + self.selected_range = 0..0; + self.marked_range = None; + self.reset_blink(cx); + cx.notify(); + } + + pub fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context) { + self.cursor = self.text(cx).len(); + self.selected_range = self.cursor..self.cursor; + self.marked_range = None; + self.reset_blink(cx); + cx.notify(); + } + + pub fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor > 0 { + let prev = previous_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(prev..cursor); + cx.notify(); + }); + self.cursor = prev; + } + self.selected_range = self.cursor..self.cursor; + self.marked_range = None; + self.reset_blink(cx); + cx.notify(); + } + + pub fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context) { + let content = self.text(cx); + if self.cursor < content.len() { + let next = next_boundary(&content, self.cursor); + let cursor = self.cursor; + self.value.update(cx, |s, cx| { + s.drain(cursor..next); + cx.notify(); + }); + } + self.selected_range = self.cursor..self.cursor; + self.marked_range = None; + self.reset_blink(cx); + cx.notify(); + } + + pub fn insert_newline(&mut self, cx: &mut Context) { + let mut content = self.text(cx); + let cursor = floor_grapheme_boundary(&content, self.cursor); + content.insert(cursor, '\n'); + self.cursor = cursor + 1; + self.selected_range = self.cursor..self.cursor; + self.marked_range = None; + self.expected_value = Some(content.clone()); + self.value.update(cx, |value, cx| { + *value = content; + cx.notify(); + }); + self.reset_blink(cx); + cx.notify(); + } +} + +fn floor_grapheme_boundary(content: &str, offset: usize) -> usize { + let offset = offset.min(content.len()); + content + .grapheme_indices(true) + .map(|(ix, _)| ix) + .chain(std::iter::once(content.len())) + .take_while(|ix| *ix <= offset) + .last() + .unwrap_or(0) +} + +fn previous_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .rev() + .find_map(|(idx, _)| (idx < offset).then_some(idx)) + .unwrap_or(0) +} + +fn next_boundary(content: &str, offset: usize) -> usize { + content + .grapheme_indices(true) + .find_map(|(idx, _)| (idx > offset).then_some(idx)) + .unwrap_or(content.len()) +} + +fn offset_from_utf16(content: &str, offset: usize) -> usize { + let mut utf8_offset = 0; + let mut utf16_count = 0; + for ch in content.chars() { + if utf16_count >= offset { + break; + } + utf16_count += ch.len_utf16(); + utf8_offset += ch.len_utf8(); + } + utf8_offset +} + +fn offset_to_utf16(content: &str, offset: usize) -> usize { + let mut utf16_offset = 0; + let mut utf8_count = 0; + for ch in content.chars() { + if utf8_count >= offset { + break; + } + utf8_count += ch.len_utf8(); + utf16_offset += ch.len_utf16(); + } + utf16_offset +} + +fn range_to_utf16(content: &str, range: &Range) -> Range { + offset_to_utf16(content, range.start)..offset_to_utf16(content, range.end) +} + +fn range_from_utf16(content: &str, range_utf16: &Range) -> Range { + offset_from_utf16(content, range_utf16.start)..offset_from_utf16(content, range_utf16.end) +} + +impl Focusable for Editor { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EntityInputHandler for Editor { + fn text_for_range( + &mut self, + range_utf16: Range, + actual_range: &mut Option>, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + let range = range_from_utf16(&content, &range_utf16); + actual_range.replace(range_to_utf16(&content, &range)); + Some(content[range].to_string()) + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Option { + let content = self.text(cx); + Some(UTF16Selection { + range: range_to_utf16(&content, &self.selected_range), + reversed: false, + }) + } + + fn marked_text_range( + &self, + _window: &mut Window, + cx: &mut Context, + ) -> Option> { + let content = self.text(cx); + self.marked_range + .as_ref() + .map(|range| range_to_utf16(&content, range)) + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) { + self.marked_range = None; + } + + fn replace_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + _window: &mut Window, + cx: &mut Context, + ) { + let content = self.text(cx); + let range = range_utf16 + .as_ref() + .map(|r| range_from_utf16(&content, r)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selected_range.clone()); + + let new_content = content[..range.start].to_owned() + new_text + &content[range.end..]; + self.cursor = range.start + new_text.len(); + self.selected_range = self.cursor..self.cursor; + self.marked_range = None; + self.expected_value = Some(new_content.clone()); + self.value.update(cx, |s, cx| { + *s = new_content; + cx.notify(); + }); + self.reset_blink(cx); + cx.notify(); + } + + fn replace_and_mark_text_in_range( + &mut self, + range_utf16: Option>, + new_text: &str, + new_selected_range_utf16: Option>, + _window: &mut Window, + cx: &mut Context, + ) { + let content = self.text(cx); + let range = range_utf16 + .as_ref() + .map(|range| range_from_utf16(&content, range)) + .or_else(|| self.marked_range.clone()) + .unwrap_or_else(|| self.selected_range.clone()); + let new_content = content[..range.start].to_owned() + new_text + &content[range.end..]; + let inserted_range = range.start..range.start + new_text.len(); + self.marked_range = (!new_text.is_empty()).then_some(inserted_range.clone()); + self.selected_range = new_selected_range_utf16 + .as_ref() + .map(|selection| range_from_utf16(new_text, selection)) + .map(|selection| range.start + selection.start..range.start + selection.end) + .unwrap_or(inserted_range.end..inserted_range.end); + self.cursor = self.selected_range.end; + self.expected_value = Some(new_content.clone()); + self.value.update(cx, |value, cx| { + *value = new_content; + cx.notify(); + }); + self.reset_blink(cx); + cx.notify(); + } + + fn bounds_for_range( + &mut self, + _range_utf16: Range, + _bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: gpui::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } +} + +impl gpui::Render for Editor { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + EditorText { + editor: cx.entity(), + } + } +} + +#[path = "editor_text.rs"] +mod editor_text; + +use editor_text::EditorText; + +pub fn standard_actions(editor: Entity) -> impl FnOnce(E) -> E { + move |element| { + element + .on_action({ + let editor = editor.clone(); + move |a: &Left, window, cx| editor.update(cx, |e, cx| e.left(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Right, window, cx| editor.update(cx, |e, cx| e.right(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Home, window, cx| editor.update(cx, |e, cx| e.home(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &End, window, cx| editor.update(cx, |e, cx| e.end(a, window, cx)) + }) + .on_action({ + let editor = editor.clone(); + move |a: &Backspace, window, cx| { + editor.update(cx, |e, cx| e.backspace(a, window, cx)) + } + }) + .on_action(move |a: &Delete, window, cx| { + editor.update(cx, |e, cx| e.delete(a, window, cx)) + }) + } +} diff --git a/crates/gpui/examples/view_example/example_input.rs b/crates/gpui/examples/view_example/example_input.rs new file mode 100644 index 00000000..25d74013 --- /dev/null +++ b/crates/gpui/examples/view_example/example_input.rs @@ -0,0 +1,121 @@ +//! `Input` — a single-line text input. The shaping layer over `Editor`. +//! +//! Construct it two ways, depending on how much state you want to own: +//! * `Input::new(value: Entity)` — you hold just the string; the input +//! allocates the `Editor` internally via `use_state`. Value readable, cursor hidden. +//! * `Input::editor(editor: Entity)` — you hold the editor; cursor/selection +//! are now yours to read and drive too. +//! +//! Either way the chrome is identical. Because the string (or editor) is the +//! input's *identity*, the internal `use_state(Editor)` is collision-safe across +//! any number of inputs. + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, Pixels, StyleRefinement, + Window, div, hsla, point, prelude::*, px, white, +}; + +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct Input { + source: Source, + width: Option, + color: Option, +} + +impl Input { + /// Backed by a bare string; the editor is allocated internally. + pub fn new(value: Entity) -> Self { + Self { + source: Source::Value(value), + width: None, + color: None, + } + } + + /// Backed by an editor you own (so you can read/drive its cursor). + pub fn editor(editor: Entity) -> Self { + Self { + source: Source::Editor(editor), + width: None, + color: None, + } + } + + pub fn width(mut self, width: Pixels) -> Self { + self.width = Some(width); + self + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for Input { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + // Get the editor: use the one we were handed, or allocate it under our + // own (string-derived) identity so it persists and never collides. + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let box_width = self.width.unwrap_or(px(300.)); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("input") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + .w(box_width) + .h(px(36.)) + .px(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .flex() + .items_center() + .line_height(px(20.)) + .text_size(px(14.)) + .text_color(text_color) + .child(editor.cached(StyleRefinement::default().size_full())) + } +} diff --git a/crates/gpui/examples/view_example/example_tests.rs b/crates/gpui/examples/view_example/example_tests.rs new file mode 100644 index 00000000..dc19de3b --- /dev/null +++ b/crates/gpui/examples/view_example/example_tests.rs @@ -0,0 +1,191 @@ +//! Tests for the input composition. Require the `test-support` feature: +//! +//! ```sh +//! cargo test -p gpui --example view_example --features test-support +//! ``` + +#[cfg(test)] +mod tests { + use gpui::{ + Context, Entity, EntityInputHandler, KeyBinding, TestAppContext, Window, prelude::*, + }; + + use crate::example_editor::Editor; + use crate::example_input::Input; + use crate::{Backspace, Delete, End, Home, Left, Right}; + + /// Two inputs, each backed by an editor we own (so the test can focus and + /// read them). Proves data flows through the shared `String` and that + /// sibling inputs stay isolated. + struct Harness { + a: Entity, + b: Entity, + } + + impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div() + .child(Input::editor(self.a.clone())) + .child(Input::editor(self.b.clone())) + } + } + + fn bind_keys(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + ]); + }); + } + + fn setup( + cx: &mut TestAppContext, + ) -> ( + Entity, + Entity, + Entity, + &mut gpui::VisualTestContext, + ) { + bind_keys(cx); + + let (harness, cx) = cx.add_window_view(|window, cx| { + let a_value = cx.new(|_| String::new()); + let b_value = cx.new(|_| String::new()); + let a = cx.new(|cx| Editor::over(a_value, window, cx)); + let b = cx.new(|cx| Editor::over(b_value, window, cx)); + Harness { a, b } + }); + + let a = cx.read_entity(&harness, |h, _| h.a.clone()); + let b = cx.read_entity(&harness, |h, _| h.b.clone()); + let a_value = cx.read_entity(&a, |e, _| e.value.clone()); + let b_value = cx.read_entity(&b, |e, _| e.value.clone()); + + // Focus the first input's editor. + cx.update(|window, cx| { + let focus_handle = a.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }); + + (a, a_value, b_value, cx) + } + + #[gpui::test] + fn typing_updates_the_shared_string(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("hello"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "hello")); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 5)); + } + + #[gpui::test] + fn sibling_inputs_are_isolated(cx: &mut TestAppContext) { + let (_editor, a_value, b_value, cx) = setup(cx); + + cx.simulate_input("x"); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "x")); + cx.read_entity(&b_value, |value, _| { + assert_eq!(value, "", "typing in input A must not touch input B") + }); + } + + #[gpui::test] + fn external_writes_clamp_the_cursor(cx: &mut TestAppContext) { + let (editor, a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("a"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1)); + + // Byte 1 is a UTF-8 character boundary, but it lies inside the first + // grapheme ("e" plus a combining accent). + cx.update(|_, cx| { + a_value.update(cx, |value, cx| { + *value = "e\u{301}x".to_string(); + cx.notify(); + }) + }); + + cx.read_entity(&a_value, |value, _| assert_eq!(value, "e\u{301}x")); + cx.read_entity(&editor, |editor, _| { + assert_eq!(editor.cursor, 0, "cursor must clamp to a grapheme boundary"); + }); + } + + #[gpui::test] + fn ime_composition_replaces_the_active_mark_and_tracks_selection(cx: &mut TestAppContext) { + let (editor, value, _b_value, cx) = setup(cx); + + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + editor.replace_and_mark_text_in_range(None, "日本", Some(1..1), window, cx); + }); + }); + + cx.read_entity(&value, |value, _| assert_eq!(value, "日本")); + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + assert_eq!(editor.marked_text_range(window, cx), Some(0..2)); + assert_eq!( + editor.selected_text_range(false, window, cx).unwrap().range, + 1..1 + ); + assert_eq!(editor.cursor, "日".len()); + }); + }); + + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + editor.replace_and_mark_text_in_range(None, "語", Some(1..1), window, cx); + }); + }); + + cx.read_entity(&value, |value, _| assert_eq!(value, "語")); + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + assert_eq!(editor.marked_text_range(window, cx), Some(0..1)); + editor.replace_text_in_range(None, "go", window, cx); + assert_eq!(editor.marked_text_range(window, cx), None); + assert_eq!( + editor.selected_text_range(false, window, cx).unwrap().range, + 2..2 + ); + }); + }); + cx.read_entity(&value, |value, _| assert_eq!(value, "go")); + } + + #[gpui::test] + fn arrows_move_the_cursor(cx: &mut TestAppContext) { + let (editor, _a_value, _b_value, cx) = setup(cx); + + cx.simulate_input("abc"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 3)); + + cx.simulate_keystrokes("left left"); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1)); + } + + #[gpui::test] + fn newline_normalizes_a_cursor_inside_a_grapheme(cx: &mut TestAppContext) { + let (editor, value, _b_value, cx) = setup(cx); + cx.simulate_input("é"); + + cx.update(|_, cx| { + editor.update(cx, |editor, cx| { + editor.cursor = 1; + editor.insert_newline(cx); + }); + }); + + cx.read_entity(&value, |value, _| assert_eq!(value, "\né")); + cx.read_entity(&editor, |editor, _| assert_eq!(editor.cursor, 1)); + } +} diff --git a/crates/gpui/examples/view_example/example_text_area.rs b/crates/gpui/examples/view_example/example_text_area.rs new file mode 100644 index 00000000..07640b93 --- /dev/null +++ b/crates/gpui/examples/view_example/example_text_area.rs @@ -0,0 +1,118 @@ +//! `TextArea` — a multi-line text box. Same `Editor` workhorse, taller chrome, +//! and `Enter` inserts a newline instead of being ignored. Constructible from a +//! string or an editor, exactly like [`Input`](crate::example_input::Input). + +use gpui::{ + App, BoxShadow, CursorStyle, Entity, EntityId, Hsla, IntoElement, StyleRefinement, Window, div, + hsla, point, prelude::*, px, white, +}; + +use crate::Enter; +use crate::example_editor::{Editor, standard_actions}; + +enum Source { + Value(Entity), + Editor(Entity), +} + +#[derive(IntoElement)] +pub struct TextArea { + source: Source, + rows: usize, + color: Option, +} + +impl TextArea { + pub fn new(value: Entity, rows: usize) -> Self { + Self { + source: Source::Value(value), + rows, + color: None, + } + } + + pub fn editor(editor: Entity, rows: usize) -> Self { + Self { + source: Source::Editor(editor), + rows, + color: None, + } + } + + pub fn color(mut self, color: Hsla) -> Self { + self.color = Some(color); + self + } +} + +impl gpui::View for TextArea { + fn entity_id(&self) -> Option { + Some(match &self.source { + Source::Value(value) => value.entity_id(), + Source::Editor(editor) => editor.entity_id(), + }) + } + + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let editor = match self.source { + Source::Value(value) => { + window.use_state(cx, move |window, cx| Editor::over(value, window, cx)) + } + Source::Editor(editor) => editor, + }; + + let focus_handle = editor.read(cx).focus_handle.clone(); + let is_focused = focus_handle.is_focused(window); + let text_color = self.color.unwrap_or(hsla(0., 0., 0.1, 1.)); + let row_height = px(20.); + let box_height = row_height * self.rows as f32 + px(16.); + + let border = if is_focused { + hsla(220. / 360., 0.8, 0.5, 1.) + } else { + hsla(0., 0., 0.75, 1.) + }; + + div() + .id("text-area") + .key_context("TextInput") + .track_focus(&focus_handle) + .cursor(CursorStyle::IBeam) + .map(standard_actions(editor.clone())) + // Enter is the one binding that differs from a single-line input. + .on_action({ + let editor = editor.clone(); + move |_: &Enter, _window, cx| editor.update(cx, |e, cx| e.insert_newline(cx)) + }) + .w(px(400.)) + .h(box_height) + .p(px(8.)) + .bg(white()) + .border_1() + .border_color(border) + .when(is_focused, |this| { + this.shadow(vec![BoxShadow { + color: hsla(220. / 360., 0.8, 0.5, 0.3), + offset: point(px(0.), px(0.)), + blur_radius: px(4.), + spread_radius: px(1.), + inset: false, + }]) + }) + .rounded(px(4.)) + .overflow_hidden() + .line_height(row_height) + .text_size(px(14.)) + .text_color(text_color) + // The cache style is computed from the `rows` prop: change `rows` and + // the editor's cached bounds change, busting its cache and re-laying + // out the text. (`Input` just uses `size_full()` — nothing to vary.) + .child( + editor.cached( + StyleRefinement::default() + .w_full() + .h(row_height * self.rows as f32), + ), + ) + } +} diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs new file mode 100644 index 00000000..0eac8494 --- /dev/null +++ b/crates/gpui/examples/view_example/view_example_main.rs @@ -0,0 +1,173 @@ +#![cfg_attr(target_family = "wasm", no_main)] + +//! View example — composing a text input from the `View` primitives. +//! +//! The whole point: a text input is deceptively complicated, and `View` makes it +//! easy to compose one. Three pieces, each shown in its own section: +//! +//! * `Editor` — the workhorse entity: cursor, blink, focus, keyboard, and a +//! specialized text renderer. All the hard parts live here. +//! * `String` — the data plane. `editor.text(cx)` / `value.read(cx)` get it out. +//! * `Input` / `TextArea` — the shaping layer. Each takes a `String` (and grows +//! the editor internally) OR an `Editor` (so you can read the cursor). +//! +//! Run: `cargo run -p gpui --example view_example` + +mod example_editor; +mod example_input; +mod example_text_area; + +#[cfg(test)] +mod example_tests; + +use example_editor::Editor; +use example_input::Input; +use example_text_area::TextArea; + +use gpui::{ + App, Bounds, Context, Div, Entity, IntoElement, KeyBinding, Render, SharedString, Window, + WindowBounds, WindowOptions, actions, div, hsla, prelude::*, px, rgb, size, +}; +use gpui_platform::application; + +actions!( + view_example, + [Backspace, Delete, Left, Right, Home, End, Enter, Quit] +); + +/// A tiny stateless view that reads an editor's cursor and is composed *beside* +/// the thing editing it — two views over one entity, zero wiring. +#[derive(IntoElement)] +struct CursorReadout { + editor: Entity, +} + +impl CursorReadout { + fn new(editor: Entity) -> Self { + Self { editor } + } +} + +impl gpui::RenderOnce for CursorReadout { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let cursor = self.editor.read(cx).cursor; + div() + .text_sm() + .text_color(hsla(0., 0., 0.45, 1.)) + .child(SharedString::from(format!("cursor @ {cursor}"))) + } +} + +struct ViewExample; + +impl ViewExample { + fn new() -> Self { + Self + } +} + +impl Render for ViewExample { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + // The data plane: plain strings, allocated at the top by the hook. + let name = window.use_state(cx, |_, _| String::new()); + let email = window.use_state(cx, |_, _| String::from("me@example.com")); + let bio = window.use_state(cx, |_, _| String::new()); + // Editors that own their own string internally — no extra wiring up top. + let notes = window.use_state(cx, |window, cx| Editor::new("multi\nline", window, cx)); + let owned = window.use_state(cx, |window, cx| Editor::new("editable", window, cx)); + + div() + .flex() + .flex_col() + .size_full() + .bg(rgb(0xf0f0f0)) + .p(px(24.)) + .gap(px(24.)) + .child( + section("Inputs — from a String (cursor stays internal)") + .child(Input::new(name).width(px(320.))) + .child( + Input::new(email) + .width(px(320.)) + .color(hsla(0., 0., 0.3, 1.)), + ), + ) + .child( + section("Input — from an Editor (read its cursor beside it)").child( + div() + .flex() + .items_center() + .gap(px(12.)) + .child(Input::editor(owned.clone()).width(px(320.))) + .child(CursorReadout::new(owned)), + ), + ) + .child( + section("Text areas — from a String, or from an Editor") + .child(TextArea::new(bio, 3)) + .child( + div() + .flex() + .items_start() + .gap(px(12.)) + .child(TextArea::editor(notes.clone(), 3).color(hsla( + 250. / 360., + 0.7, + 0.4, + 1., + ))) + .child(CursorReadout::new(notes)), + ), + ) + } +} + +/// A labeled vertical section. +fn section(title: &str) -> Div { + div().flex().flex_col().gap(px(8.)).child( + div() + .text_sm() + .text_color(hsla(0., 0., 0.3, 1.)) + .child(SharedString::from(title.to_string())), + ) +} + +fn run_example() { + application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx); + cx.bind_keys([ + KeyBinding::new("backspace", Backspace, None), + KeyBinding::new("delete", Delete, None), + KeyBinding::new("left", Left, None), + KeyBinding::new("right", Right, None), + KeyBinding::new("home", Home, None), + KeyBinding::new("end", End, None), + KeyBinding::new("enter", Enter, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| ViewExample::new()), + ) + .unwrap(); + + cx.on_action(|_: &Quit, cx| cx.quit()); + cx.activate(true); + }); +} + +#[cfg(not(target_family = "wasm"))] +fn main() { + run_example(); +} + +#[cfg(target_family = "wasm")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn start() { + gpui_platform::web_init(); + run_example(); +} diff --git a/crates/gpui/examples/window_shadow.rs b/crates/gpui/examples/window_shadow.rs index 3f3cdb37..0dc6c0c9 100644 --- a/crates/gpui/examples/window_shadow.rs +++ b/crates/gpui/examples/window_shadow.rs @@ -105,18 +105,19 @@ impl Render for WindowShadow { .when(!tiling.left, |div| div.border_l(border_size)) .when(!tiling.right, |div| div.border_r(border_size)) .when(!tiling.is_tiled(), |div| { - div.shadow(vec![gpui::BoxShadow { - color: Hsla { - h: 0., - s: 0., - l: 0., - a: 0.4, - }, - blur_radius: shadow_size / 2., - spread_radius: px(0.), - inset: false, - offset: point(px(0.0), px(0.0)), - }]) + div.shadow(vec![ + gpui::BoxShadow::new( + px(0.), + px(0.), + Hsla { + h: 0., + s: 0., + l: 0., + a: 0.4, + }, + ) + .blur_radius(shadow_size / 2.), + ]) }), }) .on_mouse_move(|_e, _, cx| { @@ -146,18 +147,19 @@ impl Render for WindowShadow { .w(px(200.0)) .h(px(100.0)) .bg(green()) - .shadow(vec![gpui::BoxShadow { - color: Hsla { - h: 0., - s: 0., - l: 0., - a: 1.0, - }, - blur_radius: px(20.0), - spread_radius: px(0.0), - inset: false, - offset: point(px(0.0), px(0.0)), - }]) + .shadow(vec![ + gpui::BoxShadow::new( + px(0.), + px(0.), + Hsla { + h: 0., + s: 0., + l: 0., + a: 1.0, + }, + ) + .blur_radius(px(20.0)), + ]) .map(|div| match decorations { Decorations::Server => div, Decorations::Client { .. } => div diff --git a/crates/gpui/resources/windows/gpui.manifest.xml b/crates/gpui/resources/windows/gpui.manifest.xml index c3a99d23..d11e1a4b 100644 --- a/crates/gpui/resources/windows/gpui.manifest.xml +++ b/crates/gpui/resources/windows/gpui.manifest.xml @@ -16,6 +16,7 @@ true/pm PerMonitorV2 + SegmentHeap diff --git a/crates/gpui/src/_accessibility.rs b/crates/gpui/src/_accessibility.rs index 3962bca2..17d1d937 100644 --- a/crates/gpui/src/_accessibility.rs +++ b/crates/gpui/src/_accessibility.rs @@ -63,6 +63,16 @@ //! [`.on_click()`][StatefulInteractiveElement::on_click] adds an //! [`AccessibleAction::Click`] handler that calls the click handler. //! +//! ## Synthetic children +//! +//! A custom [`Element`] can represent accessibility nodes that do not each +//! correspond to a GPUI element by implementing +//! [`Element::a11y_synthetic_children`]. The callback receives an +//! [`A11ySubtreeBuilder`] after prepaint, so it may use prepaint state to append +//! synthetic leaf nodes or update the parent node. Use +//! [`A11ySubtreeBuilder::synthetic_node_id`] with keys that are unique among a +//! parent's synthetic children to derive IDs that remain stable across frames. +//! //! ## Further reading //! //! - [AccessKit]: The cross-platform accessibility toolkit GPUI uses diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index 585712e1..3b353452 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -1,5 +1,5 @@ use anyhow::{Context as _, Result}; -use collections::HashMap; +use collections::{HashMap, TypeIdHashMap}; pub use gpui_macros::Action; pub use no_action::{NoAction, Unbind, is_no_action, is_unbind}; use serde_json::json; @@ -232,7 +232,7 @@ type ActionBuilder = fn(json: serde_json::Value) -> anyhow::Result, - names_by_type_id: HashMap, + names_by_type_id: TypeIdHashMap<&'static str>, all_names: Vec<&'static str>, // So we can return a static slice. deprecated_aliases: HashMap<&'static str, &'static str>, // deprecated name -> preferred name deprecation_messages: HashMap<&'static str, &'static str>, // action name -> deprecation message @@ -356,6 +356,11 @@ impl ActionRegistry { Ok(self.build_action(name, None)?) } + #[cfg(feature = "profiler")] + pub(crate) fn try_resolve_action(&self, type_id: &TypeId) -> Option<&'static str> { + self.names_by_type_id.get(type_id).copied() + } + /// Construct an action based on its name and optional JSON parameters sourced from the keymap. pub fn build_action( &self, diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 146d7d6a..df93fbf1 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -23,7 +23,9 @@ use slotmap::SlotMap; use crate::util::{ResultExt, debug_panic}; pub use async_context::*; -use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; +#[cfg(feature = "bench")] +pub use bench_context::{BenchAppContext, BenchReport, BenchWindowContext, bench_platform}; +use collections::{FxHashMap, FxHashSet, HashMap, TypeIdHashMap, TypeIdHashSet, VecDeque}; pub use context::*; pub use entity_map::*; use http_client::{HttpClient, Url}; @@ -50,6 +52,8 @@ use crate::{ }; mod async_context; +#[cfg(feature = "bench")] +mod bench_context; mod context; mod entity_map; #[cfg(any(test, feature = "test-support"))] @@ -129,6 +133,29 @@ impl Drop for AppRefMut<'_> { /// You won't interact with this type much outside of initial configuration and startup. pub struct Application(Rc); +/// A strong handle to an [`Application`] started with [`Application::run_embedded`]. +/// +/// Dropping this handle releases the app, so an embedder must hold it for as long as the +/// app should run. While held, it is the embedder's entry point back into GPUI each time +/// the external run loop gives it control. +pub struct ApplicationHandle { + app: Rc, +} + +impl ApplicationHandle { + /// Invoke `f` with the app context. + /// + /// This must not be called re-entrantly from code that is already inside an update. + pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { + self.app.borrow_mut().update(f) + } + + /// Return an async-friendly weak handle to the application. + pub fn to_async(&self) -> AsyncApp { + self.update(|cx| cx.to_async()) + } +} + /// Represents an application before it is fully launched. Once your app is /// configured, you'll start the app with `App::run`. impl Application { @@ -192,6 +219,24 @@ impl Application { })); } + /// Start the application for an embedder that drives the run loop itself. + /// + /// Embedded platforms invoke the launch callback and return immediately from + /// [`Platform::run`]. The returned handle keeps the application alive and lets the + /// embedder re-enter it when its external run loop yields control. + pub fn run_embedded(self, on_finish_launching: F) -> ApplicationHandle + where + F: 'static + FnOnce(&mut App), + { + let this = self.0.clone(); + let platform = self.0.borrow().platform.clone(); + platform.run(Box::new(move || { + let cx = &mut *this.borrow_mut(); + on_finish_launching(cx); + })); + ApplicationHandle { app: self.0 } + } + /// Register a handler to be invoked when the platform instructs the application /// to open one or more URLs. pub fn on_open_urls(&self, mut callback: F) -> &Self @@ -217,6 +262,23 @@ impl Application { self } + /// Invoke a handler when the system wakes from sleep. + pub fn on_system_wake(&self, mut callback: F) -> &Self + where + F: 'static + FnMut(&mut App), + { + let this = Rc::downgrade(&self.0); + self.0 + .borrow_mut() + .platform + .on_system_wake(Box::new(move || { + if let Some(app) = this.upgrade() { + callback(&mut app.borrow_mut()); + } + })); + self + } + /// Returns a handle to the [`BackgroundExecutor`] associated with this app, which can be used to spawn futures in the background. pub fn background_executor(&self) -> BackgroundExecutor { self.0.borrow().background_executor.clone() @@ -613,7 +675,7 @@ pub struct App { pub(crate) keyboard_layout: Box, pub(crate) keyboard_mapper: Rc, pub(crate) global_action_listeners: - FxHashMap>>, + TypeIdHashMap>>, pending_effects: VecDeque, pub(crate) observers: SubscriberSet, @@ -638,7 +700,7 @@ pub struct App { // callbacks are marked cancelled at this point as this will also shutdown // the tokio runtime. As any task attempting to spawn a blocking tokio task, // might panic. - pub(crate) globals_by_type: FxHashMap>, + pub(crate) globals_by_type: TypeIdHashMap>, // assets pub(crate) loading_assets: FxHashMap<(TypeId, u64), Box>, @@ -648,7 +710,7 @@ pub struct App { // below is plain data, the drop order is insignificant here pub(crate) pending_notifications: FxHashSet, - pub(crate) pending_global_notifications: FxHashSet, + pub(crate) pending_global_notifications: TypeIdHashSet, pub(crate) restart_path: Option, pub(crate) layout_id_buffer: Vec, // We recycle this memory across layout requests. pub(crate) propagate_event: bool, @@ -720,7 +782,7 @@ impl App { loading_assets: Default::default(), asset_source, http_client, - globals_by_type: FxHashMap::default(), + globals_by_type: Default::default(), entities, new_entity_observers: SubscriberSet::new(), windows: SlotMap::with_key(), @@ -730,10 +792,10 @@ impl App { keymap: Rc::new(RefCell::new(Keymap::default())), keyboard_layout, keyboard_mapper, - global_action_listeners: FxHashMap::default(), + global_action_listeners: Default::default(), pending_effects: VecDeque::new(), pending_notifications: FxHashSet::default(), - pending_global_notifications: FxHashSet::default(), + pending_global_notifications: Default::default(), observers: SubscriberSet::new(), tracked_entities: FxHashMap::default(), window_invalidators_by_entity: FxHashMap::default(), @@ -1449,7 +1511,7 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench"))] for window in self .windows .values() @@ -2740,9 +2802,73 @@ impl<'a, T> Drop for GpuiBorrow<'a, T> { #[cfg(test)] mod test { - use std::{cell::RefCell, rc::Rc}; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + sync::Arc, + }; + + use crate::{ + AppContext, Application, BackgroundExecutor, Context, Empty, Entity, ForegroundExecutor, + Global, Render, TestAppContext, TestDispatcher, TestPlatform, Window, + }; - use crate::{AppContext, Context, Empty, Entity, Render, TestAppContext, Window}; + #[derive(Default)] + struct EmbeddedState(u32); + + impl Global for EmbeddedState {} + + fn test_application() -> (Application, Rc) { + let dispatcher = Arc::new(TestDispatcher::new(0)); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher); + let platform = TestPlatform::new(background_executor, foreground_executor); + (Application::with_platform(platform.clone()), platform) + } + + #[test] + fn embedded_application_runs_launch_callback_and_keeps_app_alive() { + let (application, _) = test_application(); + let launched = Rc::new(Cell::new(false)); + let launched_for_callback = launched.clone(); + + let handle = application.run_embedded(move |cx| { + launched_for_callback.set(true); + cx.set_global(EmbeddedState(1)); + }); + + assert!(launched.get()); + handle.update(|cx| cx.global_mut::().0 += 1); + let async_app = handle.to_async(); + async_app.update(|cx| cx.global_mut::().0 += 1); + assert_eq!(handle.update(|cx| cx.global::().0), 3); + } + + #[test] + fn embedded_application_update_flushes_effects_before_returning() { + let (application, _) = test_application(); + let handle = application.run_embedded(|_| {}); + let deferred = Rc::new(Cell::new(false)); + + handle.update({ + let deferred = deferred.clone(); + move |cx| cx.defer(move |_| deferred.set(true)) + }); + + assert!(deferred.get()); + } + + #[test] + fn test_platform_simulates_system_wake() { + let (application, platform) = test_application(); + application.on_system_wake(|cx| cx.global_mut::().0 += 1); + let handle = application.run_embedded(|cx| cx.set_global(EmbeddedState::default())); + + platform.simulate_system_wake(); + platform.simulate_system_wake(); + + assert_eq!(handle.update(|cx| cx.global::().0), 2); + } #[test] fn test_gpui_borrow() { diff --git a/crates/gpui/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs index 051a2af6..f894e00a 100644 --- a/crates/gpui/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -619,13 +619,10 @@ mod tests { let (mut async_cx, _) = open_async_window_context(cx); let builder_invoked = Rc::new(Cell::new(false)); - let entity = { - let builder_invoked = builder_invoked.clone(); - async_cx.new(|_| { - builder_invoked.set(true); - 42usize - }) - }; + let entity = async_cx.new(|_| { + builder_invoked.set(true); + 42usize + }); assert!(builder_invoked.get()); assert_eq!(async_cx.read_entity(&entity, |value, _| *value), 42); diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs new file mode 100644 index 00000000..b72819be --- /dev/null +++ b/crates/gpui/src/app/bench_context.rs @@ -0,0 +1,800 @@ +use std::{ + cell::{OnceCell, RefCell}, + future::Future, + rc::Rc, + sync::Arc, + time::Duration, +}; + +use anyhow::{Result, anyhow}; +use hdrhistogram::Histogram; + +use crate::{ + AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BenchDispatcher, + Bounds, Context, Empty, Entity, EntityId, Focusable, ForegroundExecutor, Global, Platform, + PlatformHeadlessRenderer, PlatformTextSystem, Render, Reservation, Task, TestPlatform, + VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, + app::GpuiBorrow, + profiler::{self, FrameTiming, FrameTimingCollector}, +}; + +/// Returns a benchmark platform backed by this thread's shared dispatcher. +/// +/// The platform uses this thread's shared multithreaded [`BenchDispatcher`], so +/// background work runs with production concurrency in real time. The dispatcher +/// is cached per thread and reused across benchmark invocations so worker and +/// timer threads persist for the whole process instead of being recreated for +/// every Criterion calibration pass. +/// +/// Text is shaped with the provided platform text system. Benchmarks generated +/// by `#[gpui::bench]` use the current platform's text system, so text-heavy +/// benchmark measurements include production shaping and glyph rasterization. +/// +/// `headless_renderer_factory` supplies a renderer for benchmark windows, e.g. +/// `gpui_platform::current_headless_renderer`. When present, scenes drawn by +/// benchmarks are rasterized through the real sprite atlas and submitted to +/// the GPU on present, so quad/sprite regressions show up in measurements. +/// When `None`, presenting discards the scene. Currently only macOS provides +/// a headless renderer (Metal), so GPU submission is excluded from benchmark +/// measurements on other platforms. +pub fn bench_platform( + headless_renderer_factory: Option Option>>>, + text_system: Arc, +) -> Rc { + thread_local! { + static DISPATCHER: OnceCell> = const { OnceCell::new() }; + } + let dispatcher = DISPATCHER.with(|cell| { + cell.get_or_init(|| Arc::new(BenchDispatcher::new())) + .clone() + }); + let background_executor = BackgroundExecutor::new(dispatcher.clone()); + let foreground_executor = ForegroundExecutor::new(dispatcher); + TestPlatform::with_platform( + background_executor, + foreground_executor, + text_system, + headless_renderer_factory, + ) as Rc +} + +/// Default target frame rate when a benchmark doesn't specify `fps = N`. +const DEFAULT_FPS: u64 = 120; + +const NANOS_PER_SECOND: u128 = 1_000_000_000; + +/// A small report produced by GPUI benchmarks. +#[derive(Clone)] +pub struct BenchReport { + frame_snapshot: Rc>, + frame_budget_nanos: u128, +} + +impl Default for BenchReport { + fn default() -> Self { + Self::with_fps(DEFAULT_FPS) + } +} + +impl BenchReport { + /// Creates a report whose per-frame budget is one frame at `fps` when + /// counting frame budget overruns. + pub fn with_fps(fps: u64) -> Self { + assert!(fps > 0, "frame rate must be greater than zero"); + Self::with_frame_budget_nanos(NANOS_PER_SECOND / fps as u128) + } + + /// Creates a report that treats `frame_budget_nanos` as the per-frame budget + /// when counting frame budget overruns. + /// + /// # Panics + /// + /// Panics if `frame_budget_nanos` is zero. + pub fn with_frame_budget_nanos(frame_budget_nanos: u128) -> Self { + assert!( + frame_budget_nanos > 0, + "frame budget must be greater than zero" + ); + Self { + frame_snapshot: Rc::new(RefCell::new(WindowFrameSnapshot::new())), + frame_budget_nanos, + } + } + + fn record_frame_timings<'i>(&self, timings: impl IntoIterator) { + let mut snapshot = self.frame_snapshot.borrow_mut(); + // `.ok()` on `record`: this operation is infallible (the histograms auto-resize). + for timing in timings { + snapshot + .draw + .record(timing.draw_duration().as_nanos() as u64) + .ok(); + if let Some(dirty_to_draw) = timing.dirty_to_draw_duration() { + snapshot + .dirty_to_draw + .record(dirty_to_draw.as_nanos() as u64) + .ok(); + } + if timing.invalidations > 0 { + snapshot + .invalidations_per_frame + .record(timing.invalidations) + .ok(); + } + } + } + + fn total_budget_overruns(&self, histogram: &Histogram) -> u64 { + histogram + .iter_recorded() + .map(|value| { + self.budget_overruns(Duration::from_nanos(value.value_iterated_to())) + * value.count_at_value() + }) + .sum() + } + + /// Returns how many whole frame budgets `foreground_time` exceeded the + /// per frame budget by. This is a synthetic proxy for missed frames: the + /// benchmark harness has no vsync, so it counts how many frame deadlines + /// would have elapsed while the foreground thread was busy. + fn budget_overruns(&self, foreground_time: Duration) -> u64 { + let foreground_nanos = foreground_time.as_nanos(); + if foreground_nanos <= self.frame_budget_nanos { + return 0; + } + + let over_budget_nanos = foreground_nanos - self.frame_budget_nanos; + over_budget_nanos.div_ceil(self.frame_budget_nanos) as u64 + } + + /// Prints this report to stderr. + pub fn print(&self, benchmark_name: Option<&'static str>) { + let frame_snapshot = self.frame_snapshot.borrow(); + if frame_snapshot.is_empty() { + return; + } + + let benchmark_name = benchmark_name.unwrap_or("unknown benchmark"); + eprintln!("GPUI bench report (all observed iterations): {benchmark_name}"); + eprintln!(" note: includes Criterion warmup/calibration"); + self.print_histogram("window dirty-to-draw", &frame_snapshot.dirty_to_draw); + self.print_histogram("window draw", &frame_snapshot.draw); + if !frame_snapshot.invalidations_per_frame.is_empty() { + eprintln!( + " invalidations per frame: mean {:.2}, max {}", + frame_snapshot.invalidations_per_frame.mean(), + frame_snapshot.invalidations_per_frame.max() + ); + } + } + + fn print_histogram(&self, name: &str, histogram: &Histogram) { + if histogram.is_empty() { + return; + } + + let max_foreground_time = Duration::from_nanos(histogram.max()); + eprintln!(" {name}:"); + eprintln!(" samples: {}", histogram.len()); + eprintln!( + " mean: {}", + format_duration(Duration::from_nanos(histogram.mean() as u64)) + ); + eprintln!( + " p50: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.50))) + ); + eprintln!( + " p90: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.90))) + ); + eprintln!( + " p95: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.95))) + ); + eprintln!( + " p99: {}", + format_duration(Duration::from_nanos(histogram.value_at_quantile(0.99))) + ); + eprintln!(" max: {}", format_duration(max_foreground_time)); + eprintln!( + " frame budget overruns total: {}", + self.total_budget_overruns(histogram) + ); + eprintln!( + " frame budget overruns max: {}", + self.budget_overruns(max_foreground_time) + ); + } +} + +struct WindowFrameSnapshot { + dirty_to_draw: Histogram, + draw: Histogram, + invalidations_per_frame: Histogram, +} + +impl WindowFrameSnapshot { + fn new() -> Self { + Self { + dirty_to_draw: Histogram::new(3).expect("3 significant digits is valid"), + draw: Histogram::new(3).expect("3 significant digits is valid"), + invalidations_per_frame: Histogram::new(3).expect("3 significant digits is valid"), + } + } + + fn is_empty(&self) -> bool { + self.dirty_to_draw.is_empty() && self.draw.is_empty() + } +} + +fn format_duration(duration: Duration) -> String { + format!("{:.3}ms", duration.as_secs_f64() * 1000.) +} + +/// Enables frame tracing for the duration of a measurement and collects the +/// frames recorded within it. The previous tracing state is restored on drop, +/// so a panicking measurement doesn't leave tracing enabled for unrelated code +/// (e.g. a later benchmark in the same process). +struct FrameTraceScope { + collector: FrameTimingCollector, + was_already_enabled: bool, +} + +impl FrameTraceScope { + fn start() -> Self { + let was_already_enabled = !profiler::set_frame_trace_enabled(true); + Self { + collector: FrameTimingCollector::new(), + was_already_enabled, + } + } + + fn finish(mut self) -> Vec { + self.collector.collect_unseen() + // Dropping `self` restores the previous tracing state. + } +} + +impl Drop for FrameTraceScope { + fn drop(&mut self) { + if !self.was_already_enabled { + profiler::set_frame_trace_enabled(false); + } + } +} + +/// A GPUI app context for Criterion benchmarks. +/// +/// `BenchAppContext` is intentionally separate from `TestAppContext`: it owns a +/// benchmark app instance and exposes only the app/window operations needed by +/// benchmark setup. Criterion remains responsible for the measured loop via its +/// `Bencher` API. +#[derive(Clone)] +pub struct BenchAppContext<'a, 'measurement> { + app: Rc, + background_executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + benchmark_name: Option<&'static str>, + bencher: Rc>>>, + report: BenchReport, +} + +impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + pub fn new( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + ) -> Self { + Self::build(platform, benchmark_name, bencher, BenchReport::default()) + } + + /// Creates a new benchmark app context backed by the provided platform. + /// + /// The platform's executors must be backed by a [`BenchDispatcher`] + /// (see [`bench_platform`]) so the context can drain foreground work via + /// [`Self::run_until_idle`]; panics otherwise. + #[doc(hidden)] + pub fn new_with_platform_and_report( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + Self::build(platform, benchmark_name, bencher, report) + } + + fn build( + platform: Rc, + benchmark_name: Option<&'static str>, + bencher: &'a mut criterion::Bencher<'measurement>, + report: BenchReport, + ) -> Self { + let background_executor = platform.background_executor(); + // Validate up front so misconfiguration fails at construction with a + // clear message instead of deep inside `run_until_idle`. + assert!( + background_executor.dispatcher().as_bench().is_some(), + "BenchAppContext requires a platform whose executors are backed by a \ + BenchDispatcher; construct one with gpui::bench_platform" + ); + let foreground_executor = platform.foreground_executor(); + let asset_source = Arc::new(()); + let http_client = http_client::FakeHttpClient::with_404_response(); + let app = App::new_app(platform, asset_source, http_client); + + Self { + app, + background_executor, + foreground_executor, + benchmark_name, + bencher: Rc::new(RefCell::new(Some(bencher))), + report, + } + } + + /// The benchmark function name that created this context. + pub fn benchmark_name(&self) -> Option<&'static str> { + self.benchmark_name + } + + /// Returns the background executor used by this benchmark app. + pub fn background_executor(&self) -> &BackgroundExecutor { + &self.background_executor + } + + /// Returns the foreground executor used by this benchmark app. + pub fn foreground_executor(&self) -> &ForegroundExecutor { + &self.foreground_executor + } + + /// Updates the app and flushes synchronous GPUI effects afterward. + pub fn update(&mut self, update: impl FnOnce(&mut App) -> R) -> R { + let mut app = self.app.borrow_mut(); + app.update(update) + } + + /// Reads app state. + pub fn read(&self, read: impl FnOnce(&App) -> R) -> R { + let app = self.app.borrow(); + read(&app) + } + + /// Runs queued foreground tasks on this thread and waits for in flight + /// background work to finish. Timers that aren't due yet are not waited + /// for (see [`BenchDispatcher::run_until_idle`]). + pub fn run_until_idle(&self) { + self.background_executor + .dispatcher() + .as_bench() + .expect("validated in BenchAppContext::build") + .run_until_idle(); + } + + /// Measures a generic benchmark workload using Criterion's iteration loop. + /// + /// The closure is invoked once per Criterion iteration with this + /// benchmark app context so it can update GPUI state. + /// + /// Any window draws triggered by the workload are recorded into the + /// benchmark's frame report through the GPUI frame profiler. + pub fn bench_iter(&mut self, mut benchmark: impl FnMut(&mut Self)) { + let bencher = self.take_bencher("bench_iter"); + let collector = FrameTraceScope::start(); + let mut benchmark = || benchmark(self); + bencher.iter(&mut benchmark); + self.report.record_frame_timings(collector.finish().iter()); + self.replace_bencher(bencher); + } + + /// Measures frame latency after updating a GPUI entity in its current window. + /// + /// Each iteration runs `update` against the entity in its current window. In + /// bench builds, flushing the update's effects synchronously draws dirty + /// windows. The entity should be part of the window's render tree, such as the + /// root view or a child of it. + /// + /// Frame timings are collected through the GPUI frame profiler + /// ([`crate::profiler::record_frame_timing`]), which is enabled for the + /// duration of the measurement. + pub fn bench_renderer( + &mut self, + view: Entity, + mut update: impl FnMut(&mut V, &mut Window, &mut Context), + ) where + V: 'static + Render, + { + let bencher = self.take_bencher("bench_renderer"); + let window_id = self + .with_window(view.entity_id(), |window, _| { + window.window_handle().window_id() + }) + .expect("cannot benchmark renderer for entity without a current window"); + + let dispatcher = self.background_executor.dispatcher().clone(); + let collector = FrameTraceScope::start(); + + let mut benchmark = || { + dispatcher + .as_bench() + .expect("validated in BenchAppContext::build") + .run_ready_main_tasks(); + self.with_window(view.entity_id(), |window, cx| { + view.update(cx, |view, cx| update(view, window, cx)); + }) + .expect("cannot benchmark renderer for entity without a current window"); + // Submit the frame drawn by the update's effect flush, mirroring + // production where every drawn frame is presented. With a headless + // renderer this includes scene submission to the GPU. + self.with_window(view.entity_id(), |window, _| { + window.present_if_needed(); + }) + .expect("cannot benchmark renderer for entity without a current window"); + }; + bencher.iter(&mut benchmark); + + let timings = collector.finish(); + self.report.record_frame_timings( + timings + .iter() + .filter(|timing| timing.window_id == window_id), + ); + self.replace_bencher(bencher); + } + + /// Adds a window with an empty root view for benchmark setup. + pub fn add_empty_window(&mut self) -> BenchWindowContext<'a, 'measurement> { + let bounds = { + let app = self.app.borrow(); + Bounds::maximized(None, &app) + }; + let window = { + let mut app = self.app.borrow_mut(); + let window: AnyWindowHandle = app + .open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(|_| Empty), + ) + .expect("failed to open benchmark window") + .into(); + window + }; + + self.run_until_idle(); + BenchWindowContext { + cx: self.clone(), + window, + } + } + + fn take_bencher(&self, benchmark_kind: &str) -> &'a mut criterion::Bencher<'measurement> { + self.bencher.borrow_mut().take().unwrap_or_else(|| { + panic!("cannot start {benchmark_kind}: benchmark measurement is already running") + }) + } + + fn replace_bencher(&self, bencher: &'a mut criterion::Bencher<'measurement>) { + let previous = self.bencher.borrow_mut().replace(bencher); + assert!( + previous.is_none(), + "benchmark bencher was unexpectedly present after measurement" + ); + } + + /// Runs GPUI benchmark teardown. + /// + /// Cancels any timers still armed on the shared dispatcher and drains the + /// work that cancellation unblocks so they can't fire during a later + /// benchmark; assumes no other `BenchAppContext` is live on this thread. + pub fn teardown(mut self) { + self.run_until_idle(); + self.update(|cx| { + cx.quit(); + }); + self.run_until_idle(); + + let dispatcher = self.background_executor.dispatcher(); + let dispatcher = dispatcher + .as_bench() + .expect("validated in BenchAppContext::build"); + + drop(self.app); + drop(self.foreground_executor); + + for _ in 0..100 { + if dispatcher.cancel_pending_timers() == 0 { + return; + } + dispatcher.run_until_idle(); + } + panic!( + "benchmark teardown kept scheduling timers: {}", + dispatcher.debug_state() + ); + } +} + +impl AppContext for BenchAppContext<'_, '_> { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { + let mut app = self.app.borrow_mut(); + app.new(build_entity) + } + + fn reserve_entity(&mut self) -> Reservation { + let mut app = self.app.borrow_mut(); + app.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: Reservation, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Entity { + let mut app = self.app.borrow_mut(); + app.insert_entity(reservation, build_entity) + } + + fn update_entity( + &mut self, + handle: &Entity, + update: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + let mut app = self.app.borrow_mut(); + app.update_entity(handle, update) + } + + fn as_mut<'b, T>(&'b mut self, _: &Entity) -> GpuiBorrow<'b, T> + where + T: 'static, + { + panic!("Cannot use as_mut with BenchAppContext. Call update() instead.") + } + + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R + where + T: 'static, + { + let app = self.app.borrow(); + app.read_entity(handle, read) + } + + fn update_window(&mut self, window: AnyWindowHandle, update: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + let mut app = self.app.borrow_mut(); + app.update_window(window, update) + } + + fn with_window( + &mut self, + entity_id: EntityId, + update: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + let mut app = self.app.borrow_mut(); + app.with_window(entity_id, update) + } + + fn read_window( + &self, + window: &WindowHandle, + read: impl FnOnce(Entity, &App) -> R, + ) -> Result + where + T: 'static, + { + let app = self.app.borrow(); + app.read_window(window, read) + } + + fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.background_executor.spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R + where + G: Global, + { + let app = self.app.borrow(); + app.read_global(callback) + } +} + +/// A window-specific context for GPUI benchmarks. +/// +/// This is separate from `VisualTestContext`; it provides access to a benchmark +/// window without exposing test-only helpers such as input simulation. +#[derive(Clone)] +pub struct BenchWindowContext<'a, 'measurement> { + cx: BenchAppContext<'a, 'measurement>, + window: AnyWindowHandle, +} + +impl<'a, 'measurement> BenchWindowContext<'a, 'measurement> { + /// Returns the underlying benchmark app context. + pub fn app_context(&mut self) -> &mut BenchAppContext<'a, 'measurement> { + &mut self.cx + } + + /// Returns the window associated with this context. + pub fn window_handle(&self) -> AnyWindowHandle { + self.window + } + + /// Runs queued foreground tasks on this thread and waits for in-flight + /// background work to finish. Pending timers are not waited for. + pub fn run_until_idle(&self) { + self.cx.run_until_idle(); + } + + /// Updates the benchmark window. + pub fn update(&mut self, update: impl FnOnce(&mut Window, &mut App) -> R) -> R { + self.cx + .update_window(self.window, |_, window, cx| update(window, cx)) + .expect("benchmark window was unexpectedly closed") + } +} + +impl AppContext for BenchWindowContext<'_, '_> { + fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { + self.window + .update(&mut self.cx, |_, _, cx| cx.new(build_entity)) + .expect("benchmark window was unexpectedly closed") + } + + fn reserve_entity(&mut self) -> Reservation { + self.cx.reserve_entity() + } + + fn insert_entity( + &mut self, + reservation: Reservation, + build_entity: impl FnOnce(&mut Context) -> T, + ) -> Entity { + self.window + .update(&mut self.cx, |_, _, cx| { + cx.insert_entity(reservation, build_entity) + }) + .expect("benchmark window was unexpectedly closed") + } + + fn update_entity( + &mut self, + handle: &Entity, + update: impl FnOnce(&mut T, &mut Context) -> R, + ) -> R { + self.cx.update_entity(handle, update) + } + + fn as_mut<'b, T>(&'b mut self, handle: &Entity) -> GpuiBorrow<'b, T> + where + T: 'static, + { + self.cx.as_mut(handle) + } + + fn read_entity(&self, handle: &Entity, read: impl FnOnce(&T, &App) -> R) -> R + where + T: 'static, + { + self.cx.read_entity(handle, read) + } + + fn update_window(&mut self, window: AnyWindowHandle, update: F) -> Result + where + F: FnOnce(AnyView, &mut Window, &mut App) -> T, + { + self.cx.update_window(window, update) + } + + fn with_window( + &mut self, + entity_id: EntityId, + update: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + self.cx.with_window(entity_id, update) + } + + fn read_window( + &self, + window: &WindowHandle, + read: impl FnOnce(Entity, &App) -> R, + ) -> Result + where + T: 'static, + { + self.cx.read_window(window, read) + } + + fn background_spawn(&self, future: impl Future + Send + 'static) -> Task + where + R: Send + 'static, + { + self.cx.background_spawn(future) + } + + fn read_global(&self, callback: impl FnOnce(&G, &App) -> R) -> R + where + G: Global, + { + self.cx.read_global(callback) + } +} + +impl VisualContext for BenchWindowContext<'_, '_> { + type Result = Result; + + fn window_handle(&self) -> AnyWindowHandle { + self.window + } + + fn update_window_entity( + &mut self, + entity: &Entity, + update: impl FnOnce(&mut T, &mut Window, &mut Context) -> R, + ) -> Result { + let entity = entity.clone(); + self.cx + .app + .borrow_mut() + .with_window(entity.entity_id(), |window, app| { + entity.update(app, |entity, cx| update(entity, window, cx)) + }) + .ok_or_else(|| { + anyhow!("entity has no current window; use `update` instead of `update_in`") + }) + } + + fn new_window_entity( + &mut self, + build_entity: impl FnOnce(&mut Window, &mut Context) -> T, + ) -> Result> { + self.window.update(&mut self.cx, |_, window, cx| { + cx.new(|cx| build_entity(window, cx)) + }) + } + + fn replace_root_view( + &mut self, + build_view: impl FnOnce(&mut Window, &mut Context) -> V, + ) -> Result> + where + V: 'static + Render, + { + self.window.update(&mut self.cx, |_, window, cx| { + window.replace_root(cx, build_view) + }) + } + + fn focus(&mut self, entity: &Entity) -> Result<()> + where + V: Focusable, + { + self.window.update(&mut self.cx, |_, window, cx| { + entity.read(cx).focus_handle(cx).focus(window, cx) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[should_panic(expected = "frame budget must be greater than zero")] + fn zero_frame_budget_is_rejected_at_construction() { + BenchReport::with_frame_budget_nanos(0); + } +} diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index c4962b1d..d7fd4090 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -342,6 +342,20 @@ impl TestAppContext { self.test_platform.simulate_new_path_selection(select_path); } + /// Simulates responding to a `prompt_for_paths` ("Open") dialog. + pub fn simulate_path_prompt_response( + &self, + select_paths: impl FnOnce(&crate::PathPromptOptions) -> Option>, + ) { + self.test_platform + .simulate_path_prompt_response(select_paths); + } + + /// Returns whether a path selection dialog is pending. + pub fn did_prompt_for_paths(&self) -> bool { + self.test_platform.did_prompt_for_paths() + } + /// Simulates clicking a button in an platform-level alert dialog. #[track_caller] pub fn simulate_prompt_answer(&self, button: &str) { @@ -1080,3 +1094,54 @@ impl AnyWindowHandle { .unwrap() } } + +#[cfg(test)] +mod tests { + use crate::{PathPromptOptions, TestAppContext}; + use std::path::PathBuf; + + #[gpui::test] + async fn test_simulate_path_prompt_response(cx: &mut TestAppContext) { + assert!(!cx.did_prompt_for_paths()); + + let receiver = cx.update(|cx| { + cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: true, + prompt: None, + }) + }); + assert!(cx.did_prompt_for_paths()); + + let selected = vec![PathBuf::from("/a"), PathBuf::from("/b")]; + cx.simulate_path_prompt_response({ + let selected = selected.clone(); + move |options| { + assert!(options.multiple); + Some(selected) + } + }); + assert!(!cx.did_prompt_for_paths()); + + let response = receiver.await.unwrap().unwrap(); + assert_eq!(response, Some(selected)); + } + + #[gpui::test] + async fn test_simulate_path_prompt_cancellation(cx: &mut TestAppContext) { + let receiver = cx.update(|cx| { + cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: None, + }) + }); + + cx.simulate_path_prompt_response(|_options| None); + + let response = receiver.await.unwrap().unwrap(); + assert_eq!(response, None); + } +} diff --git a/crates/gpui/src/asset_cache.rs b/crates/gpui/src/asset_cache.rs index 9afbba8a..bab0b268 100644 --- a/crates/gpui/src/asset_cache.rs +++ b/crates/gpui/src/asset_cache.rs @@ -2,7 +2,7 @@ use crate::{App, SharedString, SharedUri}; use futures::{Future, TryFutureExt}; use std::fmt::Debug; -use std::hash::{Hash, Hasher}; +use std::hash::{BuildHasher, Hash}; use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -78,7 +78,5 @@ where /// Use a quick, non-cryptographically secure hash function to get an identifier from data pub fn hash(data: &T) -> u64 { - let mut hasher = collections::FxHasher::default(); - data.hash(&mut hasher); - hasher.finish() + collections::FxBuildHasher.hash_one(data) } diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index 75585bcd..8fc91b81 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -69,6 +69,68 @@ impl Rgba { } } } + + /// Returns a new RGBA color with the same red, green and blue channels, but + /// with a new alpha value. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0xFF0000FF); + /// let faded = color.alpha(0.25); + /// assert_eq!(faded.a, 0.25); + /// ``` + /// + /// This will return a red color with 25% opacity. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0x3399FFCC); + /// let transparent = color.alpha(0.0); + /// assert_eq!(transparent.a, 0.0); + /// ``` + /// + /// This will return the same blue color, fully transparent. + pub fn alpha(&self, a: f32) -> Self { + Rgba { + r: self.r, + g: self.g, + b: self.b, + a: a.clamp(0., 1.), + } + } + + /// Returns a new RGBA color with the same red, green, and blue channels, + /// but with the alpha channel multiplied by the given factor. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0xFF0000FF); // Fully opaque red + /// let faded = color.opacity(0.5); + /// assert_eq!(faded.a, 0.5); + /// ``` + /// + /// This will return a red color with 50% opacity. + /// + /// Example: + /// ``` + /// use gpui::rgba; + /// let color = rgba(0x3399FFCC); // A light blue with 80% opacity + /// let faded = color.opacity(0.5); + /// assert!((faded.a - 0.4).abs() < 1e-6); + /// ``` + /// + /// This will return the same blue color scaled down to 40% opacity. + pub fn opacity(&self, factor: f32) -> Self { + Rgba { + r: self.r, + g: self.g, + b: self.b, + a: self.a * factor.clamp(0., 1.), + } + } } impl From for u32 { @@ -953,4 +1015,30 @@ mod tests { assert!(!background.is_transparent()); assert!(background.opacity(0.0).is_transparent()); } + + #[test] + fn test_rgba_alpha() { + let color = Rgba { + r: 0.2, + g: 0.6, + b: 1.0, + a: 0.8, + }; + + assert_eq!(color.alpha(0.25).a, 0.25); + assert_eq!(color.alpha(1.5).a, 1.0); + } + + #[test] + fn test_rgba_opacity() { + let color = Rgba { + r: 0.2, + g: 0.6, + b: 1.0, + a: 0.8, + }; + + assert!((color.opacity(0.5).a - 0.4).abs() < 1e-6); + assert_eq!(color.opacity(2.0).a, 0.8); + } } diff --git a/crates/gpui/src/element.rs b/crates/gpui/src/element.rs index 4aa0307c..0f725889 100644 --- a/crates/gpui/src/element.rs +++ b/crates/gpui/src/element.rs @@ -32,13 +32,13 @@ //! your own custom layout algorithm or rendering a code editor. use crate::{ - App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, FocusHandle, - InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window, + A11ySubtreeBuilder, App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, + FocusHandle, InspectorElementId, LayoutId, Pixels, Point, Size, Style, Window, util::FluentBuilder, window::with_element_arena, }; use derive_more::{Deref, DerefMut}; use std::{ - any::{Any, type_name}, + any::Any, fmt::{self, Debug, Display}, mem, panic, sync::Arc, @@ -114,6 +114,14 @@ pub trait Element: 'static + IntoElement { /// Write accessibility properties to the given node. fn write_a11y_info(&self, _node: &mut accesskit::Node) {} + /// Add synthetic accessibility child nodes after this element has been prepainted. + fn a11y_synthetic_children( + &mut self, + _prepaint: &mut Self::PrepaintState, + _builder: &mut A11ySubtreeBuilder, + ) { + } + /// Convert this element into a dynamically-typed [`AnyElement`]. fn into_any(self) -> AnyElement { AnyElement::new(self) @@ -187,116 +195,6 @@ pub trait ParentElement { } } -/// An element for rendering components. An implementation detail of the [`IntoElement`] derive macro -/// for [`RenderOnce`] -#[doc(hidden)] -pub struct Component { - component: Option, - #[cfg(debug_assertions)] - source: &'static core::panic::Location<'static>, -} - -impl Component { - /// Create a new component from the given RenderOnce type. - #[track_caller] - pub fn new(component: C) -> Self { - Component { - component: Some(component), - #[cfg(debug_assertions)] - source: core::panic::Location::caller(), - } - } -} - -fn prepaint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.prepaint(window, cx); - }) -} - -fn paint_component( - (element, name): &mut (AnyElement, &'static str), - window: &mut Window, - cx: &mut App, -) { - window.with_id(ElementId::Name(SharedString::new_static(name)), |window| { - element.paint(window, cx); - }) -} -impl Element for Component { - type RequestLayoutState = (AnyElement, &'static str); - type PrepaintState = (); - - fn id(&self) -> Option { - None - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - #[cfg(debug_assertions)] - return Some(self.source); - - #[cfg(not(debug_assertions))] - return None; - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_id(ElementId::Name(type_name::().into()), |window| { - let mut element = self - .component - .take() - .unwrap() - .render(window, cx) - .into_any_element(); - - let layout_id = element.request_layout(window, cx); - (layout_id, (element, type_name::())) - }) - } - - fn prepaint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) { - prepaint_component(state, window, cx); - } - - fn paint( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _: Bounds, - state: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - paint_component(state, window, cx); - } -} - -impl IntoElement for Component { - type Element = Self; - - fn into_element(self) -> Self::Element { - self - } -} - /// A globally unique identifier for an element, used to track state across frames. #[derive(Deref, DerefMut, Clone, Default, Debug, Eq, PartialEq, Hash)] pub struct GlobalElementId(pub(crate) Arc<[ElementId]>); @@ -442,7 +340,7 @@ impl Drawable { } let bounds = window.layout_bounds(layout_id); - let mut pushed_a11y_node = false; + let mut pushed_a11y_node = None; if window.a11y.is_active() { if let Some(global_id) = global_id.as_ref() { if let Some(role) = self.element.a11y_role() { @@ -456,8 +354,8 @@ impl Drawable { y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, }); self.element.write_a11y_info(&mut node); - pushed_a11y_node = window.a11y.nodes.push(node_id, node); - if pushed_a11y_node { + if window.a11y.nodes.push(node_id, node) { + pushed_a11y_node = Some(node_id); window.a11y.node_bounds.insert(node_id, bounds); } } @@ -465,7 +363,7 @@ impl Drawable { } let node_id = window.next_frame.dispatch_tree.push_node(); - let prepaint = self.element.prepaint( + let mut prepaint = self.element.prepaint( global_id.as_ref(), inspector_id.as_ref(), bounds, @@ -475,7 +373,12 @@ impl Drawable { ); window.next_frame.dispatch_tree.pop_node(); - if pushed_a11y_node { + if let Some(node_id) = pushed_a11y_node { + if window.a11y.nodes.has_current_node(node_id) { + let mut builder = A11ySubtreeBuilder::new(node_id, &mut window.a11y.nodes); + self.element + .a11y_synthetic_children(&mut prepaint, &mut builder); + } window.a11y.nodes.pop(); } diff --git a/crates/gpui/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs index d2b5be74..c31325ca 100644 --- a/crates/gpui/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -4,7 +4,8 @@ use std::{ }; use crate::{ - AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, Window, + AnyElement, App, Element, ElementId, GlobalElementId, InspectorElementId, IntoElement, + ParentElement, Window, }; pub use easing::*; @@ -131,6 +132,16 @@ pub struct AnimationElement { animator: Box E + 'static>, } +impl ParentElement for AnimationElement { + fn extend(&mut self, elements: impl IntoIterator) { + let Some(element) = &mut self.element else { + return; + }; + + element.extend(elements); + } +} + impl AnimationElement { /// Returns a new [`AnimationElement`] after applying the given function /// to the element being animated. @@ -346,3 +357,22 @@ mod easing { } } } + +#[cfg(test)] +mod tests { + use crate::{InteractiveElement, ParentElement, div}; + + use super::*; + + #[test] + fn animated_parent_accepts_children() { + div() + .id("animated-parent") + .with_animation( + "animation", + Animation::new(Duration::from_secs(1)), + |el, _| el, + ) + .child(div()); + } +} diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 52975e03..c1c13b7f 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -17,13 +17,13 @@ use crate::{ AbsoluteLength, Action, AnyDrag, AnyElement, AnyTooltip, AnyView, App, Bounds, ClickEvent, - DispatchPhase, Display, Element, ElementId, Entity, FocusHandle, Global, GlobalElementId, - Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, KeyContext, - KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, ModifiersChangedEvent, - MouseButton, MouseClickEvent, MouseDownEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, - Overflow, ParentElement, Pixels, Point, Render, ScrollWheelEvent, SharedString, Size, Style, - StyleRefinement, Styled, Task, TooltipId, Visibility, Window, WindowControlArea, point, px, - size, + DispatchPhase, Display, Element, ElementId, Entity, EntityId, FocusHandle, Global, + GlobalElementId, Hitbox, HitboxBehavior, HitboxId, InspectorElementId, IntoElement, IsZero, + KeyContext, KeyDownEvent, KeyUpEvent, KeyboardButton, KeyboardClickEvent, LayoutId, + ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, MouseExitEvent, + MouseMoveEvent, MousePressureEvent, MouseUpEvent, Overflow, ParentElement, Pixels, Point, + Render, ScrollWheelEvent, SharedString, Size, Style, StyleRefinement, Styled, Task, TooltipId, + Visibility, Window, WindowControlArea, point, px, size, }; use collections::HashMap; use refineable::Refineable; @@ -43,7 +43,7 @@ use std::{ use super::ImageCacheProvider; const DRAG_THRESHOLD: f64 = 2.; -const TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500); +const DEFAULT_TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500); const HOVERABLE_TOOLTIP_HIDE_DELAY: Duration = Duration::from_millis(500); /// The styling information for a given group. @@ -304,6 +304,22 @@ impl Interactivity { })); } + /// Bind the given callback to the mouse exit event, during the bubble phase. + /// The imperative API equivalent to [`InteractiveElement::on_mouse_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + pub fn on_mouse_exit( + &mut self, + listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static, + ) { + self.mouse_exit_listeners + .push(Box::new(move |event, phase, hitbox, window, cx| { + if phase == DispatchPhase::Bubble && hitbox.is_hovered(window) { + (listener)(event, window, cx); + } + })); + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -614,6 +630,12 @@ impl Interactivity { }); } + /// Set the delay before this element's tooltip is shown. + /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip_show_delay`]. + pub fn tooltip_show_delay(&mut self, delay: Duration) { + self.tooltip_show_delay = Some(delay); + } + /// Block the mouse from all interactions with elements behind this element's hitbox. Typically /// `block_mouse_except_scroll` should be preferred. /// @@ -877,6 +899,18 @@ pub trait InteractiveElement: Sized { self } + /// Bind the given callback to the mouse exit event, during the bubble phase. + /// The fluent API equivalent to [`Interactivity::on_mouse_exit`]. + /// + /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. + fn on_mouse_exit( + mut self, + listener: impl Fn(&MouseExitEvent, &mut Window, &mut App) + 'static, + ) -> Self { + self.interactivity().on_mouse_exit(listener); + self + } + /// Bind the given callback to the mouse drag event of the given type. Note that this /// will be called for all move events, inside or outside of this element, as long as the /// drag was started with this element under the mouse. Useful for implementing draggable @@ -1136,6 +1170,23 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Report this node as the active descendant when one of its ancestors is focused. + fn aria_active_descendant(mut self) -> Self { + self.interactivity() + .a11y_state_mut() + .report_active_descendant_focus = true; + self + } + + /// Contribute synthetic accessibility nodes after this element is prepainted. + fn a11y_synthetic_children( + mut self, + f: impl FnOnce(&mut crate::A11ySubtreeBuilder) + 'static, + ) -> Self { + self.interactivity().a11y_state_mut().synthetic_children = Some(Box::new(f)); + self + } + /// Set the selected state for this element. fn aria_selected(mut self, selected: bool) -> Self { self.interactivity().a11y_state_mut().aria_selected = Some(selected); @@ -1160,6 +1211,26 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Set the numeric step used by assistive-technology increment/decrement actions. + fn aria_numeric_value_step(mut self, step: f64) -> Self { + self.interactivity() + .a11y_state_mut() + .aria_numeric_value_step = Some(step); + self + } + + /// Set this element's string value. + fn aria_value(mut self, value: impl Into) -> Self { + self.interactivity().a11y_state_mut().aria_value = Some(value.into()); + self + } + + /// Set this element's placeholder string. + fn aria_placeholder(mut self, placeholder: impl Into) -> Self { + self.interactivity().a11y_state_mut().aria_placeholder = Some(placeholder.into()); + self + } + /// Set the minimum numeric value for this element. fn aria_min_numeric_value(mut self, value: f64) -> Self { self.interactivity().a11y_state_mut().aria_min_numeric_value = Some(value); @@ -1388,6 +1459,16 @@ pub trait StatefulInteractiveElement: InteractiveElement { self.interactivity().hoverable_tooltip(build_tooltip); self } + + /// Set the delay before this element's tooltip is shown. + /// The fluent API equivalent to [`Interactivity::tooltip_show_delay`]. + fn tooltip_show_delay(mut self, delay: Duration) -> Self + where + Self: Sized, + { + self.interactivity().tooltip_show_delay(delay); + self + } } pub(crate) type MouseDownListener = @@ -1398,6 +1479,8 @@ pub(crate) type MousePressureListener = Box; pub(crate) type MouseMoveListener = Box; +pub(crate) type MouseExitListener = + Box; pub(crate) type ScrollWheelListener = Box; @@ -1550,6 +1633,21 @@ impl Element for Div { self.interactivity.write_a11y_info(node); } + fn a11y_synthetic_children( + &mut self, + _prepaint: &mut Self::PrepaintState, + builder: &mut crate::A11ySubtreeBuilder, + ) { + if let Some(f) = self + .interactivity + .a11y_state + .as_mut() + .and_then(|state| state.synthetic_children.take()) + { + f(builder); + } + } + #[stacksafe] fn request_layout( &mut self, @@ -1768,6 +1866,7 @@ pub struct Interactivity { pub(crate) mouse_up_listeners: Vec, pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, + pub(crate) mouse_exit_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, @@ -1780,6 +1879,7 @@ pub struct Interactivity { pub(crate) drag_listener: Option<(Arc, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, + pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, pub(crate) hitbox_behavior: HitboxBehavior, pub(crate) tab_index: Option, @@ -1797,6 +1897,8 @@ pub struct Interactivity { #[derive(Default)] pub(crate) struct A11yState { action_listeners: Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>, + synthetic_children: Option>, + report_active_descendant_focus: bool, override_role: Option, aria_label: Option, aria_selected: Option, @@ -1805,6 +1907,9 @@ pub(crate) struct A11yState { aria_numeric_value: Option, aria_min_numeric_value: Option, aria_max_numeric_value: Option, + aria_numeric_value_step: Option, + aria_value: Option, + aria_placeholder: Option, aria_orientation: Option, aria_level: Option, aria_position_in_set: Option, @@ -1997,11 +2102,18 @@ impl Interactivity { window.a11y.node_bounds.insert(node_id, translated_bounds); } if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { - window.a11y.focus_ids.insert(node_id, focus_handle.id); + window.a11y.set_focusable(node_id, focus_handle.id); if focus_handle.is_focused(window) { - window.a11y.nodes.set_focus(node_id); + window.a11y.set_focus(node_id); } } + if self + .a11y_state + .as_deref() + .is_some_and(|state| state.report_active_descendant_focus) + { + window.a11y.set_active_descendant(node_id); + } } } @@ -2046,6 +2158,7 @@ impl Interactivity { || !self.mouse_pressure_listeners.is_empty() || !self.mouse_down_listeners.is_empty() || !self.mouse_move_listeners.is_empty() + || !self.mouse_exit_listeners.is_empty() || !self.click_listeners.is_empty() || !self.aux_click_listeners.is_empty() || !self.scroll_wheel_listeners.is_empty() @@ -2461,6 +2574,13 @@ impl Interactivity { }) } + for listener in self.mouse_exit_listeners.drain(..) { + let hitbox = hitbox.clone(); + window.on_mouse_event(move |event: &MouseExitEvent, phase, window, cx| { + listener(event, phase, &hitbox, window, cx); + }) + } + for listener in self.scroll_wheel_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &ScrollWheelEvent, phase, window, cx| { @@ -2593,6 +2713,11 @@ impl Interactivity { .get_or_insert_with(Default::default) .clone(); + let pending_keyboard_down = element_state + .pending_keyboard_down + .get_or_insert_with(Default::default) + .clone(); + let clicked_state = element_state .clicked_state .get_or_insert_with(Default::default) @@ -2646,6 +2771,20 @@ impl Interactivity { }); if is_focused { + window.on_key_event({ + let pending_keyboard_down = pending_keyboard_down.clone(); + move |event: &KeyDownEvent, phase, window, _cx| { + if phase.bubble() && !window.default_prevented() { + let stroke = &event.keystroke; + let is_activation_key = (stroke.key.eq("enter") + || stroke.key.eq("space")) + && !stroke.modifiers.modified(); + *pending_keyboard_down.borrow_mut() = + is_activation_key.then_some(window.focus_generation); + } + } + }); + // Press enter, space to trigger click, when the element is focused. window.on_key_event({ let click_listeners = click_listeners.clone(); @@ -2664,6 +2803,12 @@ impl Interactivity { if let Some(button) = keyboard_button && !stroke.modifiers.modified() { + let pending = + std::mem::take(&mut *pending_keyboard_down.borrow_mut()); + if pending != Some(window.focus_generation) { + return; + } + let click_event = ClickEvent::Keyboard(KeyboardClickEvent { button, bounds: hitbox.bounds, @@ -2672,6 +2817,8 @@ impl Interactivity { for listener in &click_listeners { listener(&click_event, window, cx); } + } else { + *pending_keyboard_down.borrow_mut() = None; } } } @@ -2729,7 +2876,6 @@ impl Interactivity { } if let Some(hover_listener) = self.hover_listener.take() { - let hitbox = hitbox.clone(); let was_hovered = element_state .hover_listener_state .get_or_insert_with(Default::default) @@ -2738,22 +2884,33 @@ impl Interactivity { .pending_mouse_down .get_or_insert_with(Default::default) .clone(); - - window.on_mouse_event(move |_: &MouseMoveEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble { - return; - } - let is_hovered = has_mouse_down.borrow().is_none() - && !cx.has_active_drag() - && hitbox.is_hovered(window); + let hover_listener = Rc::new(hover_listener); + let update_hover = move |is_hovered: bool, window: &mut Window, cx: &mut App| { let mut was_hovered = was_hovered.borrow_mut(); - if is_hovered != *was_hovered { *was_hovered = is_hovered; drop(was_hovered); - hover_listener(&is_hovered, window, cx); } + }; + + window.on_mouse_event({ + let update_hover = update_hover.clone(); + let hitbox = hitbox.clone(); + move |_: &MouseMoveEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + let is_hovered = has_mouse_down.borrow().is_none() + && !cx.has_active_drag() + && hitbox.is_hovered(window); + update_hover(is_hovered, window, cx); + } + } + }); + + window.on_mouse_event(move |_: &MouseExitEvent, phase, window, cx| { + if phase == DispatchPhase::Bubble { + update_hover(false, window, cx); + } }); } @@ -2792,6 +2949,7 @@ impl Interactivity { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + self.tooltip_show_delay, window, ); } @@ -3109,6 +3267,15 @@ impl A11yState { if let Some(value) = self.aria_max_numeric_value { node.set_max_numeric_value(value); } + if let Some(step) = self.aria_numeric_value_step { + node.set_numeric_value_step(step); + } + if let Some(value) = &self.aria_value { + node.set_value(value.to_string()); + } + if let Some(placeholder) = &self.aria_placeholder { + node.set_placeholder(placeholder.to_string()); + } if let Some(orientation) = self.aria_orientation { node.set_orientation(orientation); } @@ -3148,6 +3315,7 @@ pub struct InteractiveElementState { pub(crate) hover_state: Option>>, pub(crate) hover_listener_state: Option>>, pub(crate) pending_mouse_down: Option>>>, + pub(crate) pending_keyboard_down: Option>>>, pub(crate) scroll_offset: Option>>>, pub(crate) active_tooltip: Option>>>, } @@ -3241,8 +3409,12 @@ pub(crate) fn register_tooltip_mouse_handlers( build_tooltip: Rc Option<(AnyView, bool)>>, check_is_hovered: Rc bool>, check_is_hovered_during_prepaint: Rc bool>, + show_delay: Option, window: &mut Window, ) { + let current_view = window.current_view(); + let show_delay = show_delay.unwrap_or(DEFAULT_TOOLTIP_SHOW_DELAY); + window.on_mouse_event({ let active_tooltip = active_tooltip.clone(); let build_tooltip = build_tooltip.clone(); @@ -3253,7 +3425,10 @@ pub(crate) fn register_tooltip_mouse_handlers( &build_tooltip, &check_is_hovered, &check_is_hovered_during_prepaint, + tooltip_id, + current_view, phase, + show_delay, window, cx, ) @@ -3295,7 +3470,10 @@ fn handle_tooltip_mouse_move( build_tooltip: &Rc Option<(AnyView, bool)>>, check_is_hovered: &Rc bool>, check_is_hovered_during_prepaint: &Rc bool>, + tooltip_id: Option, + current_view: EntityId, phase: DispatchPhase, + show_delay: Duration, window: &mut Window, cx: &mut App, ) { @@ -3305,6 +3483,7 @@ fn handle_tooltip_mouse_move( None, CancelShow, ScheduleShow, + CheckVisible, } let action = match active_tooltip.borrow().as_ref() { @@ -3324,9 +3503,26 @@ fn handle_tooltip_mouse_move( Action::CancelShow } } - // These are handled in check_visible_and_update. - Some(ActiveTooltip::Visible { .. }) | Some(ActiveTooltip::WaitingForHide { .. }) => { - Action::None + Some(ActiveTooltip::Visible { is_hoverable, .. }) => { + if phase.capture() + && !check_is_hovered(window) + && (!*is_hoverable + || !tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window))) + { + Action::CheckVisible + } else { + Action::None + } + } + Some(ActiveTooltip::WaitingForHide { .. }) => { + if phase.capture() + && (check_is_hovered(window) + || tooltip_id.is_some_and(|tooltip_id| tooltip_id.is_hovered(window))) + { + Action::CheckVisible + } else { + Action::None + } } }; @@ -3338,21 +3534,29 @@ fn handle_tooltip_mouse_move( } Action::ScheduleShow => { let delayed_show_task = window.spawn(cx, { - let active_tooltip = active_tooltip.clone(); + let weak_active_tooltip = Rc::downgrade(active_tooltip); let build_tooltip = build_tooltip.clone(); let check_is_hovered_during_prepaint = check_is_hovered_during_prepaint.clone(); async move |cx| { - cx.background_executor().timer(TOOLTIP_SHOW_DELAY).await; + cx.background_executor().timer(show_delay).await; + let Some(active_tooltip) = weak_active_tooltip.upgrade() else { + return; + }; cx.update(|window, cx| { let new_tooltip = build_tooltip(window, cx).map(|(view, tooltip_is_hoverable)| { - let active_tooltip = active_tooltip.clone(); + let weak_active_tooltip = Rc::downgrade(&active_tooltip); ActiveTooltip::Visible { tooltip: AnyTooltip { view, mouse_position: window.mouse_position(), check_visible_and_update: Rc::new( move |tooltip_bounds, window, cx| { + let Some(active_tooltip) = + weak_active_tooltip.upgrade() + else { + return false; + }; handle_tooltip_check_visible_and_update( &active_tooltip, tooltip_is_hoverable, @@ -3379,6 +3583,7 @@ fn handle_tooltip_mouse_move( _task: delayed_show_task, }); } + Action::CheckVisible => cx.notify(current_view), } } @@ -3431,11 +3636,14 @@ fn handle_tooltip_check_visible_and_update( Action::Hide => clear_active_tooltip(active_tooltip, window), Action::ScheduleHide(tooltip) => { let delayed_hide_task = window.spawn(cx, { - let active_tooltip = active_tooltip.clone(); + let weak_active_tooltip = Rc::downgrade(active_tooltip); async move |cx| { cx.background_executor() .timer(HOVERABLE_TOOLTIP_HIDE_DELAY) .await; + let Some(active_tooltip) = weak_active_tooltip.upgrade() else { + return; + }; if active_tooltip.borrow_mut().take().is_some() { cx.update(|window, _cx| window.refresh()).ok(); } @@ -3540,6 +3748,14 @@ where self.element.write_a11y_info(node); } + fn a11y_synthetic_children( + &mut self, + prepaint: &mut Self::PrepaintState, + builder: &mut crate::A11ySubtreeBuilder, + ) { + self.element.a11y_synthetic_children(prepaint, builder); + } + fn request_layout( &mut self, id: Option<&GlobalElementId>, @@ -3857,13 +4073,287 @@ impl ScrollHandle { mod tests { use super::*; use crate::{ - AnyView, AppContext, Context, DrawPhase, Drawable, Render, StyleRefinement, TestAppContext, - deferred, text, + AnyView, AnyWindowHandle, AppContext, Context, DrawPhase, Drawable, Keystroke, Render, + StyleRefinement, TestAppContext, deferred, interactive::InputEvent, text, + util::FluentBuilder as _, }; use std::cell::Cell; - use std::rc::Rc; + use std::rc::{Rc, Weak}; use std::sync::Arc; + struct TestTooltipView; + + impl Render for TestTooltipView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().w(px(20.)).h(px(20.)).child("tooltip") + } + } + + type CapturedActiveTooltip = Rc>>>>>; + + struct TooltipCaptureElement { + child: AnyElement, + captured_active_tooltip: CapturedActiveTooltip, + } + + impl IntoElement for TooltipCaptureElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl Element for TooltipCaptureElement { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (self.child.request_layout(window, cx), ()) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + self.child.prepaint(window, cx); + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + _prepaint: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + self.child.paint(window, cx); + window.with_global_id("target".into(), |global_id, window| { + window.with_element_state::( + global_id, + |state, _window| { + let state = state.unwrap(); + *self.captured_active_tooltip.borrow_mut() = + state.active_tooltip.as_ref().map(Rc::downgrade); + ((), state) + }, + ) + }); + } + } + + struct TooltipOwner { + captured_active_tooltip: CapturedActiveTooltip, + show_delay_override: Option, + } + + impl Render for TooltipOwner { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + TooltipCaptureElement { + child: div() + .size_full() + .child( + div() + .id("target") + .w(px(50.)) + .h(px(50.)) + .tooltip(|_, cx| cx.new(|_| TestTooltipView).into()) + .when_some(self.show_delay_override, |this, delay| { + this.tooltip_show_delay(delay) + }), + ) + .into_any_element(), + captured_active_tooltip: self.captured_active_tooltip.clone(), + } + } + } + + fn setup_tooltip_owner_test( + show_delay_override: Option, + ) -> ( + TestAppContext, + crate::AnyWindowHandle, + CapturedActiveTooltip, + ) { + let mut test_app = TestAppContext::single(); + let captured_active_tooltip: CapturedActiveTooltip = Rc::new(RefCell::new(None)); + let window = test_app.add_window({ + let captured_active_tooltip = captured_active_tooltip.clone(); + move |_, _| TooltipOwner { + captured_active_tooltip, + show_delay_override, + } + }); + let any_window = window.into(); + + test_app + .update_window(any_window, |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + MouseMoveEvent { + position: point(px(10.), px(10.)), + modifiers: Default::default(), + pressed_button: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + test_app + .update_window(any_window, |_, window, cx| { + window.draw(cx).clear(); + }) + .unwrap(); + + (test_app, any_window, captured_active_tooltip) + } + + #[test] + fn tooltip_waiting_for_show_is_released_when_its_owner_disappears() { + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(None); + + let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); + let active_tooltip = weak_active_tooltip.upgrade().unwrap(); + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::WaitingForShow { .. }) + )); + + test_app + .update_window(any_window, |_, window, _| { + window.remove_window(); + }) + .unwrap(); + test_app.run_until_parked(); + drop(active_tooltip); + + assert!(weak_active_tooltip.upgrade().is_none()); + } + + #[test] + fn tooltip_respects_custom_show_delay() { + let extra_delay = Duration::from_secs(1); + let show_delay_override = DEFAULT_TOOLTIP_SHOW_DELAY + extra_delay; + let (mut test_app, _any_window, captured_active_tooltip) = + setup_tooltip_owner_test(Some(show_delay_override)); + + let active_tooltip = captured_active_tooltip + .borrow() + .clone() + .unwrap() + .upgrade() + .unwrap(); + + test_app + .dispatcher + .advance_clock(DEFAULT_TOOLTIP_SHOW_DELAY); + test_app.run_until_parked(); + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::WaitingForShow { .. }) + )); + + test_app.dispatcher.advance_clock(extra_delay); + test_app.run_until_parked(); + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + } + + #[test] + fn tooltip_is_released_when_its_owner_disappears() { + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(None); + + let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); + let active_tooltip = weak_active_tooltip.upgrade().unwrap(); + + test_app + .dispatcher + .advance_clock(DEFAULT_TOOLTIP_SHOW_DELAY); + test_app.run_until_parked(); + + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + + test_app + .update_window(any_window, |_, window, _| { + window.remove_window(); + }) + .unwrap(); + test_app.run_until_parked(); + drop(active_tooltip); + + assert!(weak_active_tooltip.upgrade().is_none()); + } + + #[test] + fn tooltip_hides_after_mouse_leaves_origin() { + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(None); + + let active_tooltip = captured_active_tooltip + .borrow() + .clone() + .unwrap() + .upgrade() + .unwrap(); + + test_app + .dispatcher + .advance_clock(DEFAULT_TOOLTIP_SHOW_DELAY); + test_app.run_until_parked(); + assert!(matches!( + active_tooltip.borrow().as_ref(), + Some(ActiveTooltip::Visible { .. }) + )); + + test_app + .update_window(any_window, |_, window, cx| { + window.dispatch_event( + MouseMoveEvent { + position: point(px(75.), px(75.)), + modifiers: Default::default(), + pressed_button: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + + assert!(active_tooltip.borrow().is_none()); + } + fn draw_accessible( cx: &mut crate::VisualTestContext, origin: Point, @@ -4207,7 +4697,6 @@ mod tests { let focus_handle = cx.update(|_, cx| cx.focus_handle()); let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { - let focus_handle = focus_handle.clone(); move |_, _| { div().invisible().child( div() @@ -4291,6 +4780,42 @@ mod tests { assert_eq!(mouse_position.get(), None); } + #[gpui::test] + fn explicit_a11y_click_listener_takes_precedence_over_on_click(cx: &mut TestAppContext) { + let ordinary_clicks = Rc::new(Cell::new(0)); + let explicit_clicks = Rc::new(Cell::new(0)); + let cx = cx.add_empty_window(); + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let ordinary_clicks = ordinary_clicks.clone(); + let explicit_clicks = explicit_clicks.clone(); + move |_, _| { + div() + .id("explicit-click") + .role(accesskit::Role::Button) + .on_click(move |_, _, _| ordinary_clicks.set(ordinary_clicks.get() + 1)) + .on_a11y_action(accesskit::Action::Click, move |_, _, _| { + explicit_clicks.set(explicit_clicks.get() + 1) + }) + } + }); + let (node_id, _) = node_with_role(&update, accesskit::Role::Button).unwrap(); + + cx.update(|window, cx| { + window.handle_a11y_action( + accesskit::ActionRequest { + action: accesskit::Action::Click, + target_tree: accesskit::TreeId::ROOT, + target_node: node_id, + data: None, + }, + cx, + ); + }); + + assert_eq!(explicit_clicks.get(), 1); + assert_eq!(ordinary_clicks.get(), 0); + } + #[gpui::test] fn a11y_window_transact_rolls_back_prepaint_state(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); @@ -4353,6 +4878,276 @@ mod tests { assert!(!focus_ids.contains_key(&rejected_id)); } + #[test] + fn write_a11y_info_maps_string_and_numeric_properties() { + let state = A11yState { + aria_label: Some("Buffer Font Size".into()), + aria_value: Some("15".into()), + aria_placeholder: Some("Search".into()), + aria_numeric_value: Some(15.0), + aria_min_numeric_value: Some(6.0), + aria_max_numeric_value: Some(72.0), + aria_numeric_value_step: Some(1.0), + ..Default::default() + }; + let mut node = accesskit::Node::new(accesskit::Role::SpinButton); + + state.write_a11y_info(&mut node); + + assert_eq!(node.label(), Some("Buffer Font Size")); + assert_eq!(node.value(), Some("15")); + assert_eq!(node.placeholder(), Some("Search")); + assert_eq!(node.numeric_value(), Some(15.0)); + assert_eq!(node.min_numeric_value(), Some(6.0)); + assert_eq!(node.max_numeric_value(), Some(72.0)); + assert_eq!(node.numeric_value_step(), Some(1.0)); + } + + #[gpui::test] + fn synthetic_children_are_emitted_and_can_mutate_parent(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let update = draw_accessible( + cx, + point(px(0.), px(0.)), + size(px(100.), px(100.)), + |_, _| { + div() + .id("editor") + .role(accesskit::Role::TextInput) + .a11y_synthetic_children(|builder| { + let child_id = builder.synthetic_node_id("run"); + let mut child = accesskit::Node::new(accesskit::Role::TextRun); + child.set_value("hello"); + assert!(builder.push_child(child_id, child)); + builder.parent_node().set_label("Editor"); + }) + }, + ); + let (_, parent) = node_with_role(&update, accesskit::Role::TextInput).unwrap(); + assert_eq!(parent.label(), Some("Editor")); + assert_eq!(parent.children().len(), 1); + assert_eq!( + update + .nodes + .iter() + .find(|(id, _)| *id == parent.children()[0]) + .unwrap() + .1 + .value(), + Some("hello") + ); + } + + #[gpui::test] + fn synthetic_callback_is_not_run_for_suppressed_parent(cx: &mut TestAppContext) { + let called = Rc::new(Cell::new(false)); + let cx = cx.add_empty_window(); + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let called = called.clone(); + move |_, _| { + div() + .id("hidden-editor") + .role(accesskit::Role::TextInput) + .invisible() + .a11y_synthetic_children(move |_| called.set(true)) + } + }); + + assert!(!called.get()); + assert!(node_with_role(&update, accesskit::Role::TextInput).is_none()); + } + + #[gpui::test] + fn aria_active_descendant_reports_focused_child(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let focus = cx.update(|_, cx| cx.focus_handle()); + cx.update(|window, cx| window.focus(&focus, cx)); + let update = draw_accessible(cx, point(px(0.), px(0.)), size(px(100.), px(100.)), { + let focus = focus.clone(); + move |_, _| { + div() + .id("list") + .role(accesskit::Role::ListBox) + .track_focus(&focus) + .child( + div() + .id("active-item") + .role(accesskit::Role::ListBoxOption) + .aria_active_descendant(), + ) + } + }); + + let (active_id, _) = node_with_role(&update, accesskit::Role::ListBoxOption).unwrap(); + assert_eq!(update.focus, active_id); + } + + #[gpui::test] + fn window_reports_public_a11y_activity(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + cx.update(|window, _| { + assert!(!window.is_a11y_active()); + window.a11y.set_active_for_test(true); + assert!(window.is_a11y_active()); + window.a11y.set_active_for_test(false); + }); + } + + struct KeyboardActivationTest { + focus_a: FocusHandle, + focus_b: FocusHandle, + clicks: Rc>>, + } + + impl Render for KeyboardActivationTest { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let clicks_a = self.clicks.clone(); + let clicks_b = self.clicks.clone(); + div() + .size_full() + .child( + div() + .id("a") + .w(px(50.)) + .h(px(50.)) + .track_focus(&self.focus_a) + .on_click(move |_, _, _| clicks_a.borrow_mut().push("a")), + ) + .child( + div() + .id("b") + .w(px(50.)) + .h(px(50.)) + .track_focus(&self.focus_b) + .on_click(move |_, _, _| clicks_b.borrow_mut().push("b")), + ) + } + } + + fn setup_keyboard_activation_test() -> ( + TestAppContext, + AnyWindowHandle, + Rc>>, + FocusHandle, + FocusHandle, + ) { + let mut cx = TestAppContext::single(); + let (focus_a, focus_b) = cx.update(|cx| (cx.focus_handle(), cx.focus_handle())); + let clicks = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let focus_a = focus_a.clone(); + let focus_b = focus_b.clone(); + let clicks = clicks.clone(); + move |_, _| KeyboardActivationTest { + focus_a, + focus_b, + clicks, + } + }); + (cx, window.into(), clicks, focus_a, focus_b) + } + + fn focus_and_draw(cx: &mut TestAppContext, window: AnyWindowHandle, handle: &FocusHandle) { + cx.update_window(window, |_, window, cx| window.focus(handle, cx)) + .unwrap(); + cx.run_until_parked(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear()) + .unwrap(); + } + + fn key_down(cx: &mut TestAppContext, window: AnyWindowHandle, key: &str) { + let keystroke = Keystroke::parse(key).unwrap(); + cx.update_window(window, |_, window, cx| { + window.dispatch_event( + KeyDownEvent { + keystroke, + is_held: false, + prefer_character_input: false, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + } + + fn key_up(cx: &mut TestAppContext, window: AnyWindowHandle, key: &str) { + let keystroke = Keystroke::parse(key).unwrap(); + cx.update_window(window, |_, window, cx| { + window.dispatch_event(KeyUpEvent { keystroke }.to_platform_input(), cx); + }) + .unwrap(); + } + + #[test] + fn keyboard_activation_fires_click_on_same_element() { + let (mut cx, window, clicks, focus_a, _) = setup_keyboard_activation_test(); + focus_and_draw(&mut cx, window, &focus_a); + key_down(&mut cx, window, "enter"); + key_up(&mut cx, window, "enter"); + assert_eq!(*clicks.borrow(), vec!["a"]); + } + + #[test] + fn keyboard_activation_does_not_leak_across_focus_change() { + let (mut cx, window, clicks, focus_a, focus_b) = setup_keyboard_activation_test(); + focus_and_draw(&mut cx, window, &focus_a); + key_down(&mut cx, window, "enter"); + focus_and_draw(&mut cx, window, &focus_b); + key_up(&mut cx, window, "enter"); + assert!(clicks.borrow().is_empty()); + } + + #[test] + fn keyboard_activation_does_not_leak_when_focus_returns() { + let (mut cx, window, clicks, focus_a, focus_b) = setup_keyboard_activation_test(); + focus_and_draw(&mut cx, window, &focus_a); + key_down(&mut cx, window, "enter"); + focus_and_draw(&mut cx, window, &focus_b); + focus_and_draw(&mut cx, window, &focus_a); + key_up(&mut cx, window, "enter"); + assert!(clicks.borrow().is_empty()); + } + + #[test] + fn keyboard_activation_cleared_by_intervening_key_release() { + let (mut cx, window, clicks, focus_a, _) = setup_keyboard_activation_test(); + focus_and_draw(&mut cx, window, &focus_a); + key_down(&mut cx, window, "escape"); + key_down(&mut cx, window, "space"); + key_up(&mut cx, window, "escape"); + key_up(&mut cx, window, "space"); + assert!(clicks.borrow().is_empty()); + } + + #[test] + fn keyboard_activation_pairs_space_down_with_enter_up() { + let (mut cx, window, clicks, focus_a, _) = setup_keyboard_activation_test(); + focus_and_draw(&mut cx, window, &focus_a); + key_down(&mut cx, window, "space"); + key_up(&mut cx, window, "enter"); + assert_eq!(*clicks.borrow(), vec!["a"]); + } + + #[test] + fn keyboard_activation_cleared_by_intervening_keydown() { + let (mut cx, window, clicks, focus_a, _) = setup_keyboard_activation_test(); + focus_and_draw(&mut cx, window, &focus_a); + key_down(&mut cx, window, "enter"); + key_down(&mut cx, window, "a"); + key_up(&mut cx, window, "enter"); + assert!(clicks.borrow().is_empty()); + } + + #[test] + fn keyboard_activation_ignores_modified_keys() { + let (mut cx, window, clicks, focus_a, _) = setup_keyboard_activation_test(); + focus_and_draw(&mut cx, window, &focus_a); + key_down(&mut cx, window, "cmd-enter"); + key_up(&mut cx, window, "cmd-enter"); + assert!(clicks.borrow().is_empty()); + } + #[cfg(not(debug_assertions))] #[gpui::test] fn a11y_duplicate_id_does_not_override_focus_fallback(cx: &mut TestAppContext) { diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 586ad30b..2fdb8ca0 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -340,6 +340,17 @@ impl ListState { self } + /// Pre-populate every unmeasured item with a uniform height hint so the scrollbar thumb + /// is correctly sized from the first frame, without measuring all items up front. + /// + /// As items are actually rendered their real heights replace the hint, so the scrollbar + /// converges to the exact size over time. This is a cheaper alternative to [`Self::measure_all`] + /// for lists where items have roughly uniform heights (e.g. table rows). + pub fn with_uniform_item_height(self, height: Pixels) -> Self { + self.apply_uniform_item_height(height); + self + } + /// Reset this instantiation of the list state. /// /// Note that this will cause scroll events to be dropped until the next paint. @@ -350,6 +361,7 @@ impl ListState { state.measuring_behavior.reset(); state.logical_scroll_top = None; state.last_layout_scroll_top = None; + state.pending_scroll = None; state.scrollbar_drag_start_height = None; state.items.summary().count }; @@ -357,6 +369,33 @@ impl ListState { self.splice(0..old_count, element_count); } + /// Reset the list to `element_count` items, pre-populating every item with a + /// uniform height hint so the scrollbar thumb is correctly sized from the first + /// frame even for off-screen items. + pub fn reset_with_uniform_height(&self, element_count: usize, height: Pixels) { + self.reset(element_count); + self.apply_uniform_item_height(height); + } + + fn apply_uniform_item_height(&self, height: Pixels) { + let size_hint = Size { + width: px(0.), + height, + }; + let mut state = self.0.borrow_mut(); + let new_items = state + .items + .iter() + .map(|item| ListItem::Unmeasured { + size_hint: Some(item.size_hint().unwrap_or(size_hint)), + focus_handle: item.focus_handle(), + }) + .collect::>(); + let mut tree = SumTree::default(); + tree.extend(new_items, ()); + state.items = tree; + } + /// Remeasure all items while preserving proportional scroll position. /// /// Use this when item heights may have changed (e.g., font size changes) @@ -558,10 +597,13 @@ impl ListState { cursor.seek(&Height(new_pixel_offset), Bias::Right); } - state.logical_scroll_top = Some(ListOffset { + let scroll_top = ListOffset { item_ix: cursor.start().count, offset_in_item: new_pixel_offset - cursor.start().height, - }); + }; + drop(cursor); + state.rebase_pending_scroll(scroll_top); + state.logical_scroll_top = Some(scroll_top); state.last_layout_scroll_top = None; } @@ -569,6 +611,7 @@ impl ListState { pub fn scroll_to_end(&self) { let state = &mut *self.0.borrow_mut(); let item_count = state.items.summary().count; + state.pending_scroll = None; state.logical_scroll_top = match state.alignment { ListAlignment::Top => Some(ListOffset { item_ix: item_count, @@ -588,6 +631,7 @@ impl ListState { FollowMode::Tail => { state.follow_state = FollowState::Tail { is_following: true }; let item_count = state.items.summary().count; + state.pending_scroll = None; state.logical_scroll_top = match state.alignment { ListAlignment::Top => Some(ListOffset { item_ix: item_count, @@ -618,6 +662,7 @@ impl ListState { state.follow_state.stop_following(); } + state.rebase_pending_scroll(scroll_top); state.logical_scroll_top = Some(scroll_top); state.last_layout_scroll_top = None; } @@ -652,6 +697,7 @@ impl ListState { } } + state.rebase_pending_scroll(scroll_top); state.logical_scroll_top = Some(scroll_top); state.last_layout_scroll_top = None; } @@ -733,12 +779,14 @@ impl ListState { /// Returns whether the item is entirely above the viewport, or `None` if /// the list has not measured enough layout to know. + /// + /// A zero-height viewport still yields a definitive answer: callers may + /// size sibling UI based on this query (potentially squeezing the list + /// itself to zero height), so returning `None` in that case would make + /// the answer oscillate from frame to frame. pub fn item_is_above_viewport(&self, ix: usize) -> Option { let state = self.0.borrow(); - let viewport_bounds = state.last_layout_bounds.unwrap_or_default(); - if viewport_bounds.size.height == px(0.0) { - return None; - } + let viewport_bounds = state.last_layout_bounds?; let scroll_top = state.last_layout_scroll_top?; if scroll_top.item_ix < state.items.summary().count && ix < scroll_top.item_ix { @@ -753,12 +801,12 @@ impl ListState { /// Returns whether the item is entirely below the viewport, or `None` if /// the list has not measured enough layout to know. + /// + /// See [`Self::item_is_above_viewport`] for why a zero-height viewport + /// still yields a definitive answer. pub fn item_is_below_viewport(&self, ix: usize) -> Option { let state = self.0.borrow(); - let viewport_bounds = state.last_layout_bounds.unwrap_or_default(); - if viewport_bounds.size.height == px(0.0) { - return None; - } + let viewport_bounds = state.last_layout_bounds?; let scroll_top = state.last_layout_scroll_top?; if scroll_top.item_ix < state.items.summary().count && ix < scroll_top.item_ix { @@ -773,6 +821,39 @@ impl ListState { } impl StateInner { + /// Re-anchor a pending scroll adjustment from a remeasure onto a newly set + /// scroll position, so it clamps to the remeasured item's new height on + /// the next layout instead of reverting the scroll. + fn rebase_pending_scroll(&mut self, scroll_top: ListOffset) { + let Some(pending) = self.pending_scroll.take() else { + return; + }; + if scroll_top.item_ix >= self.items.summary().count { + return; + } + + self.pending_scroll = match pending { + PendingScroll::Absolute { .. } => Some(PendingScroll::Absolute { + item_ix: scroll_top.item_ix, + offset: scroll_top.offset_in_item, + }), + PendingScroll::Proportional(_) => { + let mut cursor = self.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + cursor + .item() + .and_then(|item| item.size_hint()) + .filter(|size| size.height.0 > 0.0) + .map(|size| { + PendingScroll::Proportional(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction: (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0), + }) + }) + } + }; + } + fn bounds_for_item_at_scroll_top( &self, ix: usize, @@ -846,17 +927,18 @@ impl StateInner { .min(scroll_max); if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.pending_scroll = None; self.logical_scroll_top = None; } else { let (start, ..) = self.items .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = start.count; - let offset_in_item = new_scroll_top - start.height; - self.logical_scroll_top = Some(ListOffset { - item_ix, - offset_in_item, - }); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + self.rebase_pending_scroll(scroll_top); + self.logical_scroll_top = Some(scroll_top); } self.last_layout_scroll_top = None; @@ -1210,9 +1292,38 @@ impl StateInner { && autoscroll { if autoscroll_bounds.top() < bounds.top() { + let mut item_ix = item.index; + let mut offset_in_item = autoscroll_bounds.top() - item_origin.y; + + // The requested top can sit above this item's own + // top. Walk into earlier items so the offset stays + // non-negative and no blank space appears above the + // list. + if offset_in_item < Pixels::ZERO { + let mut cursor = self.items.cursor::(()); + cursor.seek(&Count(item_ix), Bias::Right); + while offset_in_item < Pixels::ZERO { + cursor.prev(); + let Some(prev_item) = cursor.item() else { + offset_in_item = Pixels::ZERO; + break; + }; + let size = prev_item.size().unwrap_or_else(|| { + let mut element = render_item(cursor.start().0, window, cx); + let item_available_size = size( + bounds.size.width.into(), + AvailableSpace::MinContent, + ); + element.layout_as_root(item_available_size, window, cx) + }); + item_ix = cursor.start().0; + offset_in_item += size.height; + } + } + return Err(ListOffset { - item_ix: item.index, - offset_in_item: autoscroll_bounds.top() - item_origin.y, + item_ix, + offset_in_item, }); } else if autoscroll_bounds.bottom() > bounds.bottom() { let mut cursor = self.items.cursor::(()); @@ -1280,6 +1391,7 @@ impl StateInner { scroll_max > px(0.) && new_scroll_top >= (scroll_max - px(1.0)).max(px(0.)); if dragged_to_end && matches!(self.follow_state, FollowState::Tail { .. }) { self.follow_state = FollowState::Tail { is_following: true }; + self.pending_scroll = None; if self.alignment == ListAlignment::Bottom { self.logical_scroll_top = None; } else { @@ -1295,18 +1407,19 @@ impl StateInner { self.follow_state.stop_following(); if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { + self.pending_scroll = None; self.logical_scroll_top = None; } else { let (start, _, _) = self.items .find::((), &Height(new_scroll_top), Bias::Right); - let item_ix = start.count; - let offset_in_item = new_scroll_top - start.height; - self.logical_scroll_top = Some(ListOffset { - item_ix, - offset_in_item, - }); + let scroll_top = ListOffset { + item_ix: start.count, + offset_in_item: new_scroll_top - start.height, + }; + self.rebase_pending_scroll(scroll_top); + self.logical_scroll_top = Some(scroll_top); } self.last_layout_scroll_top = None; } @@ -1613,10 +1726,71 @@ mod test { use std::rc::Rc; use crate::{ - self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render, - Styled, TestAppContext, Window, div, list, point, px, size, + self as gpui, AppContext, Bounds, Context, Element, FollowMode, IntoElement, ListState, + Render, Styled, TestAppContext, Window, canvas, div, list, point, px, size, }; + #[test] + fn uniform_item_height_estimates_full_scroll_extent() { + let state = + ListState::new(4, crate::ListAlignment::Top, px(0.)).with_uniform_item_height(px(25.)); + + assert_eq!(state.max_offset_for_scrollbar().y, px(100.)); + + state.reset_with_uniform_height(3, px(10.)); + assert_eq!(state.max_offset_for_scrollbar().y, px(30.)); + } + + #[gpui::test] + fn test_autoscroll_above_item_top_renders_items_above(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)); + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |ix, _, _| { + if ix == 2 { + canvas( + |bounds, window, _| { + window.request_autoscroll(Bounds::from_corners( + point(bounds.left(), bounds.top() - px(30.)), + point(bounds.right(), bounds.top() + px(5.)), + )); + }, + |_, _, _, _| {}, + ) + .h(px(20.)) + .w_full() + .into_any() + } else { + div().h(px(20.)).w_full().into_any() + } + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(60.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + let scroll_top = state.logical_scroll_top(); + assert!( + scroll_top.offset_in_item >= px(0.), + "offset_in_item must never be negative (would leave blank space above), got {:?}", + scroll_top.offset_in_item, + ); + assert_eq!(scroll_top.item_ix, 0); + assert_eq!(scroll_top.offset_in_item, px(10.)); + } + #[gpui::test] fn test_reset_after_paint_before_scroll(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); @@ -1818,6 +1992,33 @@ mod test { assert_eq!(state.item_is_below_viewport(3), Some(true)); } + #[gpui::test] + fn test_item_viewport_queries_remain_stable_with_zero_height_viewport(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + state.scroll_to(gpui::ListOffset { + item_ix: 2, + offset_in_item: px(0.), + }); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(0.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + assert_eq!(state.item_is_above_viewport(1), Some(true)); + assert_eq!(state.item_is_below_viewport(1), Some(false)); + assert_eq!(state.item_is_above_viewport(3), Some(false)); + assert_eq!(state.item_is_below_viewport(3), Some(true)); + } + #[gpui::test] fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); @@ -2103,6 +2304,119 @@ mod test { assert_eq!(offset.offset_in_item, px(20.)); } + #[gpui::test] + fn test_remeasure_then_scroll_does_not_revert_scroll_position(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(100.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = { + let state = state.clone(); + cx.update(|_, cx| cx.new(|_| TestView(state))) + }; + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.remeasure_items(5..6); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-30.))), + ..Default::default() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(70.)); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!( + offset.offset_in_item, + px(70.), + "scrolling after a remeasure should not be reverted by the stale pending scroll" + ); + } + + #[gpui::test] + fn test_scroll_after_remeasure_clamps_to_shrunk_item_height(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let item_height = Rc::new(Cell::new(100usize)); + let state = ListState::new(20, crate::ListAlignment::Top, px(10.)); + + struct TestView { + state: ListState, + item_height: Rc>, + } + + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let height = self.item_height.get(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 5 { height } else { 100 }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = { + let state = state.clone(); + let item_height = item_height.clone(); + cx.update(|_, cx| cx.new(|_| TestView { state, item_height })) + }; + + state.scroll_to(gpui::ListOffset { + item_ix: 5, + offset_in_item: px(40.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + item_height.set(50); + state.remeasure_items(5..6); + + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-30.))), + ..Default::default() + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 5); + assert_eq!(offset.offset_in_item, px(50.)); + } + #[gpui::test] fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 59c87bb6..5a1de19d 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -546,6 +546,7 @@ impl TextLayout { match text_overflow { TextOverflow::Truncate(s) => (width, s, TruncateFrom::End), TextOverflow::TruncateStart(s) => (width, s, TruncateFrom::Start), + TextOverflow::TruncateMiddle(s) => (width, s, TruncateFrom::Middle), } } else { (None, "".into(), TruncateFrom::End) @@ -751,12 +752,6 @@ impl TextLayout { let element_state = element_state .as_ref() .expect("measurement has not been performed"); - let bounds = element_state - .bounds - .expect("prepaint has not been performed"); - let line_height = element_state.line_height; - - let mut line_origin = bounds.origin; let mut line_start_ix = 0; for line in &element_state.lines { @@ -764,7 +759,6 @@ impl TextLayout { if index < line_start_ix { break; } else if index > line_end_ix { - line_origin.y += line.size(line_height).height; line_start_ix = line_end_ix + 1; continue; } else { @@ -775,6 +769,18 @@ impl TextLayout { None } + /// Retrieve all line layouts in source order. + pub fn line_layouts(&self) -> SmallVec<[Arc; 1]> { + self.0 + .borrow() + .as_ref() + .expect("measurement has not been performed") + .lines + .iter() + .map(|line| line.layout.clone()) + .collect() + } + /// The bounds of this layout. pub fn bounds(&self) -> Bounds { self.0.borrow().as_ref().unwrap().bounds.unwrap() @@ -1096,6 +1102,7 @@ impl Element for InteractiveText { build_tooltip, check_is_hovered, check_is_hovered_during_prepaint, + None, window, ); } diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index 0e423a07..fcf1a0a2 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -245,10 +245,11 @@ impl UniformListScrollHandle { return None; } let offset = state.base_handle.offset(); + let threshold = px(1.); Some(if state.y_flipped { - offset.y == px(0.) + offset.y >= -threshold } else { - -offset.y >= max_offset.height + -offset.y >= max_offset.height - threshold }) } @@ -778,7 +779,7 @@ mod test { assert_eq!(handle.is_scrolled_to_end(), Some(false)); let base_handle = handle.0.borrow().base_handle.clone(); - base_handle.set_offset(point(px(0.), -base_handle.max_offset().height)); + base_handle.set_offset(point(px(0.), -base_handle.max_offset().height + px(0.5))); assert_eq!(handle.is_scrolled_to_end(), Some(true)); } @@ -795,7 +796,7 @@ mod test { assert_eq!(handle.is_scrolled_to_end(), Some(false)); - base_handle.set_offset(point(px(0.), px(0.))); + base_handle.set_offset(point(px(0.), px(-0.5))); assert_eq!(handle.is_scrolled_to_end(), Some(true)); } diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index c2978889..eadc14a7 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -9,7 +9,7 @@ use refineable::Refineable; use schemars::{JsonSchema, json_schema}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::borrow::Cow; -use std::ops::Range; +use std::ops::{AddAssign, Range}; use std::{ cmp::{self, PartialOrd}, fmt::{self, Display}, @@ -3262,10 +3262,18 @@ impl MulAssign for ScaledPixels { pub struct Rems(pub f32); impl Rems { + /// A length of zero. + pub const ZERO: Self = Self(0.0); + /// Convert this Rem value to pixels. pub fn to_pixels(self, rem_size: Pixels) -> Pixels { self * rem_size } + + /// Convert from pixels to Rem. + pub fn from_pixels(length: Pixels, window: &gpui::Window) -> Self { + Self(length / window.rem_size()) + } } impl Mul for Rems { @@ -3276,6 +3284,12 @@ impl Mul for Rems { } } +impl AddAssign for Rems { + fn add_assign(&mut self, rhs: Rems) { + self.0 += rhs.0; + } +} + impl Display for Rems { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}rem", self.0) @@ -4026,6 +4040,15 @@ where mod tests { use super::*; + #[test] + fn rems_zero_and_add_assign() { + let mut value = Rems::ZERO; + value += rems(1.25); + value += rems(0.75); + + assert_eq!(value, rems(2.0)); + } + #[test] fn from_anchor_and_size_covers_all_anchors() { let origin = point(10, 20); diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index b94fd71d..9b3706e4 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -35,7 +35,13 @@ mod platform; pub mod prelude; /// Profiling utilities for task timing and thread performance tracking. pub mod profiler; -#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] +#[cfg(any( + test, + target_os = "windows", + target_os = "linux", + target_family = "wasm", + feature = "bench" +))] #[expect(missing_docs)] pub mod queue; mod scene; @@ -90,7 +96,24 @@ pub use elements::*; pub use executor::*; pub use geometry::*; pub use global::*; -pub use gpui_macros::{AppContext, IntoElement, Render, VisualContext, register_action, test}; +pub use gpui_macros::{ + AppContext, IntoElement, Render, VisualContext, bench, register_action, test, +}; +/// Defines a Criterion benchmark group for benchmarks annotated with [`gpui::bench`]. +#[macro_export] +macro_rules! bench_group { + ($($tokens:tt)*) => { + criterion::criterion_group!($($tokens)*); + }; +} + +/// Defines the entry point for GPUI Criterion benchmark groups. +#[macro_export] +macro_rules! bench_main { + ($($tokens:tt)*) => { + criterion::criterion_main!($($tokens)*); + }; +} pub use gpui_shared_string::*; pub use gpui_util::arc_cow::ArcCow; pub use http_client; @@ -103,7 +126,13 @@ pub use path_builder::*; pub use platform::*; pub use pollster::block_on; pub use profiler::*; -#[cfg(any(target_os = "windows", target_os = "linux", target_family = "wasm"))] +#[cfg(any( + test, + target_os = "windows", + target_os = "linux", + target_family = "wasm", + feature = "bench" +))] pub use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; diff --git a/crates/gpui/src/inspector.rs b/crates/gpui/src/inspector.rs index ad3ba6a4..12995f03 100644 --- a/crates/gpui/src/inspector.rs +++ b/crates/gpui/src/inspector.rs @@ -22,7 +22,7 @@ pub use conditional::*; mod conditional { use super::*; use crate::{AnyElement, App, Context, Empty, IntoElement, Render, Window}; - use collections::FxHashMap; + use collections::{FxHashMap, TypeIdHashMap}; use std::any::{Any, TypeId}; /// `GlobalElementId` qualified by source location of element construction. @@ -64,14 +64,14 @@ mod conditional { struct InspectedElement { id: InspectorElementId, - states: FxHashMap>, + states: TypeIdHashMap>, } impl InspectedElement { fn new(id: InspectorElementId) -> Self { InspectedElement { id, - states: FxHashMap::default(), + states: Default::default(), } } } diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index eaf582a0..ade499b8 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -5,9 +5,8 @@ pub use binding::*; pub use context::*; use crate::{Action, AsKeystroke, Keystroke, Unbind, is_no_action, is_unbind}; -use collections::{HashMap, HashSet}; +use collections::{HashSet, TypeIdHashMap}; use smallvec::SmallVec; -use std::any::TypeId; /// An opaque identifier of which version of the keymap is currently active. /// The keymap's version is changed whenever bindings are added or removed. @@ -18,7 +17,7 @@ pub struct KeymapVersion(usize); #[derive(Default)] pub struct Keymap { bindings: Vec, - binding_indices_by_action_id: HashMap>, + binding_indices_by_action_id: TypeIdHashMap>, disabled_binding_indices: Vec, version: KeymapVersion, } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 283baf87..ebed241b 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -6,6 +6,12 @@ mod keystroke; #[expect(missing_docs)] pub mod layer_shell; +/// Types for configuring parent-anchored popup windows such as menus, dropdowns and tooltips. +pub mod popup; + +#[cfg(any(test, feature = "bench"))] +mod bench_dispatcher; + #[cfg(any(test, feature = "test-support"))] mod test; @@ -32,10 +38,10 @@ use crate::util; use crate::{ Action, AnyWindowHandle, App, AsyncWindowContext, BackgroundExecutor, Bounds, DEFAULT_WINDOW_SIZE, DevicePixels, DispatchEventResult, Font, FontId, FontMetrics, FontRun, - ForegroundExecutor, GlyphId, GpuSpecs, ImageSource, Keymap, LineLayout, Pixels, PlatformInput, - Point, Priority, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, Scene, - ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, SystemWindowTab, Task, - ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size, + ForegroundExecutor, GlyphId, GpuSpecs, Hsla, ImageSource, Keymap, LineLayout, Pixels, + PlatformInput, Point, Priority, RenderGlyphParams, RenderImage, RenderImageParams, + RenderSvgParams, Scene, ShapedGlyph, ShapedRun, SharedString, Size, SvgRenderer, + SystemWindowTab, Task, ThreadTaskTimings, Window, WindowControlArea, hash, point, px, size, }; use anyhow::Result; use async_task::Runnable; @@ -75,6 +81,9 @@ pub(crate) use test::*; #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; +#[cfg(any(test, feature = "bench"))] +pub use bench_dispatcher::BenchDispatcher; + #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test::VisualTestPlatform; @@ -174,6 +183,7 @@ pub trait Platform: 'static { fn on_quit(&self, callback: Box); fn on_reopen(&self, callback: Box); + fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, menus: Vec, keymap: &Keymap); fn get_menus(&self) -> Option> { @@ -543,6 +553,8 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { } fn set_edited(&mut self, _edited: bool) {} fn set_document_path(&self, _path: Option<&std::path::Path>) {} + #[cfg(target_os = "macos")] + fn set_traffic_light_position(&self, _position: Point) {} fn show_character_palette(&self) {} fn titlebar_double_click(&self) {} fn on_move_tab_to_new_window(&self, _callback: Box) {} @@ -566,6 +578,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn show_window_menu(&self, _position: Point) {} fn start_window_move(&self) {} fn start_window_resize(&self, _edge: ResizeEdge) {} + fn set_input_region(&self, _region: Option<&[Bounds]>) {} fn window_decorations(&self) -> Decorations { Decorations::Server } @@ -638,12 +651,24 @@ pub type RunnableVariant = Runnable; #[doc(hidden)] pub type TimerResolutionGuard = util::Deferred>; +#[doc(hidden)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TasksIncluded { + OnlyCompleted, + CompletedAndRunning, +} + /// This type is public so that our test macro can generate and use it, but it should not /// be considered part of our public API. #[doc(hidden)] pub trait PlatformDispatcher: Send + Sync { - fn get_all_timings(&self) -> Vec; - fn get_current_thread_timings(&self) -> ThreadTaskTimings; + fn get_all_timings(&self) -> Vec { + crate::profiler::get_all_timings(TasksIncluded::OnlyCompleted) + } + + fn get_current_thread_timings(&self) -> ThreadTaskTimings { + crate::profiler::get_current_thread_timings(TasksIncluded::OnlyCompleted) + } fn is_main_thread(&self) -> bool; fn dispatch(&self, runnable: RunnableVariant, priority: Priority); fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority); @@ -663,6 +688,11 @@ pub trait PlatformDispatcher: Send + Sync { fn as_test(&self) -> Option<&TestDispatcher> { None } + + #[cfg(any(test, feature = "bench"))] + fn as_bench(&self) -> Option<&BenchDispatcher> { + None + } } #[expect(missing_docs)] @@ -693,6 +723,10 @@ pub trait PlatformTextSystem: Send + Sync { /// Returns the recommended text rendering mode for the given font and size. fn recommended_rendering_mode(&self, _font_id: FontId, _font_size: Pixels) -> TextRenderingMode; + /// Returns the dilation level to use for a glyph painted in the given color. + fn glyph_dilation_for_color(&self, _color: Hsla) -> u8 { + 0 + } } #[expect(missing_docs)] @@ -1123,17 +1157,49 @@ impl PlatformInputHandler { self.handler.replace_text_in_range(None, input, window, cx); } + pub fn compute_ime_candidate_bounds( + marked_range: Option>, + selection: &UTF16Selection, + mut bounds_for_range: impl FnMut(Range) -> Option>, + ) -> Option> { + if let Some(marked_range) = marked_range { + let mut line_start = marked_range.start; + let caret = selection.range.end; + if let Some(caret_bounds) = bounds_for_range(caret..caret) { + for index in (marked_range.start..caret).rev() { + if let Some(bounds) = bounds_for_range(index..index) + && (bounds.origin.y - caret_bounds.origin.y).abs() > px(0.1) + { + line_start = index + 1; + break; + } + } + } + bounds_for_range(line_start..line_start) + } else { + let offset = if selection.reversed { + selection.range.start + } else { + selection.range.end + }; + bounds_for_range(offset..offset) + } + } + pub fn selected_bounds(&mut self, window: &mut Window, cx: &mut App) -> Option> { + let marked_range = self.handler.marked_text_range(window, cx); let selection = self.handler.selected_text_range(true, window, cx)?; - self.handler.bounds_for_range( - if selection.reversed { - selection.range.start..selection.range.start - } else { - selection.range.end..selection.range.end - }, - window, - cx, - ) + Self::compute_ime_candidate_bounds(marked_range, &selection, |range| { + self.handler.bounds_for_range(range, window, cx) + }) + } + + pub fn ime_candidate_bounds(&mut self) -> Option> { + let marked_range = self.marked_text_range(); + let selection = self.selected_text_range(true)?; + Self::compute_ime_candidate_bounds(marked_range, &selection, |range| { + self.bounds_for_range(range) + }) } #[allow(unused)] @@ -1448,6 +1514,8 @@ pub struct WindowParams { #[cfg_attr(feature = "wayland", allow(dead_code))] pub display_id: Option, + pub app_id: Option, + pub window_min_size: Option>, #[cfg(target_os = "macos")] pub tabbing_identifier: Option, @@ -1556,6 +1624,14 @@ pub enum WindowKind { /// use sparingly! PopUp, + /// A parent-anchored, platform-native popup window for menus, comboboxes, context menus and + /// tooltips. Unlike [`WindowKind::PopUp`], it is positioned relative to a parent window. + /// + /// The popup's size comes from [`WindowOptions::window_bounds`], whose origin is ignored. + /// See [`popup::PopupOptions`] for the placement options. Platforms without a native + /// implementation reject it with [`popup::PopupNotSupportedError`]. + AnchoredPopup(popup::PopupOptions), + /// A floating window that appears on top of its parent window Floating, @@ -1721,7 +1797,7 @@ impl PromptButton { impl From<&str> for PromptButton { fn from(value: &str) -> Self { match value.to_lowercase().as_str() { - "ok" => PromptButton::Ok("Ok".into()), + "ok" => PromptButton::Ok("OK".into()), "cancel" => PromptButton::Cancel("Cancel".into()), _ => PromptButton::Other(SharedString::from(value.to_owned())), } @@ -2205,21 +2281,131 @@ impl From for ClipboardString { #[cfg(test)] mod tests { use super::*; + use crate::{Empty, TestAppContext}; + + #[test] + fn canonical_ok_prompt_button_uses_standard_capitalization() { + assert_eq!(PromptButton::from("ok").label().as_ref(), "OK"); + assert_eq!(PromptButton::from("OK").label().as_ref(), "OK"); + } #[test] - fn overlay_surface_options_preserve_blurred_rounded_background() { + fn overlay_surface_options_preserve_overlay_window_contract() { + let bounds = WindowBounds::Windowed(Bounds::new( + point(px(10.), px(20.)), + size(px(360.), px(72.)), + )); let options = OverlaySurfaceOptions { + window_bounds: Some(bounds), + show: true, + focus: true, window_background: WindowBackgroundAppearance::Blurred { corner_radius: px(12.), }, + has_shadow: Some(false), + app_id: Some("com.example.overlay".into()), + window_min_size: Some(size(px(180.), px(36.))), + input_mode: OverlayInputMode::ClickThrough, ..Default::default() }; + + assert_eq!(options.input_mode, OverlayInputMode::ClickThrough); let window_options = options.into_window_options(); + + assert_eq!(window_options.window_bounds, Some(bounds)); + assert_eq!(window_options.kind, WindowKind::PopUp); + assert!(window_options.titlebar.is_none()); + assert!(window_options.show); + assert!(window_options.focus); + assert!(!window_options.is_movable); + assert!(!window_options.is_resizable); + assert!(!window_options.is_minimizable); assert_eq!( window_options.window_background, WindowBackgroundAppearance::Blurred { corner_radius: px(12.) } ); + assert_eq!(window_options.has_shadow, Some(false)); + assert_eq!( + window_options.app_id.as_deref(), + Some("com.example.overlay") + ); + assert_eq!( + window_options.window_min_size, + Some(size(px(180.), px(36.))) + ); + assert!(window_options.window_decorations.is_none()); + assert!(window_options.tabbing_identifier.is_none()); + } + + #[test] + fn overlay_surface_defaults_to_interactive_transparency() { + let options = OverlaySurfaceOptions::default(); + + assert_eq!(options.input_mode, OverlayInputMode::Interactive); + assert_eq!( + options.window_background, + WindowBackgroundAppearance::Transparent + ); + assert!(!options.show); + assert!(!options.focus); + assert_eq!(options.into_window_options().kind, WindowKind::PopUp); + } + + #[gpui::test] + fn anchored_popup_is_distinct_from_overlay_popup(cx: &mut TestAppContext) { + let parent: AnyWindowHandle = cx.add_window(|_, _| Empty).into(); + let kind = WindowKind::AnchoredPopup(popup::PopupOptions { + parent, + anchor_rect: Bounds::new(point(px(10.), px(12.)), size(px(80.), px(24.))), + anchor: popup::PopupAnchor::BottomLeft, + gravity: popup::PopupGravity::BottomRight, + constraint_adjustment: popup::PopupConstraintAdjustment::FLIP_Y, + offset: point(px(0.), px(4.)), + grab: true, + }); + + assert!(matches!(kind, WindowKind::AnchoredPopup(_))); + assert_ne!(kind, WindowKind::PopUp); + } + + #[test] + fn ime_candidate_bounds_use_the_caret_end_without_composition() { + let selection = UTF16Selection { + range: 3..7, + reversed: false, + }; + + let bounds = + PlatformInputHandler::compute_ime_candidate_bounds(None, &selection, |range| { + Some(Bounds::new( + point(px(range.start as f32), px(0.)), + size(px(1.), px(1.)), + )) + }) + .unwrap(); + + assert_eq!(bounds.origin.x, px(7.)); + } + + #[test] + fn ime_candidate_bounds_use_the_current_composition_line() { + let selection = UTF16Selection { + range: 2..8, + reversed: false, + }; + + let bounds = + PlatformInputHandler::compute_ime_candidate_bounds(Some(2..8), &selection, |range| { + let y = if range.start < 5 { px(0.) } else { px(20.) }; + Some(Bounds::new( + point(px(range.start as f32), y), + size(px(1.), px(1.)), + )) + }) + .unwrap(); + + assert_eq!(bounds.origin, point(px(5.), px(20.))); } } diff --git a/crates/gpui/src/platform/bench_dispatcher.rs b/crates/gpui/src/platform/bench_dispatcher.rs new file mode 100644 index 00000000..139434bd --- /dev/null +++ b/crates/gpui/src/platform/bench_dispatcher.rs @@ -0,0 +1,501 @@ +use std::{ + collections::BinaryHeap, + sync::Arc, + thread, + time::{Duration, Instant}, +}; + +use parking_lot::{Condvar, Mutex}; + +use crate::{ + PlatformDispatcher, Priority, RunnableVariant, profiler, + queue::{PriorityQueueReceiver, PriorityQueueSender}, +}; + +const MIN_THREADS: usize = 2; + +/// A multithreaded [`PlatformDispatcher`] for benchmarks. +/// +/// Background tasks run in parallel on a pool of worker threads and timers fire +/// in real time on a dedicated timer thread, mirroring the production +/// dispatchers (see `LinuxDispatcher`). Main-thread tasks are queued until the +/// benchmark thread drains them via [`Self::run_until_idle`], since there is no +/// platform run loop pumping them. +/// +/// Unlike [`TestDispatcher`](crate::TestDispatcher), which runs everything on a +/// single thread with a virtual clock, work dispatched through this dispatcher +/// executes with production concurrency, so wall-clock measurements reflect +/// real parallelism. +pub struct BenchDispatcher { + background_sender: PriorityQueueSender, + main_sender: PriorityQueueSender, + main_receiver: Mutex>, + timers: Arc, + idle: Arc, + main_thread_id: thread::ThreadId, +} + +/// Tracks how many background and timer runnables are queued or running so +/// [`BenchDispatcher::run_until_idle`] knows when to stop waiting. +#[derive(Default)] +struct IdleTracker { + inflight: Mutex, + condvar: Condvar, +} + +impl IdleTracker { + fn increment(&self) { + *self.inflight.lock() += 1; + } + + fn decrement(&self) { + let mut inflight = self.inflight.lock(); + *inflight -= 1; + if *inflight == 0 { + self.condvar.notify_all(); + } + } + + /// Returns a guard that decrements the in-flight count when dropped, so + /// the count stays correct even if the runnable being executed panics. + fn decrement_on_drop(&self) -> impl Drop + '_ { + gpui_util::defer(|| self.decrement()) + } + + /// Notifies waiters while holding the in-flight lock. `run_until_idle` + /// re-checks its wake conditions under this lock before waiting, so the + /// notification can't slip between its check and its wait and be lost. + fn notify_under_lock(&self) { + let _inflight = self.inflight.lock(); + self.condvar.notify_all(); + } +} + +struct TimerQueue { + state: Mutex, + condvar: Condvar, +} + +struct TimerQueueState { + heap: BinaryHeap, + next_seq: u64, +} + +struct TimerEntry { + due: Instant, + seq: u64, + runnable: RunnableVariant, +} + +impl PartialEq for TimerEntry { + fn eq(&self, other: &Self) -> bool { + self.due == other.due && self.seq == other.seq + } +} + +impl Eq for TimerEntry {} + +impl PartialOrd for TimerEntry { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TimerEntry { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reversed so that the entry with the earliest due time (breaking ties + // by insertion order) is at the top of the max-heap. + other + .due + .cmp(&self.due) + .then_with(|| other.seq.cmp(&self.seq)) + } +} + +impl Default for BenchDispatcher { + fn default() -> Self { + Self::new() + } +} + +impl BenchDispatcher { + /// Creates a dispatcher whose main thread is the calling thread. + /// + /// Worker and timer threads live for the lifetime of the process; the + /// dispatcher is expected to be created once and reused across benchmarks. + pub fn new() -> Self { + let (background_sender, background_receiver) = PriorityQueueReceiver::new(); + let (main_sender, main_receiver) = PriorityQueueReceiver::new(); + let idle = Arc::new(IdleTracker::default()); + + let thread_count = + thread::available_parallelism().map_or(MIN_THREADS, |i| i.get().max(MIN_THREADS)); + for i in 0..thread_count { + let mut receiver: PriorityQueueReceiver = background_receiver.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name(format!("BenchWorker-{i}")) + .spawn(move || { + while let Ok(runnable) = receiver.pop() { + let _decrement = idle.decrement_on_drop(); + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + } + }) + .expect("failed to spawn benchmark worker thread"); + } + drop(background_receiver); + + let timers = Arc::new(TimerQueue { + state: Mutex::new(TimerQueueState { + heap: BinaryHeap::new(), + next_seq: 0, + }), + condvar: Condvar::new(), + }); + { + let timers = timers.clone(); + let idle = idle.clone(); + thread::Builder::new() + .name("BenchTimer".to_owned()) + .spawn(move || { + let mut state = timers.state.lock(); + loop { + let Some(entry) = state.heap.peek() else { + timers.condvar.wait(&mut state); + continue; + }; + let due = entry.due; + if due > Instant::now() { + timers.condvar.wait_until(&mut state, due); + continue; + } + let Some(entry) = state.heap.pop() else { + continue; + }; + // Count the firing timer as in-flight before releasing + // the lock so it can spawn follow-up work that + // `run_until_idle` will wait for. Lock order is always + // timer state, then in-flight count; `run_until_idle` + // never takes them in the opposite order. + idle.increment(); + drop(state); + + { + let _decrement = idle.decrement_on_drop(); + let location = entry.runnable.metadata().location; + let spawned = entry.runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + entry.runnable.run(); + profiler::save_task_timing(); + } + + state = timers.state.lock(); + } + }) + .expect("failed to spawn benchmark timer thread"); + } + + Self { + background_sender, + main_sender, + main_receiver: Mutex::new(main_receiver), + timers, + idle, + main_thread_id: thread::current().id(), + } + } + + /// Runs queued main thread tasks and waits until no background or timer + /// work is queued, running, or already due. + /// + /// Timers that haven't reached their due time yet are *not* waited for: + /// the dispatcher runs in real time and cannot skip ahead like the + /// `TestDispatcher`'s virtual clock, so waiting on a future timer would + /// block for its full real duration. Tasks sleeping on such timers are + /// considered idle. Must be called on the thread that created this + /// dispatcher. + pub fn run_until_idle(&self) { + assert!( + self.is_main_thread(), + "run_until_idle must be called on the benchmark main thread" + ); + loop { + if self.drain_main_queue() { + continue; + } + + // Checked before taking the in-flight lock; the timer thread + // locks them in the opposite order, so nesting would deadlock. + if self.has_due_timer() { + // Poll briefly: a firing timer leaves the heap just before it + // registers as in-flight. + let mut inflight = self.idle.inflight.lock(); + self.idle + .condvar + .wait_for(&mut inflight, Duration::from_millis(1)); + continue; + } + + let mut inflight = self.idle.inflight.lock(); + // Re-checked under the lock that `dispatch_on_main_thread` + // notifies under, so the notification can't be lost. + if self.main_queue_has_work() { + continue; + } + if *inflight == 0 { + // Main-thread sends happen before in-flight decrements, and + // decrements happen under this lock, so the check above + // observed all completed work. + return; + } + // Woken when main-thread work arrives or the in-flight count + // reaches zero; both notify under this lock. + self.idle.condvar.wait(&mut inflight); + } + } + + /// Runs all main-thread tasks that are queued right now, without waiting for + /// background work or timers to finish. + pub fn run_ready_main_tasks(&self) -> bool { + assert!( + self.is_main_thread(), + "run_ready_main_tasks must be called on the benchmark main thread" + ); + self.drain_main_queue() + } + + /// Cancels all pending timers so timers armed by one benchmark can't fire + /// during a later benchmark sharing this process-lifetime dispatcher. + /// + /// Dropping a timer runnable drops its completion sender, waking the task + /// awaiting the timer. Call [`Self::run_until_idle`] after this method to + /// drain any work that cancellation unblocks. + pub fn cancel_pending_timers(&self) -> usize { + let timers = { + let mut state = self.timers.state.lock(); + let timers: Vec<_> = state.heap.drain().collect(); + self.timers.condvar.notify_all(); + timers + }; + let canceled = timers.len(); + drop(timers); + canceled + } + + /// Describes the dispatcher's idle-tracking state, for diagnosing + /// benchmarks that fail to reach quiescence. + pub fn debug_state(&self) -> String { + let inflight = *self.idle.inflight.lock(); + let timers = self.timers.state.lock().heap.len(); + let main_queue_has_work = self.main_queue_has_work(); + format!( + "BenchDispatcher {{ inflight: {inflight}, pending_timers: {timers}, \ + main_queue_has_work: {main_queue_has_work} }}" + ) + } + + fn has_due_timer(&self) -> bool { + let state = self.timers.state.lock(); + state + .heap + .peek() + .is_some_and(|entry| entry.due <= Instant::now()) + } + + fn main_queue_has_work(&self) -> bool { + !self.main_receiver.lock().is_empty() + } + + fn drain_main_queue(&self) -> bool { + let mut ran_any = false; + loop { + // Lock only around the pop so runnables can re-entrantly dispatch + // more main-thread work through the sender while they run. + let runnable = self.main_receiver.lock().try_pop(); + match runnable { + Ok(Some(runnable)) => { + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); + runnable.run(); + profiler::save_task_timing(); + ran_any = true; + } + Ok(None) | Err(_) => return ran_any, + } + } + } +} + +impl PlatformDispatcher for BenchDispatcher { + fn is_main_thread(&self) -> bool { + thread::current().id() == self.main_thread_id + } + + fn dispatch(&self, runnable: RunnableVariant, priority: Priority) { + self.idle.increment(); + self.background_sender + .send(priority, runnable) + .unwrap_or_else(|_| panic!("benchmark worker threads are no longer running")); + } + + fn dispatch_on_main_thread(&self, runnable: RunnableVariant, priority: Priority) { + if let Err(error) = self.main_sender.send(priority, runnable) { + // The main receiver lives as long as this dispatcher, so a failed + // send means we're mid-teardown. The runnable may wrap a !Send + // future, so forget it rather than dropping it on this thread + // (mirrors LinuxDispatcher). + std::mem::forget(error); + return; + } + // Wake `run_until_idle` if it's waiting for main-thread work. + self.idle.notify_under_lock(); + } + + fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { + let mut state = self.timers.state.lock(); + let seq = state.next_seq; + state.next_seq += 1; + state.heap.push(TimerEntry { + due: Instant::now() + duration, + seq, + runnable, + }); + self.timers.condvar.notify_one(); + } + + fn spawn_realtime(&self, f: Box) { + // Benchmarks don't need realtime scheduling priority; a plain thread + // keeps this portable. + thread::Builder::new() + .name("BenchRealtime".to_owned()) + .spawn(f) + .expect("failed to spawn benchmark realtime thread"); + } + + fn as_bench(&self) -> Option<&BenchDispatcher> { + Some(self) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + use crate::{BackgroundExecutor, ForegroundExecutor}; + + #[test] + fn run_ready_main_tasks_does_not_wait_for_background_handoffs() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher.clone()); + let foreground = ForegroundExecutor::new(dispatcher.clone()); + + let (sender, receiver) = futures::channel::oneshot::channel(); + background + .spawn(async move { + thread::sleep(Duration::from_millis(10)); + sender.send(()).ok(); + }) + .detach(); + + let completed = Arc::new(AtomicBool::new(false)); + foreground + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.store(true, Ordering::SeqCst); + } + }) + .detach(); + + assert!(dispatcher.run_ready_main_tasks()); + assert!(!completed.load(Ordering::SeqCst)); + + dispatcher.run_until_idle(); + assert!(completed.load(Ordering::SeqCst)); + } + + #[test] + fn run_until_idle_completes_background_to_main_handoffs() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher.clone()); + let foreground = ForegroundExecutor::new(dispatcher.clone()); + + let (sender, receiver) = futures::channel::oneshot::channel(); + background + .spawn(async move { + thread::sleep(Duration::from_millis(10)); + sender.send(()).ok(); + }) + .detach(); + + let completed = Arc::new(AtomicBool::new(false)); + foreground + .spawn({ + let completed = completed.clone(); + async move { + receiver.await.ok(); + completed.store(true, Ordering::SeqCst); + } + }) + .detach(); + + dispatcher.run_until_idle(); + assert!(completed.load(Ordering::SeqCst)); + } + + #[test] + fn timers_fire_in_real_time() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher); + + let fired = Arc::new(AtomicBool::new(false)); + let timer = background.timer(Duration::from_millis(10)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + let deadline = Instant::now() + Duration::from_secs(10); + while !fired.load(Ordering::SeqCst) && Instant::now() < deadline { + thread::sleep(Duration::from_millis(1)); + } + assert!(fired.load(Ordering::SeqCst)); + } + + #[test] + fn cancel_pending_timers_wakes_waiters_without_waiting_for_deadline() { + let dispatcher = Arc::new(BenchDispatcher::new()); + let background = BackgroundExecutor::new(dispatcher.clone()); + + let fired = Arc::new(AtomicBool::new(false)); + let timer = background.timer(Duration::from_secs(10)); + background + .spawn({ + let fired = fired.clone(); + async move { + timer.await; + fired.store(true, Ordering::SeqCst); + } + }) + .detach(); + + dispatcher.run_until_idle(); + assert_eq!(dispatcher.cancel_pending_timers(), 1); + dispatcher.run_until_idle(); + + assert!(fired.load(Ordering::SeqCst)); + assert_eq!(dispatcher.cancel_pending_timers(), 0); + } +} diff --git a/crates/gpui/src/platform/popup.rs b/crates/gpui/src/platform/popup.rs new file mode 100644 index 00000000..ebafd9b3 --- /dev/null +++ b/crates/gpui/src/platform/popup.rs @@ -0,0 +1,164 @@ +use bitflags::bitflags; +use thiserror::Error; + +use crate::{AnyWindowHandle, Bounds, Pixels, Point}; + +/// Options for a parent-anchored popup window such as a menu, dropdown, context menu or tooltip. +/// +/// A popup is placed relative to an anchor rectangle on its parent window rather than at an +/// absolute screen position. The platform resolves the final position, so this works both on +/// systems where the compositor owns window placement (Wayland) and on platforms with absolute +/// coordinates. +/// +/// The popup's size comes from [`WindowOptions::window_bounds`](crate::WindowOptions), whose +/// origin is ignored. All coordinates are in logical pixels. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PopupOptions { + /// The window the popup is anchored to. + pub parent: AnyWindowHandle, + + /// The rectangle the popup is positioned relative to, in the parent window's coordinate + /// space (the same space element bounds are in). For example, a dropdown menu uses the + /// bounds of the button that opened it. + pub anchor_rect: Bounds, + + /// Which point of [`Self::anchor_rect`] the popup is anchored to. + pub anchor: PopupAnchor, + + /// The direction in which the popup extends away from the anchor point. A dropdown that + /// drops below its button anchors to [`PopupAnchor::BottomLeft`] with a gravity of + /// [`PopupGravity::BottomRight`] so it grows down and to the right. + pub gravity: PopupGravity, + + /// How the platform may adjust the popup if the requested placement would put it off-screen. + pub constraint_adjustment: PopupConstraintAdjustment, + + /// An additional offset applied to the popup after anchoring. + pub offset: Point, + + /// Whether the popup should take an explicit input grab. + /// + /// Grabbing popups behave like menus: they take keyboard focus and are dismissed when the + /// user clicks outside of them or presses a dismissing key. Use it for menus and comboboxes, + /// not for tooltips or other passive popups. + /// + /// A grab must be requested while the triggering input is still active, in practice the + /// press of the mouse button that opens the popup. Open grabbing popups from a mouse-down + /// handler rather than a click handler, otherwise the grab is refused. + /// + /// Automatic dismissal only covers input aimed at other applications. A click elsewhere in + /// your own application still reaches it as usual, so closing the popup in that case is up + /// to you. Nested grabbing popups must be closed in the reverse order they were opened. + pub grab: bool, +} + +/// The point of the anchor rectangle that a popup is anchored to. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupAnchor { + /// Anchor to the center of the anchor rectangle. + #[default] + Center, + /// Anchor to the center of the top edge. + Top, + /// Anchor to the center of the bottom edge. + Bottom, + /// Anchor to the center of the left edge. + Left, + /// Anchor to the center of the right edge. + Right, + /// Anchor to the top-left corner. + TopLeft, + /// Anchor to the bottom-left corner. + BottomLeft, + /// Anchor to the top-right corner. + TopRight, + /// Anchor to the bottom-right corner. + BottomRight, +} + +/// The direction in which a popup extends away from its anchor point. +/// +/// For instance, a gravity of [`PopupGravity::BottomRight`] places the popup below and to the +/// right of the anchor point. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum PopupGravity { + /// The popup is centered over the anchor point. + #[default] + Center, + /// The popup extends upwards from the anchor point. + Top, + /// The popup extends downwards from the anchor point. + Bottom, + /// The popup extends to the left of the anchor point. + Left, + /// The popup extends to the right of the anchor point. + Right, + /// The popup extends up and to the left of the anchor point. + TopLeft, + /// The popup extends down and to the left of the anchor point. + BottomLeft, + /// The popup extends up and to the right of the anchor point. + TopRight, + /// The popup extends down and to the right of the anchor point. + BottomRight, +} + +bitflags! { + /// How a popup may be adjusted by the platform if the requested placement would put it + /// off-screen. If no flags are set, the popup is placed exactly as requested and may be + /// clipped. + #[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] + pub struct PopupConstraintAdjustment: u32 { + /// The popup may be slid horizontally to stay on-screen. + const SLIDE_X = 1; + /// The popup may be slid vertically to stay on-screen. + const SLIDE_Y = 2; + /// The popup's anchor and gravity may be flipped horizontally to stay on-screen. + const FLIP_X = 4; + /// The popup's anchor and gravity may be flipped vertically to stay on-screen. + const FLIP_Y = 8; + /// The popup may be shrunk horizontally to stay on-screen. + const RESIZE_X = 16; + /// The popup may be shrunk vertically to stay on-screen. + const RESIZE_Y = 32; + } +} + +/// Returned when the current platform has no native popup implementation yet. +/// +/// Native popups are separate from gpui's in-window popovers, which are drawn as elements inside +/// an existing window. A caller that wants a popup on every platform should treat this error as +/// a cue to fall back to that in-window rendering. +#[derive(Debug, Error)] +#[error("popups are not supported on this platform")] +pub struct PopupNotSupportedError; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn popup_anchor_and_gravity_default_to_center() { + assert_eq!(PopupAnchor::default(), PopupAnchor::Center); + assert_eq!(PopupGravity::default(), PopupGravity::Center); + } + + #[test] + fn popup_constraint_adjustment_uses_platform_bit_values() { + assert!(PopupConstraintAdjustment::default().is_empty()); + assert_eq!(PopupConstraintAdjustment::SLIDE_X.bits(), 1); + assert_eq!(PopupConstraintAdjustment::SLIDE_Y.bits(), 2); + assert_eq!(PopupConstraintAdjustment::FLIP_X.bits(), 4); + assert_eq!(PopupConstraintAdjustment::FLIP_Y.bits(), 8); + assert_eq!(PopupConstraintAdjustment::RESIZE_X.bits(), 16); + assert_eq!(PopupConstraintAdjustment::RESIZE_Y.bits(), 32); + + let adjustments = PopupConstraintAdjustment::SLIDE_X + | PopupConstraintAdjustment::FLIP_Y + | PopupConstraintAdjustment::RESIZE_X; + assert!(adjustments.contains(PopupConstraintAdjustment::SLIDE_X)); + assert!(adjustments.contains(PopupConstraintAdjustment::FLIP_Y)); + assert!(adjustments.contains(PopupConstraintAdjustment::RESIZE_X)); + assert!(!adjustments.contains(PopupConstraintAdjustment::SLIDE_Y)); + } +} diff --git a/crates/gpui/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs index 8bf77dc9..2421b109 100644 --- a/crates/gpui/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -107,19 +107,6 @@ impl Clone for TestDispatcher { } impl PlatformDispatcher for TestDispatcher { - fn get_all_timings(&self) -> Vec { - Vec::new() - } - - fn get_current_thread_timings(&self) -> crate::ThreadTaskTimings { - crate::ThreadTaskTimings { - thread_name: None, - thread_id: std::thread::current().id(), - timings: Vec::new(), - total_pushed: 0, - } - } - fn is_main_thread(&self) -> bool { self.scheduler.is_main_thread() } diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index e3388d2e..44b100ea 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,9 +1,10 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, - PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, - Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform, + PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, + SourceMetadata, Task, TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, + size, }; use anyhow::Result; use collections::VecDeque; @@ -24,6 +25,7 @@ pub(crate) struct TestPlatform { pub(crate) active_window: RefCell>, active_display: Rc, active_cursor: Mutex, + cursor_hidden_until_mouse_moves: Mutex, current_clipboard_item: Mutex>, #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, @@ -34,6 +36,7 @@ pub(crate) struct TestPlatform { pub opened_url: RefCell>, pub text_system: Arc, pub expect_restart: RefCell>>>, + system_wake_callback: RefCell>>, headless_renderer_factory: Option Option>>>, weak: Weak, } @@ -85,6 +88,10 @@ struct TestPrompt { pub(crate) struct TestPrompts { multiple_choice: VecDeque, new_path: VecDeque<(PathBuf, oneshot::Sender>>)>, + paths: VecDeque<( + PathPromptOptions, + oneshot::Sender>>>, + )>, } impl TestPlatform { @@ -120,9 +127,11 @@ impl TestPlatform { prompts: Default::default(), screen_capture_sources: Default::default(), active_cursor: Default::default(), + cursor_hidden_until_mouse_moves: Default::default(), active_display: Rc::new(TestDisplay::new()), active_window: Default::default(), expect_restart: Default::default(), + system_wake_callback: Default::default(), current_clipboard_item: Mutex::new(None), #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex::new(None), @@ -148,6 +157,33 @@ impl TestPlatform { tx.send(Ok(select_path(&path))).ok(); } + pub(crate) fn simulate_path_prompt_response( + &self, + select_paths: impl FnOnce(&PathPromptOptions) -> Option>, + ) { + let (options, tx) = self + .prompts + .borrow_mut() + .paths + .pop_front() + .expect("no pending paths prompt"); + let selection = select_paths(&options); + if let Some(paths) = &selection + && !options.multiple + && paths.len() > 1 + { + panic!( + "selected {} paths for a prompt that does not allow multiple selection", + paths.len() + ); + } + tx.send(Ok(selection)).ok(); + } + + pub(crate) fn did_prompt_for_paths(&self) -> bool { + !self.prompts.borrow().paths.is_empty() + } + #[track_caller] pub(crate) fn simulate_prompt_answer(&self, response: &str) { let prompt = self @@ -227,6 +263,19 @@ impl TestPlatform { pub(crate) fn did_prompt_for_new_path(&self) -> bool { !self.prompts.borrow().new_path.is_empty() } + + pub(crate) fn simulate_mouse_move(&self) { + *self.cursor_hidden_until_mouse_moves.lock() = false; + } + + #[cfg(test)] + pub(crate) fn simulate_system_wake(&self) { + let Some(mut callback) = self.system_wake_callback.borrow_mut().take() else { + return; + }; + callback(); + *self.system_wake_callback.borrow_mut() = Some(callback); + } } impl Platform for TestPlatform { @@ -258,8 +307,8 @@ impl Platform for TestPlatform { ThermalState::Nominal } - fn run(&self, _on_finish_launching: Box) { - unimplemented!() + fn run(&self, on_finish_launching: Box) { + on_finish_launching() } fn quit(&self) {} @@ -349,9 +398,11 @@ impl Platform for TestPlatform { fn prompt_for_paths( &self, - _options: crate::PathPromptOptions, + options: crate::PathPromptOptions, ) -> oneshot::Receiver>>> { - unimplemented!() + let (tx, rx) = oneshot::channel(); + self.prompts.borrow_mut().paths.push_back((options, tx)); + rx } fn prompt_for_new_path( @@ -381,6 +432,10 @@ impl Platform for TestPlatform { unimplemented!() } + fn on_system_wake(&self, callback: Box) { + *self.system_wake_callback.borrow_mut() = Some(callback); + } + fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} @@ -404,10 +459,12 @@ impl Platform for TestPlatform { *self.active_cursor.lock() = style; } - fn hide_cursor_until_mouse_moves(&self) {} + fn hide_cursor_until_mouse_moves(&self) { + *self.cursor_hidden_until_mouse_moves.lock() = true; + } fn is_cursor_visible(&self) -> bool { - true + !*self.cursor_hidden_until_mouse_moves.lock() } fn should_auto_hide_scrollbars(&self) -> bool { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index bbf89dc5..9bc3fcc3 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -31,6 +31,7 @@ pub(crate) struct TestWindowState { resize_callback: Option, f32)>>, moved_callback: Option>, input_handler: Option, + input_region: Option>>, is_fullscreen: bool, } @@ -82,10 +83,16 @@ impl TestWindow { resize_callback: None, moved_callback: None, input_handler: None, + input_region: None, is_fullscreen: false, }))) } + #[cfg(test)] + pub(crate) fn input_region(&self) -> Option>> { + self.0.lock().input_region.clone() + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -111,6 +118,11 @@ impl TestWindow { pub fn simulate_input(&mut self, event: PlatformInput) -> bool { let mut lock = self.0.lock(); + if matches!(event, PlatformInput::MouseMove(_)) + && let Some(platform) = lock.platform.upgrade() + { + platform.simulate_mouse_move(); + } let Some(mut callback) = lock.input_callback.take() else { return false; }; @@ -320,6 +332,10 @@ impl PlatformWindow for TestWindow { unimplemented!() } + fn set_input_region(&self, region: Option<&[Bounds]>) { + self.0.lock().input_region = region.map(<[_]>::to_vec); + } + fn update_ime_position(&self, _bounds: Bounds) {} fn gpu_specs(&self) -> Option { @@ -343,6 +359,25 @@ impl TestAtlas { } } +#[cfg(test)] +mod tests { + use crate::{Modifiers, TestAppContext, point, px}; + + #[gpui::test] + fn mouse_move_restores_cursor_visibility(cx: &mut TestAppContext) { + let mut cx = cx.add_empty_window(); + + cx.update(|_, cx| { + cx.platform.hide_cursor_until_mouse_moves(); + assert!(!cx.is_cursor_visible()); + }); + + cx.simulate_mouse_move(point(px(10.), px(10.)), None, Modifiers::default()); + + cx.update(|_, cx| assert!(cx.is_cursor_visible())); + } +} + impl PlatformAtlas for TestAtlas { fn get_or_insert_with<'a>( &self, diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index 1d5422d7..545c9122 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -119,7 +119,10 @@ impl Scheduler for PlatformScheduler { // Create a runnable that will send the completion signal let location = std::panic::Location::caller(); let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) + .metadata(RunnableMeta { + location, + spawned: scheduler::SpawnTime(Instant::now()), + }) .spawn( move |_| async move { let _ = tx.send(()); diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 4b60b500..039f02ef 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -1,22 +1,120 @@ +use itertools::Itertools; +use scheduler::SpawnTime; use std::{ cell::LazyCell, collections::{HashMap, VecDeque}, hash::{DefaultHasher, Hash, Hasher}, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, thread::ThreadId, - time::Instant, + time::{Duration, Instant}, }; +mod actions; +pub use actions::{ActionStatistics, ActionTiming, take_action_stats}; +pub(crate) use actions::{save_action_timing, update_running_action}; + use serde::{Deserialize, Serialize}; -use crate::SharedString; +use crate::{SharedString, TasksIncluded, WindowId}; + +#[cfg(feature = "profiler")] +#[doc(hidden)] +pub fn get_all_timings(included: gpui::TasksIncluded) -> Vec { + let global_thread_timings = GLOBAL_THREAD_TIMINGS.lock(); + ThreadTaskTimings::collect(&global_thread_timings, included) +} + +#[cfg(feature = "profiler")] +#[doc(hidden)] +pub fn get_current_thread_timings(included: TasksIncluded) -> gpui::ThreadTaskTimings { + gpui::profiler::get_current_thread_task_timings_including(included) +} + +#[cfg(feature = "profiler")] +#[doc(hidden)] +pub fn take_all_stats(included: TasksIncluded) -> Vec { + let global_timings = GLOBAL_THREAD_TIMINGS.lock(); + ThreadTaskStatistics::collect_and_reset(&global_timings, included) +} + +#[cfg(not(feature = "profiler"))] +#[doc(hidden)] +pub fn get_all_timings(_included: gpui::TasksIncluded) -> Vec { + Vec::new() +} +#[cfg(not(feature = "profiler"))] +#[doc(hidden)] +pub fn get_current_thread_timings(_included: TasksIncluded) -> gpui::ThreadTaskTimings { + gpui::ThreadTaskTimings { + thread_name: None, + thread_id: std::thread::current().id(), + timings: Vec::new(), + stats: TaskStatistics::default(), + total_pushed: 0, + } +} +#[cfg(not(feature = "profiler"))] +#[doc(hidden)] +pub fn take_all_stats(_included: TasksIncluded) -> Vec { + Vec::new() +} #[doc(hidden)] #[derive(Debug, Copy, Clone)] +pub struct YieldTime(pub Instant); + +#[doc(hidden)] +#[derive(Copy, Clone)] pub struct TaskTiming { pub location: &'static core::panic::Location<'static>, + pub spawned: SpawnTime, + pub start: Instant, + pub end: YieldTime, +} + +impl std::fmt::Debug for TaskTiming { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TaskTiming") + .field("location", &self.location) + .field("since_spawned", &self.spawned.0.elapsed()) + .field("last_poll_duration", &self.poll_duration()) + .field("total_runtime", &self.since_spawn()) + .finish() + } +} + +#[doc(hidden)] +#[derive(Debug, Copy, Clone)] +pub struct ActiveTiming { + pub location: &'static core::panic::Location<'static>, + pub spawned: SpawnTime, pub start: Instant, - pub end: Option, +} + +impl TaskTiming { + /// A task timing with a duration of zero. Any task will replace this in history. + pub fn placeholder() -> Self { + let now = Instant::now(); + Self { + location: std::panic::Location::caller(), + spawned: SpawnTime(now), + start: now, + end: YieldTime(now), + } + } + + #[inline(always)] + pub fn poll_duration(&self) -> Duration { + self.end.0 - self.start + } + + #[inline(always)] + fn since_spawn(&self) -> Duration { + self.end.0 - self.spawned.0 + } } #[doc(hidden)] @@ -25,12 +123,21 @@ pub struct ThreadTaskTimings { pub thread_name: Option, pub thread_id: ThreadId, pub timings: Vec, + pub stats: TaskStatistics, pub total_pushed: u64, } impl ThreadTaskTimings { - /// Convert global thread timings into their structured format. + /// Convert completed global thread timings into their structured format. + /// + /// This preserves the standalone fork's pre-profiler-feature API. New + /// callers that need active tasks should use [`Self::collect`]. pub fn convert(timings: &[GlobalThreadTimings]) -> Vec { + Self::collect(timings, TasksIncluded::OnlyCompleted) + } + + /// Convert global thread timings into their structured format. + pub fn collect(timings: &[GlobalThreadTimings], included: TasksIncluded) -> Vec { timings .iter() .filter_map(|t| match t.timings.upgrade() { @@ -41,18 +148,28 @@ impl ThreadTaskTimings { let timings = timings.lock(); let thread_name = timings.thread_name.clone(); let total_pushed = timings.total_pushed; - let timings = &timings.timings; + let completed = &timings.timings; - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); + let mut vec = Vec::with_capacity(completed.len() + 1); // +1 for running task + let (s1, s2) = completed.as_slices(); vec.extend_from_slice(s1); vec.extend_from_slice(s2); + if let TasksIncluded::CompletedAndRunning = included + && let Some(running) = timings.running + { + vec.push(TaskTiming { + location: running.location, + spawned: running.spawned, + start: running.start, + end: YieldTime(Instant::now()), + }) + } ThreadTaskTimings { thread_name, thread_id, timings: vec, + stats: timings.stats.clone(), total_pushed, } }) @@ -60,6 +177,58 @@ impl ThreadTaskTimings { } } +#[doc(hidden)] +#[derive(Debug)] +pub struct ThreadTaskStatistics { + pub thread_name: Option, + pub thread_id: ThreadId, + pub stats: TaskStatistics, +} + +impl ThreadTaskStatistics { + pub fn collect_and_reset( + timings: &[GlobalThreadTimings], + include_running: TasksIncluded, + ) -> Vec { + timings + .iter() + .filter_map(|t| match t.timings.upgrade() { + Some(timings) => Some((t.thread_id, timings)), + _ => None, + }) + .map(|(thread_id, timings)| { + let mut timings = timings.lock(); + let thread_name = timings.thread_name.clone(); + + let mut stats = std::mem::take(&mut timings.stats); + if let TasksIncluded::CompletedAndRunning = include_running + && let Some(ActiveTiming { + location, + spawned, + start, + }) = timings.running + { + let end = YieldTime(Instant::now()); + let timing = TaskTiming { + location, + spawned, + start, + end, + }; + stats.add_runtime(timing); + stats.add_yield_timing(timing); + } + + Self { + thread_name, + thread_id, + stats, + } + }) + .collect() + } +} + /// Serializable variant of [`core::panic::Location`] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SerializedLocation { @@ -103,11 +272,7 @@ impl SerializedTaskTiming { .iter() .map(|timing| { let start = timing.start.duration_since(anchor).as_nanos(); - let duration = timing - .end - .unwrap_or_else(|| Instant::now()) - .duration_since(timing.start) - .as_nanos(); + let duration = timing.end.0.duration_since(timing.start).as_nanos(); SerializedTaskTiming { location: timing.location.into(), start, @@ -118,6 +283,17 @@ impl SerializedTaskTiming { serialized } + + /// `anchor` - [`Instant`] that should be earlier than all timings to use as base anchor + pub fn from(anchor: Instant, timing: TaskTiming) -> SerializedTaskTiming { + let start = timing.start.duration_since(anchor).as_nanos(); + let duration = timing.end.0.duration_since(timing.start).as_nanos(); + SerializedTaskTiming { + location: timing.location.into(), + start, + duration, + } + } } /// Serializable variant of [`ThreadTaskTimings`] @@ -207,18 +383,7 @@ impl ProfilingCollector { &thread.timings[skip.min(thread.timings.len())..] }; - // Don't emit the last entry if it's still in-progress (end: None). - let incomplete_at_end = slice.last().is_some_and(|t| t.end.is_none()); - if incomplete_at_end { - slice = &slice[..slice.len() - 1]; - } - - let cursor_advance = if incomplete_at_end { - thread.total_pushed.saturating_sub(1) - } else { - thread.total_pushed - }; - + let cursor_advance = thread.total_pushed; self.cursors.insert(thread.thread_id, cursor_advance); if slice.is_empty() { @@ -242,11 +407,17 @@ impl ProfilingCollector { } } -// Allow 16MiB of task timing entries. Keep as power-of-two because VecDeque doubles capacity. +// Allow 16MiB of task timing entries. +// VecDeque grows by doubling its capacity when full, so keep this a power of 2 to avoid wasting +// memory. +#[cfg(feature = "profiler")] const MAX_TASK_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); +const MIN_PROFILED_TASK_DURATION: Duration = Duration::from_micros(100); + #[doc(hidden)] pub(crate) type TaskTimings = VecDeque; + #[doc(hidden)] pub type GuardedTaskTimings = spin::Mutex; @@ -256,6 +427,110 @@ pub struct GlobalThreadTimings { pub timings: std::sync::Weak, } +#[doc(hidden)] +#[derive(Debug, Clone)] +pub struct TaskStatistics { + pub poll_time_to_beat: Duration, + pub runtime_to_beat: Duration, + pub longest_poll_times: [TaskTiming; 5], + pub longest_runtimes: [TaskTiming; 5], +} + +impl std::fmt::Display for TaskStatistics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Tasks that blocked the longest before yielding\n")?; + for timing in self.longest_poll_times { + f.write_fmt(format_args!( + "{:<20} - {}:{}\n", + format!("{:?}", timing.poll_duration()), + timing.location.file(), + timing.location.column() + ))?; + } + f.write_str("Tasks that ran the longest\n")?; + for timing in self.longest_runtimes { + f.write_fmt(format_args!( + "{:<20} - {}:{}\n", + format!("{:?}", timing.since_spawn()), + timing.location.file(), + timing.location.column() + ))?; + } + Ok(()) + } +} + +impl Default for TaskStatistics { + fn default() -> Self { + Self { + // Do not track polls that are not problematic + // this keeps more calls on the fast path + poll_time_to_beat: MIN_PROFILED_TASK_DURATION, + runtime_to_beat: MIN_PROFILED_TASK_DURATION, + longest_poll_times: [TaskTiming::placeholder(); 5], + longest_runtimes: [TaskTiming::placeholder(); 5], + } + } +} + +impl TaskStatistics { + #[inline(always)] + fn add_yield_timing(&mut self, task: TaskTiming) { + let yielded_after = task.poll_duration(); + if yielded_after >= self.poll_time_to_beat { + std::hint::cold_path(); // most tasks are not the worst, optimize for that + let to_replace = self + .longest_poll_times + .iter() + .position_min_by_key(|task| task.poll_duration()) + .expect("guarded by the comparison with nth_longest_yield_time"); + self.longest_poll_times[to_replace] = task; + + self.poll_time_to_beat = if self + .longest_poll_times + .iter() + .all(|timing| !timing.poll_duration().is_zero()) + { + self.longest_poll_times + .iter() + .map(TaskTiming::poll_duration) + .min() + .expect("never empty") + } else { + MIN_PROFILED_TASK_DURATION + }; + } + } + + #[inline(always)] + fn add_runtime(&mut self, task: TaskTiming) { + let runtime = task.since_spawn(); + if runtime >= self.runtime_to_beat { + std::hint::cold_path(); // most tasks are not the worst, optimize for that + let to_replace = self + .longest_runtimes + .iter() + .position_min_by_key(|task| task.since_spawn()) + .expect("guarded by the comparison with nth_longest_yield_time"); + self.longest_runtimes[to_replace] = task; + + self.runtime_to_beat = if self + .longest_runtimes + .iter() + .all(|timing| !timing.since_spawn().is_zero()) + { + self.longest_runtimes + .iter() + .map(|task| task.since_spawn()) + .min() + .expect("never empty") + } else { + MIN_PROFILED_TASK_DURATION + }; + } + } +} + #[doc(hidden)] pub static GLOBAL_THREAD_TIMINGS: spin::Mutex> = spin::Mutex::new(Vec::new()); @@ -287,6 +562,8 @@ pub struct ThreadTimings { pub thread_name: Option, pub thread_id: ThreadId, pub timings: TaskTimings, + pub running: Option, + pub stats: TaskStatistics, pub total_pushed: u64, } @@ -296,35 +573,95 @@ impl ThreadTimings { thread_name, thread_id, timings: TaskTimings::new(), + stats: TaskStatistics::default(), total_pushed: 0, + running: None, } } - /// Add timing or update in-progress timing for same task. - pub fn add_task_timing(&mut self, timing: TaskTiming) { - if let Some(last_timing) = self.timings.back_mut() - && last_timing.end.is_none() - && last_timing.location == timing.location - && last_timing.start == timing.start - { - last_timing.end = timing.end; - } else { - while self.timings.len() + 1 > MAX_TASK_TIMINGS { - self.timings.pop_front(); - } - self.timings.push_back(timing); - self.total_pushed += 1; - } + #[cfg(feature = "profiler")] + pub fn update_running_task( + &mut self, + spawned: SpawnTime, + location: &'static std::panic::Location<'_>, + ) { + let start = Instant::now(); + self.running = Some(ActiveTiming { + spawned, + location, + start, + }); } + #[cfg(not(feature = "profiler"))] + pub fn update_running_task(&mut self, _: SpawnTime, _: &'static std::panic::Location<'_>) {} + + #[cfg(feature = "profiler")] + pub fn save_task_timing(&mut self, ended: YieldTime) { + let ActiveTiming { + location, + start, + spawned, + } = self + .running + .take() + .expect("this function is only ever called after register_task_start"); + + let timing = TaskTiming { + location, + spawned, + start, + end: ended, + }; + self.add_task_timing(timing); + } + #[cfg(not(feature = "profiler"))] + pub fn save_task_timing(&mut self, _: YieldTime) {} - pub fn get_thread_task_timings(&self) -> ThreadTaskTimings { + // Running tasks are included in the reliability trace, which is written + // whenever the foreground executor makes no progress for > n seconds + pub fn get_thread_task_timings_including(&self, includes: TasksIncluded) -> ThreadTaskTimings { ThreadTaskTimings { thread_name: self.thread_name.clone(), thread_id: self.thread_id, - timings: self.timings.iter().cloned().collect(), + timings: self + .timings + .iter() + .cloned() + .chain( + self.running + .filter(|_| matches!(includes, TasksIncluded::CompletedAndRunning)) + .map(|running| TaskTiming { + spawned: running.spawned, + location: running.location, + start: running.start, + end: YieldTime(Instant::now()), + }), + ) + .collect(), + stats: self.stats.clone(), total_pushed: self.total_pushed, } } + + /// Return completed timings, preserving the standalone fork's original API. + pub fn get_thread_task_timings(&self) -> ThreadTaskTimings { + self.get_thread_task_timings_including(TasksIncluded::OnlyCompleted) + } + + #[cfg(feature = "profiler")] + fn add_task_timing(&mut self, timing: TaskTiming) { + self.stats.add_yield_timing(timing); + self.stats.add_runtime(timing); + + if trace_enabled() { + std::hint::cold_path(); // optimize for when tracing is off + if self.timings.len() >= MAX_TASK_TIMINGS { + self.timings.pop_front(); + } + self.timings.push_back(timing); + self.total_pushed += 1; + } + } } impl Drop for ThreadTimings { @@ -343,13 +680,299 @@ impl Drop for ThreadTimings { } #[doc(hidden)] -pub fn add_task_timing(timing: TaskTiming) { +#[cfg(feature = "profiler")] +pub fn update_running_task(spawned: SpawnTime, location: &'static std::panic::Location<'_>) { THREAD_TIMINGS.with(|timings| { - timings.lock().add_task_timing(timing); + timings.lock().update_running_task(spawned, location); }); } +#[doc(hidden)] +#[cfg(not(feature = "profiler"))] +#[inline(always)] +pub fn update_running_task(_: SpawnTime, _: &'static std::panic::Location<'_>) {} + +#[doc(hidden)] +#[cfg(feature = "profiler")] +pub fn save_task_timing() { + let yielded_at = YieldTime(Instant::now()); + THREAD_TIMINGS.with(|timings| { + timings.lock().save_task_timing(yielded_at); + }); +} + +#[doc(hidden)] +#[cfg(not(feature = "profiler"))] +#[inline(always)] +pub fn save_task_timing() {} + +#[doc(hidden)] +#[cfg(feature = "profiler")] +pub fn add_task_timing(timing: TaskTiming) { + THREAD_TIMINGS.with(|timings| timings.lock().add_task_timing(timing)); +} + +#[doc(hidden)] +#[cfg(not(feature = "profiler"))] +#[inline(always)] +pub fn add_task_timing(_: TaskTiming) {} + +#[doc(hidden)] +pub fn get_current_thread_task_timings_including( + include_running: TasksIncluded, +) -> ThreadTaskTimings { + THREAD_TIMINGS.with(|timings| { + timings + .lock() + .get_thread_task_timings_including(include_running) + }) +} + #[doc(hidden)] pub fn get_current_thread_task_timings() -> ThreadTaskTimings { THREAD_TIMINGS.with(|timings| timings.lock().get_thread_task_timings()) } + +static PROFILER_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Enables or disables task timing trace collection at runtime. +/// +/// When transitioning from enabled to disabled, `add_task_timing` becomes a +/// cheaper since only cheap statistics are gathered. The existing per-thread +/// buffers for traces are cleared so stale data isn't reported after a later +/// re-enable. Calls with the current value are a no-op. +pub fn set_trace_enabled(enabled: bool) -> bool { + if PROFILER_ENABLED.swap(enabled, Ordering::AcqRel) == enabled { + return false; + } + + if !enabled { + for global in GLOBAL_THREAD_TIMINGS.lock().iter() { + if let Some(timings) = global.timings.upgrade() { + let mut timings = timings.lock(); + timings.timings.clear(); + timings.timings.shrink_to_fit(); + timings.total_pushed = 0; + } + } + } + true +} + +/// Returns whether task timing tracing is enabled. +pub fn trace_enabled() -> bool { + PROFILER_ENABLED.load(Ordering::Relaxed) +} + +/// Timing for a single drawn window frame. +#[derive(Debug, Copy, Clone)] +pub struct FrameTiming { + /// The window that was drawn. + pub window_id: WindowId, + /// When the frame first became dirty (its first invalidation). `None` if + /// frame tracing was not yet enabled when the invalidation occurred. + pub dirty_at: Option, + /// Number of invalidations coalesced into this frame. + pub invalidations: u64, + /// When `Window::draw` started. + pub draw_start: Instant, + /// When `Window::draw` finished. + pub draw_end: Instant, +} + +impl FrameTiming { + /// Time spent inside `Window::draw`. + pub fn draw_duration(&self) -> Duration { + self.draw_end.duration_since(self.draw_start) + } + + /// Time from the frame's first invalidation to the end of its draw, if the + /// first invalidation was observed. + pub fn dirty_to_draw_duration(&self) -> Option { + self.dirty_at + .map(|dirty_at| self.draw_end.duration_since(dirty_at)) + } +} + +// Allow 16MiB of frame timing entries. +const MAX_FRAME_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); + +struct FrameTimings { + timings: VecDeque, + total_pushed: u64, +} + +static FRAME_TIMINGS: spin::Mutex = spin::Mutex::new(FrameTimings { + timings: VecDeque::new(), + total_pushed: 0, +}); + +static FRAME_TRACE_ENABLED: AtomicBool = AtomicBool::new(false); + +/// Enables or disables frame timing collection at runtime. +/// +/// When transitioning from enabled to disabled, the buffered frame timings are +/// cleared so stale data isn't reported after a later re-enable. Returns false +/// if the value was unchanged. +pub fn set_frame_trace_enabled(enabled: bool) -> bool { + if FRAME_TRACE_ENABLED.swap(enabled, Ordering::AcqRel) == enabled { + return false; + } + + if !enabled { + let mut frames = FRAME_TIMINGS.lock(); + frames.timings.clear(); + frames.timings.shrink_to_fit(); + frames.total_pushed = 0; + } + true +} + +/// Returns whether frame timing collection is enabled. +pub fn frame_trace_enabled() -> bool { + FRAME_TRACE_ENABLED.load(Ordering::Relaxed) +} + +/// Records the timing of a drawn window frame. +/// +/// No-op unless frame tracing is enabled via [`set_frame_trace_enabled`]. +pub fn record_frame_timing(timing: FrameTiming) { + if !frame_trace_enabled() { + return; + } + std::hint::cold_path(); // optimize for when profiling is off + + let mut frames = FRAME_TIMINGS.lock(); + if frames.timings.len() >= MAX_FRAME_TIMINGS { + frames.timings.pop_front(); + } + frames.timings.push_back(timing); + frames.total_pushed += 1; +} + +/// Drains frame timings recorded after this collector was created, tracking a +/// cursor so each call to [`Self::collect_unseen`] returns only new entries. +pub struct FrameTimingCollector { + cursor: u64, +} + +impl Default for FrameTimingCollector { + fn default() -> Self { + Self::new() + } +} + +impl FrameTimingCollector { + /// Creates a collector that only sees frames recorded from this point on. + pub fn new() -> Self { + Self { + cursor: FRAME_TIMINGS.lock().total_pushed, + } + } + + /// Returns frame timings recorded since the previous call (or since the + /// collector was created). If the ring buffer wrapped around since the + /// previous poll, the evicted entries are lost. + pub fn collect_unseen(&mut self) -> Vec { + let frames = FRAME_TIMINGS.lock(); + let buffer_len = frames.timings.len() as u64; + let buffer_start = frames.total_pushed.saturating_sub(buffer_len); + let skip = self.cursor.saturating_sub(buffer_start) as usize; + let unseen = frames + .timings + .iter() + .skip(skip.min(frames.timings.len())) + .copied() + .collect(); + self.cursor = frames.total_pushed; + unseen + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(not(feature = "profiler"))] + fn disabled_profiler_does_not_record_task_timings() { + let mut timings = ThreadTimings::new(None, std::thread::current().id()); + let started = Instant::now(); + + timings.update_running_task(SpawnTime(started), core::panic::Location::caller()); + timings.save_task_timing(YieldTime(Instant::now())); + + assert!(timings.get_thread_task_timings().timings.is_empty()); + } + + #[test] + #[cfg(feature = "profiler")] + fn enabled_profiler_records_completed_task_timings() { + let mut timings = ThreadTimings::new(None, std::thread::current().id()); + set_trace_enabled(true); + let started = Instant::now(); + + timings.update_running_task(SpawnTime(started), core::panic::Location::caller()); + timings.save_task_timing(YieldTime(Instant::now())); + + let collected = timings.get_thread_task_timings(); + assert_eq!(collected.timings.len(), 1); + assert!(collected.timings[0].poll_duration() <= collected.timings[0].since_spawn()); + set_trace_enabled(false); + } + + #[test] + fn convert_compatibility_wrapper_excludes_running_tasks() { + let timings = Arc::new(GuardedTaskTimings::new(ThreadTimings::new( + None, + std::thread::current().id(), + ))); + timings + .lock() + .update_running_task(SpawnTime(Instant::now()), core::panic::Location::caller()); + let globals = [GlobalThreadTimings { + thread_id: std::thread::current().id(), + timings: Arc::downgrade(&timings), + }]; + + let collected = ThreadTaskTimings::convert(&globals); + + assert_eq!(collected.len(), 1); + assert!(collected[0].timings.is_empty()); + } + + #[test] + #[cfg(feature = "profiler")] + fn poll_statistics_threshold_tracks_poll_duration() { + let mut statistics = TaskStatistics::default(); + for (poll_millis, runtime_millis) in [(1, 10), (2, 9), (3, 8), (4, 7), (5, 6)] { + let end = Instant::now(); + statistics.add_yield_timing(TaskTiming { + location: core::panic::Location::caller(), + spawned: SpawnTime(end - Duration::from_millis(runtime_millis)), + start: end - Duration::from_millis(poll_millis), + end: YieldTime(end), + }); + } + + assert_eq!(statistics.poll_time_to_beat, Duration::from_millis(1)); + } + + #[test] + #[cfg(feature = "profiler")] + fn task_statistics_keep_minimum_threshold_until_all_slots_are_filled() { + let mut statistics = TaskStatistics::default(); + let end = Instant::now(); + let timing = TaskTiming { + location: core::panic::Location::caller(), + spawned: SpawnTime(end - Duration::from_millis(5)), + start: end - Duration::from_millis(1), + end: YieldTime(end), + }; + statistics.add_yield_timing(timing); + statistics.add_runtime(timing); + + assert_eq!(statistics.poll_time_to_beat, Duration::from_micros(100)); + assert_eq!(statistics.runtime_to_beat, Duration::from_micros(100)); + } +} diff --git a/crates/gpui/src/profiler/actions.rs b/crates/gpui/src/profiler/actions.rs new file mode 100644 index 00000000..d240c399 --- /dev/null +++ b/crates/gpui/src/profiler/actions.rs @@ -0,0 +1,306 @@ +use std::time::{Duration, Instant}; + +use itertools::Itertools; + +use crate::action::Action; + +const MIN_PROFILED_ACTION_DURATION: Duration = Duration::from_micros(100); + +#[doc(hidden)] +#[derive(Clone)] +pub struct ActionStatistics { + runtime_to_beat: Duration, + + longest_runtimes: heapless::Vec, + running: Option<(&'static str, Instant)>, +} + +impl std::fmt::Debug for ActionStatistics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActionStatistics") + .field("runtime_to_beat", &self.runtime_to_beat) + .field("longest_runtimes", &self.longest_runtimes) + .field( + "running", + &self.running.map(|(id, started)| (id, started.elapsed())), + ) + .finish() + } +} + +impl std::fmt::Display for ActionStatistics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("Actions that blocked the longest\n")?; + for action in self + .longest_runtimes(true) + .sorted_by_key(|action| action.runtime()) + .rev() + { + f.write_fmt(format_args!( + "{:<20} - {}", + format!("{:?}", action.runtime()), // impl dbg does not support alignment + action.name + ))?; + writeln!(f)?; + } + Ok(()) + } +} + +impl Default for ActionStatistics { + fn default() -> Self { + Self::new() + } +} + +impl ActionStatistics { + const fn new() -> Self { + Self { + // This keeps more calls on the fast path by only tracking + // problematic polls + runtime_to_beat: MIN_PROFILED_ACTION_DURATION, + longest_runtimes: heapless::Vec::new(), + running: None, + } + } + + pub fn take(&mut self) -> Self { + let taken = std::mem::take(self); + self.running = taken.running; + taken + } + + pub fn is_empty(&self) -> bool { + self.longest_runtimes.is_empty() + } + + #[cfg(feature = "profiler")] + pub fn update_running_action(&mut self, action: &'static str, started: Instant) { + self.running = Some((action, started)); + } + #[cfg(not(feature = "profiler"))] + pub fn update_running_action(&mut self, _action: &'static str, _started: Instant) {} + + #[cfg(feature = "profiler")] + pub fn save_action_timing(&mut self) { + let now = Instant::now(); + + let Some((action, started)) = self.running.take() else { + // Actions are ran only on the foreground executor and therefore + // sequentially _except_ in tests where they can run concurrently. + // + // When ran sequentially self.running will always be Some. When ran + // concurrently that is no longer true. But that is fine, we do not + // need to track action timings in tests. + std::hint::cold_path(); + return; + }; + + let runtime = now.duration_since(started); + if runtime >= self.runtime_to_beat { + std::hint::cold_path(); // most actions are not the worst, optimize for that + + if self.longest_runtimes.is_full() + && let Some(to_replace) = self + .longest_runtimes + .iter_mut() + .min_by_key(|action| action.runtime()) + { + *to_replace = ActionTiming { + name: action, + start: started, + end: now, + }; + } else { + self.longest_runtimes + .push(ActionTiming { + name: action, + start: started, + end: now, + }) + .expect("just checked it is not full"); + }; + + self.runtime_to_beat = if self.longest_runtimes.is_full() { + self.longest_runtimes + .iter() + .map(|action| action.runtime()) + .min() + .expect("never empty") + } else { + MIN_PROFILED_ACTION_DURATION + }; + } + } + #[cfg(not(feature = "profiler"))] + pub fn save_action_timing(&mut self) {} + + pub fn longest_runtimes(&self, include_running: bool) -> impl Iterator { + self.longest_runtimes.iter().copied().chain( + self.running + .into_iter() + .filter(move |_| include_running) + .map(|(name, start)| ActionTiming { + name, + start, + end: Instant::now(), + }), + ) + } +} + +#[doc(hidden)] +/// UNSTABLE only for use in the profiler and zed-reliability +#[derive(Copy, Clone)] +pub struct ActionTiming { + pub name: &'static str, + pub start: Instant, + pub end: Instant, +} + +impl core::fmt::Debug for ActionTiming { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActionTiming") + .field("name", &self.name) + .field("runtime", &self.runtime()) + .finish() + } +} + +impl ActionTiming { + pub fn duration(&self) -> Duration { + self.end.saturating_duration_since(self.start) + } +} + +impl ActionTiming { + #[doc(hidden)] + pub fn runtime(&self) -> Duration { + self.end - self.start + } +} + +// The profiler is careful to never block when the lock is held, therefore a +// spinlock is optimal. +#[cfg(feature = "profiler")] +static ACTION_STATISTICS: spin::Mutex = + const { spin::Mutex::new(ActionStatistics::new()) }; + +#[doc(hidden)] +#[cfg(feature = "profiler")] +pub(crate) fn update_running_action(action: &(dyn Action + 'static), cx: &mut crate::App) { + let now = Instant::now(); + let action = action.type_id(); + let action = cx.actions.try_resolve_action(&action).unwrap_or("un-named"); + ACTION_STATISTICS.lock().update_running_action(action, now); +} + +#[doc(hidden)] +#[cfg(not(feature = "profiler"))] +#[inline(always)] +pub(crate) fn update_running_action(_: &(dyn Action + 'static), _: &mut crate::App) {} + +#[doc(hidden)] +#[cfg(feature = "profiler")] +pub(crate) fn save_action_timing() { + ACTION_STATISTICS.lock().save_action_timing(); +} + +#[doc(hidden)] +#[cfg(not(feature = "profiler"))] +#[inline(always)] +pub(crate) fn save_action_timing() {} + +#[doc(hidden)] +#[cfg(feature = "profiler")] +pub fn take_action_stats() -> ActionStatistics { + ACTION_STATISTICS.lock().take() +} + +#[doc(hidden)] +#[cfg(not(feature = "profiler"))] +pub fn take_action_stats() -> ActionStatistics { + ActionStatistics::default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + #[cfg(feature = "profiler")] + fn enabled_action_profiler_records_slow_actions() { + let mut statistics = ActionStatistics::new(); + statistics.update_running_action("test-action", Instant::now() - Duration::from_millis(1)); + + statistics.save_action_timing(); + + let timing = statistics + .longest_runtimes(false) + .next() + .expect("slow action should be recorded"); + assert_eq!(timing.name, "test-action"); + } + + #[test] + #[cfg(feature = "profiler")] + fn saving_without_a_running_action_is_tolerated() { + let mut statistics = ActionStatistics::new(); + statistics.save_action_timing(); + assert!(statistics.is_empty()); + } + + #[test] + #[cfg(feature = "profiler")] + fn full_action_statistics_replaces_the_shortest_action() { + let mut statistics = ActionStatistics::new(); + for (name, duration) in [ + ("one", 1), + ("two", 2), + ("three", 3), + ("four", 4), + ("five", 5), + ("replacement", 3), + ] { + statistics + .update_running_action(name, Instant::now() - Duration::from_millis(duration)); + statistics.save_action_timing(); + } + + let names = statistics + .longest_runtimes(false) + .map(|timing| timing.name) + .collect::>(); + assert!(!names.contains(&"one")); + assert!(names.contains(&"five")); + assert!(names.contains(&"replacement")); + } + + #[test] + #[cfg(feature = "profiler")] + fn action_statistics_fill_all_slots_before_raising_threshold() { + let mut statistics = ActionStatistics::new(); + for (name, duration) in [ + ("five", 5), + ("four", 4), + ("three", 3), + ("two", 2), + ("one", 1), + ] { + statistics + .update_running_action(name, Instant::now() - Duration::from_millis(duration)); + statistics.save_action_timing(); + } + + assert_eq!(statistics.longest_runtimes(false).count(), 5); + } + + #[test] + #[cfg(not(feature = "profiler"))] + fn disabled_action_profiler_is_a_noop() { + let mut statistics = ActionStatistics::new(); + statistics.update_running_action("test-action", Instant::now() - Duration::from_millis(1)); + statistics.save_action_timing(); + assert!(statistics.is_empty()); + } +} diff --git a/crates/gpui/src/queue.rs b/crates/gpui/src/queue.rs index 8a9cc10a..98f0ae46 100644 --- a/crates/gpui/src/queue.rs +++ b/crates/gpui/src/queue.rs @@ -232,6 +232,11 @@ impl PriorityQueueReceiver { (sender, receiver) } + /// Returns whether the queue currently contains no elements. + pub fn is_empty(&self) -> bool { + self.state.queues.lock().is_empty() + } + /// Tries to pop one element from the priority queue without blocking. /// /// This will early return if there are no elements in the queue. diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index ed27b3c2..f383d114 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -22,6 +22,17 @@ pub type PathVertex_ScaledPixels = PathVertex; #[expect(missing_docs)] pub type DrawOrder = u32; +/// A boolean stored as an initialized `u32` for padding-free GPU buffer layouts. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +#[repr(transparent)] +pub struct PaddedBool32(u32); + +impl From for PaddedBool32 { + fn from(value: bool) -> Self { + Self(value as u32) + } +} + #[derive(Default)] #[expect(missing_docs)] pub struct Scene { @@ -654,7 +665,7 @@ pub struct Underline { pub content_mask: ContentMask, pub color: Hsla, pub thickness: ScaledPixels, - pub wavy: u32, + pub wavy: PaddedBool32, } impl From for Primitive { @@ -907,7 +918,7 @@ impl From for Primitive { pub struct PolychromeSprite { pub order: DrawOrder, pub pad: u32, - pub grayscale: bool, + pub grayscale: PaddedBool32, pub opacity: f32, pub bounds: Bounds, pub content_mask: ContentMask, @@ -1108,6 +1119,7 @@ impl PathVertex { #[cfg(test)] mod tests { + use std::mem::{align_of, offset_of, size_of}; use std::sync::Arc; use super::*; @@ -1124,6 +1136,125 @@ mod tests { GlobalElementId(Arc::from([ElementId::from("layer")])) } + fn test_backdrop_blur(order: DrawOrder) -> BackdropBlur { + let bounds = test_bounds(); + BackdropBlur { + order, + pad: 0, + bounds, + content_mask: ContentMask { bounds }, + corner_radii: Corners::all(ScaledPixels(2.0)), + blur_radius: ScaledPixels(12.0), + source_origin_x: 0.0, + source_origin_y: 0.0, + source_width: 1.0, + source_height: 1.0, + pad2: 0, + } + } + + #[test] + fn backdrop_blur_gpu_layout_matches_shader_storage_contract() { + assert_eq!(align_of::(), 4); + assert_eq!(size_of::(), 80); + assert_eq!(offset_of!(BackdropBlur, order), 0); + assert_eq!(offset_of!(BackdropBlur, bounds), 8); + assert_eq!(offset_of!(BackdropBlur, content_mask), 24); + assert_eq!(offset_of!(BackdropBlur, corner_radii), 40); + assert_eq!(offset_of!(BackdropBlur, blur_radius), 56); + assert_eq!(offset_of!(BackdropBlur, source_origin_x), 60); + assert_eq!(offset_of!(BackdropBlur, source_height), 72); + assert_eq!(offset_of!(BackdropBlur, pad2), 76); + } + + #[test] + fn padded_bool32_has_initialized_u32_representation() { + assert_eq!(size_of::(), size_of::()); + assert_eq!(align_of::(), align_of::()); + assert_eq!(PaddedBool32::from(false).0, 0); + assert_eq!(PaddedBool32::from(true).0, 1); + } + + #[test] + fn underline_gpu_layout_matches_shader_storage_contract() { + assert_eq!(align_of::(), 4); + assert_eq!(size_of::(), 64); + assert_eq!(offset_of!(Underline, order), 0); + assert_eq!(offset_of!(Underline, bounds), 8); + assert_eq!(offset_of!(Underline, content_mask), 24); + assert_eq!(offset_of!(Underline, color), 40); + assert_eq!(offset_of!(Underline, thickness), 56); + assert_eq!(offset_of!(Underline, wavy), 60); + } + + #[test] + fn polychrome_sprite_gpu_layout_matches_shader_storage_contract() { + assert_eq!(align_of::(), 4); + assert_eq!(size_of::(), 96); + assert_eq!(offset_of!(PolychromeSprite, order), 0); + assert_eq!(offset_of!(PolychromeSprite, grayscale), 8); + assert_eq!(offset_of!(PolychromeSprite, opacity), 12); + assert_eq!(offset_of!(PolychromeSprite, bounds), 16); + assert_eq!(offset_of!(PolychromeSprite, content_mask), 32); + assert_eq!(offset_of!(PolychromeSprite, corner_radii), 48); + assert_eq!(offset_of!(PolychromeSprite, tile), 64); + } + + #[test] + fn backdrop_blurs_sort_and_batch_between_surrounding_primitives() { + let bounds = test_bounds(); + let mut scene = Scene::default(); + scene.backdrop_blurs = vec![test_backdrop_blur(3), test_backdrop_blur(2)]; + scene.quads = vec![ + Quad { + order: 4, + bounds, + content_mask: ContentMask { bounds }, + ..Default::default() + }, + Quad { + order: 1, + bounds, + content_mask: ContentMask { bounds }, + ..Default::default() + }, + ]; + + scene.finish(); + + assert_eq!( + scene + .backdrop_blurs + .iter() + .map(|blur| blur.order) + .collect::>(), + vec![2, 3] + ); + assert_eq!( + scene + .quads + .iter() + .map(|quad| quad.order) + .collect::>(), + vec![1, 4] + ); + + let mut batches = scene.batches(); + assert!(matches!( + batches.next(), + Some(PrimitiveBatch::Quads(range)) if range == (0..1) + )); + assert!(matches!( + batches.next(), + Some(PrimitiveBatch::BackdropBlurs(range)) if range == (0..2) + )); + assert!(matches!( + batches.next(), + Some(PrimitiveBatch::Quads(range)) if range == (1..2) + )); + assert!(batches.next().is_none()); + } + #[test] fn replay_retained_layer_ranges_and_marks_content_clean() { let bounds = test_bounds(); diff --git a/crates/gpui/src/style.rs b/crates/gpui/src/style.rs index c5bb61a9..78d95338 100644 --- a/crates/gpui/src/style.rs +++ b/crates/gpui/src/style.rs @@ -9,7 +9,7 @@ use crate::{ CornersRefinement, CursorStyle, DefiniteLength, DevicePixels, Edges, EdgesRefinement, Font, FontFallbacks, FontFeatures, FontStyle, FontWeight, GridLocation, Hsla, Length, Pixels, Point, PointRefinement, Rgba, SharedString, Size, SizeRefinement, Styled, TextRun, Window, black, phi, - point, quad, rems, size, + point, px, quad, rems, size, }; use collections::HashSet; use refineable::Refineable; @@ -362,6 +362,39 @@ pub struct BoxShadow { pub inset: bool, } +impl BoxShadow { + /// Creates a new [`BoxShadow`] with the given offset and color, matching the order + /// of the CSS `box-shadow` property. Use the builder methods to set blur radius, + /// spread radius, and inset. + pub fn new(offset_x: Pixels, offset_y: Pixels, color: Hsla) -> Self { + Self { + color, + offset: point(offset_x, offset_y), + blur_radius: px(0.), + spread_radius: px(0.), + inset: false, + } + } + + /// Sets the shadow blur radius. + pub fn blur_radius(mut self, blur_radius: Pixels) -> Self { + self.blur_radius = blur_radius; + self + } + + /// Sets the shadow spread radius. + pub fn spread_radius(mut self, spread_radius: Pixels) -> Self { + self.spread_radius = spread_radius; + self + } + + /// Marks the shadow as inset (drawn inside the element's bounds). + pub fn inset(mut self) -> Self { + self.inset = true; + self + } +} + /// How to handle whitespace in text #[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub enum WhiteSpace { @@ -382,6 +415,10 @@ pub enum TextOverflow { /// displaying the provided string at the beginning (e.g., "…ong text here"). /// Typically more adequate for file paths where the end is more important than the beginning. TruncateStart(SharedString), + /// Truncate the text in the middle when it doesn't fit, preserving both the start and end + /// of the string (e.g., "long fi…name.rs"). Useful for filenames where both the prefix + /// and the extension are important context. + TruncateMiddle(SharedString), } /// How to align text within the element @@ -1305,6 +1342,26 @@ mod tests { use super::*; + #[test] + fn box_shadow_builder_sets_css_ordered_fields_and_defaults() { + let color = red(); + let shadow = BoxShadow::new(px(1.), px(2.), color) + .blur_radius(px(3.)) + .spread_radius(px(4.)) + .inset(); + + assert_eq!(shadow.offset, point(px(1.), px(2.))); + assert_eq!(shadow.color, color); + assert_eq!(shadow.blur_radius, px(3.)); + assert_eq!(shadow.spread_radius, px(4.)); + assert!(shadow.inset); + + let defaults = BoxShadow::new(px(5.), px(6.), color); + assert_eq!(defaults.blur_radius, px(0.)); + assert_eq!(defaults.spread_radius, px(0.)); + assert!(!defaults.inset); + } + use util_macros::perf; #[perf] @@ -1518,7 +1575,7 @@ mod tests { legacy_shadow, BoxShadow { inset: false, - ..shadow.clone() + ..shadow } ); diff --git a/crates/gpui/src/styled.rs b/crates/gpui/src/styled.rs index 168e4f3a..5381b65c 100644 --- a/crates/gpui/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -90,6 +90,14 @@ pub trait Styled: Sized { self } + /// Sets the truncate overflowing text with an ellipsis (…) in the middle if needed. + /// Preserves the beginning and end of the text. Useful for filenames. + /// Note: This doesn't exist in Tailwind CSS. + fn text_ellipsis_middle(mut self) -> Self { + self.text_style().text_overflow = Some(TextOverflow::TruncateMiddle(ELLIPSIS)); + self + } + /// Sets the text overflow behavior of the element. fn text_overflow(mut self, overflow: TextOverflow) -> Self { self.text_style().text_overflow = Some(overflow); @@ -211,6 +219,7 @@ pub trait Styled: Sized { fn flex_none(mut self) -> Self { self.style().flex_grow = Some(0.); self.style().flex_shrink = Some(0.); + self.style().flex_basis = Some(Length::Auto); self } @@ -221,17 +230,31 @@ pub trait Styled: Sized { self } - /// Sets the element to allow a flex item to grow to fill any available space. + /// Sets the flex item's grow factor. /// [Docs](https://tailwindcss.com/docs/flex-grow) - fn flex_grow(mut self) -> Self { + fn flex_grow(mut self, grow: f32) -> Self { + self.style().flex_grow = Some(grow); + self + } + + /// Disables flex item growth (flex-grow: 0). + /// [Docs](https://tailwindcss.com/docs/flex-grow#dont-grow) + fn flex_grow_0(mut self) -> Self { + self.style().flex_grow = Some(0.); + self + } + + /// Enables flex item growth (flex-grow: 1). + /// [Docs](https://tailwindcss.com/docs/flex-grow#grow-1) + fn flex_grow_1(mut self) -> Self { self.style().flex_grow = Some(1.); self } - /// Sets the element to allow a flex item to shrink if needed. + /// Sets the flex item's shrink factor. /// [Docs](https://tailwindcss.com/docs/flex-shrink) - fn flex_shrink(mut self) -> Self { - self.style().flex_shrink = Some(1.); + fn flex_shrink(mut self, shrink: f32) -> Self { + self.style().flex_shrink = Some(shrink); self } @@ -242,6 +265,13 @@ pub trait Styled: Sized { self } + /// Enables flex item shrinking (flex-shrink: 1). + /// [Docs](https://tailwindcss.com/docs/flex-shrink#shrink-1) + fn flex_shrink_1(mut self) -> Self { + self.style().flex_shrink = Some(1.); + self + } + /// Sets the element to allow flex items to wrap. /// [Docs](https://tailwindcss.com/docs/flex-wrap#wrap-normally) fn flex_wrap(mut self) -> Self { @@ -870,3 +900,40 @@ pub trait Styled: Sized { self } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flex_helpers_set_custom_and_standard_factors() { + let mut style = StyleRefinement::default().flex_grow(2.5).flex_shrink(0.25); + assert_eq!(style.flex_grow, Some(2.5)); + assert_eq!(style.flex_shrink, Some(0.25)); + + style = style.flex_grow_1().flex_shrink_1(); + assert_eq!(style.flex_grow, Some(1.0)); + assert_eq!(style.flex_shrink, Some(1.0)); + } + + #[test] + fn flex_none_resets_all_flex_components() { + let style = StyleRefinement::default() + .flex_basis(relative(0.5)) + .flex_none(); + + assert_eq!(style.flex_grow, Some(0.0)); + assert_eq!(style.flex_shrink, Some(0.0)); + assert_eq!(style.flex_basis, Some(Length::Auto)); + } + + #[test] + fn text_ellipsis_middle_sets_middle_overflow() { + let mut style = StyleRefinement::default().text_ellipsis_middle(); + + assert_eq!( + style.text_style().text_overflow, + Some(TextOverflow::TruncateMiddle(ELLIPSIS)) + ); + } +} diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs index abc24008..d52d4dd6 100644 --- a/crates/gpui/src/svg_renderer.rs +++ b/crates/gpui/src/svg_renderer.rs @@ -102,6 +102,28 @@ pub enum SvgSize { ScaleFactor(f32), } +const MAX_PIXMAP_DIMENSION: f32 = 8192.; + +fn capped_pixmap_scale(width: f32, height: f32, mut scale: f32) -> f32 { + let scaled_width = width * scale; + if scaled_width > MAX_PIXMAP_DIMENSION { + scale *= MAX_PIXMAP_DIMENSION / scaled_width; + } + + let scaled_height = height * scale; + if scaled_height > MAX_PIXMAP_DIMENSION { + scale *= MAX_PIXMAP_DIMENSION / scaled_height; + } + scale +} + +fn pixmap_dimensions(width: f32, height: f32, scale: f32) -> (u32, u32) { + ( + (width * scale).max(1.) as u32, + (height * scale).max(1.) as u32, + ) +} + impl SvgRenderer { /// Creates a new SVG renderer with the provided asset source. pub fn new(asset_source: Arc) -> Self { @@ -229,17 +251,23 @@ impl SvgRenderer { fn render_pixmap(&self, bytes: &[u8], size: SvgSize) -> Result { let tree = usvg::Tree::from_data(bytes, &self.usvg_options)?; let svg_size = tree.size(); - let scale = match size { + let requested_scale = match size { SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(), SvgSize::ScaleFactor(scale) => scale, }; + let scale = capped_pixmap_scale(svg_size.width(), svg_size.height(), requested_scale); + if scale < requested_scale { + log::warn!( + "Capping SVG pixmap from {}x{} to at most {MAX_PIXMAP_DIMENSION} pixels per dimension", + svg_size.width() * requested_scale, + svg_size.height() * requested_scale, + ); + } // Render the SVG to a pixmap with the specified width and height. - let mut pixmap = resvg::tiny_skia::Pixmap::new( - (svg_size.width() * scale) as u32, - (svg_size.height() * scale) as u32, - ) - .ok_or(usvg::Error::InvalidSize)?; + let (width, height) = pixmap_dimensions(svg_size.width(), svg_size.height(), scale); + let mut pixmap = + resvg::tiny_skia::Pixmap::new(width, height).ok_or(usvg::Error::InvalidSize)?; let transform = resvg::tiny_skia::Transform::from_scale(scale, scale); @@ -293,3 +321,49 @@ fn fix_generic_font_families(db: &mut usvg::fontdb::Database) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + const IBM_PLEX_REGULAR: &[u8] = + include_bytes!("../../gpui_web/assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const LILEX_REGULAR: &[u8] = + include_bytes!("../../gpui_web/assets/fonts/lilex/Lilex-Regular.ttf"); + + #[test] + fn pixmap_scale_caps_each_dimension_at_8192() { + assert_eq!(capped_pixmap_scale(100., 200., 2.), 2.); + assert_eq!(capped_pixmap_scale(16_384., 4_096., 1.), 0.5); + assert_eq!(capped_pixmap_scale(4_096., 16_384., 1.), 0.5); + assert_eq!(capped_pixmap_scale(16_384., 32_768., 1.), 0.25); + } + + #[test] + fn pixmap_dimensions_preserve_extreme_aspect_ratios() { + let scale = capped_pixmap_scale(1_000_000., 1., 1.); + assert_eq!(pixmap_dimensions(1_000_000., 1., scale), (8192, 1)); + + let scale = capped_pixmap_scale(1., 1_000_000., 1.); + assert_eq!(pixmap_dimensions(1., 1_000_000., scale), (1, 8192)); + } + + #[test] + fn text_with_split_glyph_clusters_in_mixed_fonts_does_not_panic() { + let mut db = usvg::fontdb::Database::new(); + db.load_font_data(IBM_PLEX_REGULAR.to_vec()); + db.load_font_data(LILEX_REGULAR.to_vec()); + let options = usvg::Options { + fontdb: std::sync::Arc::new(db), + ..Default::default() + }; + + let zalgo = "e\u{0301}\u{0302}\u{0303}\u{0304}\u{0306}\u{0307}\u{0308}\u{030a}"; + let svg = format!( + r#"{zalgo}{zalgo}"# + ); + + usvg::Tree::from_data(svg.as_bytes(), &options) + .expect("SVG with mixed-font text should parse"); + } +} diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index b6754ade..d4654f1a 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -97,7 +97,7 @@ impl TextSystem { .map(|font| font.family.to_string()), ); names.push(".SystemUIFont".to_string()); - names.sort(); + names.sort_unstable(); names.dedup(); names } @@ -356,6 +356,11 @@ impl TextSystem { .rasterize_glyph(params, raster_bounds) } + /// Returns the dilation level to use for a glyph painted in the given color. + pub(crate) fn glyph_dilation_for_color(&self, color: Hsla) -> u8 { + self.platform_text_system.glyph_dilation_for_color(color) + } + /// Returns the text rendering mode recommended by the platform for the given font and size. /// The return value will never be [`TextRenderingMode::PlatformDefault`]. pub(crate) fn recommended_rendering_mode( @@ -1043,6 +1048,7 @@ pub struct RenderGlyphParams { pub scale_factor: f32, pub is_emoji: bool, pub subpixel_rendering: bool, + pub dilation: u8, } impl Eq for RenderGlyphParams {} @@ -1056,6 +1062,7 @@ impl Hash for RenderGlyphParams { self.scale_factor.to_bits().hash(state); self.is_emoji.hash(state); self.subpixel_rendering.hash(state); + self.dilation.hash(state); } } diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index a0c9c2df..6eb73b23 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -10,6 +10,8 @@ pub enum TruncateFrom { Start, /// Truncate text from the end. End, + /// Truncate text from the middle, preserving the start and end. + Middle, } /// The GPUI line wrapper, used to wrap lines of text to a given width. @@ -180,11 +182,68 @@ impl LineWrapper { } } } + TruncateFrom::Middle => {} } None } + fn should_truncate_line_middle( + &mut self, + line: &str, + truncate_width: Pixels, + truncation_affix: &str, + ) -> Option<(usize, usize)> { + let affix_width = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.0), |width, char_width| width + char_width); + let total_width = line + .chars() + .map(|c| self.width_for_char(c)) + .fold(px(0.0), |width, char_width| width + char_width); + + if total_width <= truncate_width { + return None; + } + + let content_budget = truncate_width - affix_width; + if content_budget <= px(0.) { + return Some((0, line.len())); + } + + let front_budget = content_budget * (2.0 / 3.0); + let back_budget = content_budget - front_budget; + + let mut front_width = px(0.); + let mut front_end_ix = 0; + for (ix, c) in line.char_indices() { + let char_width = self.width_for_char(c); + if front_width + char_width > front_budget { + break; + } + front_width += char_width; + front_end_ix = ix + c.len_utf8(); + } + + let mut back_width = px(0.); + let mut back_start_ix = line.len(); + for (ix, c) in line.char_indices().rev() { + let char_width = self.width_for_char(c); + if back_width + char_width > back_budget { + break; + } + back_width += char_width; + back_start_ix = ix; + } + + if front_end_ix >= back_start_ix { + return Some((0, line.len())); + } + + Some((front_end_ix, back_start_ix)) + } + /// Truncate a line of text to the given width with this wrapper's font and font size. pub fn truncate_line<'a>( &mut self, @@ -194,6 +253,28 @@ impl LineWrapper { runs: &'a [TextRun], truncate_from: TruncateFrom, ) -> (SharedString, Cow<'a, [TextRun]>) { + if truncate_from == TruncateFrom::Middle { + if let Some((front_end_ix, back_start_ix)) = + self.should_truncate_line_middle(&line, truncate_width, truncation_affix) + { + let result = SharedString::from(format!( + "{}{truncation_affix}{}", + &line[..front_end_ix], + &line[back_start_ix..] + )); + let mut runs = runs.to_vec(); + update_runs_after_middle_truncation( + truncation_affix, + &mut runs, + front_end_ix, + back_start_ix, + ); + return (result, Cow::Owned(runs)); + } + + return (line, Cow::Borrowed(runs)); + } + if let Some(truncate_ix) = self.should_truncate_line(&line, truncate_width, truncation_affix, truncate_from) { @@ -220,6 +301,16 @@ impl LineWrapper { return (SharedString::from(""), Cow::Owned(Vec::new())); } + if truncate_from == TruncateFrom::Middle { + return self.truncate_wrapped_line_middle( + text, + wrap_width, + max_lines, + truncation_affix, + runs, + ); + } + if max_lines <= 1 { return self.truncate_single_wrapped_line( text, @@ -358,6 +449,61 @@ impl LineWrapper { (text, Cow::Borrowed(runs)) } + fn truncate_wrapped_line_middle<'a>( + &mut self, + text: SharedString, + wrap_width: Pixels, + max_lines: usize, + truncation_affix: &str, + runs: &'a [TextRun], + ) -> (SharedString, Cow<'a, [TextRun]>) { + if self.wrapped_line_count(&text, wrap_width) <= max_lines { + return (text, Cow::Borrowed(runs)); + } + + let mut boundaries = Vec::with_capacity(text.chars().count() + 1); + boundaries.push(0); + boundaries.extend(text.char_indices().map(|(ix, c)| ix + c.len_utf8())); + let character_count = boundaries.len() - 1; + + let candidate = |retained_characters: usize| { + let front_characters = retained_characters.saturating_mul(2).div_ceil(3); + let back_characters = retained_characters - front_characters; + let front_end_ix = boundaries[front_characters]; + let back_start_ix = boundaries[character_count - back_characters]; + let result = SharedString::from(format!( + "{}{truncation_affix}{}", + &text[..front_end_ix], + &text[back_start_ix..] + )); + (result, front_end_ix, back_start_ix) + }; + + // The line-count check keeps hard newlines and word wrapping authoritative; + // multiplying the one-line width by `max_lines` does not model either. + let mut low = 0; + let mut high = character_count.saturating_sub(1); + while low < high { + let mid = (low + high).div_ceil(2); + let (result, _, _) = candidate(mid); + if self.wrapped_line_count(&result, wrap_width) <= max_lines { + low = mid; + } else { + high = mid - 1; + } + } + + let (result, front_end_ix, back_start_ix) = candidate(low); + let mut runs = runs.to_vec(); + update_runs_after_middle_truncation( + truncation_affix, + &mut runs, + front_end_ix, + back_start_ix, + ); + (result, Cow::Owned(runs)) + } + fn truncate_single_wrapped_line<'a>( &mut self, text: SharedString, @@ -463,6 +609,9 @@ impl LineWrapper { trim_end_before_truncation_affix(&line[..truncate_ix], truncation_affix) )) } + TruncateFrom::Middle => { + unreachable!("middle truncation is handled before wrapped-line truncation") + } } } } @@ -575,7 +724,10 @@ impl LineWrapper { // is included so it stays attached to the preceding word when wrapping. matches!(c, '-' | '_' | '.' | '\'' | '\u{2018}' | '\u{2019}' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') || // `⋯` character is special used in Zed, to keep this at the end of the line. - matches!(c, '⋯') + matches!(c, '⋯') || + + // Non-breaking glue characters + matches!(c, '\u{202F}' | '\u{00A0}' | '\u{2011}') } #[inline(always)] @@ -617,6 +769,7 @@ fn truncated_line_text( "{}{truncation_affix}", trim_end_before_truncation_affix(&line[..truncate_ix], truncation_affix) )), + TruncateFrom::Middle => unreachable!("middle truncation is handled by truncate_line"), } } @@ -708,10 +861,67 @@ fn update_runs_after_truncation( runs.truncate(run_index + 1); } } + TruncateFrom::Middle => { + unreachable!("middle truncation updates runs through its dedicated helper") + } } runs.retain(|run| run.len > 0); } +fn update_runs_after_middle_truncation( + ellipsis: &str, + runs: &mut Vec, + front_end_ix: usize, + back_start_ix: usize, +) { + let original_runs = std::mem::take(runs); + let mut result_runs = Vec::with_capacity(original_runs.len()); + + let mut front_remaining = front_end_ix; + let mut front_done = false; + for run in &original_runs { + if front_done { + break; + } + if run.len <= front_remaining { + result_runs.push(run.clone()); + front_remaining -= run.len; + } else { + let mut partial = run.clone(); + partial.len = front_remaining + ellipsis.len(); + result_runs.push(partial); + front_done = true; + } + } + if !front_done { + if let Some(last) = result_runs.last_mut() { + last.len += ellipsis.len(); + } else if let Some(first) = original_runs.first() { + let mut affix_run = first.clone(); + affix_run.len = ellipsis.len(); + result_runs.push(affix_run); + } + } + + let mut byte_pos = 0; + for run in &original_runs { + let run_end = byte_pos + run.len; + if run_end > back_start_ix { + if byte_pos < back_start_ix { + let mut partial = run.clone(); + partial.len = run_end - back_start_ix; + result_runs.push(partial); + } else { + result_runs.push(run.clone()); + } + } + byte_pos = run_end; + } + + result_runs.retain(|run| run.len > 0); + *runs = result_runs; +} + /// A fragment of a line that can be wrapped. pub enum LineFragment<'a> { /// A text fragment consisting of characters. @@ -842,6 +1052,16 @@ mod tests { Boundary::new(18, 0) ], ); + + assert_eq!( + wrapper + .wrap_line( + &[LineFragment::text("a\u{202f}b\u{00a0}c\u{2011}d e")], + px(72.0) + ) + .collect::>(), + &[Boundary::new(12, 0)], + ); assert_eq!( wrapper .wrap_line(&[LineFragment::text("aaa aaaaaaaaaaaaaaaaaa")], px(72.0)) @@ -1415,6 +1635,90 @@ mod tests { assert!(truncated.ends_with('…')); } + #[test] + fn test_truncate_line_middle() { + let mut wrapper = build_wrapper(); + + let short_text = "hello world"; + let runs = generate_test_runs(&[short_text.len()]); + let (result, result_runs) = wrapper.truncate_line( + short_text.into(), + px(10000.), + "…", + &runs, + TruncateFrom::Middle, + ); + assert_eq!(result.as_ref(), short_text); + assert!(matches!(result_runs, Cow::Borrowed(_))); + + let long_text = "abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz"; + let runs = generate_test_runs(&[long_text.len()]); + let (result, _) = + wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle); + assert!(result.contains('…')); + assert!(result.chars().count() < long_text.chars().count()); + assert_eq!(result.chars().next(), long_text.chars().next()); + assert_eq!(result.chars().last(), long_text.chars().last()); + + let text = "abcdef"; + let runs = generate_test_runs(&[text.len()]); + let (result, result_runs) = + wrapper.truncate_line(text.into(), px(1.), "…", &runs, TruncateFrom::Middle); + assert_eq!(result.as_ref(), "…"); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + "…".len() + ); + + let runs = generate_test_runs(&[20, 20, long_text.len() - 40]); + let (result, result_runs) = + wrapper.truncate_line(long_text.into(), px(100.), "…", &runs, TruncateFrom::Middle); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + } + + #[test] + fn test_wrapped_middle_truncation_respects_max_lines() { + let mut wrapper = build_wrapper(); + let text = "beginning alpha beta gamma\nmiddle delta epsilon zeta\nending eta theta omega"; + let wrap_width = px(72.); + let max_lines = 2; + let runs = generate_test_runs(&[10, 15, text.len() - 25]); + + let (result, result_runs) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "…", + &runs, + TruncateFrom::Middle, + ); + + assert_eq!(result.chars().next(), text.chars().next()); + assert_eq!(result.chars().last(), text.chars().last()); + assert!(result.contains('…')); + assert!(wrapped_line_count(&mut wrapper, &result, wrap_width) <= max_lines); + assert_eq!( + result_runs.iter().map(|run| run.len).sum::(), + result.len() + ); + assert!(result_runs.iter().all(|run| run.len > 0)); + } + + #[test] + fn test_middle_truncation_drops_empty_boundary_runs() { + let mut runs = generate_test_runs(&[4, 4, 4]); + + update_runs_after_middle_truncation("", &mut runs, 4, 8); + + assert_eq!( + runs.iter().map(|run| run.len).collect::>(), + vec![4, 4] + ); + } + #[test] fn test_multiline_truncation_trailing_newline() { let mut wrapper = build_wrapper(); @@ -1608,6 +1912,10 @@ mod tests { assert_not_word("こんにちは"); assert_not_word("😀😁😂"); assert_not_word("()[]{}<>"); + + assert_word("\u{202f}"); + assert_word("\u{00a0}"); + assert_word("\u{2011}"); } // For compatibility with the test macro diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 5c1d6609..6b1ae7a4 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -1,36 +1,24 @@ use crate::{ AnyElement, AnyEntity, AnyWeakEntity, App, Bounds, ContentMask, Context, Element, ElementId, Entity, EntityId, GlobalElementId, InspectorElementId, IntoElement, LayoutId, PaintIndex, - Pixels, PrepaintStateIndex, Render, Style, StyleRefinement, TextStyle, WeakEntity, + Pixels, PrepaintStateIndex, Render, RenderOnce, Style, StyleRefinement, TextStyle, WeakEntity, }; use crate::{Empty, Window}; use anyhow::Result; use collections::FxHashSet; use refineable::Refineable; use std::mem; -use std::rc::Rc; use std::{any::TypeId, fmt, ops::Range}; -struct AnyViewState { - prepaint_range: Range, - paint_range: Range, - cache_key: ViewCacheKey, - accessed_entities: FxHashSet, -} - -#[derive(Default)] -struct ViewCacheKey { - bounds: Bounds, - content_mask: ContentMask, - text_style: TextStyle, -} - -/// A dynamically-typed handle to a view, which can be downcast to a [Entity] for a specific type. +/// A dynamically-typed view handle that can be downcast to a specific `Entity`. +/// +/// This is the type-erased counterpart to [`ViewElement`]: it holds an entity plus +/// a function pointer to its render, and is itself a [`View`], so embedding it as an +/// element goes through the same [`ViewElement`] machinery as any other view. #[derive(Clone, Debug)] pub struct AnyView { entity: AnyEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, - cached_style: Option>, } impl From> for AnyView { @@ -38,18 +26,18 @@ impl From> for AnyView { AnyView { entity: value.into_any(), render: any_view::render::, - cached_style: None, } } } impl AnyView { - /// Indicate that this view should be cached when using it as an element. - /// When using this method, the view's previous layout and paint will be recycled from the previous frame if [Context::notify] has not been called since it was rendered. - /// The one exception is when [Window::refresh] is called, in which case caching is ignored. - pub fn cached(mut self, style: StyleRefinement) -> Self { - self.cached_style = Some(style.into()); - self + /// Embed this view as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is recycled from the previous frame unless + /// [Context::notify] was called on the backing entity since it was rendered + /// (or [Window::refresh] is called, which ignores caching). + pub fn cached(self, style: StyleRefinement) -> ViewElement { + ViewElement::new(self).cached(style) } /// Convert this to a weak handle. @@ -68,7 +56,6 @@ impl AnyView { Err(entity) => Err(Self { entity, render: self.render, - cached_style: self.cached_style, }), } } @@ -78,7 +65,7 @@ impl AnyView { self.entity.entity_type } - /// Gets the entity id of this handle. + /// The [`EntityId`] of this view. pub fn entity_id(&self) -> EntityId { self.entity.entity_id() } @@ -92,185 +79,48 @@ impl PartialEq for AnyView { impl Eq for AnyView {} -impl Element for AnyView { - type RequestLayoutState = Option; - type PrepaintState = Option; - - fn id(&self) -> Option { - Some(ElementId::View(self.entity_id())) - } - - fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { - None - } - - fn request_layout( - &mut self, - _id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - window.with_rendered_view(self.entity_id(), |window| { - // Disable caching when inspecting so that mouse_hit_test has all hitboxes. - let caching_disabled = window.is_inspector_picking(cx); - match self.cached_style.as_ref() { - Some(style) if !caching_disabled => { - let mut root_style = Style::default(); - root_style.refine(style); - let layout_id = window.request_layout(root_style, None, cx); - (layout_id, None) - } - _ => { - let mut element = (self.render)(self, window, cx); - let layout_id = element.request_layout(window, cx); - (layout_id, Some(element)) - } - } - }) - } - - fn prepaint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - bounds: Bounds, - element: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Option { - window.set_view_id(self.entity_id()); - window.with_rendered_view(self.entity_id(), |window| { - if let Some(mut element) = element.take() { - element.prepaint(window, cx); - return Some(element); - } - - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let content_mask = window.content_mask(); - let text_style = window.text_style(); - - if let Some(mut element_state) = element_state - && element_state.cache_key.bounds == bounds - && element_state.cache_key.content_mask == content_mask - && element_state.cache_key.text_style == text_style - && !window.dirty_views.contains(&self.entity_id()) - && !window.refreshing - && !window.a11y.is_active() - { - let prepaint_start = window.prepaint_index(); - window.reuse_prepaint(element_state.prepaint_range.clone()); - cx.entities - .extend_accessed(&element_state.accessed_entities); - let prepaint_end = window.prepaint_index(); - element_state.prepaint_range = prepaint_start..prepaint_end; - - return (None, element_state); - } - - let refreshing = mem::replace(&mut window.refreshing, true); - let prepaint_start = window.prepaint_index(); - let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { - let mut element = (self.render)(self, window, cx); - element.layout_as_root(bounds.size.into(), window, cx); - element.prepaint_at(bounds.origin, window, cx); - element - }); - - let prepaint_end = window.prepaint_index(); - window.refreshing = refreshing; - - ( - Some(element), - AnyViewState { - accessed_entities, - prepaint_range: prepaint_start..prepaint_end, - paint_range: PaintIndex::default()..PaintIndex::default(), - cache_key: ViewCacheKey { - bounds, - content_mask, - text_style, - }, - }, - ) - }, - ) - }) +/// `AnyView` is the type-erased [`View`]: its `render` is a function pointer rather +/// than a concrete type, but it participates in the reactive graph exactly like any +/// other view via [`ViewElement`]. +impl View for AnyView { + fn entity_id(&self) -> Option { + Some(self.entity.entity_id()) } - fn paint( - &mut self, - global_id: Option<&GlobalElementId>, - _inspector_id: Option<&InspectorElementId>, - _bounds: Bounds, - _: &mut Self::RequestLayoutState, - element: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - window.with_rendered_view(self.entity_id(), |window| { - let caching_disabled = window.is_inspector_picking(cx); - if self.cached_style.is_some() && !caching_disabled { - window.with_element_state::( - global_id.unwrap(), - |element_state, window| { - let mut element_state = element_state.unwrap(); - - let paint_start = window.paint_index(); - - if let Some(element) = element { - let refreshing = mem::replace(&mut window.refreshing, true); - element.paint(window, cx); - window.refreshing = refreshing; - } else { - window.reuse_paint(element_state.paint_range.clone()); - } - - let paint_end = window.paint_index(); - element_state.paint_range = paint_start..paint_end; - - ((), element_state) - }, - ) - } else { - element.as_mut().unwrap().paint(window, cx); - } - }); + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + (self.render)(&self, window, cx) } } impl IntoElement for Entity { - type Element = AnyView; + type Element = ViewElement>; fn into_element(self) -> Self::Element { - self.into() + ViewElement::new(self) } } impl IntoElement for AnyView { - type Element = Self; + type Element = ViewElement; fn into_element(self) -> Self::Element { - self + ViewElement::new(self) } } -/// A weak, dynamically-typed view handle that does not prevent the view from being released. +/// A weak, dynamically-typed view handle. pub struct AnyWeakView { entity: AnyWeakEntity, render: fn(&AnyView, &mut Window, &mut App) -> AnyElement, } impl AnyWeakView { - /// Convert to a strongly-typed handle if the referenced view has not yet been released. + /// Upgrade to a strong `AnyView` handle, if the view is still alive. pub fn upgrade(&self) -> Option { let entity = self.entity.upgrade()?; Some(AnyView { entity, render: self.render, - cached_style: None, }) } } @@ -311,6 +161,336 @@ mod any_view { } } +/// A renderable that participates in GPUI's reactive graph — the unifying model +/// behind [`Render`] and [`RenderOnce`]. +/// +/// When `entity_id()` returns `Some`, that id becomes the view's identity: it gets +/// a unique element-id space (so internal `use_state` / `.id(..)` never collide +/// across siblings) and `cx.notify()` on that entity re-renders only this view's +/// subtree. `None` behaves like a stateless component. +/// +/// You rarely implement `View` directly. `Entity` and any `T: RenderOnce` +/// get a blanket impl below; implement it by hand only when a component needs both +/// parent-supplied props *and* a backing entity for identity. +pub trait View: 'static + Sized { + /// This view's identity, if it has one. A view typically holds the backing + /// entity as a field and returns its [`EntityId`] here. + /// + /// The id becomes this view's [`ElementId`], so two views keyed on the same + /// entity must not be rendered at the same position in the element tree + /// (e.g. as siblings under the same parent): their internal element state + /// (`use_state`, scroll offsets, etc.) would silently collide. Nesting is + /// fine — the id is scoped by the parent path. + fn entity_id(&self) -> Option; + + /// Render this view into an element tree, consuming `self`. + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement; +} + +/// A stateless component (`RenderOnce`) is a `View` with no identity. +impl View for T { + fn entity_id(&self) -> Option { + None + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + RenderOnce::render(self, window, cx) + } +} + +/// An entity that renders itself (`Render`) is a `View` keyed on its own id. +impl View for Entity { + fn entity_id(&self) -> Option { + Some(Entity::entity_id(self)) + } + + #[inline] + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + self.update(cx, |this, cx| { + Render::render(this, window, cx).into_any_element() + }) + } +} + +impl Entity { + /// Embed this entity as a cached [`ViewElement`] laid out at `style`. + /// + /// The rendered subtree is reused until the entity is notified (or the + /// cached bounds / text style change). Caching requires a definite size: + /// a cached view is laid out from `style` and is *not* measured from its + /// contents. Use [`ViewElement::new`] (or `.child(entity)`) for the + /// uncached case. + #[track_caller] + pub fn cached(self, style: StyleRefinement) -> ViewElement> { + ViewElement::new(self).cached(style) + } +} + +/// The element type for [`View`] implementations. Wraps a `View` and hooks it +/// into layout, prepaint, and paint. Constructed via [`ViewElement::new`]. +#[doc(hidden)] +pub struct ViewElement { + view: Option, + entity_id: Option, + cached_style: Option, + #[cfg(debug_assertions)] + source: &'static core::panic::Location<'static>, +} + +impl ViewElement { + /// Wrap a [`View`] as an element. + #[track_caller] + pub fn new(view: V) -> Self { + let entity_id = view.entity_id(); + ViewElement { + entity_id, + cached_style: None, + view: Some(view), + #[cfg(debug_assertions)] + source: core::panic::Location::caller(), + } + } + + /// Enable caching of this view's rendered subtree, laid out at `style`. + /// The composer supplies the layout style because caching skips rendering + /// the contents to measure them. + /// + /// Crate-private on purpose: caching is only sound for entity-backed views, + /// where [`Context::notify`] is the contract that busts the cache. A stateless + /// view has no such contract, so a frozen subtree could never be invalidated. + /// Reach this through [`Entity::cached`] or [`AnyView::cached`], which are + /// entity-backed by construction. + pub(crate) fn cached(mut self, style: StyleRefinement) -> Self { + self.cached_style = Some(style); + self + } +} + +impl IntoElement for ViewElement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +struct ViewElementState { + prepaint_range: Range, + paint_range: Range, + cache_key: ViewElementCacheKey, + accessed_entities: FxHashSet, +} + +struct ViewElementCacheKey { + bounds: Bounds, + content_mask: ContentMask, + text_style: TextStyle, +} + +impl Element for ViewElement { + type RequestLayoutState = Option; + type PrepaintState = Option; + + fn id(&self) -> Option { + self.entity_id.map(ElementId::View) + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + #[cfg(debug_assertions)] + return Some(self.source); + + #[cfg(not(debug_assertions))] + return None; + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + if let Some(entity_id) = self.entity_id { + // Stateful path: create a reactive boundary. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + match self.cached_style.as_ref() { + Some(style) if !caching_disabled => { + let mut root_style = Style::default(); + root_style.refine(style); + let layout_id = window.request_layout(root_style, None, cx); + (layout_id, None) + } + _ => { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + } + } + }) + } else { + // Stateless path: isolate subtree via type name (no entity identity). + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + let layout_id = element.request_layout(window, cx); + (layout_id, Some(element)) + }, + ) + } + } + + fn prepaint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + bounds: Bounds, + element: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Option { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.set_view_id(entity_id); + window.with_rendered_view(entity_id, |window| { + if let Some(mut element) = element.take() { + element.prepaint(window, cx); + return Some(element); + } + + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let content_mask = window.content_mask(); + let text_style = window.text_style(); + + if let Some(mut element_state) = element_state + && element_state.cache_key.bounds == bounds + && element_state.cache_key.content_mask == content_mask + && element_state.cache_key.text_style == text_style + && !window.dirty_views.contains(&entity_id) + && !window.refreshing + && !window.a11y.is_active() + { + let prepaint_start = window.prepaint_index(); + window.reuse_prepaint(element_state.prepaint_range.clone()); + cx.entities + .extend_accessed(&element_state.accessed_entities); + let prepaint_end = window.prepaint_index(); + element_state.prepaint_range = prepaint_start..prepaint_end; + + return (None, element_state); + } + + let refreshing = mem::replace(&mut window.refreshing, true); + let prepaint_start = window.prepaint_index(); + let (mut element, accessed_entities) = cx.detect_accessed_entities(|cx| { + let mut element = self + .view + .take() + .unwrap() + .render(window, cx) + .into_any_element(); + element.layout_as_root(bounds.size.into(), window, cx); + element.prepaint_at(bounds.origin, window, cx); + element + }); + + let prepaint_end = window.prepaint_index(); + window.refreshing = refreshing; + + ( + Some(element), + ViewElementState { + accessed_entities, + prepaint_range: prepaint_start..prepaint_end, + paint_range: PaintIndex::default()..PaintIndex::default(), + cache_key: ViewElementCacheKey { + bounds, + content_mask, + text_style, + }, + }, + ) + }, + ) + }) + } else { + // Stateless path: just prepaint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().prepaint(window, cx); + }, + ); + Some(element.take().unwrap()) + } + } + + fn paint( + &mut self, + global_id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _request_layout: &mut Self::RequestLayoutState, + element: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + if let Some(entity_id) = self.entity_id { + // Stateful path. + window.with_rendered_view(entity_id, |window| { + let caching_disabled = window.is_inspector_picking(cx); + if self.cached_style.is_some() && !caching_disabled { + window.with_element_state::( + global_id.unwrap(), + |element_state, window| { + let mut element_state = element_state.unwrap(); + + let paint_start = window.paint_index(); + + if let Some(element) = element { + let refreshing = mem::replace(&mut window.refreshing, true); + element.paint(window, cx); + window.refreshing = refreshing; + } else { + window.reuse_paint(element_state.paint_range.clone()); + } + + let paint_end = window.paint_index(); + element_state.paint_range = paint_start..paint_end; + + ((), element_state) + }, + ) + } else { + element.as_mut().unwrap().paint(window, cx); + } + }); + } else { + // Stateless path: just paint the element. + window.with_id( + ElementId::Name(std::any::type_name::().into()), + |window| { + element.as_mut().unwrap().paint(window, cx); + }, + ); + } + } +} + /// A view that renders nothing pub struct EmptyView; @@ -319,3 +499,7 @@ impl Render for EmptyView { Empty } } + +#[cfg(test)] +#[path = "view_tests.rs"] +mod tests; diff --git a/crates/gpui/src/view_tests.rs b/crates/gpui/src/view_tests.rs new file mode 100644 index 00000000..9265a284 --- /dev/null +++ b/crates/gpui/src/view_tests.rs @@ -0,0 +1,133 @@ +use std::{cell::Cell, rc::Rc}; + +use crate::{ + App, AppContext, Context, Element, ElementId, Entity, IntoElement, Render, RenderOnce, + StyleRefinement, Styled, TestAppContext, View, ViewElement, Window, div, px, +}; + +#[derive(IntoElement)] +struct StatelessComponent { + render_count: Rc>, +} + +impl RenderOnce for StatelessComponent { + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + self.render_count.set(self.render_count.get() + 1); + div() + } +} + +struct StatelessRoot { + render_count: Rc>, +} + +impl Render for StatelessRoot { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + StatelessComponent { + render_count: self.render_count.clone(), + } + } +} + +#[gpui::test] +fn derived_render_once_uses_the_stateless_view_path(cx: &mut TestAppContext) { + let render_count = Rc::new(Cell::new(0)); + let (_, _cx) = cx.add_window_view(|_, _| StatelessRoot { + render_count: render_count.clone(), + }); + + assert_eq!(render_count.get(), 1); +} + +struct IdentityModel; + +impl Render for IdentityModel { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + } +} + +struct EntityBackedView { + model: Entity, +} + +impl View for EntityBackedView { + fn entity_id(&self) -> Option { + Some(self.model.entity_id()) + } + + fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement { + self.model + } +} + +#[gpui::test] +fn manual_view_uses_its_backing_entity_as_element_identity(cx: &mut TestAppContext) { + let model = cx.new(|_| IdentityModel); + let element = ViewElement::new(EntityBackedView { + model: model.clone(), + }); + + assert_eq!(element.id(), Some(ElementId::View(model.entity_id()))); +} + +struct CachedChild { + render_count: Rc>, +} + +impl Render for CachedChild { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.render_count.set(self.render_count.get() + 1); + div() + } +} + +struct CachedRoot { + child: Entity, +} + +impl Render for CachedRoot { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + self.child + .clone() + .cached(StyleRefinement::default().w(px(20.)).h(px(20.))) + } +} + +#[gpui::test] +fn cached_entity_reuses_until_notified_and_rebuilds_for_a11y(cx: &mut TestAppContext) { + let render_count = Rc::new(Cell::new(0)); + let (root, cx) = cx.add_window_view({ + let render_count = render_count.clone(); + move |_, cx| CachedRoot { + child: cx.new(|_| CachedChild { + render_count: render_count.clone(), + }), + } + }); + + assert_eq!(render_count.get(), 1); + + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(render_count.get(), 1, "an unchanged cached view is reused"); + + let child = root.read_with(cx, |root, _| root.child.clone()); + child.update(cx, |_, cx| cx.notify()); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(render_count.get(), 2, "notification invalidates the cache"); + + cx.update(|window, cx| { + window.a11y.set_active_for_test(true); + let _ = window.draw(cx); + window.a11y.set_active_for_test(false); + }); + assert_eq!( + render_count.get(), + 3, + "active accessibility rebuilds instead of replaying cached paint" + ); +} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 3a504f9e..1cd7b3b7 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -19,7 +19,7 @@ use crate::{ SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, - WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, profiler, px, rems, size, transparent_black, }; use anyhow::{Context as _, Result, anyhow}; @@ -59,6 +59,7 @@ pub(crate) mod a11y; mod prompts; use self::a11y::A11y; +pub use self::a11y::A11ySubtreeBuilder; #[cfg(not(target_family = "wasm"))] use self::a11y::ROOT_NODE_ID; use crate::util::atomic_incr_if_not_zero; @@ -109,6 +110,14 @@ struct WindowInvalidatorInner { pub dirty: bool, pub draw_phase: DrawPhase, pub dirty_views: FxHashSet, + pub frame_dirty: FrameDirtyAccumulator, +} + +/// Per-frame invalidation bookkeeping used by the runtime-gated frame tracer. +#[derive(Default)] +struct FrameDirtyAccumulator { + dirty_at: Option, + invalidations: u64, } #[derive(Clone)] @@ -123,6 +132,7 @@ impl WindowInvalidator { dirty: true, draw_phase: DrawPhase::None, dirty_views: FxHashSet::default(), + frame_dirty: FrameDirtyAccumulator::default(), })), } } @@ -131,6 +141,7 @@ impl WindowInvalidator { let mut inner = self.inner.borrow_mut(); inner.dirty_views.insert(entity); if inner.draw_phase == DrawPhase::None { + Self::record_frame_dirty(&mut inner); inner.dirty = true; cx.push_effect(Effect::Notify { emitter: entity }); true @@ -144,13 +155,28 @@ impl WindowInvalidator { } pub fn set_dirty(&self, dirty: bool) { - self.inner.borrow_mut().dirty = dirty + let mut inner = self.inner.borrow_mut(); + inner.dirty = dirty; + if dirty { + Self::record_frame_dirty(&mut inner); + } } pub fn set_phase(&self, phase: DrawPhase) { self.inner.borrow_mut().draw_phase = phase } + fn record_frame_dirty(inner: &mut WindowInvalidatorInner) { + if profiler::frame_trace_enabled() { + inner.frame_dirty.dirty_at.get_or_insert_with(Instant::now); + inner.frame_dirty.invalidations += 1; + } + } + + fn take_frame_dirty(&self) -> FrameDirtyAccumulator { + mem::take(&mut self.inner.borrow_mut().frame_dirty) + } + pub fn take_views(&self) -> FxHashSet { mem::take(&mut self.inner.borrow_mut().dirty_views) } @@ -1025,6 +1051,7 @@ pub struct Window { pub(crate) focus: Option, focus_before_deactivation: Option, focus_enabled: bool, + pub(crate) focus_generation: u64, pending_input: Option, pending_modifier: ModifierState, pub(crate) pending_input_observers: SubscriberSet<(), AnyObserver>, @@ -1201,6 +1228,10 @@ impl Window { tabbing_identifier, } = options; + let initial_window_title = titlebar + .as_ref() + .and_then(|titlebar| titlebar.title.clone()); + let window_bounds = window_bounds.unwrap_or_else(|| default_bounds(display_id, cx)); let mut platform_window = cx.platform.open_window( handle, @@ -1214,6 +1245,7 @@ impl Window { focus, show, display_id, + app_id: app_id.clone(), window_min_size, #[cfg(target_os = "macos")] tabbing_identifier, @@ -1261,8 +1293,12 @@ impl Window { #[cfg(not(target_family = "wasm"))] if !accessibility_force_disabled { + let mut initial_root_node = accesskit::Node::new(accesskit::Role::Window); + if let Some(title) = &initial_window_title { + initial_root_node.set_label(title.to_string()); + } let initial_tree = accesskit::TreeUpdate { - nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))], + nodes: vec![(ROOT_NODE_ID, initial_root_node)], tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), tree_id: accesskit::TreeId::ROOT, focus: ROOT_NODE_ID, @@ -1613,6 +1649,7 @@ impl Window { focus: None, focus_before_deactivation: None, focus_enabled: true, + focus_generation: 0, pending_input: None, pending_modifier: ModifierState::default(), pending_input_observers: SubscriberSet::new(), @@ -1622,7 +1659,11 @@ impl Window { captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, - a11y: A11y::new(a11y_active_flag, accessibility_force_disabled), + a11y: A11y::new( + a11y_active_flag, + accessibility_force_disabled, + initial_window_title, + ), }) } @@ -1769,6 +1810,7 @@ impl Window { } self.focus = Some(handle.id); + self.focus_generation = self.focus_generation.wrapping_add(1); self.clear_pending_keystrokes(); // Avoid re-entrant entity updates by deferring observer notifications to the end of the @@ -1791,6 +1833,9 @@ impl Window { return; } + if self.focus.is_some() { + self.focus_generation = self.focus_generation.wrapping_add(1); + } self.focus = None; self.refresh(); } @@ -1854,6 +1899,16 @@ impl Window { self.platform_window.start_window_resize(edge); } + /// Linux (Wayland) only: Set the window's input region, the area that receives pointer + /// and touch input. Events outside it pass through to whatever is below the window. + /// + /// - `Some(rects)` restricts input to the union of `rects`, in window coordinates. + /// - `Some(&[])` is an empty region, so the window receives no pointer or touch input. + /// - `None` resets the region to the default, so the whole window receives input again. + pub fn set_input_region(&self, region: Option<&[Bounds]>) { + self.platform_window.set_input_region(region); + } + /// Return the `WindowBounds` to indicate that how a window should be opened /// after it has been closed pub fn window_bounds(&self) -> WindowBounds { @@ -2095,6 +2150,7 @@ impl Window { self.scale_factor = self.platform_window.scale_factor(); self.viewport_size = self.platform_window.content_size(); self.display_id = self.platform_window.display().map(|display| display.id()); + self.mouse_position = self.platform_window.mouse_position(); self.refresh(); @@ -2224,6 +2280,13 @@ impl Window { /// Updates the window's title at the platform level. pub fn set_window_title(&mut self, title: &str) { self.platform_window.set_title(title); + self.a11y.set_window_title(title.to_string()); + } + + /// Sets the position of the macOS traffic light buttons. + #[cfg(target_os = "macos")] + pub fn set_traffic_light_position(&self, position: Point) { + self.platform_window.set_traffic_light_position(position); } /// Sets the application identifier. @@ -2424,6 +2487,11 @@ impl Window { /// the contents of the new [`Scene`], use [`Self::present`]. #[profiling::function] pub fn draw(&mut self, cx: &mut App) -> ArenaClearNeeded { + // Drain unconditionally so enable/disable transitions cannot leak a + // stale first-invalidation timestamp into a later frame. + let frame_dirty = self.invalidator.take_frame_dirty(); + let draw_started_at = profiler::frame_trace_enabled().then(Instant::now); + // Set up the per-App arena for element allocation during this draw. // This ensures that multiple test Apps have isolated arenas. let _arena_scope = ElementArenaScope::enter(&cx.element_arena); @@ -2517,6 +2585,16 @@ impl Window { self.invalidator.set_phase(DrawPhase::None); self.needs_present.set(true); + if let Some(draw_start) = draw_started_at { + profiler::record_frame_timing(profiler::FrameTiming { + window_id: self.handle.window_id(), + dirty_at: frame_dirty.dirty_at, + invalidations: frame_dirty.invalidations, + draw_start, + draw_end: Instant::now(), + }); + } + ArenaClearNeeded::new(&cx.element_arena) } @@ -2549,6 +2627,14 @@ impl Window { profiling::finish_frame!(); } + /// Presents the most recently drawn frame if it has not been presented. + #[cfg(feature = "bench")] + pub fn present_if_needed(&self) { + if self.needs_present.get() { + self.present(); + } + } + fn draw_roots(&mut self, cx: &mut App) { self.invalidator.set_phase(DrawPhase::Prepaint); self.tooltip_bounds.take(); @@ -2577,7 +2663,7 @@ impl Window { }; // Layout all root elements. - let mut root_element = self.root.as_ref().unwrap().clone().into_any(); + let mut root_element = self.root.as_ref().unwrap().clone().into_any_element(); root_element.prepaint_as_root(Point::default(), root_size.into(), self, cx); #[cfg(any(feature = "inspector", debug_assertions))] @@ -2589,12 +2675,12 @@ impl Window { let mut active_drag_element = None; let mut tooltip_element = None; if let Some(prompt) = self.prompt.take() { - let mut element = prompt.view.any_view().into_any(); + let mut element = prompt.view.any_view().into_any_element(); element.prepaint_as_root(Point::default(), root_size.into(), self, cx); prompt_element = Some(element); self.prompt = Some(prompt); } else if let Some(active_drag) = cx.active_drag.take() { - let mut element = active_drag.view.clone().into_any(); + let mut element = active_drag.view.clone().into_any_element(); let offset = self.mouse_position() - active_drag.cursor_offset; element.prepaint_as_root(offset, AvailableSpace::min_size(), self, cx); active_drag_element = Some(element); @@ -2654,7 +2740,7 @@ impl Window { log::error!("Unexpectedly absent TooltipRequest"); continue; }; - let mut element = tooltip_request.tooltip.view.clone().into_any(); + let mut element = tooltip_request.tooltip.view.clone().into_any_element(); let mouse_position = tooltip_request.tooltip.mouse_position; let tooltip_size = element.layout_as_root(AvailableSpace::min_size(), self, cx); @@ -3595,7 +3681,7 @@ impl Window { content_mask: content_mask.scale(scale_factor), color: style.color.unwrap_or_default().opacity(element_opacity), thickness: style.thickness.scale(scale_factor), - wavy: if style.wavy { 1 } else { 0 }, + wavy: style.wavy.into(), }); } @@ -3626,7 +3712,7 @@ impl Window { content_mask: content_mask.scale(scale_factor), thickness: style.thickness.scale(scale_factor), color: style.color.unwrap_or_default().opacity(opacity), - wavy: 0, + wavy: false.into(), }); } @@ -3657,6 +3743,7 @@ impl Window { y: (glyph_origin.y.0.fract() * SUBPIXEL_VARIANTS_Y as f32).floor() as u8, }; let subpixel_rendering = self.should_use_subpixel_rendering(font_id, font_size); + let dilation = self.text_system().glyph_dilation_for_color(color); let params = RenderGlyphParams { font_id, glyph_id, @@ -3665,6 +3752,7 @@ impl Window { scale_factor, is_emoji: false, subpixel_rendering, + dilation, }; let raster_bounds = self.text_system().raster_bounds(¶ms)?; @@ -3790,7 +3878,7 @@ impl Window { self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, - grayscale: false, + grayscale: false.into(), bounds, corner_radii: Default::default(), content_mask, @@ -3848,6 +3936,7 @@ impl Window { scale_factor, is_emoji: true, subpixel_rendering: false, + dilation: 0, }; let raster_bounds = self.text_system().raster_bounds(¶ms)?; @@ -3870,7 +3959,7 @@ impl Window { self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, - grayscale: false, + grayscale: false.into(), bounds, corner_radii: Default::default(), content_mask, @@ -3986,7 +4075,7 @@ impl Window { self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, - grayscale, + grayscale: grayscale.into(), bounds: bounds .map_origin(|origin| origin.floor()) .map_size(|size| size.ceil()), @@ -4931,7 +5020,9 @@ impl Window { .remove(&action.as_any().type_id()) { for listener in &global_listeners { + profiler::update_running_action(action, cx); listener(action.as_any(), DispatchPhase::Capture, cx); + profiler::save_action_timing(); if !cx.propagate_event { break; } @@ -4961,7 +5052,9 @@ impl Window { { let any_action = action.as_any(); if action_type == any_action.type_id() { + profiler::update_running_action(action, cx); listener(any_action, DispatchPhase::Capture, self, cx); + profiler::save_action_timing(); if !cx.propagate_event { return; @@ -4981,7 +5074,9 @@ impl Window { let any_action = action.as_any(); if action_type == any_action.type_id() { cx.propagate_event = false; // Actions stop propagation by default during the bubble phase + profiler::update_running_action(action, cx); listener(any_action, DispatchPhase::Bubble, self, cx); + profiler::save_action_timing(); if !cx.propagate_event { return; @@ -4998,7 +5093,9 @@ impl Window { for listener in global_listeners.iter().rev() { cx.propagate_event = false; // Actions stop propagation by default during the bubble phase + profiler::update_running_action(action, cx); listener(action.as_any(), DispatchPhase::Bubble, cx); + profiler::save_action_timing(); if !cx.propagate_event { break; } @@ -5375,6 +5472,11 @@ impl Window { self.platform_window.play_system_bell() } + /// Returns whether assistive technology has requested an accessibility tree this frame. + pub fn is_a11y_active(&self) -> bool { + self.a11y.is_active() + } + /// Register a listener for an accessibility action on a specific node. pub fn on_a11y_action( &mut self, @@ -6035,6 +6137,59 @@ impl From<&'static core::panic::Location<'static>> for ElementId { } } +#[cfg(test)] +mod bounds_change_tests { + use crate::{ + Bounds, Empty, Modifiers, Point, TestAppContext, VisualTestContext, point, px, size, + }; + + #[gpui::test] + fn bounds_changed_refreshes_mouse_position_from_platform(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let mut cx = VisualTestContext::from_window(window.into(), cx); + let stale_position = point(px(48.), px(32.)); + + cx.simulate_mouse_move(stale_position, None, Modifiers::default()); + assert_eq!( + cx.update(|window, _| window.mouse_position()), + stale_position + ); + + cx.simulate_resize(size(px(640.), px(480.))); + cx.run_until_parked(); + + assert_eq!( + cx.update(|window, _| window.mouse_position()), + Point::default() + ); + } + + #[gpui::test] + fn input_region_is_forwarded_to_the_platform(cx: &mut TestAppContext) { + let window = cx.add_window(|_, _| Empty); + let region = [Bounds::new( + point(px(12.), px(16.)), + size(px(120.), px(48.)), + )]; + let platform_window = cx.test_window(*window); + + window + .update(cx, |_, window, _| window.set_input_region(Some(®ion))) + .expect("test window should remain open"); + assert_eq!(platform_window.input_region(), Some(region.to_vec())); + + window + .update(cx, |_, window, _| window.set_input_region(Some(&[]))) + .expect("test window should remain open"); + assert_eq!(platform_window.input_region(), Some(Vec::new())); + + window + .update(cx, |_, window, _| window.set_input_region(None)) + .expect("test window should remain open"); + assert_eq!(platform_window.input_region(), None); + } +} + /// A rectangle to be rendered in the window at the given position and size. /// Passed as an argument [`Window::paint_quad`]. #[derive(Clone)] diff --git a/crates/gpui/src/window/a11y.rs b/crates/gpui/src/window/a11y.rs index 8107d615..bc45e1c0 100644 --- a/crates/gpui/src/window/a11y.rs +++ b/crates/gpui/src/window/a11y.rs @@ -2,12 +2,15 @@ //! //! User-facing guide-level docs live in [`crate::_accessibility`]. -use crate::{App, Bounds, FocusId, GlobalElementId, Pixels, Window}; +use crate::{App, Bounds, FocusId, GlobalElementId, Pixels, SharedString, Window}; use accesskit::{Action, NodeId, TreeUpdate}; use collections::{FxHashMap, FxHashSet}; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, +use std::{ + hash::{Hash, Hasher}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; mod builder; @@ -33,10 +36,15 @@ pub(crate) struct A11y { pub(crate) focus_ids: FxHashMap, pub(crate) node_bounds: FxHashMap>, pub(crate) action_listeners: FxHashMap>, + window_title: Option, } impl A11y { - pub(crate) fn new(active_flag: Arc, force_disabled: bool) -> Self { + pub(crate) fn new( + active_flag: Arc, + force_disabled: bool, + window_title: Option, + ) -> Self { Self { force_disabled, active_flag, @@ -48,9 +56,14 @@ impl A11y { focus_ids: FxHashMap::default(), node_bounds: FxHashMap::default(), action_listeners: FxHashMap::default(), + window_title, } } + pub(crate) fn set_window_title(&mut self, title: impl Into) { + self.window_title = Some(title.into()); + } + pub(crate) fn sync_active_flag(&mut self) { self.active_this_frame = !self.force_disabled && self.active_flag.load(Ordering::SeqCst); } @@ -59,6 +72,39 @@ impl A11y { self.active_this_frame } + pub(crate) fn set_focusable(&mut self, node_id: NodeId, focus_id: FocusId) { + self.focus_ids.insert(node_id, focus_id); + } + + pub(crate) fn set_focus(&mut self, node_id: NodeId) { + if !self.focus_ids.contains_key(&node_id) { + if cfg!(debug_assertions) { + panic!("set_focus called for a node that was not registered with set_focusable"); + } else { + log::warn!( + "a11y: set_focus called for a node that was not registered with set_focusable ({node_id:?})" + ); + } + } + if self.nodes.has_node(node_id) { + self.nodes.set_focus(node_id); + } + } + + pub(crate) fn set_active_descendant(&mut self, node_id: NodeId) { + if self.nodes.node_is_focused(node_id) { + if cfg!(debug_assertions) { + panic!("set_active_descendant called on the focused node"); + } else { + log::warn!("a11y: set_active_descendant called on the focused node ({node_id:?})"); + } + return; + } + if self.nodes.has_node(node_id) && self.nodes.focus_is_ancestor_of_current() { + self.nodes.set_active_descendant(node_id); + } + } + /// Force accessibility active/inactive for tests without going through a platform adapter. #[cfg(test)] pub(crate) fn set_active_for_test(&mut self, active: bool) { @@ -71,7 +117,7 @@ impl A11y { self.node_bounds.clear(); self.action_listeners.clear(); self.visited_global_ids.clear(); - self.nodes.begin_frame(); + self.nodes.begin_frame(self.window_title.as_ref()); } pub(crate) fn node_id_for(&mut self, global_id: &GlobalElementId) -> NodeId { @@ -135,6 +181,38 @@ impl A11y { } } +/// Builder API for accessibility nodes that do not correspond to GPUI elements. +pub struct A11ySubtreeBuilder<'a> { + parent_id: NodeId, + nodes: &'a mut A11yNodeBuilder, +} + +impl<'a> A11ySubtreeBuilder<'a> { + pub(crate) fn new(parent_id: NodeId, nodes: &'a mut A11yNodeBuilder) -> Self { + Self { parent_id, nodes } + } + + /// Derive a stable synthetic child ID from this parent and a caller-provided key. + pub fn synthetic_node_id(&self, key: impl Hash) -> NodeId { + let mut hasher = std::hash::DefaultHasher::default(); + self.parent_id.0.hash(&mut hasher); + key.hash(&mut hasher); + NodeId(hasher.finish()) + } + + /// Append a synthetic leaf node as a child of this element's accessibility node. + pub fn push_child(&mut self, id: NodeId, node: accesskit::Node) -> bool { + self.nodes.push_leaf(id, node) + } + + /// Mutate the accessibility node belonging to the parent element. + pub fn parent_node(&mut self) -> &mut accesskit::Node { + self.nodes + .current_node_mut() + .expect("A11ySubtreeBuilder exists only while its element node is current") + } +} + #[cfg(test)] mod tests { use super::*; @@ -152,7 +230,7 @@ mod tests { #[test] fn restores_prepaint_snapshot_for_a11y_maps() { - let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false, None); a11y.begin_frame(); let focus_id = FocusId::from(KeyData::from_ffi(1)); @@ -183,7 +261,7 @@ mod tests { #[test] fn allocates_stable_unique_node_ids_for_global_element_ids() { - let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false, None); let first_id = global_id("first"); let second_id = global_id("second"); @@ -201,7 +279,7 @@ mod tests { #[test] fn retains_visited_suppressed_node_id_and_sweeps_omitted_node_id() { - let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false, None); let hidden_id = global_id("hidden"); a11y.begin_frame(); @@ -225,7 +303,7 @@ mod tests { #[test] fn sweeps_per_node_maps_by_live_emitted_nodes_after_end_frame() { - let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false, None); let live_bounds = Bounds::new(point(px(1.), px(2.)), size(px(3.), px(4.))); let stale_bounds = Bounds::new(point(px(5.), px(6.)), size(px(7.), px(8.))); let live_focus = FocusId::from(KeyData::from_ffi(1)); @@ -260,7 +338,7 @@ mod tests { #[test] fn restores_prepaint_snapshot_for_node_id_allocator() { - let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false, None); let accepted_id = global_id("accepted"); let rejected_id = global_id("rejected"); let next_id = global_id("next"); @@ -282,7 +360,7 @@ mod tests { #[test] fn restores_prepaint_snapshot_for_visited_globals() { - let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false); + let mut a11y = A11y::new(Arc::new(AtomicBool::new(false)), false, None); let accepted_id = global_id("accepted"); let rejected_id = global_id("rejected"); @@ -307,4 +385,136 @@ mod tests { ); assert_eq!(a11y.node_id_for_existing(&rejected_id), None); } + + #[test] + fn window_title_labels_initial_and_updated_root() { + let mut a11y = A11y::new( + Arc::new(AtomicBool::new(false)), + false, + Some("Initial".into()), + ); + a11y.begin_frame(); + let initial = a11y.end_frame(); + assert_eq!( + initial + .nodes + .iter() + .find(|(id, _)| *id == ROOT_NODE_ID) + .unwrap() + .1 + .label(), + Some("Initial") + ); + + a11y.set_window_title("Updated"); + a11y.begin_frame(); + let updated = a11y.end_frame(); + assert_eq!( + updated + .nodes + .iter() + .find(|(id, _)| *id == ROOT_NODE_ID) + .unwrap() + .1 + .label(), + Some("Updated") + ); + } + + #[test] + #[cfg_attr( + debug_assertions, + should_panic(expected = "was not registered with set_focusable") + )] + fn set_focus_requires_focusable_registration() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(true)), false, None); + a11y.begin_frame(); + assert!( + a11y.nodes + .push(NodeId(1), accesskit::Node::new(accesskit::Role::Button)) + ); + a11y.set_focus(NodeId(1)); + } + + #[test] + #[cfg_attr(debug_assertions, should_panic(expected = "on the focused node"))] + fn active_descendant_cannot_be_focused_node() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(true)), false, None); + a11y.begin_frame(); + let focus_id = FocusId::from(KeyData::from_ffi(1)); + assert!( + a11y.nodes + .push(NodeId(1), accesskit::Node::new(accesskit::Role::ListBox)) + ); + a11y.set_focusable(NodeId(1), focus_id); + a11y.set_focus(NodeId(1)); + a11y.set_active_descendant(NodeId(1)); + } + + #[test] + fn active_descendant_in_unfocused_subtree_keeps_real_focus() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(true)), false, None); + a11y.begin_frame(); + let focus_id = FocusId::from(KeyData::from_ffi(1)); + assert!( + a11y.nodes + .push(NodeId(1), accesskit::Node::new(accesskit::Role::Button)) + ); + a11y.set_focusable(NodeId(1), focus_id); + a11y.set_focus(NodeId(1)); + a11y.nodes.pop(); + assert!( + a11y.nodes + .push(NodeId(2), accesskit::Node::new(accesskit::Role::ListBox)) + ); + assert!(a11y.nodes.push( + NodeId(3), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + a11y.set_active_descendant(NodeId(3)); + a11y.nodes.pop(); + a11y.nodes.pop(); + + assert_eq!(a11y.end_frame().focus, NodeId(1)); + } + + #[test] + #[cfg_attr( + debug_assertions, + should_panic(expected = "active descendant claimed by multiple nodes") + )] + fn sibling_active_descendant_claims_fail_in_debug() { + let mut a11y = A11y::new(Arc::new(AtomicBool::new(true)), false, None); + a11y.begin_frame(); + assert!( + a11y.nodes + .push(NodeId(1), accesskit::Node::new(accesskit::Role::ListBox)) + ); + a11y.set_focusable(NodeId(1), FocusId::from(KeyData::from_ffi(1))); + a11y.set_focus(NodeId(1)); + + assert!(a11y.nodes.push( + NodeId(2), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + a11y.set_active_descendant(NodeId(2)); + a11y.nodes.pop(); + assert!(a11y.nodes.push( + NodeId(3), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + a11y.set_active_descendant(NodeId(3)); + } + + #[test] + fn synthetic_ids_are_stable_per_parent_and_key() { + let mut nodes = A11yNodeBuilder::new(); + nodes.begin_frame(None); + assert!(nodes.push(NodeId(1), accesskit::Node::new(accesskit::Role::TextInput))); + let first = A11ySubtreeBuilder::new(NodeId(1), &mut nodes).synthetic_node_id("run"); + let same = A11ySubtreeBuilder::new(NodeId(1), &mut nodes).synthetic_node_id("run"); + let other = A11ySubtreeBuilder::new(NodeId(2), &mut nodes).synthetic_node_id("run"); + assert_eq!(first, same); + assert_ne!(first, other); + } } diff --git a/crates/gpui/src/window/a11y/builder.rs b/crates/gpui/src/window/a11y/builder.rs index a92038f6..acf39960 100644 --- a/crates/gpui/src/window/a11y/builder.rs +++ b/crates/gpui/src/window/a11y/builder.rs @@ -1,23 +1,24 @@ use super::ROOT_NODE_ID; -use crate::{Bounds, FocusId, GlobalElementId, Pixels}; +use crate::{Bounds, FocusId, GlobalElementId, Pixels, SharedString}; use accesskit::{NodeId, TreeUpdate}; use collections::{FxHashMap, FxHashSet}; use smallvec::SmallVec; +#[derive(Clone)] pub(crate) struct A11yNodeBuilder { ids_stack: SmallVec<[NodeId; 16]>, nodes_stack: SmallVec<[accesskit::Node; 16]>, suppression_stack: SmallVec<[bool; 16]>, ambient_suppression_depth: usize, all_nodes: Vec<(NodeId, accesskit::Node)>, + emitted_node_indices: FxHashMap, seen_ids: FxHashSet, - focus: NodeId, - #[cfg(debug_assertions)] - has_set_focus: bool, + focus: Option, + active_descendant: Option, } pub(crate) struct A11yPrepaintSnapshot { - pub(super) nodes: A11yNodeBuilderPrepaintSnapshot, + pub(super) nodes: A11yNodeBuilder, pub(super) node_ids: FxHashMap, pub(super) visited_global_ids: FxHashSet, pub(super) next_node_id: u64, @@ -25,18 +26,6 @@ pub(crate) struct A11yPrepaintSnapshot { pub(super) node_bounds: FxHashMap>, } -pub(super) struct A11yNodeBuilderPrepaintSnapshot { - ids_stack: SmallVec<[NodeId; 16]>, - nodes_stack: SmallVec<[accesskit::Node; 16]>, - suppression_stack: SmallVec<[bool; 16]>, - ambient_suppression_depth: usize, - all_nodes: Vec<(NodeId, accesskit::Node)>, - seen_ids: FxHashSet, - focus: NodeId, - #[cfg(debug_assertions)] - has_set_focus: bool, -} - impl A11yNodeBuilder { pub(super) fn new() -> Self { Self { @@ -45,14 +34,14 @@ impl A11yNodeBuilder { suppression_stack: SmallVec::new(), ambient_suppression_depth: 0, all_nodes: Vec::new(), + emitted_node_indices: FxHashMap::default(), seen_ids: FxHashSet::default(), - focus: ROOT_NODE_ID, - #[cfg(debug_assertions)] - has_set_focus: false, + focus: None, + active_descendant: None, } } - pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { + fn can_push(&mut self, id: NodeId) -> bool { debug_assert!(!self.ids_stack.is_empty(), "push called before begin_frame"); if self.is_suppressed() { @@ -67,40 +56,61 @@ impl A11yNodeBuilder { return false; } + true + } + + pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { + if !self.can_push(id) { + return false; + } + self.ids_stack.push(id); self.nodes_stack.push(node); self.suppression_stack.push(false); true } + pub(crate) fn push_leaf(&mut self, id: NodeId, node: accesskit::Node) -> bool { + if !self.can_push(id) { + return false; + } + + let Some(parent) = self.nodes_stack.last_mut() else { + return false; + }; + parent.push_child(id); + self.emitted_node_indices.insert(id, self.all_nodes.len()); + self.all_nodes.push((id, node)); + true + } + pub(crate) fn pop(&mut self) { debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node"); self.pop_any(); } - pub(super) fn begin_frame(&mut self) { + pub(super) fn begin_frame(&mut self, window_title: Option<&SharedString>) { self.all_nodes.clear(); + self.emitted_node_indices.clear(); self.ids_stack.clear(); self.nodes_stack.clear(); self.suppression_stack.clear(); self.ambient_suppression_depth = 0; self.seen_ids.clear(); self.seen_ids.insert(ROOT_NODE_ID); - #[cfg(debug_assertions)] - { - self.has_set_focus = false; - } - self.ids_stack.push(ROOT_NODE_ID); - self.nodes_stack - .push(accesskit::Node::new(accesskit::Role::Window)); + let mut root = accesskit::Node::new(accesskit::Role::Window); + if let Some(title) = window_title { + root.set_label(title.to_string()); + } + self.nodes_stack.push(root); self.suppression_stack.push(false); - self.focus = ROOT_NODE_ID; + self.focus = None; + self.active_descendant = None; } - #[cfg(test)] - fn has_node(&self, id: NodeId) -> bool { + pub(crate) fn has_node(&self, id: NodeId) -> bool { id == ROOT_NODE_ID || self.seen_ids.contains(&id) } @@ -108,44 +118,54 @@ impl A11yNodeBuilder { self.ids_stack.last().copied() == Some(id) && !self.is_suppressed() } - pub(crate) fn set_focus(&mut self, id: NodeId) { - #[cfg(debug_assertions)] + pub(crate) fn node_is_focused(&self, id: NodeId) -> bool { + self.focus == Some(id) + } + + pub(crate) fn focus_is_ancestor_of_current(&self) -> bool { + let Some(focus) = self.focus else { + return false; + }; + + let ancestor_count = self.ids_stack.len().saturating_sub(1); + self.ids_stack[..ancestor_count].contains(&focus) + } + + pub(crate) fn set_active_descendant(&mut self, id: NodeId) { + if self + .active_descendant + .is_some_and(|existing| existing != id) { - debug_assert!( - !self.has_set_focus, - "set_focus called more than once in a single frame" - ); - self.has_set_focus = true; + if cfg!(debug_assertions) { + panic!("active descendant claimed by multiple nodes in one frame"); + } else { + log::warn!( + "a11y: multiple nodes claimed the active descendant this frame; using last-wins ({id:?})" + ); + } } - self.focus = id; + self.active_descendant = Some(id); } - pub(super) fn prepaint_snapshot(&self) -> A11yNodeBuilderPrepaintSnapshot { - A11yNodeBuilderPrepaintSnapshot { - ids_stack: self.ids_stack.clone(), - nodes_stack: self.nodes_stack.clone(), - suppression_stack: self.suppression_stack.clone(), - ambient_suppression_depth: self.ambient_suppression_depth, - all_nodes: self.all_nodes.clone(), - seen_ids: self.seen_ids.clone(), - focus: self.focus, - #[cfg(debug_assertions)] - has_set_focus: self.has_set_focus, + pub(crate) fn set_focus(&mut self, id: NodeId) { + if self.focus.is_some() { + if cfg!(debug_assertions) { + panic!("set_focus called more than once in a single frame"); + } else { + log::warn!( + "a11y: set_focus called more than once in a single frame; using last-wins ({id:?})" + ); + } } + self.focus = Some(id); } - pub(super) fn restore_prepaint_snapshot(&mut self, snapshot: A11yNodeBuilderPrepaintSnapshot) { - self.ids_stack = snapshot.ids_stack; - self.nodes_stack = snapshot.nodes_stack; - self.suppression_stack = snapshot.suppression_stack; - self.ambient_suppression_depth = snapshot.ambient_suppression_depth; - self.all_nodes = snapshot.all_nodes; - self.seen_ids = snapshot.seen_ids; - self.focus = snapshot.focus; - #[cfg(debug_assertions)] - { - self.has_set_focus = snapshot.has_set_focus; - } + pub(super) fn prepaint_snapshot(&self) -> A11yNodeBuilder { + self.clone() + } + + pub(super) fn restore_prepaint_snapshot(&mut self, snapshot: A11yNodeBuilder) { + *self = snapshot; } pub(crate) fn update_current_node_bounds( @@ -154,7 +174,7 @@ impl A11yNodeBuilder { bounds: Bounds, scale_factor: f32, ) -> bool { - let Some(node) = self.current_node_mut(id) else { + let Some(node) = self.current_node_mut_for_id(id) else { return false; }; @@ -225,17 +245,41 @@ impl A11yNodeBuilder { self.pop_any(); } + let focus = match self.active_descendant { + Some(id) if self.has_node(id) => id, + Some(id) => { + if cfg!(debug_assertions) { + panic!("active_descendant set to {id:?}, which is not in the tree"); + } else { + log::warn!("active_descendant set to {id:?}, which is not in the tree"); + self.focus.unwrap_or(ROOT_NODE_ID) + } + } + None => self.focus.unwrap_or(ROOT_NODE_ID), + }; + + let nodes = std::mem::take(&mut self.all_nodes); + self.emitted_node_indices.clear(); + let update = TreeUpdate { - nodes: std::mem::take(&mut self.all_nodes), + nodes, tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), tree_id: accesskit::TreeId::ROOT, - focus: self.focus, + focus, }; Self::repair_tree_update(update) } - fn current_node_mut(&mut self, id: NodeId) -> Option<&mut accesskit::Node> { + pub(crate) fn current_node_mut(&mut self) -> Option<&mut accesskit::Node> { + if self.is_suppressed() { + None + } else { + self.nodes_stack.last_mut() + } + } + + fn current_node_mut_for_id(&mut self, id: NodeId) -> Option<&mut accesskit::Node> { if self.ids_stack.len() <= 1 || self.ids_stack.last().copied() != Some(id) || self.suppression_stack.last().copied().unwrap_or(true) @@ -258,17 +302,14 @@ impl A11yNodeBuilder { if let Some(current_node) = self.nodes_stack.last() { let mut pending = current_node.children().to_vec(); if !pending.is_empty() { - let emitted_nodes_by_id = self - .all_nodes - .iter() - .map(|(node_id, node)| (*node_id, node)) - .collect::>(); while let Some(child_id) = pending.pop() { if !pruned_ids.insert(child_id) { continue; } - if let Some(child_node) = emitted_nodes_by_id.get(&child_id) { + if let Some(index) = self.emitted_node_indices.get(&child_id).copied() + && let Some((_, child_node)) = self.all_nodes.get(index) + { pending.extend(child_node.children().iter().copied()); } } @@ -279,12 +320,19 @@ impl A11yNodeBuilder { self.seen_ids.remove(node_id); } - if pruned_ids.contains(&self.focus) { - self.focus = ROOT_NODE_ID; + if self.focus.is_some_and(|focus| pruned_ids.contains(&focus)) { + self.focus = None; + } + if self + .active_descendant + .is_some_and(|active| pruned_ids.contains(&active)) + { + self.active_descendant = None; } self.all_nodes .retain(|(node_id, _)| !pruned_ids.contains(node_id)); + self.rebuild_emitted_node_indices(); for (_, node) in &mut self.all_nodes { Self::remove_child_refs(node, &pruned_ids); @@ -294,6 +342,16 @@ impl A11yNodeBuilder { } } + fn rebuild_emitted_node_indices(&mut self) { + self.emitted_node_indices.clear(); + self.emitted_node_indices.extend( + self.all_nodes + .iter() + .enumerate() + .map(|(index, (node_id, _))| (*node_id, index)), + ); + } + fn remove_child_refs(node: &mut accesskit::Node, removed_ids: &FxHashSet) { if node .children() @@ -327,6 +385,7 @@ impl A11yNodeBuilder { parent.push_child(id); } } + self.emitted_node_indices.insert(id, self.all_nodes.len()); self.all_nodes.push((id, node)); } } diff --git a/crates/gpui/src/window/a11y/builder_tests.rs b/crates/gpui/src/window/a11y/builder_tests.rs index 476b3e54..69af787a 100644 --- a/crates/gpui/src/window/a11y/builder_tests.rs +++ b/crates/gpui/src/window/a11y/builder_tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::{point, px, size}; -fn node<'a>(update: &'a TreeUpdate, id: NodeId) -> &'a accesskit::Node { +fn node(update: &TreeUpdate, id: NodeId) -> &accesskit::Node { update .nodes .iter() @@ -16,7 +16,7 @@ fn has_update_node(update: &TreeUpdate, id: NodeId) -> bool { #[test] fn preserves_unsuppressed_tree_shape() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); @@ -32,7 +32,7 @@ fn preserves_unsuppressed_tree_shape() { #[test] fn updates_current_non_root_node_bounds() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(builder.update_current_node_bounds( @@ -57,7 +57,7 @@ fn updates_current_non_root_node_bounds() { #[test] fn suppresses_current_node_before_finalization() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(builder.suppress_current_node(NodeId(1))); @@ -72,7 +72,7 @@ fn suppresses_current_node_before_finalization() { #[test] fn skips_descendants_while_current_node_is_suppressed() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(builder.suppress_current_node(NodeId(1))); @@ -91,7 +91,7 @@ fn skips_descendants_while_current_node_is_suppressed() { #[test] fn ambient_descendant_suppression_under_root_skips_child_push_without_suppressing_root() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); builder.begin_suppressing_descendants(); assert!(!builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); @@ -109,7 +109,7 @@ fn ambient_descendant_suppression_under_root_skips_child_push_without_suppressin #[test] fn id_specific_update_requires_current_node() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(!builder.update_current_node_bounds( @@ -126,7 +126,7 @@ fn id_specific_update_requires_current_node() { #[test] fn id_specific_suppress_requires_current_node() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(!builder.suppress_current_node(NodeId(2))); @@ -144,7 +144,7 @@ fn id_specific_suppress_requires_current_node() { #[test] fn suppressing_focused_current_node_removes_focus_from_tree() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); builder.set_focus(NodeId(1)); @@ -159,7 +159,7 @@ fn suppressing_focused_current_node_removes_focus_from_tree() { #[test] fn suppressing_current_node_prunes_already_emitted_descendants() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); @@ -186,7 +186,7 @@ fn suppressing_current_node_prunes_already_emitted_descendants() { #[test] fn parent_children_do_not_reference_suppressed_descendants() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); @@ -206,7 +206,7 @@ fn parent_children_do_not_reference_suppressed_descendants() { #[test] fn restores_prepaint_snapshot_for_builder_state() { let mut builder = A11yNodeBuilder::new(); - builder.begin_frame(); + builder.begin_frame(None); assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); let snapshot = builder.prepaint_snapshot(); @@ -230,3 +230,212 @@ fn restores_prepaint_snapshot_for_builder_state() { assert_eq!(node(&update, ROOT_NODE_ID).children(), &[NodeId(1)]); assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); } + +#[test] +fn restored_snapshot_keeps_emitted_node_index_consistent() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Label))); + assert!(builder.push(NodeId(3), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.pop(); + let snapshot = builder.prepaint_snapshot(); + + assert!(builder.push(NodeId(4), accesskit::Node::new(accesskit::Role::Label))); + builder.pop(); + builder.restore_prepaint_snapshot(snapshot); + + assert!(builder.suppress_current_node(NodeId(1))); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(update.nodes.len(), 1); + assert_eq!(node(&update, ROOT_NODE_ID).children(), &[]); + assert!(!has_update_node(&update, NodeId(1))); + assert!(!has_update_node(&update, NodeId(2))); + assert!(!has_update_node(&update, NodeId(3))); + assert!(!has_update_node(&update, NodeId(4))); +} + +#[test] +fn labels_root_with_window_title() { + let mut builder = A11yNodeBuilder::new(); + let title = SharedString::from("Inspector"); + builder.begin_frame(Some(&title)); + + let update = builder.finalize(); + + assert_eq!(node(&update, ROOT_NODE_ID).label(), Some("Inspector")); +} + +#[test] +fn synthetic_leaf_is_inserted_under_current_parent() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::TextInput))); + + let mut child = accesskit::Node::new(accesskit::Role::TextRun); + child.set_value("hello"); + assert!(builder.push_leaf(NodeId(2), child)); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, NodeId(1)).children(), &[NodeId(2)]); + assert_eq!(node(&update, NodeId(2)).value(), Some("hello")); +} + +#[test] +#[cfg_attr(debug_assertions, should_panic(expected = "duplicate a11y node id"))] +fn duplicate_synthetic_leaf_is_rejected() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::TextInput))); + assert!(builder.push_leaf(NodeId(2), accesskit::Node::new(accesskit::Role::TextRun))); + assert!(!builder.push_leaf(NodeId(2), accesskit::Node::new(accesskit::Role::TextRun))); +} + +#[test] +fn synthetic_leaf_is_rejected_while_parent_is_suppressed() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::TextInput))); + assert!(builder.suppress_current_node(NodeId(1))); + assert!(!builder.push_leaf(NodeId(2), accesskit::Node::new(accesskit::Role::TextRun))); + builder.pop(); + + let update = builder.finalize(); + assert!(!has_update_node(&update, NodeId(1))); + assert!(!has_update_node(&update, NodeId(2))); +} + +#[test] +fn snapshot_rollback_discards_synthetic_leaf_and_restores_parent() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::TextInput))); + let snapshot = builder.prepaint_snapshot(); + + assert!(builder.push_leaf(NodeId(2), accesskit::Node::new(accesskit::Role::TextRun))); + builder.current_node_mut().unwrap().set_label("rejected"); + builder.restore_prepaint_snapshot(snapshot); + builder.current_node_mut().unwrap().set_label("accepted"); + builder.pop(); + + let update = builder.finalize(); + assert_eq!(node(&update, NodeId(1)).label(), Some("accepted")); + assert_eq!(node(&update, NodeId(1)).children(), &[]); + assert!(!has_update_node(&update, NodeId(2))); +} + +#[test] +fn active_descendant_honored_when_container_focused() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::ListBox))); + builder.set_focus(NodeId(1)); + assert!(builder.push( + NodeId(2), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + assert!(builder.focus_is_ancestor_of_current()); + builder.set_active_descendant(NodeId(2)); + builder.pop(); + builder.pop(); + + assert_eq!(builder.finalize().focus, NodeId(2)); +} + +#[test] +fn active_descendant_honored_for_deep_descendant() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::ListBox))); + builder.set_focus(NodeId(1)); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::Group))); + assert!(builder.push( + NodeId(3), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + assert!(builder.focus_is_ancestor_of_current()); + builder.set_active_descendant(NodeId(3)); + builder.pop(); + builder.pop(); + builder.pop(); + + assert_eq!(builder.finalize().focus, NodeId(3)); +} + +#[test] +fn active_descendant_ignored_in_unfocused_subtree() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::Button))); + builder.set_focus(NodeId(1)); + builder.pop(); + assert!(builder.push(NodeId(2), accesskit::Node::new(accesskit::Role::ListBox))); + assert!(builder.push( + NodeId(3), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + assert!(!builder.focus_is_ancestor_of_current()); + builder.pop(); + builder.pop(); + + assert_eq!(builder.finalize().focus, NodeId(1)); +} + +#[test] +fn active_descendant_ignored_when_nothing_is_focused() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::ListBox))); + assert!(builder.push( + NodeId(2), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + assert!(!builder.focus_is_ancestor_of_current()); + builder.pop(); + builder.pop(); + + assert_eq!(builder.finalize().focus, ROOT_NODE_ID); +} + +#[test] +fn focus_ancestor_check_excludes_self() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + assert!(builder.push(NodeId(1), accesskit::Node::new(accesskit::Role::ListBox))); + builder.set_focus(NodeId(1)); + assert!(!builder.focus_is_ancestor_of_current()); + assert!(builder.push( + NodeId(2), + accesskit::Node::new(accesskit::Role::ListBoxOption) + )); + assert!(builder.focus_is_ancestor_of_current()); +} + +#[test] +#[cfg_attr( + debug_assertions, + should_panic(expected = "active descendant claimed by multiple nodes") +)] +fn multiple_active_descendant_claims_fail_in_debug() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + builder.set_active_descendant(NodeId(1)); + builder.set_active_descendant(NodeId(2)); +} + +#[test] +#[cfg_attr( + debug_assertions, + should_panic(expected = "set_focus called more than once") +)] +fn multiple_focus_claims_fail_in_debug() { + let mut builder = A11yNodeBuilder::new(); + builder.begin_frame(None); + builder.set_focus(NodeId(1)); + builder.set_focus(NodeId(2)); +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index 3dc0bf2f..0e535a87 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -59,24 +59,27 @@ accesskit_unix.workspace = true anyhow.workspace = true bytemuck = "1" collections.workspace = true +image.workspace = true futures.workspace = true gpui.workspace = true -gpui_wgpu = { workspace = true, optional = true } +gpui_util.workspace = true +gpui_wgpu = { workspace = true, optional = true, features = ["font-kit"] } http_client.workspace = true itertools.workspace = true libc.workspace = true log.workspace = true parking_lot.workspace = true pathfinder_geometry = "0.5" +pollster.workspace = true profiling.workspace = true smallvec.workspace = true smol.workspace = true strum.workspace = true -util.workspace = true +url.workspace = true uuid.workspace = true # Always used -oo7 = { version = "0.5.0", default-features = false, features = [ +oo7 = { version = "0.6", default-features = false, features = [ "async-std", "native_crypto", ] } @@ -88,7 +91,7 @@ ashpd = { workspace = true, optional = true } cosmic-text = { version = "0.17.0", optional = true } swash = { version = "0.2.6" } # WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io -font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", features = [ +font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", features = [ "source-fontconfig-dlopen", ], optional = true } bitflags = { workspace = true, optional = true } @@ -102,7 +105,7 @@ scap = { workspace = true, optional = true } # Wayland calloop-wayland-source = { version = "0.4.1", optional = true } -wayland-backend = { version = "0.3.3", features = [ +wayland-backend = { version = "0.3.15", features = [ "client_system", "dlopen", ], optional = true } diff --git a/crates/gpui_linux/src/gpui_linux.rs b/crates/gpui_linux/src/gpui_linux.rs index 62d972dc..223cf9d9 100644 --- a/crates/gpui_linux/src/gpui_linux.rs +++ b/crates/gpui_linux/src/gpui_linux.rs @@ -1,4 +1,7 @@ #![cfg(any(target_os = "linux", target_os = "freebsd"))] mod linux; -pub use linux::current_platform; +pub use linux::{ + current_platform, current_platform_with_startup_activation_token, + take_startup_activation_token_from_environment, +}; diff --git a/crates/gpui_linux/src/linux.rs b/crates/gpui_linux/src/linux.rs index a1123932..22f5e9e3 100644 --- a/crates/gpui_linux/src/linux.rs +++ b/crates/gpui_linux/src/linux.rs @@ -27,11 +27,61 @@ pub(crate) use x11::*; use std::rc::Rc; +const XDG_ACTIVATION_TOKEN_ENV_VAR: &str = "XDG_ACTIVATION_TOKEN"; + +fn startup_activation_token_from_environment() -> Option { + std::env::var(XDG_ACTIVATION_TOKEN_ENV_VAR) + .ok() + .filter(|token| !token.is_empty()) +} + +/// Removes and returns the Wayland startup activation token from the process environment. +/// +/// Pass the result to [`current_platform_with_startup_activation_token`] so the first Wayland +/// window can consume it without mutating the environment during platform construction. +/// +/// # Safety +/// +/// The caller must ensure that no other thread can read or write the process environment and that +/// no other thread can spawn a child process for the duration of this call. This normally means +/// calling it during single-threaded process startup, before initializing worker threads or an +/// embedded host runtime. +pub unsafe fn take_startup_activation_token_from_environment() -> Option { + let token = startup_activation_token_from_environment(); + // SAFETY: The function's contract requires the caller to exclude concurrent environment access + // and child-process creation for the duration of this call. + unsafe { std::env::remove_var(XDG_ACTIVATION_TOKEN_ENV_VAR) }; + token +} + /// Returns the default platform implementation for the current OS. +/// +/// This compatibility constructor reads `XDG_ACTIVATION_TOKEN` without removing it because it +/// cannot prove that process environment mutation is safe at the call site. Applications that can +/// capture the token during single-threaded startup should call +/// [`take_startup_activation_token_from_environment`] and pass its result to +/// [`current_platform_with_startup_activation_token`]. pub fn current_platform(headless: bool) -> Rc { + current_platform_with_startup_activation_token( + headless, + startup_activation_token_from_environment(), + ) +} + +/// Returns the default platform implementation with an explicitly captured Wayland startup token. +/// +/// Passing the token explicitly lets embedders remove it at a startup boundary where process +/// environment mutation is known to be safe. On non-Wayland backends the token is ignored. +pub fn current_platform_with_startup_activation_token( + headless: bool, + startup_activation_token: Option, +) -> Rc { #[cfg(feature = "x11")] use anyhow::Context as _; + #[cfg(not(feature = "wayland"))] + let _ = startup_activation_token; + if headless { return Rc::new(LinuxPlatform { inner: HeadlessClient::new(), @@ -41,7 +91,7 @@ pub fn current_platform(headless: bool) -> Rc { match gpui::guess_compositor() { #[cfg(feature = "wayland")] "Wayland" => Rc::new(LinuxPlatform { - inner: WaylandClient::new(), + inner: WaylandClient::new(startup_activation_token), }), #[cfg(feature = "x11")] @@ -54,6 +104,8 @@ pub fn current_platform(headless: bool) -> Rc { "Headless" => Rc::new(LinuxPlatform { inner: HeadlessClient::new(), }), - _ => unreachable!(), + _ => unreachable!( + r#"At least one of the "wayland" or "x11" features must be enabled on gpui_linux or gpui_platform."# + ), } } diff --git a/crates/gpui_linux/src/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs index a72276cc..2fa88bb6 100644 --- a/crates/gpui_linux/src/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -3,17 +3,13 @@ use calloop::{ channel::{self, Sender}, timer::TimeoutAction, }; -use util::ResultExt; +use gpui_util::ResultExt; -use std::{ - mem::MaybeUninit, - thread, - time::{Duration, Instant}, -}; +use std::{mem::MaybeUninit, thread, time::Duration}; use gpui::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, - PriorityQueueSender, RunnableVariant, THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, profiler, + PlatformDispatcher, Priority, PriorityQueueReceiver, PriorityQueueSender, RunnableVariant, + profiler, }; struct TimerAfter { @@ -44,27 +40,11 @@ impl LinuxDispatcher { .name(format!("Worker-{i}")) .spawn(move || { for runnable in receiver.iter() { - let start = Instant::now(); - let location = runnable.metadata().location; - let mut timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); runnable.run(); - - let end = Instant::now(); - timing.end = Some(end); - profiler::add_task_timing(timing); - - log::trace!( - "background thread {}: ran runnable. took: {:?}", - i, - start.elapsed() - ); + profiler::save_task_timing(); } }) .unwrap() @@ -89,20 +69,11 @@ impl LinuxDispatcher { calloop::timer::Timer::from_duration(timer.duration), move |_, _, _| { if let Some(runnable) = runnable.take() { - let start = Instant::now(); let location = runnable.metadata().location; - let mut timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); runnable.run(); - let end = Instant::now(); - - timing.end = Some(end); - profiler::add_task_timing(timing); + profiler::save_task_timing(); } TimeoutAction::Drop }, @@ -129,33 +100,6 @@ impl LinuxDispatcher { } impl PlatformDispatcher for LinuxDispatcher { - fn get_all_timings(&self) -> Vec { - let global_timings = GLOBAL_THREAD_TIMINGS.lock(); - ThreadTaskTimings::convert(&global_timings) - } - - fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) - } - fn is_main_thread(&self) -> bool { thread::current().id() == self.main_thread_id } @@ -183,9 +127,9 @@ impl PlatformDispatcher for LinuxDispatcher { } fn dispatch_after(&self, duration: Duration, runnable: RunnableVariant) { - self.timer_sender - .send(TimerAfter { duration, runnable }) - .ok(); + if let Err(err) = self.timer_sender.send(TimerAfter { duration, runnable }) { + std::mem::forget(err); + } } fn spawn_realtime(&self, f: Box) { diff --git a/crates/gpui_linux/src/linux/headless.rs b/crates/gpui_linux/src/linux/headless.rs index 2237aeb1..6667a3b5 100644 --- a/crates/gpui_linux/src/linux/headless.rs +++ b/crates/gpui_linux/src/linux/headless.rs @@ -1,3 +1,4 @@ mod client; +mod window; pub(crate) use client::*; diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index 4c990540..065f13af 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -2,8 +2,9 @@ use std::cell::RefCell; use std::rc::Rc; use calloop::{EventLoop, LoopHandle}; -use util::ResultExt; +use gpui_util::ResultExt; +use crate::linux::headless::window::{HeadlessDisplay, HeadlessWindow}; use crate::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout}; use gpui::{ AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout, @@ -14,6 +15,7 @@ pub struct HeadlessClientState { pub(crate) _loop_handle: LoopHandle<'static, HeadlessClient>, pub(crate) event_loop: Option>, pub(crate) common: LinuxCommon, + pub(crate) display: Rc, } #[derive(Clone)] @@ -23,7 +25,7 @@ impl HeadlessClient { pub(crate) fn new() -> Self { let event_loop = EventLoop::try_new().unwrap(); - let (common, main_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -35,10 +37,19 @@ impl HeadlessClient { }) .ok(); + handle + .insert_source(wake_receiver, |event, _, client: &mut HeadlessClient| { + if let calloop::channel::Event::Msg(()) = event { + client.handle_system_wake(); + } + }) + .ok(); + HeadlessClient(Rc::new(RefCell::new(HeadlessClientState { event_loop: Some(event_loop), _loop_handle: handle, common, + display: Rc::new(HeadlessDisplay::new()), }))) } } @@ -53,15 +64,16 @@ impl LinuxClient for HeadlessClient { } fn displays(&self) -> Vec> { - vec![] + vec![self.0.borrow().display.clone()] } fn primary_display(&self) -> Option> { - None + Some(self.0.borrow().display.clone()) } - fn display(&self, _id: DisplayId) -> Option> { - None + fn display(&self, id: DisplayId) -> Option> { + let display = self.0.borrow().display.clone(); + (display.id() == id).then_some(display) } #[cfg(feature = "screen-capture")] @@ -88,9 +100,12 @@ impl LinuxClient for HeadlessClient { fn open_window( &self, _handle: AnyWindowHandle, - _params: WindowParams, + params: WindowParams, ) -> anyhow::Result> { - anyhow::bail!("neither DISPLAY nor WAYLAND_DISPLAY is set. You can run in headless mode"); + Ok(Box::new(HeadlessWindow::new( + params, + self.0.borrow().display.clone(), + ))) } fn compositor_name(&self) -> &'static str { diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs new file mode 100644 index 00000000..169cf378 --- /dev/null +++ b/crates/gpui_linux/src/linux/headless/window.rs @@ -0,0 +1,228 @@ +//! Windows for the headless platform client. + +use std::{cell::RefCell, rc::Rc, sync::Arc}; + +use collections::HashMap; +use parking_lot::Mutex; +use uuid::Uuid; + +use gpui::{ + AtlasKey, AtlasTextureId, AtlasTile, Bounds, Capslock, DevicePixels, DispatchEventResult, + DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, + PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, + Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowParams, px, +}; + +#[derive(Debug)] +pub(crate) struct HeadlessDisplay { + bounds: Bounds, +} + +impl HeadlessDisplay { + pub(crate) fn new() -> Self { + Self { + bounds: Bounds::from_corners(Point::default(), Point::new(px(1920.), px(1080.))), + } + } +} + +impl PlatformDisplay for HeadlessDisplay { + fn id(&self) -> DisplayId { + DisplayId::new(0) + } + + fn uuid(&self) -> anyhow::Result { + Ok(Uuid::nil()) + } + + fn bounds(&self) -> Bounds { + self.bounds + } +} + +struct HeadlessWindowState { + bounds: Bounds, + display: Rc, + input_handler: Option, + title: Option, + is_fullscreen: bool, +} + +pub(crate) struct HeadlessWindow(Rc>); + +impl raw_window_handle::HasWindowHandle for HeadlessWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl raw_window_handle::HasDisplayHandle for HeadlessWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl HeadlessWindow { + pub(crate) fn new(params: WindowParams, display: Rc) -> Self { + Self(Rc::new(RefCell::new(HeadlessWindowState { + bounds: params.bounds, + display, + input_handler: None, + title: None, + is_fullscreen: false, + }))) + } +} + +impl PlatformWindow for HeadlessWindow { + fn bounds(&self) -> Bounds { + self.0.borrow().bounds + } + fn is_maximized(&self) -> bool { + false + } + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + fn content_size(&self) -> Size { + self.bounds().size + } + fn resize(&mut self, size: Size) { + self.0.borrow_mut().bounds.size = size; + } + fn scale_factor(&self) -> f32 { + 1.0 + } + fn appearance(&self) -> WindowAppearance { + WindowAppearance::Dark + } + fn display(&self) -> Option> { + Some(self.0.borrow().display.clone()) + } + fn mouse_position(&self) -> Point { + Point::default() + } + fn modifiers(&self) -> Modifiers { + Modifiers::default() + } + fn capslock(&self) -> Capslock { + Capslock::default() + } + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.0.borrow_mut().input_handler = Some(input_handler); + } + fn take_input_handler(&mut self) -> Option { + self.0.borrow_mut().input_handler.take() + } + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + None + } + fn activate(&self) {} + fn is_active(&self) -> bool { + false + } + fn is_hovered(&self) -> bool { + false + } + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + fn set_title(&mut self, title: &str) { + self.0.borrow_mut().title = Some(title.to_owned()); + } + fn get_title(&self) -> String { + self.0.borrow().title.clone().unwrap_or_default() + } + fn set_background_appearance(&self, _background: WindowBackgroundAppearance) {} + fn minimize(&self) {} + fn zoom(&self) {} + fn toggle_fullscreen(&self) { + let mut state = self.0.borrow_mut(); + state.is_fullscreen = !state.is_fullscreen; + } + fn is_fullscreen(&self) -> bool { + self.0.borrow().is_fullscreen + } + fn on_request_frame(&self, _callback: Box) {} + fn on_input(&self, _callback: Box DispatchEventResult>) {} + fn on_active_status_change(&self, _callback: Box) {} + fn on_hover_status_change(&self, _callback: Box) {} + fn on_resize(&self, _callback: Box, f32)>) {} + fn on_moved(&self, _callback: Box) {} + fn on_should_close(&self, _callback: Box bool>) {} + fn on_close(&self, _callback: Box) {} + fn on_hit_test_window_control(&self, _callback: Box Option>) { + } + fn on_appearance_changed(&self, _callback: Box) {} + fn draw(&self, _scene: &Scene) {} + fn sprite_atlas(&self) -> Arc { + Arc::new(HeadlessAtlas::default()) + } + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + fn update_ime_position(&self, _bounds: Bounds) {} + fn gpu_specs(&self) -> Option { + None + } +} + +#[derive(Default)] +struct HeadlessAtlas(Mutex); + +#[derive(Default)] +struct HeadlessAtlasState { + next_id: u32, + tiles: HashMap, +} + +impl PlatformAtlas for HeadlessAtlas { + fn get_or_insert_with<'a>( + &self, + key: &AtlasKey, + build: &mut dyn FnMut() -> anyhow::Result< + Option<(Size, std::borrow::Cow<'a, [u8]>)>, + >, + ) -> anyhow::Result> { + if let Some(tile) = self.0.lock().tiles.get(key) { + return Ok(Some(tile.clone())); + } + let Some((size, _)) = build()? else { + return Ok(None); + }; + let mut state = self.0.lock(); + state.next_id += 1; + let texture_id = state.next_id; + state.next_id += 1; + let tile_id = state.next_id; + let tile = AtlasTile { + texture_id: AtlasTextureId { + index: texture_id, + kind: key.texture_kind(), + }, + tile_id: TileId(tile_id), + padding: 0, + bounds: Bounds { + origin: Point::default(), + size, + }, + }; + state.tiles.insert(key.clone(), tile.clone()); + Ok(Some(tile)) + } + + fn remove(&self, key: &AtlasKey) { + self.0.lock().tiles.remove(key); + } +} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index eda27f71..491c620d 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -5,19 +5,14 @@ use std::{ sync::Arc, }; #[cfg(any(feature = "wayland", feature = "x11"))] -use std::{ - ffi::OsString, - fs::File, - io::Read as _, - os::fd::{AsFd, FromRawFd, IntoRawFd}, - time::Duration, -}; +use std::{ffi::OsString, fs::File, os::fd::AsFd, time::Duration}; +#[cfg(feature = "wayland")] +use std::{io::Read as _, os::fd::AsRawFd}; use anyhow::{Context as _, anyhow}; -use calloop::LoopSignal; +use calloop::{LoopSignal, channel::Sender}; use futures::channel::oneshot; -use util::ResultExt as _; -use util::command::{new_command, new_std_command}; +use gpui_util::{ResultExt as _, new_std_command}; #[cfg(any(feature = "wayland", feature = "x11"))] use xkbcommon::xkb::{self, Keycode, Keysym, State}; @@ -46,50 +41,6 @@ pub(crate) const KEYRING_LABEL: &str = "zed-github-account"; const FILE_PICKER_PORTAL_MISSING: &str = "Couldn't open file picker due to missing xdg-desktop-portal implementation."; -#[cfg(any(feature = "x11", feature = "wayland"))] -pub trait ResultExt { - type Ok; - - fn notify_err(self, msg: &'static str) -> Self::Ok; -} - -#[cfg(any(feature = "x11", feature = "wayland"))] -impl ResultExt for anyhow::Result { - type Ok = T; - - fn notify_err(self, msg: &'static str) -> T { - match self { - Ok(v) => v, - Err(e) => { - use ashpd::desktop::notification::{Notification, NotificationProxy, Priority}; - use futures::executor::block_on; - - let proxy = block_on(NotificationProxy::new()).expect(msg); - - let notification_id = "dev.zed.Oops"; - block_on( - proxy.add_notification( - notification_id, - Notification::new("Zed failed to launch") - .body(Some( - format!( - "{e:?}. See https://zed.dev/docs/linux for troubleshooting steps." - ) - .as_str(), - )) - .priority(Priority::High) - .icon(ashpd::desktop::Icon::with_names(&[ - "dialog-question-symbolic", - ])), - ) - ).expect(msg); - - panic!("{msg}"); - } - } - } -} - pub(crate) trait LinuxClient { fn compositor_name(&self) -> &'static str; fn with_common(&self, f: impl FnOnce(&mut LinuxCommon) -> R) -> R; @@ -135,6 +86,21 @@ pub(crate) trait LinuxClient { fn window_stack(&self) -> Option>; fn run(&self); + fn handle_system_wake(&self) { + let Some(mut callback) = self.with_common(|common| common.callbacks.system_wake.take()) + else { + return; + }; + + callback(); + + self.with_common(|common| { + if common.callbacks.system_wake.is_none() { + common.callbacks.system_wake = Some(callback); + } + }); + } + #[cfg(any(feature = "wayland", feature = "x11"))] fn window_identifier( &self, @@ -152,6 +118,7 @@ pub(crate) struct PlatformHandlers { pub(crate) will_open_app_menu: Option>, pub(crate) validate_app_menu_command: Option bool>>, pub(crate) keyboard_layout_change: Option>, + pub(crate) system_wake: Option>, } pub(crate) struct LinuxCommon { @@ -163,11 +130,24 @@ pub(crate) struct LinuxCommon { pub(crate) callbacks: PlatformHandlers, pub(crate) signal: LoopSignal, pub(crate) menus: Vec, + #[cfg_attr( + not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), + allow(dead_code) + )] + wake_sender: Sender<()>, + wake_listener_started: bool, } impl LinuxCommon { - pub fn new(signal: LoopSignal) -> (Self, PriorityQueueCalloopReceiver) { + pub fn new( + signal: LoopSignal, + ) -> ( + Self, + PriorityQueueCalloopReceiver, + calloop::channel::Channel<()>, + ) { let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); + let (wake_sender, wake_receiver) = calloop::channel::channel(); #[cfg(any(feature = "wayland", feature = "x11"))] let text_system = Arc::new(crate::linux::CosmicTextSystem::new()); @@ -189,12 +169,52 @@ impl LinuxCommon { callbacks, signal, menus: Vec::new(), + wake_sender, + wake_listener_started: false, }; - (common, main_receiver) + (common, main_receiver, wake_receiver) + } + + pub(crate) fn start_wake_listener(&mut self) { + if self.wake_listener_started { + return; + } + #[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] + smol::spawn({ + let wake_sender = self.wake_sender.clone(); + async move { + if let Err(error) = listen_for_system_wake(wake_sender).await { + log::debug!("failed to listen for system wake events: {error:?}"); + } + } + }) + .detach(); + self.wake_listener_started = true; } } +#[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] +async fn listen_for_system_wake(wake_sender: Sender<()>) -> anyhow::Result<()> { + use futures::StreamExt as _; + + let connection = ashpd::zbus::Connection::system().await?; + let proxy = ashpd::zbus::Proxy::new( + &connection, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .await?; + let mut sleep_events = proxy.receive_signal("PrepareForSleep").await?; + while let Some(message) = sleep_events.next().await { + if !message.body().deserialize::()? { + wake_sender.send(()).ok(); + } + } + Ok(()) +} + pub(crate) struct LinuxPlatform

{ pub(crate) inner: P, } @@ -412,7 +432,9 @@ impl Platform for LinuxPlatform

{ response .uris() .iter() - .filter_map(|uri| uri.to_file_path().ok()) + .filter_map(|uri| { + url::Url::parse(uri.as_str()).ok()?.to_file_path().ok() + }) .collect::>(), )), Err(ashpd::Error::Response(_)) => Ok(None), @@ -471,10 +493,9 @@ impl Platform for LinuxPlatform

{ }; let result = match request.response() { - Ok(response) => Ok(response - .uris() - .first() - .and_then(|uri| uri.to_file_path().ok())), + Ok(response) => Ok(response.uris().first().and_then(|uri| { + url::Url::parse(uri.as_str()).ok()?.to_file_path().ok() + })), Err(ashpd::Error::Response(_)) => Ok(None), Err(e) => Err(e.into()), }; @@ -499,15 +520,15 @@ impl Platform for LinuxPlatform

{ let path = path.to_owned(); self.background_executor() .spawn(async move { - let _ = new_command("xdg-open") + #[allow( + clippy::disallowed_methods, + reason = "running on a background thread, so blocking is fine" + )] + new_std_command("xdg-open") .arg(path) - .spawn() - .context("invoking xdg-open") - .log_err()? .status() - .await - .log_err()?; - Some(()) + .context("invoking xdg-open") + .log_err(); }) .detach(); } @@ -524,6 +545,13 @@ impl Platform for LinuxPlatform

{ }); } + fn on_system_wake(&self, callback: Box) { + self.inner.with_common(|common| { + common.callbacks.system_wake = Some(callback); + common.start_wake_listener(); + }); + } + fn on_app_menu_action(&self, callback: Box) { self.inner.with_common(|common| { common.callbacks.app_menu_action = Some(callback); @@ -683,7 +711,7 @@ pub(super) fn open_uri_internal( uri: &str, activation_token: Option, ) { - if let Some(uri) = ashpd::url::Url::parse(uri).log_err() { + if let Some(uri) = ashpd::Uri::parse(uri).log_err() { executor .spawn(async move { let mut xdg_open_failed = false; @@ -785,12 +813,61 @@ pub(super) fn get_xkb_compose_state(cx: &xkb::Context) -> Option Result> { - let mut file = unsafe { File::from_raw_fd(fd.into_raw_fd()) }; +#[cfg(feature = "wayland")] +pub(super) const PIPE_READ_TIMEOUT: Duration = Duration::from_secs(4); + +#[cfg(feature = "wayland")] +const MAX_PIPE_READ_BYTES: usize = 64 * 1024 * 1024; + +#[cfg(feature = "wayland")] +pub(super) fn read_fd_with_timeout( + fd: filedescriptor::FileDescriptor, + timeout: Duration, +) -> Result> { + read_fd_with_timeout_and_limit(fd, timeout, MAX_PIPE_READ_BYTES) +} + +#[cfg(feature = "wayland")] +fn read_fd_with_timeout_and_limit( + mut fd: filedescriptor::FileDescriptor, + timeout: Duration, + max_bytes: usize, +) -> Result> { + fd.set_non_blocking(true)?; let mut buffer = Vec::new(); - file.read_to_end(&mut buffer)?; - Ok(buffer) + let mut chunk = [0u8; 8192]; + loop { + let mut poll_fds = [filedescriptor::pollfd { + fd: fd.as_raw_fd(), + events: filedescriptor::POLLIN, + revents: 0, + }]; + let ready = match filedescriptor::poll(&mut poll_fds, Some(timeout)) { + Ok(ready) => ready, + Err(filedescriptor::Error::Poll(err)) + if err.kind() == std::io::ErrorKind::Interrupted => + { + continue; + } + Err(err) => return Err(err.into()), + }; + if ready == 0 { + anyhow::bail!("timed out waiting for data on pipe after {timeout:?}"); + } + match fd.read(&mut chunk) { + Ok(0) => return Ok(buffer), + Ok(len) => { + if buffer.len().saturating_add(len) > max_bytes { + anyhow::bail!("pipe payload exceeds {max_bytes} byte limit"); + } + buffer.extend_from_slice(&chunk[..len]); + } + Err(err) + if err.kind() == std::io::ErrorKind::WouldBlock + || err.kind() == std::io::ErrorKind::Interrupted => {} + Err(err) => return Err(err.into()), + } + } } #[cfg(any(feature = "wayland", feature = "x11"))] @@ -1146,10 +1223,14 @@ pub(super) fn compositor_gpu_hint_from_dev_t(dev: u64) -> Option, @@ -210,7 +211,8 @@ pub struct Output { pub(crate) struct WaylandClientState { serial_tracker: SerialTracker, globals: Globals, - pub gpu_context: WgpuContext, + pub gpu_context: GpuContext, + pub compositor_gpu: Option, wl_seat: wl_seat::WlSeat, // TODO: Multi seat support wl_pointer: Option, wl_keyboard: Option, @@ -254,8 +256,10 @@ pub(crate) struct WaylandClientState { primary_data_offer: Option>, cursor: Cursor, pending_activation: Option, + startup_activation_token: Option, event_loop: Option>, pub common: LinuxCommon, + ime_enabled: Option, } pub struct DragState { @@ -287,6 +291,18 @@ pub(crate) enum PendingActivation { Window(ObjectId), } +impl WaylandClientState { + fn consume_startup_activation_token(&mut self, surface: &wl_surface::WlSurface) { + let Some(token) = self.startup_activation_token.take() else { + return; + }; + let Some(activation) = self.globals.activation.as_ref() else { + return; + }; + activation.activate(token, surface); + } +} + /// This struct is required to conform to Rust's orphan rules, so we can dispatch on the state but hand the /// window to GPUI. #[derive(Clone)] @@ -311,6 +327,7 @@ impl WaylandClientStatePtr { pub fn enable_ime(&self) { let client = self.get_client(); let mut state = client.borrow_mut(); + state.ime_enabled = Some(true); let Some(text_input) = state.text_input.take() else { return; }; @@ -336,6 +353,7 @@ impl WaylandClientStatePtr { pub fn disable_ime(&self) { let client = self.get_client(); let mut state = client.borrow_mut(); + state.ime_enabled = Some(false); state.composing = false; if let Some(text_input) = &state.text_input { text_input.disable(); @@ -343,10 +361,14 @@ impl WaylandClientStatePtr { } } + pub fn ime_enabled(&self) -> Option { + self.get_client().borrow().ime_enabled + } + pub fn update_ime_position(&self, bounds: Bounds) { let client = self.get_client(); let state = client.borrow_mut(); - if state.composing || state.text_input.is_none() || state.pre_edit_text.is_some() { + if state.text_input.is_none() || state.pre_edit_text.is_some() { return; } @@ -532,7 +554,7 @@ fn wl_output_version(version: u32) -> u32 { } impl WaylandClient { - pub(crate) fn new() -> Self { + pub(crate) fn new(startup_activation_token: Option) -> Self { let conn = Connection::connect_to_env().unwrap(); let (globals, event_queue) = registry_queue_init::(&conn).unwrap(); @@ -571,7 +593,7 @@ impl WaylandClient { let event_loop = EventLoop::::try_new().unwrap(); - let (common, main_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); handle @@ -580,29 +602,31 @@ impl WaylandClient { move |event, _, _: &mut WaylandClientStatePtr| { if let calloop::channel::Event::Msg(runnable) = event { handle.insert_idle(|_| { - let start = Instant::now(); let location = runnable.metadata().location; - let mut timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); runnable.run(); - - let end = Instant::now(); - timing.end = Some(end); - profiler::add_task_timing(timing); + profiler::save_task_timing(); }); } } }) .unwrap(); + handle + .insert_source( + wake_receiver, + |event, _, client: &mut WaylandClientStatePtr| { + if let calloop::channel::Event::Msg(()) = event { + WaylandClient(client.get_client()).handle_system_wake(); + } + }, + ) + .unwrap(); + let compositor_gpu = detect_compositor_gpu(); // This could be unified with the notification handling in zed/main:fail_to_open_window. - let gpu_context = WgpuContext::new(compositor_gpu).notify_err("Unable to init GPU context"); + let gpu_context = Rc::new(RefCell::new(None)); let seat = seat.unwrap(); let globals = Globals::new( @@ -658,6 +682,7 @@ impl WaylandClient { serial_tracker: SerialTracker::new(), globals, gpu_context, + compositor_gpu, wl_seat: seat, wl_pointer: None, wl_keyboard: None, @@ -720,7 +745,9 @@ impl WaylandClient { primary_data_offer: None, cursor, pending_activation: None, + startup_activation_token, event_loop: Some(event_loop), + ime_enabled: None, })); WaylandSource::new(conn, event_queue) @@ -796,7 +823,22 @@ impl LinuxClient for WaylandClient { ) -> anyhow::Result> { let mut state = self.0.borrow_mut(); - let parent = state.keyboard_focused_window.clone(); + let (parent, popup_grab) = match ¶ms.kind { + WindowKind::AnchoredPopup(options) => { + let parent = state + .windows + .values() + .find(|window| window.handle() == options.parent) + .cloned() + .ok_or_else(|| anyhow::anyhow!("popup parent window not found"))?; + let popup_grab = options.grab.then(|| { + let serial = state.serial_tracker.get_latest_input(); + (serial != 0).then(|| (serial, state.wl_seat.clone())) + }); + (Some(parent), popup_grab.flatten()) + } + _ => (state.keyboard_focused_window.clone(), None), + }; let target_output = match params.display_id { Some(display_id) => { @@ -818,13 +860,18 @@ impl LinuxClient for WaylandClient { let (window, surface_id) = WaylandWindow::new( handle, state.globals.clone(), - &state.gpu_context, + Rc::clone(&state.gpu_context), + state.compositor_gpu, WaylandClientStatePtr(Rc::downgrade(&self.0)), params, state.common.appearance, parent, + popup_grab, target_output, )?; + if window.0.toplevel().is_some() { + state.consume_startup_activation_token(&window.0.surface()); + } state.windows.insert(surface_id, window.0.clone()); Ok(Box::new(window)) @@ -1154,6 +1201,7 @@ delegate_noop!(WaylandClientStatePtr: ignore wl_region::WlRegion); delegate_noop!(WaylandClientStatePtr: ignore wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zxdg_decoration_manager_v1::ZxdgDecorationManagerV1); delegate_noop!(WaylandClientStatePtr: ignore zwlr_layer_shell_v1::ZwlrLayerShellV1); +delegate_noop!(WaylandClientStatePtr: ignore xdg_positioner::XdgPositioner); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur_manager::OrgKdeKwinBlurManager); delegate_noop!(WaylandClientStatePtr: ignore zwp_text_input_manager_v3::ZwpTextInputManagerV3); delegate_noop!(WaylandClientStatePtr: ignore org_kde_kwin_blur::OrgKdeKwinBlur); @@ -1324,6 +1372,28 @@ impl Dispatch for WaylandCl } } +impl Dispatch for WaylandClientStatePtr { + fn event( + this: &mut Self, + _: &xdg_popup::XdgPopup, + event: ::Event, + surface_id: &ObjectId, + _: &Connection, + _: &QueueHandle, + ) { + let client = this.get_client(); + let mut state = client.borrow_mut(); + let Some(window) = get_window(&mut state, surface_id) else { + return; + }; + drop(state); + + if window.handle_popup_event(event) { + window.close(); + } + } +} + impl Dispatch for WaylandClientStatePtr { fn event( _: &mut Self, @@ -1541,7 +1611,9 @@ impl Dispatch for WaylandClientStatePtr { state: WEnum::Value(key_state), .. } => { - state.serial_tracker.update(SerialKind::KeyPress, serial); + if key_state == wl_keyboard::KeyState::Pressed { + state.serial_tracker.update(SerialKind::KeyPress, serial); + } let focused_window = state.keyboard_focused_window.clone(); let Some(focused_window) = focused_window else { @@ -1783,8 +1855,9 @@ impl Dispatch for WaylandClientStatePtr { surface_y, .. } => { + let position = point(px(surface_x as f32), px(surface_y as f32)); state.serial_tracker.update(SerialKind::MouseEnter, serial); - state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); + state.mouse_location = Some(position); state.button_pressed = None; if let Some(window) = get_window(&mut state, &surface.id()) { @@ -1809,8 +1882,14 @@ impl Dispatch for WaylandClientStatePtr { ); } } + let modifiers = state.modifiers; drop(state); window.set_hovered(true); + window.handle_input(PlatformInput::MouseMove(MouseMoveEvent { + position, + pressed_button: None, + modifiers, + })); } } wl_pointer::Event::Leave { .. } => { @@ -1888,7 +1967,9 @@ impl Dispatch for WaylandClientStatePtr { state: WEnum::Value(button_state), .. } => { - state.serial_tracker.update(SerialKind::MousePress, serial); + if button_state == wl_pointer::ButtonState::Pressed { + state.serial_tracker.update(SerialKind::MousePress, serial); + } let button = linux_button_to_gpui(button); let Some(button) = button else { return }; if state.mouse_focused_window.is_none() { @@ -2197,7 +2278,7 @@ impl Dispatch for WaylandClientStatePtr { drop(pipe.write); let read_task = state.common.background_executor.spawn(async { - let buffer = unsafe { read_fd(fd)? }; + let buffer = read_fd_with_timeout(fd, PIPE_READ_TIMEOUT)?; let text = String::from_utf8(buffer)?; anyhow::Ok(text) }); diff --git a/crates/gpui_linux/src/linux/wayland/clipboard.rs b/crates/gpui_linux/src/linux/wayland/clipboard.rs index 49c58724..dfedf53f 100644 --- a/crates/gpui_linux/src/linux/wayland/clipboard.rs +++ b/crates/gpui_linux/src/linux/wayland/clipboard.rs @@ -10,7 +10,10 @@ use strum::IntoEnumIterator; use wayland_client::{Connection, protocol::wl_data_offer::WlDataOffer}; use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::ZwpPrimarySelectionOfferV1; -use crate::linux::{WaylandClientStatePtr, platform::read_fd}; +use crate::linux::{ + WaylandClientStatePtr, + platform::{PIPE_READ_TIMEOUT, read_fd_with_timeout}, +}; use gpui::{ClipboardEntry, ClipboardItem, Image, ImageFormat, hash}; /// Text mime types that we'll offer to other programs. @@ -86,7 +89,7 @@ impl DataOffer { connection.flush().unwrap(); - match unsafe { read_fd(fd) } { + match read_fd_with_timeout(fd, PIPE_READ_TIMEOUT) { Ok(bytes) => Some(bytes), Err(err) => { log::error!("error reading clipboard pipe: {err:?}"); diff --git a/crates/gpui_linux/src/linux/wayland/cursor.rs b/crates/gpui_linux/src/linux/wayland/cursor.rs index 957fcf39..2d66fed9 100644 --- a/crates/gpui_linux/src/linux/wayland/cursor.rs +++ b/crates/gpui_linux/src/linux/wayland/cursor.rs @@ -1,7 +1,7 @@ use crate::linux::Globals; use crate::linux::{DEFAULT_CURSOR_ICON_NAME, log_cursor_icon_warning}; use anyhow::{Context as _, anyhow}; -use util::ResultExt; +use gpui_util::ResultExt; use wayland_client::Connection; use wayland_client::protocol::wl_surface::WlSurface; diff --git a/crates/gpui_linux/src/linux/wayland/popup.rs b/crates/gpui_linux/src/linux/wayland/popup.rs new file mode 100644 index 00000000..58dc9f52 --- /dev/null +++ b/crates/gpui_linux/src/linux/wayland/popup.rs @@ -0,0 +1,37 @@ +pub use gpui::popup::*; + +use wayland_protocols::xdg::shell::client::xdg_positioner; + +pub(crate) fn wayland_anchor(anchor: PopupAnchor) -> xdg_positioner::Anchor { + match anchor { + PopupAnchor::Center => xdg_positioner::Anchor::None, + PopupAnchor::Top => xdg_positioner::Anchor::Top, + PopupAnchor::Bottom => xdg_positioner::Anchor::Bottom, + PopupAnchor::Left => xdg_positioner::Anchor::Left, + PopupAnchor::Right => xdg_positioner::Anchor::Right, + PopupAnchor::TopLeft => xdg_positioner::Anchor::TopLeft, + PopupAnchor::BottomLeft => xdg_positioner::Anchor::BottomLeft, + PopupAnchor::TopRight => xdg_positioner::Anchor::TopRight, + PopupAnchor::BottomRight => xdg_positioner::Anchor::BottomRight, + } +} + +pub(crate) fn wayland_gravity(gravity: PopupGravity) -> xdg_positioner::Gravity { + match gravity { + PopupGravity::Center => xdg_positioner::Gravity::None, + PopupGravity::Top => xdg_positioner::Gravity::Top, + PopupGravity::Bottom => xdg_positioner::Gravity::Bottom, + PopupGravity::Left => xdg_positioner::Gravity::Left, + PopupGravity::Right => xdg_positioner::Gravity::Right, + PopupGravity::TopLeft => xdg_positioner::Gravity::TopLeft, + PopupGravity::BottomLeft => xdg_positioner::Gravity::BottomLeft, + PopupGravity::TopRight => xdg_positioner::Gravity::TopRight, + PopupGravity::BottomRight => xdg_positioner::Gravity::BottomRight, + } +} + +pub(crate) fn wayland_constraint_adjustment( + adjustment: PopupConstraintAdjustment, +) -> xdg_positioner::ConstraintAdjustment { + xdg_positioner::ConstraintAdjustment::from_bits_truncate(adjustment.bits()) +} diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index dbf663bf..7486095f 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -1,12 +1,12 @@ use std::{ - cell::{Ref, RefCell, RefMut}, + cell::{Cell, Ref, RefCell, RefMut}, ffi::c_void, ptr::NonNull, rc::Rc, sync::Arc, }; -use collections::{FxHashSet, HashMap}; +use collections::{FxHashMap, HashMap}; use futures::channel::oneshot::Receiver; use raw_window_handle as rwh; @@ -14,12 +14,13 @@ use wayland_backend::client::ObjectId; use wayland_client::WEnum; use wayland_client::{ Proxy, - protocol::{wl_output, wl_surface}, + protocol::{wl_output, wl_seat, wl_surface}, }; use wayland_protocols::wp::viewporter::client::wp_viewport; use wayland_protocols::xdg::decoration::zv1::client::zxdg_toplevel_decoration_v1; use wayland_protocols::xdg::shell::client::xdg_surface; use wayland_protocols::xdg::shell::client::xdg_toplevel::{self}; +use wayland_protocols::xdg::shell::client::{xdg_popup, xdg_positioner}; use wayland_protocols::{ wp::fractional_scale::v1::client::wp_fractional_scale_v1, xdg::dialog::v1::client::xdg_dialog_v1::XdgDialogV1, @@ -38,9 +39,9 @@ use gpui::{ PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowKind, WindowParams, - layer_shell::LayerShellNotSupportedError, px, size, + layer_shell::LayerShellNotSupportedError, popup::PopupOptions, px, size, }; -use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use gpui_wgpu::{CompositorGpuHint, GpuContext, WgpuRenderer, WgpuSurfaceConfig}; #[derive(Default)] pub(crate) struct Callbacks { @@ -90,11 +91,57 @@ struct InProgressConfigure { tiling: Tiling, } +#[derive(Clone, Debug)] +struct InputRegionState { + overlay_mode: OverlayInputMode, + // Outer `None` means overlay mode was the last setter. `Some(None)` means the generic + // input-region API explicitly restored the protocol default (the whole surface). + explicit: Option>>>, +} + +#[derive(Debug, PartialEq)] +enum ResolvedInputRegion<'a> { + Default, + Rects(&'a [Bounds]), + OverlayInteractive(Bounds), +} + +impl Default for InputRegionState { + fn default() -> Self { + Self { + overlay_mode: OverlayInputMode::Interactive, + explicit: None, + } + } +} + +impl InputRegionState { + fn set_overlay_mode(&mut self, mode: OverlayInputMode) { + self.overlay_mode = mode; + self.explicit = None; + } + + fn set_explicit(&mut self, region: Option<&[Bounds]>) { + self.explicit = Some(region.map(<[_]>::to_vec)); + } + + fn resolve(&self, interactive_bounds: Bounds) -> ResolvedInputRegion<'_> { + match &self.explicit { + Some(None) => ResolvedInputRegion::Default, + Some(Some(rects)) => ResolvedInputRegion::Rects(rects), + None if self.overlay_mode == OverlayInputMode::ClickThrough => { + ResolvedInputRegion::Rects(&[]) + } + None => ResolvedInputRegion::OverlayInteractive(interactive_bounds), + } + } +} + pub struct WaylandWindowState { surface_state: WaylandSurfaceState, acknowledged_first_configure: bool, parent: Option, - children: FxHashSet, + children: FxHashMap, pub surface: wl_surface::WlSurface, app_id: Option, appearance: WindowAppearance, @@ -109,7 +156,7 @@ pub struct WaylandWindowState { input_handler: Option, decorations: WindowDecorations, background_appearance: WindowBackgroundAppearance, - input_mode: OverlayInputMode, + input_region: InputRegionState, fullscreen: bool, maximized: bool, tiling: Tiling, @@ -119,6 +166,7 @@ pub struct WaylandWindowState { active: bool, hovered: bool, pub(crate) force_render_after_recovery: bool, + renderer_presented: bool, in_progress_configure: Option, resize_throttle: bool, in_progress_window_controls: Option, @@ -130,6 +178,7 @@ pub struct WaylandWindowState { pub enum WaylandSurfaceState { Xdg(WaylandXdgSurfaceState), LayerShell(WaylandLayerSurfaceState), + Popup(WaylandPopupSurfaceState), } impl WaylandSurfaceState { @@ -138,6 +187,7 @@ impl WaylandSurfaceState { globals: &Globals, params: &WindowParams, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result { // For layer_shell windows, create a layer surface instead of an xdg surface @@ -187,6 +237,46 @@ impl WaylandSurfaceState { })); } + if let WindowKind::AnchoredPopup(options) = ¶ms.kind { + let Some(parent) = parent.as_ref() else { + return Err(anyhow::anyhow!("popup parent window not found")); + }; + let positioner = build_popup_positioner( + globals, + options, + params.bounds.size, + parent.window_geometry(), + ); + let xdg_surface = globals + .wm_base + .get_xdg_surface(surface, &globals.qh, surface.id()); + let xdg_popup = if let Some(parent_layer_surface) = parent.layer_surface() { + let popup = xdg_surface.get_popup(None, &positioner, &globals.qh, surface.id()); + parent_layer_surface.get_popup(&popup); + popup + } else { + xdg_surface.get_popup( + parent.xdg_surface().as_ref(), + &positioner, + &globals.qh, + surface.id(), + ) + }; + positioner.destroy(); + + if let Some((serial, seat)) = popup_grab { + xdg_popup.grab(&seat, serial); + } + parent.add_child(surface.id(), false); + + return Ok(WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + options: options.clone(), + next_reposition_token: Cell::new(0), + })); + } + // All other WindowKinds result in a regular xdg surface let xdg_surface = globals .wm_base @@ -207,7 +297,7 @@ impl WaylandSurfaceState { }); if let Some(parent) = parent.as_ref() { - parent.add_child(surface.id()); + parent.add_child(surface.id(), true); } dialog @@ -247,6 +337,58 @@ pub struct WaylandLayerSurfaceState { layer_surface: zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, } +pub struct WaylandPopupSurfaceState { + xdg_surface: xdg_surface::XdgSurface, + xdg_popup: xdg_popup::XdgPopup, + options: PopupOptions, + next_reposition_token: Cell, +} + +fn build_popup_positioner( + globals: &Globals, + options: &PopupOptions, + size: Size, + parent_geometry: Bounds, +) -> xdg_positioner::XdgPositioner { + let positioner = globals.wm_base.create_positioner(&globals.qh, ()); + positioner.set_size( + f32::from(size.width).max(1.0) as i32, + f32::from(size.height).max(1.0) as i32, + ); + + let anchor_rect = Bounds { + origin: options.anchor_rect.origin - parent_geometry.origin, + size: options.anchor_rect.size, + }; + let one = Point::new(px(1.0), px(1.0)); + let geometry_bottom_right = Point::new(parent_geometry.size.width, parent_geometry.size.height); + let top_left = anchor_rect + .origin + .min(&(geometry_bottom_right - one)) + .max(&Point::default()); + let bottom_right = anchor_rect + .bottom_right() + .min(&geometry_bottom_right) + .max(&(top_left + one)); + let anchor_rect = Bounds::from_corners(top_left, bottom_right); + positioner.set_anchor_rect( + f32::from(anchor_rect.origin.x) as i32, + f32::from(anchor_rect.origin.y) as i32, + f32::from(anchor_rect.size.width) as i32, + f32::from(anchor_rect.size.height) as i32, + ); + positioner.set_anchor(super::popup::wayland_anchor(options.anchor)); + positioner.set_gravity(super::popup::wayland_gravity(options.gravity)); + positioner.set_constraint_adjustment(super::popup::wayland_constraint_adjustment( + options.constraint_adjustment, + )); + positioner.set_offset( + f32::from(options.offset.x) as i32, + f32::from(options.offset.y) as i32, + ); + positioner +} + impl WaylandSurfaceState { fn ack_configure(&self, serial: u32) { match self { @@ -256,6 +398,9 @@ impl WaylandSurfaceState { WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface, .. }) => { layer_surface.ack_configure(serial); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.ack_configure(serial); + } } } @@ -275,6 +420,24 @@ impl WaylandSurfaceState { } } + fn xdg_surface(&self) -> Option<&xdg_surface::XdgSurface> { + match self { + WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) + | WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + Some(xdg_surface) + } + WaylandSurfaceState::LayerShell(_) => None, + } + } + + fn layer_surface(&self) -> Option<&zwlr_layer_surface_v1::ZwlrLayerSurfaceV1> { + if let WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) = self { + Some(layer_surface) + } else { + None + } + } + fn set_geometry(&self, x: i32, y: i32, width: i32, height: i32) { match self { WaylandSurfaceState::Xdg(WaylandXdgSurfaceState { xdg_surface, .. }) => { @@ -284,6 +447,31 @@ impl WaylandSurfaceState { // cannot set window position of a layer surface layer_surface.set_size(width as u32, height as u32); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { xdg_surface, .. }) => { + xdg_surface.set_window_geometry(x, y, width, height); + } + } + } + + fn reposition_popup( + &self, + globals: &Globals, + size: Size, + parent_geometry: Bounds, + ) { + if let WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_popup, + options, + next_reposition_token, + .. + }) = self + && xdg_popup.version() >= xdg_popup::REQ_REPOSITION_SINCE + { + let token = next_reposition_token.get(); + next_reposition_token.set(token.wrapping_add(1)); + let positioner = build_popup_positioner(globals, options, size, parent_geometry); + xdg_popup.reposition(&positioner, token); + positioner.destroy(); } } @@ -308,6 +496,14 @@ impl WaylandSurfaceState { WaylandSurfaceState::LayerShell(WaylandLayerSurfaceState { layer_surface }) => { layer_surface.destroy(); } + WaylandSurfaceState::Popup(WaylandPopupSurfaceState { + xdg_surface, + xdg_popup, + .. + }) => { + xdg_popup.destroy(); + xdg_surface.destroy(); + } } } } @@ -327,7 +523,8 @@ impl WaylandWindowState { viewport: Option, client: WaylandClientStatePtr, globals: Globals, - gpu_context: &WgpuContext, + gpu_context: GpuContext, + compositor_gpu: Option, options: WindowParams, parent: Option, ) -> anyhow::Result { @@ -349,13 +546,16 @@ impl WaylandWindowState { transparent: true, preferred_present_mode: Some(wgpu::PresentMode::Mailbox), }; - WgpuRenderer::new(gpu_context, &raw_window, config)? + WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; if let WaylandSurfaceState::Xdg(ref xdg_state) = surface_state { if let Some(title) = options.titlebar.and_then(|titlebar| titlebar.title) { xdg_state.toplevel.set_title(title.to_string()); } + if let Some(app_id) = options.app_id.as_ref() { + xdg_state.toplevel.set_app_id(app_id.clone()); + } // Set max window size based on the GPU's maximum texture dimension. // This prevents the window from being resized larger than what the GPU can render. let max_texture_size = renderer.max_texture_size() as i32; @@ -368,9 +568,9 @@ impl WaylandWindowState { surface_state, acknowledged_first_configure: false, parent, - children: FxHashSet::default(), + children: FxHashMap::default(), surface, - app_id: None, + app_id: options.app_id, blur: None, viewport, globals, @@ -382,7 +582,7 @@ impl WaylandWindowState { input_handler: None, decorations: WindowDecorations::Client, background_appearance: WindowBackgroundAppearance::Opaque, - input_mode: OverlayInputMode::Interactive, + input_region: InputRegionState::default(), fullscreen: false, maximized: false, tiling: Tiling::default(), @@ -395,6 +595,7 @@ impl WaylandWindowState { active: false, hovered: false, force_render_after_recovery: false, + renderer_presented: false, in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, @@ -536,16 +737,24 @@ impl WaylandWindow { pub fn new( handle: AnyWindowHandle, globals: Globals, - gpu_context: &WgpuContext, + gpu_context: GpuContext, + compositor_gpu: Option, client: WaylandClientStatePtr, params: WindowParams, appearance: WindowAppearance, parent: Option, + popup_grab: Option<(u32, wl_seat::WlSeat)>, target_output: Option, ) -> anyhow::Result<(Self, ObjectId)> { let surface = globals.compositor.create_surface(&globals.qh, ()); - let surface_state = - WaylandSurfaceState::new(&surface, &globals, ¶ms, parent.clone(), target_output)?; + let surface_state = WaylandSurfaceState::new( + &surface, + &globals, + ¶ms, + parent.clone(), + popup_grab, + target_output, + )?; if let Some(fractional_scale_manager) = globals.fractional_scale_manager.as_ref() { fractional_scale_manager.get_fractional_scale(&surface, &globals.qh, surface.id()); @@ -566,6 +775,7 @@ impl WaylandWindow { client, globals, gpu_context, + compositor_gpu, params, parent, )?)), @@ -592,18 +802,35 @@ impl WaylandWindowStatePtr { self.state.borrow().surface_state.toplevel().cloned() } + pub fn xdg_surface(&self) -> Option { + self.state.borrow().surface_state.xdg_surface().cloned() + } + + pub fn layer_surface(&self) -> Option { + self.state.borrow().surface_state.layer_surface().cloned() + } + + pub fn window_geometry(&self) -> Bounds { + let state = self.state.borrow(); + inset_by_tiling( + state.bounds.map_origin(|_| px(0.0)), + state.inset(), + state.tiling, + ) + } + pub fn ptr_eq(&self, other: &Self) -> bool { Rc::ptr_eq(&self.state, &other.state) } - pub fn add_child(&self, child: ObjectId) { + pub fn add_child(&self, child: ObjectId, blocking: bool) { let mut state = self.state.borrow_mut(); - state.children.insert(child); + state.children.insert(child, blocking); } pub fn is_blocked(&self) -> bool { let state = self.state.borrow(); - !state.children.is_empty() + state.children.values().any(|&blocking| blocking) } pub fn frame(&self) { @@ -620,6 +847,30 @@ impl WaylandWindowStatePtr { force_render, ..Default::default() }); + drop(cb); + self.update_ime_enabled(); + } + } + + fn update_ime_enabled(&self) { + let mut state = self.state.borrow_mut(); + if !state.active { + return; + } + let client = state.client.clone(); + let ime_enabled = state + .input_handler + .as_mut() + .map(|input_handler| input_handler.query_accepts_text_input()) + .unwrap_or(true); + drop(state); + if Some(ime_enabled) == client.ime_enabled() { + return; + } + if ime_enabled { + client.enable_ime(); + } else { + client.disable_ime(); } } @@ -877,6 +1128,29 @@ impl WaylandWindowStatePtr { } } + pub fn handle_popup_event(&self, event: xdg_popup::Event) -> bool { + match event { + xdg_popup::Event::Configure { width, height, .. } => { + let size = if width <= 0 || height <= 0 { + None + } else { + Some(size(px(width as f32), px(height as f32))) + }; + self.state.borrow_mut().in_progress_configure = Some(InProgressConfigure { + size, + fullscreen: false, + maximized: false, + resizing: false, + tiling: Tiling::default(), + }); + false + } + xdg_popup::Event::PopupDone => true, + xdg_popup::Event::Repositioned { .. } => false, + _ => false, + } + } + #[allow(clippy::mutable_key_type)] pub fn handle_surface_event( &self, @@ -960,9 +1234,7 @@ impl WaylandWindowStatePtr { let mut bounds: Option> = None; if let Some(mut input_handler) = state.input_handler.take() { drop(state); - if let Some(selection) = input_handler.marked_text_range() { - bounds = input_handler.bounds_for_range(selection.start..selection.start); - } + bounds = input_handler.ime_candidate_bounds(); self.state.borrow_mut().input_handler = Some(input_handler); } bounds @@ -985,11 +1257,11 @@ impl WaylandWindowStatePtr { state.update_accesskit_window_bounds(); let device_bounds = state.bounds.to_device_pixels(state.scale); state.renderer.update_drawable_size(device_bounds.size); - // A rounded blur region depends on the window size, so it must be + // A blurred region depends on the window size, so it must be // rebuilt on resize. Capture the flag before dropping the borrow. let needs_blur_update = matches!( state.background_appearance, - WindowBackgroundAppearance::Blurred { corner_radius } if corner_radius > px(0.) + WindowBackgroundAppearance::Blurred { .. } ); (state.bounds.size, state.scale, needs_blur_update) }; @@ -1022,8 +1294,7 @@ impl WaylandWindowStatePtr { pub fn close(&self) { let state = self.state.borrow(); let client = state.client.get_client(); - #[allow(clippy::mutable_key_type)] - let children = state.children.clone(); + let children = state.children.keys().cloned().collect::>(); drop(state); for child in children { @@ -1173,6 +1444,20 @@ impl PlatformWindow for WaylandWindow { let state = self.borrow(); let state_ptr = self.0.clone(); + if matches!(state.surface_state, WaylandSurfaceState::Popup(_)) { + if state.acknowledged_first_configure { + let parent_geometry = state + .parent + .as_ref() + .map(WaylandWindowStatePtr::window_geometry) + .unwrap_or_default(); + state + .surface_state + .reposition_popup(&state.globals, size, parent_geometry); + } + return; + } + // Keep window geometry consistent with configure handling. On Wayland, window geometry is // surface-local: resizing should not attempt to translate the window; the compositor // controls placement. We also account for client-side decoration insets and tiling. @@ -1316,10 +1601,15 @@ impl PlatformWindow for WaylandWindow { fn set_overlay_input_mode(&self, input_mode: OverlayInputMode) { let mut state = self.borrow_mut(); - if state.input_mode != input_mode { - state.input_mode = input_mode; - update_window(state); - } + state.input_region.set_overlay_mode(input_mode); + update_window(state); + } + + fn set_input_region(&self, region: Option<&[Bounds]>) { + let mut state = self.borrow_mut(); + state.input_region.set_explicit(region); + update_window(state); + self.borrow().surface.commit(); } fn background_appearance(&self) -> WindowBackgroundAppearance { @@ -1327,9 +1617,7 @@ impl PlatformWindow for WaylandWindow { } fn is_subpixel_rendering_supported(&self) -> bool { - let client = self.borrow().client.get_client(); - let state = client.borrow(); - state.gpu_context.supports_dual_source_blending() + self.borrow().renderer.supports_dual_source_blending() } fn minimize(&self) { @@ -1405,12 +1693,26 @@ impl PlatformWindow for WaylandWindow { fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); - state.renderer.draw(scene); + if state.renderer.device_lost() { + if let Err(error) = state.renderer.recover() { + log::warn!("failed to recover lost wgpu device; will retry: {error:#}"); + } + let _ = state.renderer.needs_redraw(); + state.force_render_after_recovery = true; + return; + } + state.renderer_presented = state.renderer.draw(scene); + if state.renderer.needs_redraw() { + state.force_render_after_recovery = true; + } } fn completed_frame(&self) { - let state = self.borrow(); - state.surface.commit(); + let mut state = self.borrow_mut(); + if !state.renderer_presented { + state.surface.commit(); + } + state.renderer_presented = false; } fn sprite_atlas(&self) -> Arc { @@ -1608,19 +1910,47 @@ fn update_window(mut state: RefMut) { state.surface.set_opaque_region(None); } - let input_region = state - .globals - .compositor - .create_region(&state.globals.qh, ()); - if matches!(state.input_mode, OverlayInputMode::Interactive) { - input_region.add( - opaque_area.origin.x, - opaque_area.origin.y, - opaque_area.size.width, - opaque_area.size.height, - ); - } - state.surface.set_input_region(Some(&input_region)); + let input_region = match state + .input_region + .resolve(opaque_area.map(|value| px(value as f32))) + { + ResolvedInputRegion::Default => { + state.surface.set_input_region(None); + None + } + ResolvedInputRegion::Rects(rects) => { + let input_region = state + .globals + .compositor + .create_region(&state.globals.qh, ()); + for rect in rects { + let rect = rect.map(|pixels| f32::from(pixels) as i32); + input_region.add( + rect.origin.x, + rect.origin.y, + rect.size.width, + rect.size.height, + ); + } + state.surface.set_input_region(Some(&input_region)); + Some(input_region) + } + ResolvedInputRegion::OverlayInteractive(bounds) => { + let input_region = state + .globals + .compositor + .create_region(&state.globals.qh, ()); + let bounds = bounds.map(|pixels| f32::from(pixels) as i32); + input_region.add( + bounds.origin.x, + bounds.origin.y, + bounds.size.width, + bounds.size.height, + ); + state.surface.set_input_region(Some(&input_region)); + Some(input_region) + } + }; if let Some(ref blur_manager) = state.globals.blur_manager { match state.background_appearance { @@ -1659,7 +1989,9 @@ fn update_window(mut state: RefMut) { } } - input_region.destroy(); + if let Some(input_region) = input_region { + input_region.destroy(); + } opaque_region.destroy(); } @@ -1742,7 +2074,8 @@ fn inset_by_tiling(mut bounds: Bounds, inset: Pixels, tiling: Tiling) -> #[cfg(test)] mod tests { - use super::rounded_rect_region_bands; + use super::{InputRegionState, ResolvedInputRegion, rounded_rect_region_bands}; + use gpui::{Bounds, OverlayInputMode, point, px, size}; use std::collections::HashMap; #[test] @@ -1811,4 +2144,34 @@ mod tests { assert!(first >= last, "inset(0)={first} < inset(r-1)={last}"); assert!(last >= 0); } + + #[test] + fn input_region_last_setter_wins_and_survives_resolution() { + let bounds = Bounds::new(point(px(0.), px(0.)), size(px(200.), px(120.))); + let explicit = [Bounds::new(point(px(10.), px(12.)), size(px(40.), px(30.)))]; + let mut state = InputRegionState::default(); + + assert_eq!( + state.resolve(bounds), + ResolvedInputRegion::OverlayInteractive(bounds) + ); + state.set_explicit(Some(&explicit)); + assert_eq!(state.resolve(bounds), ResolvedInputRegion::Rects(&explicit)); + assert_eq!( + state.resolve(Bounds::new(point(px(0.), px(0.)), size(px(640.), px(480.)))), + ResolvedInputRegion::Rects(&explicit) + ); + + state.set_overlay_mode(OverlayInputMode::ClickThrough); + assert_eq!(state.resolve(bounds), ResolvedInputRegion::Rects(&[])); + + state.set_explicit(None); + assert_eq!(state.resolve(bounds), ResolvedInputRegion::Default); + + state.set_overlay_mode(OverlayInputMode::Interactive); + assert_eq!( + state.resolve(bounds), + ResolvedInputRegion::OverlayInteractive(bounds) + ); + } } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 247b551b..8d2b7d52 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -6,7 +6,8 @@ use calloop::{ }; use collections::HashMap; use core::str; -use gpui::{Capslock, TaskTiming, profiler}; +use gpui::{Capslock, profiler}; +use gpui_util::ResultExt as _; use http_client::Url; use log::Level; use smallvec::SmallVec; @@ -18,7 +19,6 @@ use std::{ rc::{Rc, Weak}, time::{Duration, Instant}, }; -use util::ResultExt as _; use x11rb::{ connection::{Connection, RequestConnection}, @@ -49,10 +49,9 @@ use super::{ }; use crate::linux::{ - DEFAULT_CURSOR_ICON_NAME, LinuxClient, ResultExt as _, capslock_from_xkb, - cursor_style_to_icon_names, get_xkb_compose_state, is_within_click_distance, - keystroke_from_xkb, keystroke_underlying_dead_key, log_cursor_icon_warning, modifiers_from_xkb, - open_uri_internal, + DEFAULT_CURSOR_ICON_NAME, LinuxClient, capslock_from_xkb, cursor_style_to_icon_names, + get_xkb_compose_state, is_within_click_distance, keystroke_from_xkb, + keystroke_underlying_dead_key, log_cursor_icon_warning, modifiers_from_xkb, open_uri_internal, platform::{DOUBLE_CLICK_INTERVAL, SCROLL_LINES}, reveal_path_internal, xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, @@ -65,7 +64,7 @@ use gpui::{ PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, TouchPhase, WindowParams, point, px, }; -use gpui_wgpu::{CompositorGpuHint, WgpuContext}; +use gpui_wgpu::{CompositorGpuHint, GpuContext}; /// Value for DeviceId parameters which selects all devices. pub(crate) const XINPUT_ALL_DEVICES: xinput::DeviceId = 0; @@ -178,7 +177,8 @@ pub struct X11ClientState { pub(crate) last_location: Point, pub(crate) current_count: usize, - pub(crate) gpu_context: WgpuContext, + pub(crate) gpu_context: GpuContext, + pub(crate) compositor_gpu: Option, pub(crate) scale_factor: f32, @@ -308,7 +308,7 @@ impl X11Client { pub(crate) fn new() -> anyhow::Result { let event_loop = EventLoop::try_new()?; - let (common, main_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -321,20 +321,11 @@ impl X11Client { // events have higher priority and runnables are only worked off after the event // callbacks. handle.insert_idle(|_| { - let start = Instant::now(); let location = runnable.metadata().location; - let mut timing = TaskTiming { - location, - start, - end: None, - }; - profiler::add_task_timing(timing); - + let spawned = runnable.metadata().spawned; + profiler::update_running_task(spawned, location); runnable.run(); - - let end = Instant::now(); - timing.end = Some(end); - profiler::add_task_timing(timing); + profiler::save_task_timing(); }); } } @@ -343,6 +334,16 @@ impl X11Client { anyhow!("Failed to initialize event loop handling of foreground tasks: {err:?}") })?; + handle + .insert_source(wake_receiver, |event, _, client: &mut X11Client| { + if let calloop::channel::Event::Msg(()) = event { + client.handle_system_wake(); + } + }) + .map_err(|err| { + anyhow!("Failed to initialize event loop handling of wake events: {err:?}") + })?; + let (xcb_connection, x_root_index) = XCBConnection::connect(None)?; xcb_connection.prefetch_extension_information(xkb::X11_EXTENSION_NAME)?; xcb_connection.prefetch_extension_information(randr::X11_EXTENSION_NAME)?; @@ -439,7 +440,7 @@ impl X11Client { let screen = &xcb_connection.setup().roots[x_root_index]; let compositor_gpu = detect_compositor_gpu(&xcb_connection, screen); - let gpu_context = WgpuContext::new(compositor_gpu).notify_err("Unable to init GPU context"); + let gpu_context = Rc::new(RefCell::new(None)); let resource_database = x11rb::resource_manager::new_from_default(&xcb_connection) .context("Failed to create resource database")?; @@ -511,6 +512,7 @@ impl X11Client { last_location: Point::new(px(0.0), px(0.0)), current_count: 0, gpu_context, + compositor_gpu, scale_factor, xkb_context, @@ -1597,7 +1599,8 @@ impl LinuxClient for X11Client { handle, X11ClientStatePtr(Rc::downgrade(&self.0)), state.common.foreground_executor.clone(), - &state.gpu_context, + Rc::clone(&state.gpu_context), + state.compositor_gpu, params, &state.xcb_connection, state.client_side_decorations_supported, diff --git a/crates/gpui_linux/src/linux/x11/clipboard.rs b/crates/gpui_linux/src/linux/x11/clipboard.rs index d2ea58b3..a92c2e33 100644 --- a/crates/gpui_linux/src/linux/x11/clipboard.rs +++ b/crates/gpui_linux/src/linux/x11/clipboard.rs @@ -843,7 +843,7 @@ fn serve_requests(context: Arc) -> Result<(), Box> log::trace!("Started serve requests thread."); - let _guard = util::defer(|| { + let _guard = gpui_util::defer(|| { context.serve_stopped.store(true, Ordering::Relaxed); }); diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index 4018c406..41488e00 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -5,6 +5,7 @@ use crate::linux::X11ClientStatePtr; use crate::linux::accesskit_shims::{ TrivialActionHandler, TrivialActivationHandler, TrivialDeactivationHandler, }; +use gpui::popup::PopupNotSupportedError; use gpui::{ AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers, OverlayInputMode, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, @@ -12,11 +13,11 @@ use gpui::{ ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowDecorations, WindowKind, WindowParams, px, }; -use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; +use gpui_wgpu::{CompositorGpuHint, GpuContext, WgpuRenderer, WgpuSurfaceConfig}; use collections::FxHashSet; +use gpui_util::{ResultExt, maybe}; use raw_window_handle as rwh; -use util::{ResultExt, maybe}; use x11rb::{ connection::Connection, cookie::{Cookie, VoidCookie}, @@ -292,6 +293,35 @@ impl X11WindowState { fn is_transparent(&self) -> bool { self.background_appearance != WindowBackgroundAppearance::Opaque } + + fn update_accesskit_window_bounds(&mut self) { + let scale = self.scale_factor; + let bounds = self.bounds; + let [left, right, top, bottom] = self.last_insets; + + let x = f32::from(bounds.origin.x); + let y = f32::from(bounds.origin.y); + let width = f32::from(bounds.size.width); + let height = f32::from(bounds.size.height); + + let outer = accesskit::Rect { + x0: (x * scale) as f64, + y0: (y * scale) as f64, + x1: ((x + width) * scale) as f64, + y1: ((y + height) * scale) as f64, + }; + + let inner = accesskit::Rect { + x0: (x * scale) as f64 + left as f64, + y0: (y * scale) as f64 + top as f64, + x1: ((x + width) * scale) as f64 - right as f64, + y1: ((y + height) * scale) as f64 - bottom as f64, + }; + + if let Some(adapter) = self.accesskit_adapter.as_mut() { + adapter.set_root_window_bounds(outer, inner); + } + } } #[derive(Clone)] @@ -419,7 +449,8 @@ impl X11WindowState { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &WgpuContext, + gpu_context: GpuContext, + compositor_gpu: Option, params: WindowParams, xcb: &Rc, client_side_decorations_supported: bool, @@ -432,6 +463,10 @@ impl X11WindowState { supports_xinput_gestures: bool, is_bgr: bool, ) -> anyhow::Result { + if matches!(params.kind, WindowKind::AnchoredPopup(_)) { + return Err(PopupNotSupportedError.into()); + } + let x_screen_index = match params.display_id { Some(did) => { let index = usize::try_from(u64::from(did)) @@ -746,7 +781,7 @@ impl X11WindowState { transparent: false, preferred_present_mode: None, }; - WgpuRenderer::new(gpu_context, &raw_window, config)? + WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; renderer.set_subpixel_layout(is_bgr); @@ -872,7 +907,8 @@ impl X11Window { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: &WgpuContext, + gpu_context: GpuContext, + compositor_gpu: Option, params: WindowParams, xcb: &Rc, client_side_decorations_supported: bool, @@ -891,6 +927,7 @@ impl X11Window { client, executor, gpu_context, + compositor_gpu, params, xcb, client_side_decorations_supported, @@ -1247,6 +1284,7 @@ impl X11WindowStatePtr { } else { state.bounds = bounds; } + state.update_accesskit_window_bounds(); let gpu_size = query_render_extent(&self.xcb, self.x_window)?; if true { @@ -1433,7 +1471,11 @@ impl PlatformWindow for X11Window { ) .log_err() .map_or(Point::new(Pixels::ZERO, Pixels::ZERO), |reply| { - Point::new((reply.root_x as u32).into(), (reply.root_y as u32).into()) + let scale_factor = self.0.state.borrow().scale_factor; + Point::new( + px(reply.win_x as f32 / scale_factor), + px(reply.win_y as f32 / scale_factor), + ) }) } @@ -1584,14 +1626,8 @@ impl PlatformWindow for X11Window { self.0 .state .borrow() - .client - .0 - .upgrade() - .map(|ref_cell| { - let state = ref_cell.borrow(); - state.gpu_context.supports_dual_source_blending() - }) - .unwrap_or_default() + .renderer + .supports_dual_source_blending() } fn minimize(&self) { @@ -1682,7 +1718,18 @@ impl PlatformWindow for X11Window { fn draw(&self, scene: &Scene) { let mut inner = self.0.state.borrow_mut(); + if inner.renderer.device_lost() { + if let Err(error) = inner.renderer.recover() { + log::warn!("failed to recover lost wgpu device; will retry: {error:#}"); + } + let _ = inner.renderer.needs_redraw(); + inner.force_render_after_recovery = true; + return; + } inner.renderer.draw(scene); + if inner.renderer.needs_redraw() { + inner.force_render_after_recovery = true; + } } fn sprite_atlas(&self) -> Arc { @@ -1795,6 +1842,7 @@ impl PlatformWindow for X11Window { if state.last_insets != insets { state.last_insets = insets; + state.update_accesskit_window_bounds(); check_reply( || "X11 ChangeProperty for _GTK_FRAME_EXTENTS failed.", @@ -1911,32 +1959,6 @@ impl PlatformWindow for X11Window { } fn a11y_update_window_bounds(&self) { - let mut state = self.0.state.borrow_mut(); - let scale = state.scale_factor; - let bounds = state.bounds; - let [left, right, top, bottom] = state.last_insets; - - let x = f32::from(bounds.origin.x); - let y = f32::from(bounds.origin.y); - let width = f32::from(bounds.size.width); - let height = f32::from(bounds.size.height); - - let outer = accesskit::Rect { - x0: (x * scale) as f64, - y0: (y * scale) as f64, - x1: ((x + width) * scale) as f64, - y1: ((y + height) * scale) as f64, - }; - - let inner = accesskit::Rect { - x0: (x * scale) as f64 + left as f64, - y0: (y * scale) as f64 + top as f64, - x1: ((x + width) * scale) as f64 - right as f64, - y1: ((y + height) * scale) as f64 - bottom as f64, - }; - - if let Some(adapter) = state.accesskit_adapter.as_mut() { - adapter.set_root_window_bounds(outer, inner); - } + self.0.state.borrow_mut().update_accesskit_window_bounds(); } } diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index 5e858791..75ce276b 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -39,9 +39,10 @@ derive_more.workspace = true dispatch2 = "0.3.1" etagere = "0.2" # WARNING: If you change this, you must also publish a new version of zed-font-kit to crates.io -font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", optional = true } +font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } foreign-types = "0.5" futures.workspace = true +gpui_util.workspace = true image.workspace = true itertools.workspace = true libc.workspace = true @@ -50,15 +51,27 @@ mach2.workspace = true media.workspace = true metal.workspace = true objc.workspace = true +objc2.workspace = true +objc2-app-kit.workspace = true +objc2-foundation.workspace = true parking_lot.workspace = true pathfinder_geometry = "0.5" raw-window-handle = "0.6" semver.workspace = true smallvec.workspace = true strum.workspace = true -util.workspace = true uuid.workspace = true [target.'cfg(target_os = "macos")'.build-dependencies] cbindgen = { version = "0.28.0", default-features = false } gpui.workspace = true + +# When this crate is itself being tested (cargo test -p gpui_macos), its own +# cfg(test) flag enables impls of test-only traits like PlatformHeadlessRenderer +# and PlatformWindow::render_to_image. Those traits/methods only exist in gpui +# when gpui's `test-support` feature is on, so we have to turn that feature on +# as a dev-dependency. The `cfg(test)` flag of a dependent crate doesn't +# propagate to its dependencies, but dev-dependencies do, so this is the +# correct way to enable the feature exactly when needed. +[target.'cfg(target_os = "macos")'.dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/gpui_macos/src/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs index 1480657c..0e5ebdca 100644 --- a/crates/gpui_macos/src/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -1,8 +1,6 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; -use gpui::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, -}; +use gpui::{PlatformDispatcher, Priority, RunnableMeta, RunnableVariant}; +use gpui_util::ResultExt; use mach2::{ kern_return::KERN_SUCCESS, mach_time::mach_timebase_info_data_t, @@ -13,7 +11,6 @@ use mach2::{ thread_precedence_policy_data_t, thread_time_constraint_policy_data_t, }, }; -use util::ResultExt; use async_task::Runnable; use objc::{ @@ -21,11 +18,7 @@ use objc::{ runtime::{BOOL, YES}, sel, sel_impl, }; -use std::{ - ffi::c_void, - ptr::NonNull, - time::{Duration, Instant}, -}; +use std::{ffi::c_void, ptr::NonNull, time::Duration}; pub(crate) struct MacDispatcher; @@ -36,33 +29,6 @@ impl MacDispatcher { } impl PlatformDispatcher for MacDispatcher { - fn get_all_timings(&self) -> Vec { - let global_timings = GLOBAL_THREAD_TIMINGS.lock(); - ThreadTaskTimings::convert(&global_timings) - } - - fn get_current_thread_timings(&self) -> ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) - } - fn is_main_thread(&self) -> bool { let is_main_thread: BOOL = unsafe { msg_send![class!(NSThread), isMainThread] }; is_main_thread == YES @@ -201,37 +167,9 @@ extern "C" fn trampoline(context: *mut c_void) { let runnable = unsafe { Runnable::::from_raw(NonNull::new_unchecked(context as *mut ())) }; - let metadata = runnable.metadata(); - let location = metadata.location; - - let start = Instant::now(); - let timing = TaskTiming { - location, - start, - end: None, - }; - - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - if let Some(last_timing) = timings.iter_mut().rev().next() { - if last_timing.location == timing.location { - return; - } - } - - timings.push_back(timing); - }); - + let location = runnable.metadata().location; + let spawned = runnable.metadata().spawned; + gpui::profiler::update_running_task(spawned, location); runnable.run(); - let end = Instant::now(); - - THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - let timings = &mut timings.timings; - let Some(last_timing) = timings.iter_mut().rev().next() else { - return; - }; - last_timing.end = Some(end); - }); + gpui::profiler::save_task_timing(); } diff --git a/crates/gpui_macos/src/display_link.rs b/crates/gpui_macos/src/display_link.rs index bd1c21ca..468f3d8b 100644 --- a/crates/gpui_macos/src/display_link.rs +++ b/crates/gpui_macos/src/display_link.rs @@ -3,8 +3,8 @@ use core_graphics::display::CGDirectDisplayID; use dispatch2::{ _dispatch_source_type_data_add, DispatchObject, DispatchQueue, DispatchRetained, DispatchSource, }; +use gpui_util::ResultExt; use std::ffi::c_void; -use util::ResultExt; pub struct DisplayLink { display_link: Option, @@ -41,6 +41,7 @@ impl DisplayLink { ); frame_requests.set_context(data); frame_requests.set_event_handler_f(callback); + frame_requests.resume(); let display_link = sys::DisplayLink::new( display_id, @@ -57,7 +58,6 @@ impl DisplayLink { pub fn start(&mut self) -> Result<()> { unsafe { - self.frame_requests.resume(); self.display_link.as_mut().unwrap().start()?; } Ok(()) @@ -65,7 +65,6 @@ impl DisplayLink { pub fn stop(&mut self) -> Result<()> { unsafe { - self.frame_requests.suspend(); self.display_link.as_mut().unwrap().stop()?; } Ok(()) @@ -84,8 +83,6 @@ impl Drop for DisplayLink { // We might also want to upgrade to CADisplayLink, but that requires dropping old macOS support. std::mem::forget(self.display_link.take()); self.frame_requests.cancel(); - // A suspended DispatchSource cannot be destroyed. - self.frame_requests.resume(); } } diff --git a/crates/gpui_macos/src/events.rs b/crates/gpui_macos/src/events.rs index 5970488a..71bcb105 100644 --- a/crates/gpui_macos/src/events.rs +++ b/crates/gpui_macos/src/events.rs @@ -1,8 +1,8 @@ use gpui::{ Capslock, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MousePressureEvent, MouseUpEvent, - NavigationDirection, Pixels, PlatformInput, PressureStage, ScrollDelta, ScrollWheelEvent, - TouchPhase, point, px, + NavigationDirection, PinchEvent, Pixels, PlatformInput, PressureStage, ScrollDelta, + ScrollWheelEvent, TouchPhase, point, px, }; use crate::{ @@ -234,6 +234,27 @@ pub(crate) unsafe fn platform_input_from_native( _ => None, } } + NSEventType::NSEventTypeMagnify => window_height.map(|window_height| { + let phase = match native_event.phase() { + NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { + TouchPhase::Started + } + NSEventPhase::NSEventPhaseEnded => TouchPhase::Ended, + _ => TouchPhase::Moved, + }; + + let magnification = native_event.magnification() as f32; + + PlatformInput::Pinch(PinchEvent { + position: point( + px(native_event.locationInWindow().x as f32), + window_height - px(native_event.locationInWindow().y as f32), + ), + delta: magnification, + modifiers: read_modifiers(native_event), + phase, + }) + }), NSEventType::NSScrollWheel => window_height.map(|window_height| { let phase = match native_event.phase() { NSEventPhase::NSEventPhaseMayBegin | NSEventPhase::NSEventPhaseBegan => { diff --git a/crates/gpui_macos/src/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs index 8c527e37..1248b8b1 100644 --- a/crates/gpui_macos/src/metal_atlas.rs +++ b/crates/gpui_macos/src/metal_atlas.rs @@ -59,9 +59,10 @@ impl PlatformAtlas for MetalAtlas { fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.remove(key).map(|v| v.texture_id) else { + let Some(tile) = lock.tiles_by_key.remove(key) else { return; }; + let id = tile.texture_id; let textures = match id.kind { AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, @@ -78,6 +79,7 @@ impl PlatformAtlas for MetalAtlas { }; if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); texture.decrement_ref_count(); if texture.is_unreferenced() { textures.free_list.push(id.index as usize); @@ -260,3 +262,46 @@ fn point_from_etagere(value: etagere::Point) -> Point { struct AssertSend(T); unsafe impl Send for AssertSend {} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ImageId, RenderImageParams, size}; + + fn create_atlas() -> Option { + Some(MetalAtlas::new(metal::Device::system_default()?)) + } + + fn insert_tile(atlas: &MetalAtlas, image_id: usize, size: Size) -> AtlasTile { + let key = AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }); + atlas + .get_or_insert_with(&key, &mut || { + let byte_count = size.width.0 as usize * size.height.0 as usize * 4; + Ok(Some((size, Cow::Owned(vec![0; byte_count])))) + }) + .unwrap() + .unwrap() + } + + #[test] + fn remove_deallocates_tile_space_for_reuse() { + let Some(atlas) = create_atlas() else { + return; + }; + let small = size(DevicePixels(64), DevicePixels(64)); + let big = size(DevicePixels(700), DevicePixels(700)); + let keeper = insert_tile(&atlas, 1, small); + let removed = insert_tile(&atlas, 2, big); + assert_eq!(keeper.texture_id, removed.texture_id); + + atlas.remove(&AtlasKey::Image(RenderImageParams { + image_id: ImageId(2), + frame_index: 0, + })); + let replacement = insert_tile(&atlas, 3, big); + assert_eq!(replacement.texture_id, keeper.texture_id); + } +} diff --git a/crates/gpui_macos/src/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs index 55cffd3b..d862fc1c 100644 --- a/crates/gpui_macos/src/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -3133,3 +3133,68 @@ pub struct SurfaceBounds { pub bounds: Bounds, pub content_mask: ContentMask, } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::Corners; + + fn scene_with_retained_primitive(primitive: impl Into) -> Scene { + let bounds = Bounds::new( + Point::new(ScaledPixels(0.0), ScaledPixels(0.0)), + Size::new(ScaledPixels(100.0), ScaledPixels(100.0)), + ); + let mut scene = Scene::default(); + scene.insert_primitive(primitive); + scene.retained_layers.push(RetainedLayer { + id: GlobalElementId::default(), + content_revision: 1.into(), + content_dirty: true, + bounds, + content_mask: ContentMask { bounds }, + transform: TransformationMatrix::unit(), + opacity: 1.0, + paint_range: 0..scene.paint_operation_count(), + }); + scene + } + + #[test] + fn retained_layer_with_backdrop_blur_is_excluded_from_metal_cache() { + let bounds = Bounds::new( + Point::new(ScaledPixels(0.0), ScaledPixels(0.0)), + Size::new(ScaledPixels(100.0), ScaledPixels(100.0)), + ); + let scene = scene_with_retained_primitive(BackdropBlur { + order: 0, + pad: 0, + bounds, + content_mask: ContentMask { bounds }, + corner_radii: Corners::all(ScaledPixels(8.0)), + blur_radius: ScaledPixels(16.0), + source_origin_x: 0.0, + source_origin_y: 0.0, + source_width: 1.0, + source_height: 1.0, + pad2: 0, + }); + + assert!(MetalRenderer::retained_layers_for_scene(&scene).is_empty()); + } + + #[test] + fn retained_layer_without_backdrop_blur_remains_cacheable_by_metal() { + let bounds = Bounds::new( + Point::new(ScaledPixels(0.0), ScaledPixels(0.0)), + Size::new(ScaledPixels(100.0), ScaledPixels(100.0)), + ); + let scene = scene_with_retained_primitive(Quad { + order: 0, + bounds, + content_mask: ContentMask { bounds }, + ..Default::default() + }); + + assert_eq!(MetalRenderer::retained_layers_for_scene(&scene).len(), 1); + } +} diff --git a/crates/gpui_macos/src/open_type.rs b/crates/gpui_macos/src/open_type.rs index 048ba13d..44891910 100644 --- a/crates/gpui_macos/src/open_type.rs +++ b/crates/gpui_macos/src/open_type.rs @@ -15,6 +15,10 @@ use core_foundation::{ }; use core_foundation_sys::locale::CFLocaleCopyPreferredLanguages; use core_graphics::{display::CFDictionary, geometry::CGAffineTransform}; +use core_text::font_descriptor::{ + TraitAccessors, kCTFontFamilyNameAttribute, kCTFontItalicTrait, kCTFontSlantTrait, + kCTFontTraitsAttribute, kCTFontWeightTrait, kCTFontWidthTrait, +}; use core_text::{ font::{CTFont, CTFontRef, cascade_list_for_languages}, font_descriptor::{ @@ -39,10 +43,7 @@ pub fn apply_features_and_fallbacks( && !fallbacks.fallback_list().is_empty() { keys.push(kCTFontCascadeListAttribute); - values.push(generate_fallback_array( - fallbacks, - font.native_font().as_concrete_TypeRef(), - )); + values.push(generate_fallback_array(fallbacks, font)); } let attrs = CFDictionaryCreate( kCFAllocatorDefault, @@ -98,16 +99,54 @@ fn generate_feature_array(features: &FontFeatures) -> CFMutableArrayRef { } } -fn generate_fallback_array(fallbacks: &FontFallbacks, font_ref: CTFontRef) -> CFMutableArrayRef { +fn generate_fallback_array(fallbacks: &FontFallbacks, font: &mut FontKitFont) -> CFMutableArrayRef { unsafe { + let symbolic_traits = font.native_font().symbolic_traits(); + let all_traits = font.native_font().all_traits(); + let fallback_array = CFArrayCreateMutable(kCFAllocatorDefault, 0, &kCFTypeArrayCallBacks); for user_fallback in fallbacks.fallback_list() { let name = CFString::from(user_fallback.as_str()); - let fallback_desc = - CTFontDescriptorCreateWithNameAndSize(name.as_concrete_TypeRef(), 0.0); + + let traits_keys = [kCTFontWeightTrait, kCTFontSlantTrait]; + let weight_value = CFNumber::from(all_traits.normalized_weight()); + let slant_value = CFNumber::from(if (symbolic_traits & kCTFontItalicTrait) != 0 { + 1.0 + } else { + 0.0 + }); + let traits_values = [weight_value.as_CFTypeRef(), slant_value.as_CFTypeRef()]; + let traits = CFDictionaryCreate( + kCFAllocatorDefault, + &traits_keys as *const _ as _, + &traits_values as *const _ as _, + traits_keys.len() as isize, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ); + drop(weight_value); + drop(slant_value); + + let attr_keys = [kCTFontFamilyNameAttribute, kCTFontTraitsAttribute]; + let attr_values = [name.as_CFTypeRef(), traits as _]; + let attrs = CFDictionaryCreate( + kCFAllocatorDefault, + &attr_keys as *const _ as _, + &attr_values as *const _ as _, + attr_keys.len() as isize, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks, + ); + CFRelease(traits as _); + + let fallback_desc = CTFontDescriptorCreateWithAttributes(attrs); + CFRelease(attrs as _); + CFArrayAppendValue(fallback_array, fallback_desc as _); CFRelease(fallback_desc as _); } + + let font_ref = font.native_font().as_concrete_TypeRef(); append_system_fallbacks(fallback_array, font_ref); fallback_array } @@ -125,12 +164,12 @@ fn append_system_fallbacks(fallback_array: CFMutableArrayRef, font_ref: CTFontRe let default_fallbacks: CFArray = CFArray::wrap_under_create_rule(default_fallbacks); - default_fallbacks + for desc in default_fallbacks .iter() .filter(|desc| desc.font_path().is_some()) - .map(|desc| { - CFArrayAppendValue(fallback_array, desc.as_concrete_TypeRef() as _); - }); + { + CFArrayAppendValue(fallback_array, desc.as_concrete_TypeRef() as _); + } } } diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 034e4f78..0c6eefbb 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1,14 +1,15 @@ use crate::{ BoolExt, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, MacWindow, events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, + set_active_window_cursor_style, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; use cocoa::{ appkit::{ NSApplication, NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, NSSavePanel, - NSVisualEffectState, NSVisualEffectView, NSWindow, + NSControl as _, NSEventModifierFlags, NSMenu, NSMenuItem, NSModalResponse, NSOpenPanel, + NSSavePanel, NSVisualEffectState, NSVisualEffectView, NSWindow, }, base::{BOOL, NO, YES, id, nil, selector}, foundation::{ @@ -24,13 +25,16 @@ use core_foundation::{ string::{CFString, CFStringRef}, }; use ctor::ctor; +use dispatch2::DispatchQueue; use futures::channel::oneshot; use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, KeyContext, Keymap, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowParams, + PlatformWindow, Result, SystemMenuType, Task, ThermalState, WindowAppearance, WindowKind, + WindowParams, popup::PopupNotSupportedError, }; +use gpui_util::{ResultExt, new_std_command}; use itertools::Itertools; use objc::{ class, @@ -55,10 +59,6 @@ use std::{ atomic::{AtomicBool, Ordering}, }, }; -use util::{ - ResultExt, - command::{new_command, new_std_command}, -}; #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; @@ -152,6 +152,11 @@ unsafe fn build_classes() { on_thermal_state_change as extern "C" fn(&mut Object, Sel, id), ); + decl.add_method( + sel!(onSystemWake:), + on_system_wake as extern "C" fn(&mut Object, Sel, id), + ); + decl.register() } } @@ -170,6 +175,8 @@ pub(crate) struct MacPlatformState { reopen: Option>, on_keyboard_layout_change: Option>, on_thermal_state_change: Option>, + on_system_wake: Option>, + system_wake_observer_registered: bool, quit: Option>, menu_command: Option>, validate_menu_command: Option bool>>, @@ -193,7 +200,14 @@ impl MacPlatform { let text_system = Arc::new(crate::MacTextSystem::new()); #[cfg(not(feature = "font-kit"))] - let text_system = Arc::new(gpui::NoopTextSystem::new()); + let text_system = { + if !headless { + log::warn!( + "gpui_macos was compiled without the `font-kit` feature, so no text will be rendered." + ); + } + Arc::new(gpui::NoopTextSystem::new()) + }; let keyboard_layout = MacKeyboardLayout::new(); let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); @@ -217,6 +231,8 @@ impl MacPlatform { dock_menu: None, on_keyboard_layout_change: None, on_thermal_state_change: None, + on_system_wake: None, + system_wake_observer_registered: false, menus: None, keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), @@ -402,16 +418,18 @@ impl MacPlatform { if *checked { item.setState_(NSVisualEffectState::Active); } - if *disabled { - let _: () = msg_send![item, setEnabled: false]; - } + item.setEnabled_(if *disabled { NO } else { YES }); let tag = actions.len() as NSInteger; let _: () = msg_send![item, setTag: tag]; actions.push(action.boxed_clone()); item } - MenuItem::Submenu(Menu { name, items, .. }) => { + MenuItem::Submenu(Menu { + name, + items, + disabled, + }) => { let item = NSMenuItem::new(nil).autorelease(); let submenu = NSMenu::new(nil).autorelease(); submenu.setDelegate_(delegate); @@ -419,6 +437,7 @@ impl MacPlatform { submenu.addItem_(Self::create_menu_item(item, delegate, actions, keymap)); } item.setSubmenu_(submenu); + item.setEnabled_(if *disabled { NO } else { YES }); item.setTitle_(ns_string(name)); item } @@ -519,17 +538,19 @@ impl Platform for MacPlatform { } } - fn restart(&self, _binary_path: Option) { + fn restart(&self, binary_path: Option) { use std::os::unix::process::CommandExt as _; let app_pid = std::process::id().to_string(); - let app_path = self - .app_path() - .ok() - // When the app is not bundled, `app_path` returns the - // directory containing the executable. Disregard this - // and get the path to the executable itself. - .and_then(|path| (path.extension()?.to_str()? == "app").then_some(path)) + let app_path = binary_path + .or_else(|| { + self.app_path() + .ok() + // When the app is not bundled, `app_path` returns the + // directory containing the executable. Disregard this + // and get the path to the executable itself. + .and_then(|path| (path.extension()?.to_str()? == "app").then_some(path)) + }) .unwrap_or_else(|| std::env::current_exe().unwrap()); // Wait until this process has exited and then re-open this path. @@ -624,16 +645,28 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { - let (renderer_context, cursor_visible) = { - let state = self.0.lock(); - (state.renderer_context.clone(), state.cursor_visible.clone()) + // Native popups are not implemented on macOS yet. Rejecting lets callers fall back to + // gpui's in-window popovers. + if let WindowKind::AnchoredPopup(_) = options.kind { + return Err(PopupNotSupportedError.into()); + } + + let (cursor_visible, foreground_executor, background_executor, renderer_context) = { + let guard = self.0.lock(); + ( + guard.cursor_visible.clone(), + guard.foreground_executor.clone(), + guard.background_executor.clone(), + guard.renderer_context.clone(), + ) }; + Ok(Box::new(MacWindow::open( handle, options, cursor_visible, - self.foreground_executor(), - self.background_executor(), + foreground_executor, + background_executor, renderer_context, ))) } @@ -864,14 +897,16 @@ impl Platform for MacPlatform { .lock() .background_executor .spawn(async move { - if let Some(mut child) = new_command("open") + #[allow( + clippy::disallowed_methods, + reason = "running on a background thread, so blocking is fine" + )] + new_std_command("open") + .arg("--") .arg(path) - .spawn() + .status() .context("invoking open command") - .log_err() - { - child.status().await.log_err(); - } + .log_err(); }) .detach(); } @@ -904,6 +939,25 @@ impl Platform for MacPlatform { self.0.lock().on_thermal_state_change = Some(callback); } + fn on_system_wake(&self, callback: Box) { + let mut state = self.0.lock(); + state.on_system_wake = Some(callback); + if state.system_wake_observer_registered { + return; + } + drop(state); + + // SAFETY: APP_CLASS is registered during startup and returns the shared NSApplication. + unsafe { + let app: id = msg_send![APP_CLASS, sharedApplication]; + let delegate: id = msg_send![app, delegate]; + if delegate != nil { + register_system_wake_observer(delegate); + self.0.lock().system_wake_observer_registered = true; + } + } + } + fn thermal_state(&self) -> ThermalState { unsafe { let process_info: id = msg_send![class!(NSProcessInfo), processInfo]; @@ -990,67 +1044,25 @@ impl Platform for MacPlatform { let cursor_was_hidden_by_style = { let mut state = self.0.lock(); if style == CursorStyle::None { - if state.cursor_hidden_by_style { - return; - } - state.cursor_hidden_by_style = true; - false + std::mem::replace(&mut state.cursor_hidden_by_style, true) } else { std::mem::replace(&mut state.cursor_hidden_by_style, false) } }; unsafe { + set_active_window_cursor_style(style); + if style == CursorStyle::None { - let _: () = msg_send![class!(NSCursor), hide]; + if !cursor_was_hidden_by_style { + let _: () = msg_send![class!(NSCursor), hide]; + } return; } if cursor_was_hidden_by_style { let _: () = msg_send![class!(NSCursor), unhide]; } - - let new_cursor: id = match style { - CursorStyle::Arrow => msg_send![class!(NSCursor), arrowCursor], - CursorStyle::IBeam => msg_send![class!(NSCursor), IBeamCursor], - CursorStyle::Crosshair => msg_send![class!(NSCursor), crosshairCursor], - CursorStyle::ClosedHand => msg_send![class!(NSCursor), closedHandCursor], - CursorStyle::OpenHand => msg_send![class!(NSCursor), openHandCursor], - CursorStyle::PointingHand => msg_send![class!(NSCursor), pointingHandCursor], - CursorStyle::ResizeLeftRight => msg_send![class!(NSCursor), resizeLeftRightCursor], - CursorStyle::ResizeUpDown => msg_send![class!(NSCursor), resizeUpDownCursor], - CursorStyle::ResizeLeft => msg_send![class!(NSCursor), resizeLeftCursor], - CursorStyle::ResizeRight => msg_send![class!(NSCursor), resizeRightCursor], - CursorStyle::ResizeColumn => msg_send![class!(NSCursor), resizeLeftRightCursor], - CursorStyle::ResizeRow => msg_send![class!(NSCursor), resizeUpDownCursor], - CursorStyle::ResizeUp => msg_send![class!(NSCursor), resizeUpCursor], - CursorStyle::ResizeDown => msg_send![class!(NSCursor), resizeDownCursor], - - // Undocumented, private class methods: - // https://stackoverflow.com/questions/27242353/cocoa-predefined-resize-mouse-cursor - CursorStyle::ResizeUpLeftDownRight => { - msg_send![class!(NSCursor), _windowResizeNorthWestSouthEastCursor] - } - CursorStyle::ResizeUpRightDownLeft => { - msg_send![class!(NSCursor), _windowResizeNorthEastSouthWestCursor] - } - - CursorStyle::IBeamCursorForVerticalLayout => { - msg_send![class!(NSCursor), IBeamCursorForVerticalLayout] - } - CursorStyle::OperationNotAllowed => { - msg_send![class!(NSCursor), operationNotAllowedCursor] - } - CursorStyle::DragLink => msg_send![class!(NSCursor), dragLinkCursor], - CursorStyle::DragCopy => msg_send![class!(NSCursor), dragCopyCursor], - CursorStyle::ContextualMenu => msg_send![class!(NSCursor), contextualMenuCursor], - CursorStyle::None => unreachable!(), - }; - - let old_cursor: id = msg_send![class!(NSCursor), currentCursor]; - if new_cursor != old_cursor { - let _: () = msg_send![new_cursor, set]; - } } } @@ -1259,14 +1271,36 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { object: process_info ]; + let observer = this as *mut Object as id; let platform = get_mac_platform(this); - let callback = platform.0.lock().finish_launching.take(); + let callback = { + let mut state = platform.0.lock(); + if state.on_system_wake.is_some() && !state.system_wake_observer_registered { + register_system_wake_observer(observer); + state.system_wake_observer_registered = true; + } + state.finish_launching.take() + }; if let Some(callback) = callback { callback(); } } } +unsafe fn register_system_wake_observer(observer: id) { + // SAFETY: observer is an Objective-C object implementing onSystemWake:. + unsafe { + let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; + let workspace_center: *mut Object = msg_send![workspace, notificationCenter]; + let wake_name = ns_string("NSWorkspaceDidWakeNotification"); + let _: () = msg_send![workspace_center, addObserver: observer + selector: sel!(onSystemWake:) + name: wake_name + object: nil + ]; + } +} + extern "C" fn should_handle_reopen(this: &mut Object, _: Sel, _: id, has_open_windows: bool) { if !has_open_windows { let platform = unsafe { get_mac_platform(this) }; @@ -1309,8 +1343,6 @@ extern "C" fn on_thermal_state_change(this: &mut Object, _: Sel, _: id) { // Defer to the next run loop iteration to avoid re-entrant borrows of the App RefCell, // as NSNotificationCenter delivers this notification synchronously and it may fire while // the App is already borrowed (same pattern as quit() above). - use dispatch2::DispatchQueue; - let platform = unsafe { get_mac_platform(this) }; let platform_ptr = platform as *const MacPlatform as *mut c_void; unsafe { @@ -1332,6 +1364,27 @@ extern "C" fn on_thermal_state_change(this: &mut Object, _: Sel, _: id) { } } +extern "C" fn on_system_wake(this: &mut Object, _: Sel, _: id) { + // SAFETY: this is the registered app delegate carrying MAC_PLATFORM_IVAR. + let platform = unsafe { get_mac_platform(this) }; + let platform_ptr = platform as *const MacPlatform as *mut c_void; + // SAFETY: platform lives for the process lifetime while callbacks are registered. + unsafe { + DispatchQueue::main().exec_async_f(platform_ptr, on_system_wake); + } + + extern "C" fn on_system_wake(context: *mut c_void) { + // SAFETY: context is the MacPlatform pointer queued above. + let platform = unsafe { &*(context as *const MacPlatform) }; + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.on_system_wake.take() { + drop(lock); + callback(); + platform.0.lock().on_system_wake.get_or_insert(callback); + } + } +} + extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) { let urls = unsafe { (0..urls.count()) @@ -1433,6 +1486,7 @@ unsafe fn ns_url_to_path(url: id) -> Result { #[link(name = "Carbon", kind = "framework")] unsafe extern "C" { pub(super) fn TISCopyCurrentKeyboardLayoutInputSource() -> *mut Object; + pub(super) fn TISCopyCurrentKeyboardInputSource() -> *mut Object; pub(super) fn TISGetInputSourceProperty( inputSource: *mut Object, propertyKey: *const c_void, @@ -1454,6 +1508,9 @@ unsafe extern "C" { pub(super) static kTISPropertyUnicodeKeyLayoutData: CFStringRef; pub(super) static kTISPropertyInputSourceID: CFStringRef; pub(super) static kTISPropertyLocalizedName: CFStringRef; + pub(super) static kTISPropertyInputSourceIsASCIICapable: CFStringRef; + pub(super) static kTISPropertyInputSourceType: CFStringRef; + pub(super) static kTISTypeKeyboardInputMode: CFStringRef; } mod security { diff --git a/crates/gpui_macos/src/text_system.rs b/crates/gpui_macos/src/text_system.rs index 2511bcf1..cb975768 100644 --- a/crates/gpui_macos/src/text_system.rs +++ b/crates/gpui_macos/src/text_system.rs @@ -1,10 +1,10 @@ use anyhow::anyhow; use cocoa::appkit::CGFloat; -use collections::HashMap; +use collections::{HashMap, HashSet}; use core_foundation::{ array::{CFArray, CFArrayRef}, attributed_string::CFMutableAttributedString, - base::{CFRange, TCFType}, + base::{CFRange, CFType, TCFType}, number::CFNumber, string::CFString, }; @@ -35,9 +35,9 @@ use font_kit::{ }; use gpui::{ Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, - FontStyle, FontWeight, GlyphId, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, - Result, SUBPIXEL_VARIANTS_X, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, - point, px, size, swap_rgba_pa_to_bgra, + FontStyle, FontWeight, GlyphId, Hsla, LineLayout, Pixels, PlatformTextSystem, + RenderGlyphParams, Result, Rgba, SUBPIXEL_VARIANTS_X, ShapedGlyph, ShapedRun, SharedString, + Size, TextRenderingMode, point, px, size, swap_rgba_pa_to_bgra, }; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; use pathfinder_geometry::{ @@ -46,7 +46,7 @@ use pathfinder_geometry::{ vector::Vector2F, }; use smallvec::SmallVec; -use std::{borrow::Cow, char, convert::TryFrom, sync::Arc}; +use std::{borrow::Cow, char, convert::TryFrom, sync::Arc, sync::OnceLock}; use crate::open_type::apply_features_and_fallbacks; @@ -212,6 +212,42 @@ impl PlatformTextSystem for MacTextSystem { ) -> TextRenderingMode { TextRenderingMode::Grayscale } + + fn glyph_dilation_for_color(&self, color: Hsla) -> u8 { + // When font smoothing is enabled, CoreGraphics thickens glyph strokes by an amount that + // depends on the foreground color's luminance. We replicate the logic used by CoreGraphics + // to select between the different levels of dilation. + if !font_smoothing_allowed_by_user() { + return 0; + } + let rgba: Rgba = color.into(); + let luminance = 0.2126 * rgba.r + 0.7152 * rgba.g + 0.0722 * rgba.b; + let level = ((4.0 * luminance) + 0.5).floor() as i32; + level.clamp(0, 4) as u8 + } +} + +fn font_smoothing_allowed_by_user() -> bool { + static ALLOWED: OnceLock = OnceLock::new(); + *ALLOWED.get_or_init(|| { + use core_foundation_sys::preferences::{ + CFPreferencesCopyAppValue, kCFPreferencesCurrentApplication, + }; + + let key = CFString::new("AppleFontSmoothing"); + let value_ref = unsafe { + CFPreferencesCopyAppValue(key.as_concrete_TypeRef(), kCFPreferencesCurrentApplication) + }; + if value_ref.is_null() { + return true; + } + let value = unsafe { CFType::wrap_under_create_rule(value_ref) }; + let Some(number) = value.downcast_into::() else { + return true; + }; + // Only an explicit value of `0` means that font smoothing is disabled. + number.to_i64() != Some(0) + }) } impl MacTextSystemState { @@ -244,6 +280,7 @@ impl MacTextSystemState { let name = gpui::font_name_with_fallbacks(name, ".AppleSystemUIFont"); let mut font_ids = SmallVec::new(); + let mut postscript_names_seen = HashSet::default(); let family = self .memory_source .select_family_by_name(name) @@ -302,15 +339,38 @@ impl MacTextSystemState { .is_some()) } { log::error!( - "Failed to read traits for font {:?}", - font.postscript_name().unwrap() + "Failed to read traits for font {:?} (PostScript name {:?})", + font.full_name(), + font.postscript_name(), ); continue; } + let Some(postscript_name) = font.postscript_name() else { + log::warn!( + "font {:?} in family {:?} has no PostScript name; skipping", + font.full_name(), + name, + ); + continue; + }; + // Dedup is scoped to this single `load_family` call (issue #55472). + // The same family can be reloaded later under a different `FontKey` + // (different features/fallbacks); a global check against + // `font_ids_by_postscript_name` would skip every already-registered + // font and leave the second call's `font_ids` empty. + if !postscript_names_seen.insert(postscript_name.clone()) { + log::warn!( + "skipping duplicate font {:?} with PostScript name {:?} \ + in family {:?}", + font.full_name(), + postscript_name, + name, + ); + continue; + } let font_id = FontId(self.fonts.len()); font_ids.push(font_id); - let postscript_name = font.postscript_name().unwrap(); self.font_ids_by_postscript_name .insert(postscript_name.clone(), font_id); self.postscript_names_by_font_id @@ -359,13 +419,16 @@ impl MacTextSystemState { fn raster_bounds(&self, params: &RenderGlyphParams) -> Result> { let font = &self.fonts[params.font_id.0]; let scale = Transform2F::from_scale(params.scale_factor); - Ok(bounds_from_rect_i(font.raster_bounds( + let bounds: Bounds = bounds_from_rect_i(font.raster_bounds( params.glyph_id.0, params.font_size.into(), scale, HintingOptions::None, font_kit::canvas::RasterizationOptions::GrayscaleAa, - )?)) + )?); + + // Expand the bounds by 1 pixel on each side to give CG room for anti-aliasing. + Ok(bounds.dilate(DevicePixels(1))) } fn rasterize_glyph( @@ -427,13 +490,20 @@ impl MacTextSystemState { .subpixel_variant .map(|v| v as f32 / SUBPIXEL_VARIANTS_X as f32); cx.set_text_drawing_mode(CGTextDrawingMode::CGTextFill); - cx.set_gray_fill_color(0.0, 1.0); cx.set_allows_antialiasing(true); cx.set_should_antialias(true); cx.set_allows_font_subpixel_positioning(true); cx.set_should_subpixel_position_fonts(true); cx.set_allows_font_subpixel_quantization(false); cx.set_should_subpixel_quantize_fonts(false); + + if params.dilation > 0 { + let luminance = params.dilation as f64 * 0.25; + cx.set_should_smooth_fonts(true); + cx.set_gray_fill_color(luminance, 1.0); + } else { + cx.set_gray_fill_color(0.0, 1.0); + } self.fonts[params.font_id.0] .native_font() .clone_with_font_size(f32::from(params.font_size) as CGFloat) diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 6f48b8dd..40f5cb4a 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1,5 +1,7 @@ use crate::{ - BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, events::platform_input_from_native, + BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, TISCopyCurrentKeyboardInputSource, + TISGetInputSourceProperty, events::platform_input_from_native, + kTISPropertyInputSourceIsASCIICapable, kTISPropertyInputSourceType, kTISTypeKeyboardInputMode, ns_string, renderer, }; #[cfg(any(test, feature = "test-support"))] @@ -10,9 +12,8 @@ use cocoa::{ NSAppKitVersionNumber, NSAppKitVersionNumber12_0, NSApplication, NSBackingStoreBuffered, NSColor, NSEvent, NSEventModifierFlags, NSFilenamesPboardType, NSPasteboard, NSScreen, NSView, NSViewHeightSizable, NSViewWidthSizable, NSVisualEffectMaterial, - NSVisualEffectState, NSVisualEffectView, NSWindow, NSWindowButton, - NSWindowCollectionBehavior, NSWindowOcclusionState, NSWindowOrderingMode, - NSWindowStyleMask, NSWindowTitleVisibility, + NSVisualEffectState, NSVisualEffectView, NSWindow, NSWindowCollectionBehavior, + NSWindowOcclusionState, NSWindowOrderingMode, NSWindowStyleMask, NSWindowTitleVisibility, }, base::{id, nil}, foundation::{ @@ -23,20 +24,24 @@ use cocoa::{ }; use dispatch2::DispatchQueue; use gpui::{ - AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, ExternalPaths, FileDropEvent, - ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, OverlayInputMode, Pixels, PlatformAtlas, - PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, - PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, WindowParams, point, - px, size, + AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalPaths, + FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, OverlayInputMode, Pixels, + PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, + PromptButton, PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, + WindowParams, point, px, size, }; #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; -use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect}; +use core_foundation::base::{CFRelease, CFTypeRef}; +use core_foundation_sys::base::CFEqual; +use core_foundation_sys::number::{CFBooleanGetValue, CFBooleanRef}; +use core_graphics::display::{CGDirectDisplayID, CGRect}; use ctor::ctor; use futures::channel::oneshot; +use gpui_util::ResultExt; use objc::{ class, declare::ClassDecl, @@ -44,6 +49,12 @@ use objc::{ runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES}, sel, sel_impl, }; +use objc2::rc::Retained; +use objc2_app_kit::{ + NSButton as Objc2NSButton, NSView as Objc2NSView, NSWindow as Objc2NSWindow, + NSWindowButton as Objc2NSWindowButton, +}; +use objc2_foundation::{NSPoint as Objc2NSPoint, NSRect as Objc2NSRect}; use parking_lot::Mutex; use raw_window_handle as rwh; use smallvec::SmallVec; @@ -62,7 +73,6 @@ use std::{ }, time::Duration, }; -use util::ResultExt; #[link(name = "AppKit", kind = "framework")] unsafe extern "C" { @@ -177,6 +187,10 @@ unsafe fn build_classes() { sel!(mouseMoved:), handle_view_event as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(resetCursorRects), + reset_cursor_rects as extern "C" fn(&Object, Sel), + ); decl.add_method( sel!(pressureChangeWithEvent:), handle_view_event as extern "C" fn(&Object, Sel, id), @@ -302,6 +316,44 @@ pub(crate) fn convert_mouse_position(position: NSPoint, window_height: Pixels) - ) } +/// Stores the cursor style on the active GPUI window and invalidates its cursor rects. +/// +/// # Safety +/// +/// This function is not thread safe. Callers must ensure this is called on the AppKit main +/// thread because it reads the active AppKit window and updates GPUI window state associated +/// with Objective-C objects. +pub(crate) unsafe fn set_active_window_cursor_style(style: CursorStyle) { + // SAFETY: The caller guarantees AppKit main-thread access. `is_gpui_window` ensures the + // window has our WINDOW_STATE_IVAR before reading it. + unsafe { + let app = NSApplication::sharedApplication(nil); + let key_window: id = msg_send![app, keyWindow]; + let main_window: id = msg_send![app, mainWindow]; + let active_window = if !key_window.is_null() && is_gpui_window(key_window) { + Some(key_window) + } else if !main_window.is_null() && is_gpui_window(main_window) { + Some(main_window) + } else { + None + }; + + let Some(active_window) = active_window else { + return; + }; + + let window_state = get_window_state(&*active_window); + let mut window_state = window_state.lock(); + if window_state.cursor_style != style { + window_state.cursor_style = style; + let _: () = msg_send![ + window_state.native_window, + invalidateCursorRectsForView: window_state.native_view.as_ptr() + ]; + } + } +} + unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const Class { unsafe { let mut decl = ClassDecl::new(name, superclass).unwrap(); @@ -332,6 +384,10 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C sel!(windowWillExitFullScreen:), window_will_exit_fullscreen as extern "C" fn(&Object, Sel, id), ); + decl.add_method( + sel!(windowDidExitFullScreen:), + window_did_exit_fullscreen as extern "C" fn(&Object, Sel, id), + ); decl.add_method( sel!(windowDidMove:), window_did_move as extern "C" fn(&Object, Sel, id), @@ -410,6 +466,19 @@ unsafe fn build_window_class(name: &'static str, superclass: &Class) -> *const C } } +struct TrafficLightFrames { + titlebar: Objc2NSRect, + close: Objc2NSRect, + minimize: Objc2NSRect, + zoom: Objc2NSRect, +} + +struct TrafficLightButtons { + close: Retained, + minimize: Retained, + zoom: Retained, +} + struct MacWindowState { handle: AnyWindowHandle, foreground_executor: ForegroundExecutor, @@ -419,6 +488,7 @@ struct MacWindowState { blurred_view: Option, blurred_view_corner_radius: Option, background_appearance: WindowBackgroundAppearance, + cursor_style: CursorStyle, display_link: Option, renderer: renderer::Renderer, request_frame_callback: Option>, @@ -433,6 +503,7 @@ struct MacWindowState { last_key_equivalent: Option, synthetic_drag_counter: usize, traffic_light_position: Option>, + traffic_light_frames: Option, transparent_titlebar: bool, previous_modifiers_changed_event: Option, keystroke_for_do_command: Option, @@ -454,54 +525,121 @@ struct MacWindowState { } impl MacWindowState { - fn move_traffic_light(&self) { + fn move_traffic_light(&mut self) { if let Some(traffic_light_position) = self.traffic_light_position { if self.is_fullscreen() { - // Moving traffic lights while fullscreen doesn't work, - // see https://github.com/zed-industries/zed/issues/4712 + self.restore_traffic_light(); return; } - let titlebar_height = self.titlebar_height(); + if self.traffic_light_frames.is_none() { + self.traffic_light_frames = self.capture_traffic_light_frames(); + } - unsafe { - let close_button: id = msg_send![ - self.native_window, - standardWindowButton: NSWindowButton::NSWindowCloseButton - ]; - let min_button: id = msg_send![ - self.native_window, - standardWindowButton: NSWindowButton::NSWindowMiniaturizeButton - ]; - let zoom_button: id = msg_send![ - self.native_window, - standardWindowButton: NSWindowButton::NSWindowZoomButton - ]; - - let mut close_button_frame: CGRect = msg_send![close_button, frame]; - let mut min_button_frame: CGRect = msg_send![min_button, frame]; - let mut zoom_button_frame: CGRect = msg_send![zoom_button, frame]; - let mut origin = point( - traffic_light_position.x, - titlebar_height - - traffic_light_position.y - - px(close_button_frame.size.height as f32), + let window_height = Pixels::from(self.native_window().frame().size.height); + if self.traffic_light_frames.is_some() { + // AppKit can recreate standard buttons, so fetch the live views for each layout pass. + let Some(buttons) = self.traffic_light_buttons() else { + return; + }; + let Some(titlebar_container) = Self::titlebar_container(&buttons.close) else { + return; + }; + + let close_frame = buttons.close.frame(); + let minimize_frame = buttons.minimize.frame(); + let button_width = Pixels::from(close_frame.size.width); + let button_height = Pixels::from(close_frame.size.height); + let button_padding = Pixels::from( + minimize_frame.origin.x - close_frame.origin.x - close_frame.size.width, ); - let button_spacing = - px((min_button_frame.origin.x - close_button_frame.origin.x) as f32); + let container_height = + button_height + traffic_light_position.y + traffic_light_position.y; + + let mut titlebar_frame = titlebar_container.frame(); + titlebar_frame.size.height = container_height.to_f64(); + titlebar_frame.origin.y = (window_height - container_height).to_f64(); + + let minimize_x = traffic_light_position.x + button_width + button_padding; + let zoom_x = minimize_x + button_width + button_padding; + + titlebar_container.setFrame(titlebar_frame); + buttons.close.setFrameOrigin(Objc2NSPoint::new( + traffic_light_position.x.to_f64(), + traffic_light_position.y.to_f64(), + )); + buttons.minimize.setFrameOrigin(Objc2NSPoint::new( + minimize_x.to_f64(), + traffic_light_position.y.to_f64(), + )); + buttons.zoom.setFrameOrigin(Objc2NSPoint::new( + zoom_x.to_f64(), + traffic_light_position.y.to_f64(), + )); + + titlebar_container.updateTrackingAreas(); + buttons.close.updateTrackingAreas(); + buttons.minimize.updateTrackingAreas(); + buttons.zoom.updateTrackingAreas(); + } + } + } + + fn capture_traffic_light_frames(&self) -> Option { + let buttons = self.traffic_light_buttons()?; + let titlebar_container = Self::titlebar_container(&buttons.close)?; + + Some(TrafficLightFrames { + titlebar: titlebar_container.frame(), + close: buttons.close.frame(), + minimize: buttons.minimize.frame(), + zoom: buttons.zoom.frame(), + }) + } - close_button_frame.origin = CGPoint::new(origin.x.into(), origin.y.into()); - let _: () = msg_send![close_button, setFrame: close_button_frame]; - origin.x += button_spacing; + fn native_window(&self) -> &Objc2NSWindow { + // SAFETY: `MacWindow::open` initializes `self.native_window` with the AppKit + // window for this state. It is either `NSWindow` or `NSPanel`, so borrowing it + // as `Objc2NSWindow` is valid here. + unsafe { &*self.native_window.cast::() } + } - min_button_frame.origin = CGPoint::new(origin.x.into(), origin.y.into()); - let _: () = msg_send![min_button, setFrame: min_button_frame]; - origin.x += button_spacing; + fn traffic_light_buttons(&self) -> Option { + let window = self.native_window(); + Some(TrafficLightButtons { + close: window.standardWindowButton(Objc2NSWindowButton::CloseButton)?, + minimize: window.standardWindowButton(Objc2NSWindowButton::MiniaturizeButton)?, + zoom: window.standardWindowButton(Objc2NSWindowButton::ZoomButton)?, + }) + } - zoom_button_frame.origin = CGPoint::new(origin.x.into(), origin.y.into()); - let _: () = msg_send![zoom_button, setFrame: zoom_button_frame]; - origin.x += button_spacing; - } + fn titlebar_container(close_button: &Objc2NSButton) -> Option> { + // SAFETY: `close_button` comes from AppKit's `standardWindowButton(_:)`. + // Although `superview` is unsafe, objc2 returns each result as `Retained`. + unsafe { + let button_container = close_button.superview()?; + button_container.superview() + } + } + + fn restore_traffic_light(&mut self) { + if let Some(frames) = self.traffic_light_frames.take() { + let Some(buttons) = self.traffic_light_buttons() else { + return; + }; + let Some(titlebar_container) = Self::titlebar_container(&buttons.close) else { + return; + }; + + buttons.close.setFrame(frames.close); + buttons.minimize.setFrame(frames.minimize); + buttons.zoom.setFrame(frames.zoom); + titlebar_container.setFrame(frames.titlebar); + + titlebar_container.updateTrackingAreas(); + buttons.close.updateTrackingAreas(); + buttons.minimize.updateTrackingAreas(); + buttons.zoom.updateTrackingAreas(); } } @@ -516,7 +654,10 @@ impl MacWindowState { return; } } - let display_id = unsafe { display_id_for_screen(self.native_window.screen()) }; + let Some(display_id) = display_id_for_screen(unsafe { self.native_window.screen() }) else { + // AppKit can temporarily report no screen while displays are being reconfigured. + return; + }; if let Some(mut display_link) = DisplayLink::new(display_id, self.native_view.as_ptr() as *mut c_void, step).log_err() { @@ -583,14 +724,6 @@ impl MacWindowState { get_scale_factor(self.native_window) } - fn titlebar_height(&self) -> Pixels { - unsafe { - let frame = NSWindow::frame(self.native_window); - let content_layout_rect: CGRect = msg_send![self.native_window, contentLayoutRect]; - px((frame.size.height - content_layout_rect.size.height) as f32) - } - } - fn window_bounds(&self) -> WindowBounds { if self.is_fullscreen() { WindowBounds::Fullscreen(self.fullscreen_restore_bounds) @@ -623,6 +756,7 @@ impl MacWindow { display_id, window_min_size, tabbing_identifier, + .. }: WindowParams, cursor_visible: Arc, foreground_executor: ForegroundExecutor, @@ -664,7 +798,9 @@ impl MacWindow { WindowKind::Normal => { msg_send![WINDOW_CLASS, alloc] } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { style_mask |= NSWindowStyleMaskNonactivatingPanel; msg_send![PANEL_CLASS, alloc] } @@ -684,8 +820,10 @@ impl MacWindow { let count: u64 = cocoa::foundation::NSArray::count(screens); for i in 0..count { let screen = cocoa::foundation::NSArray::objectAtIndex(screens, i); + let Some(display_id) = display_id_for_screen(screen) else { + continue; + }; let frame = NSScreen::frame(screen); - let display_id = display_id_for_screen(screen); if display_id == display.0 { screen_frame = Some(frame); target_screen = screen; @@ -742,6 +880,7 @@ impl MacWindow { blurred_view: None, blurred_view_corner_radius: None, background_appearance: WindowBackgroundAppearance::Opaque, + cursor_style: CursorStyle::Arrow, display_link: None, renderer: renderer::new_renderer( renderer_context, @@ -764,6 +903,7 @@ impl MacWindow { traffic_light_position: titlebar .as_ref() .and_then(|titlebar| titlebar.traffic_light_position), + traffic_light_frames: None, transparent_titlebar: titlebar .as_ref() .is_none_or(|titlebar| titlebar.appears_transparent), @@ -853,7 +993,9 @@ impl MacWindow { let _: () = msg_send![native_window, setTabbingIdentifier:nil]; } } - WindowKind::PopUp => { + // `AnchoredPopup` is rejected in `MacPlatform::open_window`, grouped here only + // for exhaustiveness. + WindowKind::PopUp | WindowKind::AnchoredPopup(_) => { // Use a tracking area to allow receiving MouseMoved events even when // the window or application aren't active, which is often the case // e.g. for notification windows. @@ -1182,6 +1324,12 @@ impl PlatformWindow for MacWindow { } } + fn set_traffic_light_position(&self, position: Point) { + let mut state = self.0.lock(); + state.traffic_light_position = Some(position); + state.move_traffic_light(); + } + fn scale_factor(&self) -> f32 { self.0.as_ref().lock().scale_factor() } @@ -1264,30 +1412,17 @@ impl PlatformWindow for MacWindow { detail: Option<&str>, answers: &[PromptButton], ) -> Option> { - // macOs applies overrides to modal window buttons after they are added. - // Two most important for this logic are: - // * Buttons with "Cancel" title will be displayed as the last buttons in the modal - // * Last button added to the modal via `addButtonWithTitle` stays focused - // * Focused buttons react on "space"/" " keypresses - // * Usage of `keyEquivalent`, `makeFirstResponder` or `setInitialFirstResponder` does not change the focus - // - // See also https://developer.apple.com/documentation/appkit/nsalert/1524532-addbuttonwithtitle#discussion - // ``` - // By default, the first button has a key equivalent of Return, - // any button with a title of “Cancel” has a key equivalent of Escape, - // and any button with the title “Don’t Save” has a key equivalent of Command-D (but only if it’s not the first button). - // ``` - // - // To avoid situations when the last element added is "Cancel" and it gets the focus - // (hence stealing both ESC and Space shortcuts), we find and add one non-Cancel button - // last, so it gets focus and a Space shortcut. - // This way, "Save this file? Yes/No/Cancel"-ish modals will get all three buttons mapped with a key. - let latest_non_cancel_label = answers + // NSAlert's first button keeps Return and Cancel keeps Escape, but the keyboard + // focus (and therefore Space) defaults to Cancel, leaving the middle button of + // prompts like "Save / Don't Save / Cancel" unreachable from the keyboard. Move + // the initial focus onto the last non-cancel, non-default button instead. + let initial_focus_ix = answers .iter() .enumerate() .rev() .find(|(_, label)| !label.is_cancel()) - .filter(|&(label_index, _)| label_index > 0); + .map(|(ix, _)| ix) + .filter(|&ix| ix > 0); unsafe { let alert: id = msg_send![class!(NSAlert), alloc]; @@ -1303,25 +1438,24 @@ impl PlatformWindow for MacWindow { let _: () = msg_send![alert, setInformativeText: ns_string(detail)]; } - for (ix, answer) in answers - .iter() - .enumerate() - .filter(|&(ix, _)| Some(ix) != latest_non_cancel_label.map(|(ix, _)| ix)) - { + let mut initial_focus_button: Option = None; + for (ix, answer) in answers.iter().enumerate() { let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer.label())]; let _: () = msg_send![button, setTag: ix as NSInteger]; if answer.is_cancel() { - // Bind Escape Key to Cancel Button if let Some(key) = std::char::from_u32(crate::events::ESCAPE_KEY as u32) { let _: () = msg_send![button, setKeyEquivalent: ns_string(&key.to_string())]; } + } else if Some(ix) == initial_focus_ix { + initial_focus_button = Some(button); } } - if let Some((ix, answer)) = latest_non_cancel_label { - let button: id = msg_send![alert, addButtonWithTitle: ns_string(answer.label())]; - let _: () = msg_send![button, setTag: ix as NSInteger]; + + if let Some(button) = initial_focus_button { + let alert_window: id = msg_send![alert, window]; + let _: () = msg_send![alert_window, setInitialFirstResponder: button]; } let (done_tx, done_rx) = oneshot::channel(); @@ -1529,6 +1663,10 @@ impl PlatformWindow for MacWindow { let filename = path.map_or(ns_string(""), |p| ns_string(&p.to_string_lossy())); let _: () = msg_send![window, setRepresentedFilename: filename]; } + + // Changing the document path state resets the traffic light position, + // so we have to move it again. + self.0.lock().move_traffic_light(); } fn show_character_palette(&self) { @@ -1805,7 +1943,7 @@ impl PlatformWindow for MacWindow { // macOS handles window bounds tracking automatically via NSAccessibility. } - #[cfg(feature = "test-support")] + #[cfg(any(test, feature = "test-support"))] fn render_to_image(&self, scene: &gpui::Scene) -> Result { let mut this = self.0.lock(); this.renderer.render_to_image(scene) @@ -1870,6 +2008,14 @@ fn get_scale_factor(native_window: id) -> f32 { if factor == 0.0 { 2. } else { factor } } +/// Returns whether `window` is one of GPUI's managed windows. +unsafe fn is_gpui_window(window: id) -> bool { + unsafe { + msg_send![window, isKindOfClass: WINDOW_CLASS] + || msg_send![window, isKindOfClass: PANEL_CLASS] + } +} + unsafe fn get_window_state(object: &Object) -> Arc> { unsafe { let raw: *mut c_void = *object.get_ivar(WINDOW_STATE_IVAR); @@ -1942,6 +2088,61 @@ extern "C" fn dealloc_view(this: &Object, _: Sel) { } } +extern "C" fn reset_cursor_rects(this: &Object, _: Sel) { + // SAFETY: AppKit invokes cursor-rect updates on the main thread for GPUIView instances, + // whose WINDOW_STATE_IVAR is initialized when the view is created. Any cursor registered + // below is a valid NSCursor. + unsafe { + let _: () = msg_send![super(this, class!(NSView)), resetCursorRects]; + + let window_state = get_window_state(this); + let cursor_style = window_state.lock().cursor_style; + + let cursor: id = match cursor_style { + CursorStyle::Arrow => msg_send![class!(NSCursor), arrowCursor], + CursorStyle::IBeam => msg_send![class!(NSCursor), IBeamCursor], + CursorStyle::Crosshair => msg_send![class!(NSCursor), crosshairCursor], + CursorStyle::ClosedHand => msg_send![class!(NSCursor), closedHandCursor], + CursorStyle::OpenHand => msg_send![class!(NSCursor), openHandCursor], + CursorStyle::PointingHand => msg_send![class!(NSCursor), pointingHandCursor], + CursorStyle::ResizeLeftRight => msg_send![class!(NSCursor), resizeLeftRightCursor], + CursorStyle::ResizeUpDown => msg_send![class!(NSCursor), resizeUpDownCursor], + CursorStyle::ResizeLeft => msg_send![class!(NSCursor), resizeLeftCursor], + CursorStyle::ResizeRight => msg_send![class!(NSCursor), resizeRightCursor], + CursorStyle::ResizeColumn => msg_send![class!(NSCursor), resizeLeftRightCursor], + CursorStyle::ResizeRow => msg_send![class!(NSCursor), resizeUpDownCursor], + CursorStyle::ResizeUp => msg_send![class!(NSCursor), resizeUpCursor], + CursorStyle::ResizeDown => msg_send![class!(NSCursor), resizeDownCursor], + + // Undocumented, private class methods: + // https://stackoverflow.com/questions/27242353/cocoa-predefined-resize-mouse-cursor + CursorStyle::ResizeUpLeftDownRight => { + msg_send![class!(NSCursor), _windowResizeNorthWestSouthEastCursor] + } + CursorStyle::ResizeUpRightDownLeft => { + msg_send![class!(NSCursor), _windowResizeNorthEastSouthWestCursor] + } + + CursorStyle::IBeamCursorForVerticalLayout => { + msg_send![class!(NSCursor), IBeamCursorForVerticalLayout] + } + CursorStyle::OperationNotAllowed => { + msg_send![class!(NSCursor), operationNotAllowedCursor] + } + CursorStyle::DragLink => msg_send![class!(NSCursor), dragLinkCursor], + CursorStyle::DragCopy => msg_send![class!(NSCursor), dragCopyCursor], + CursorStyle::ContextualMenu => msg_send![class!(NSCursor), contextualMenuCursor], + // `None` is implemented with balanced `NSCursor::hide`/`unhide` calls in + // `MacPlatform::set_cursor_style`. Leaving the view without a cursor rect prevents + // AppKit from immediately overriding that hidden state. + CursorStyle::None => return, + }; + + let bounds = NSView::bounds(this as *const Object as id); + let _: () = msg_send![this, addCursorRect: bounds cursor: cursor]; + } +} + extern "C" fn handle_key_equivalent(this: &Object, _: Sel, native_event: id) -> BOOL { handle_key_event(this, native_event, true) } @@ -1979,6 +2180,45 @@ extern "C" fn handle_key_up(this: &Object, _: Sel, native_event: id) { // - in vim mode `option-4` should go to end of line (same as $) // Japanese (Romaji) layout: // - type `a i left down up enter enter` should create an unmarked text "愛" +// - In vim mode with `jj` bound to `vim::NormalBefore` in insert mode, typing 'j i' with +// Japanese IME should produce "じ" (ji), not "jい" + +/// Returns true if the current keyboard input source is a composition-based IME +/// (e.g. Japanese Hiragana, Korean, Chinese Pinyin) that produces non-ASCII output. +/// +/// This checks two properties: +/// 1. The source type is `kTISTypeKeyboardInputMode` (an IME input mode, not a plain +/// keyboard layout). This excludes non-ASCII layouts like Armenian and Ukrainian +/// that map keys directly without composition. +/// 2. The source is not ASCII-capable, which excludes modes like Japanese Romaji that +/// produce ASCII characters and should allow multi-stroke keybindings like `jj`. +unsafe fn is_ime_input_source_active() -> bool { + unsafe { + let source = TISCopyCurrentKeyboardInputSource(); + if source.is_null() { + return false; + } + + let source_type = + TISGetInputSourceProperty(source, kTISPropertyInputSourceType as *const c_void); + let is_input_mode = !source_type.is_null() + && CFEqual( + source_type as CFTypeRef, + kTISTypeKeyboardInputMode as CFTypeRef, + ) != 0; + + let is_ascii = TISGetInputSourceProperty( + source, + kTISPropertyInputSourceIsASCIICapable as *const c_void, + ); + let is_ascii_capable = !is_ascii.is_null() && CFBooleanGetValue(is_ascii as CFBooleanRef); + + CFRelease(source as CFTypeRef); + + is_input_mode && !is_ascii_capable + } +} + extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: bool) -> BOOL { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); @@ -2030,7 +2270,28 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // and keys with function, as the input handler swallows them. // and keys with platform (Cmd), so that Cmd+key events (e.g. Cmd+`) are not // consumed by the IME on non-QWERTY / dead-key layouts. + // We also send printable keys to the IME first when an IME input source (e.g. Japanese, + // Korean, Chinese) is active and the input handler accepts text input. This prevents + // multi-stroke keybindings like `jj` from intercepting keys that the IME should compose + // (e.g. typing 'ji' should produce 'じ', not 'jい'). If the IME doesn't handle the key, + // it calls `doCommandBySelector:` which routes it back to keybinding matching. + let is_ime_printable_key = !is_composing + && key_down_event + .keystroke + .key_char + .as_ref() + .is_some_and(|key_char| key_char.chars().all(|c| !c.is_control())) + && !key_down_event.keystroke.modifiers.control + && !key_down_event.keystroke.modifiers.function + && !key_down_event.keystroke.modifiers.platform + && unsafe { is_ime_input_source_active() } + && with_input_handler(this, |input_handler| { + input_handler.query_prefers_ime_for_printable_keys() + }) + .unwrap_or(false); + if is_composing + || is_ime_printable_key || (key_down_event.keystroke.key_char.is_none() && !key_down_event.keystroke.modifiers.control && !key_down_event.keystroke.modifiers.function @@ -2110,7 +2371,13 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { // AppKit unhides the cursor on the next mouse movement; mirror that here. if matches!( event, - PlatformInput::MouseMove(_) | PlatformInput::MouseExited(_) + PlatformInput::MouseMove(_) + | PlatformInput::MouseDown(_) + | PlatformInput::MouseUp(_) + | PlatformInput::MousePressure(_) + | PlatformInput::MouseExited(_) + | PlatformInput::ScrollWheel(_) + | PlatformInput::Pinch(_) ) { lock.cursor_visible.store(true, Ordering::Relaxed); } @@ -2264,6 +2531,7 @@ extern "C" fn window_will_enter_fullscreen(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let mut lock = window_state.as_ref().lock(); lock.fullscreen_restore_bounds = lock.bounds(); + lock.restore_traffic_light(); let min_version = NSOperatingSystemVersion::new(15, 3, 0); @@ -2274,6 +2542,13 @@ extern "C" fn window_will_enter_fullscreen(this: &Object, _: Sel, _: id) { } } +extern "C" fn window_did_exit_fullscreen(this: &Object, _: Sel, _: id) { + // SAFETY: This method is registered only on GPUI window classes, which initialize + // WINDOW_STATE_IVAR with an Arc> during window creation. + let window_state = unsafe { get_window_state(this) }; + window_state.as_ref().lock().move_traffic_light(); +} + extern "C" fn window_will_exit_fullscreen(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let lock = window_state.as_ref().lock(); @@ -2690,12 +2965,20 @@ extern "C" fn do_command_by_selector(this: &Object, _: Sel, _: Sel) { extern "C" fn view_did_change_effective_appearance(this: &Object, _: Sel) { unsafe { let state = get_window_state(this); - let mut lock = state.as_ref().lock(); - if let Some(mut callback) = lock.appearance_changed_callback.take() { - drop(lock); + let appearance_changed_callback = { + let mut lock = state.as_ref().lock(); + lock.appearance_changed_callback.take() + }; + + if let Some(mut callback) = appearance_changed_callback { callback(); state.lock().appearance_changed_callback = Some(callback); } + + // AppKit can relayout the standard traffic light buttons as part of + // applying a new appearance. Reapply GPUI's custom position after + // notifying appearance observers. + state.lock().move_traffic_light(); } } @@ -2849,13 +3132,17 @@ where } } -unsafe fn display_id_for_screen(screen: id) -> CGDirectDisplayID { +fn display_id_for_screen(screen: id) -> Option { + if screen.is_null() { + return None; + } + unsafe { let device_description = NSScreen::deviceDescription(screen); let screen_number_key: id = ns_string("NSScreenNumber"); let screen_number = device_description.objectForKey_(screen_number_key); let screen_number: NSUInteger = msg_send![screen_number, unsignedIntegerValue]; - screen_number as CGDirectDisplayID + Some(screen_number as CGDirectDisplayID) } } @@ -3056,3 +3343,13 @@ extern "C" fn toggle_tab_bar(this: &Object, _sel: Sel, _id: id) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_id_for_screen_returns_none_for_null_screen() { + assert_eq!(display_id_for_screen(nil), None); + } +} diff --git a/crates/gpui_macros/src/bench.rs b/crates/gpui_macros/src/bench.rs new file mode 100644 index 00000000..6242ad43 --- /dev/null +++ b/crates/gpui_macros/src/bench.rs @@ -0,0 +1,163 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{Expr, ItemFn, LitStr, parse::Parser, spanned::Spanned}; + +pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { + let mut fps: Option = None; + let mut inputs: Option = None; + let mut input_name: Option = None; + let mut group_name: Option = None; + let mut sample_size: Option = None; + if !args.is_empty() { + let parser = syn::meta::parser(|meta| { + if meta.path.is_ident("fps") { + let value: syn::LitInt = meta.value()?.parse()?; + let value = value.base10_parse::()?; + if value == 0 { + return Err(meta.error("#[gpui::bench] `fps` must be greater than zero")); + } + fps = Some(value); + Ok(()) + } else if meta.path.is_ident("inputs") { + inputs = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("input_name") { + input_name = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("group") { + group_name = Some(meta.value()?.parse()?); + Ok(()) + } else if meta.path.is_ident("sample_size") { + let value: syn::LitInt = meta.value()?.parse()?; + let value = value.base10_parse::()?; + if value == 0 { + return Err( + meta.error("#[gpui::bench] `sample_size` must be greater than zero") + ); + } + sample_size = Some(value); + Ok(()) + } else { + Err(meta.error( + "#[gpui::bench] only accepts `fps = N`, `inputs = EXPR`, `input_name = \"...\"`, `group = \"...\"`, and `sample_size = N`", + )) + } + }); + if let Err(error) = parser.parse(args) { + return error_to_stream(error); + } + } + + // The frame budget math lives in `BenchReport` so `bench_context` is the + // single source of truth; `default()` supplies the default frame rate. + let report_expr = match fps { + Some(fps) => quote! { gpui::BenchReport::with_fps(#fps) }, + None => quote! { gpui::BenchReport::default() }, + }; + + let mut inner_fn = match syn::parse::(function) { + Ok(function) => function, + Err(error) => return error_to_stream(error), + }; + + if let Some(asyncness) = &inner_fn.sig.asyncness { + return error_to_stream(syn::Error::new( + asyncness.span(), + "#[gpui::bench] does not support async benchmark functions yet", + )); + } + + let outer_fn_name = inner_fn.sig.ident.clone(); + let inner_fn_name = format_ident!("__gpui_bench_{}", outer_fn_name); + inner_fn.sig.ident = inner_fn_name.clone(); + + let benchmark = if let Some(inputs) = inputs { + let input_name = match input_name { + Some(input_name) => quote! { #input_name }, + None => quote! { stringify!(#outer_fn_name) }, + }; + let group_name = match group_name { + Some(group_name) => quote! { #group_name }, + None => quote! { stringify!(#outer_fn_name) }, + }; + let sample_size = + sample_size.map(|sample_size| quote! { group.sample_size(#sample_size); }); + quote! { + let report = #report_expr; + let mut group = criterion.benchmark_group(#group_name); + #sample_size + for input in #inputs { + group.bench_with_input(criterion::BenchmarkId::new(#input_name, &input), &input, { + let report = report.clone(); + move |bencher, input| { + let mut cx = gpui::BenchAppContext::new_with_platform_and_report( + gpui::bench_platform( + None, + gpui_platform::current_platform(true).text_system(), + ), + Some(stringify!(#outer_fn_name)), + bencher, + report.clone(), + ); + #inner_fn_name(input, &mut cx); + cx.teardown(); + } + }); + } + group.finish(); + report.print(Some(stringify!(#outer_fn_name))); + } + } else { + if let Some(input_name) = input_name { + return error_to_stream(syn::Error::new( + input_name.span(), + "#[gpui::bench] `input_name` requires `inputs`", + )); + } + if let Some(group_name) = group_name { + return error_to_stream(syn::Error::new( + group_name.span(), + "#[gpui::bench] `group` requires `inputs`", + )); + } + if sample_size.is_some() { + return error_to_stream(syn::Error::new( + proc_macro2::Span::call_site(), + "#[gpui::bench] `sample_size` requires `inputs`", + )); + } + quote! { + let report = #report_expr; + criterion.bench_function(stringify!(#outer_fn_name), { + let report = report.clone(); + move |bencher| { + let mut cx = gpui::BenchAppContext::new_with_platform_and_report( + gpui::bench_platform( + None, + gpui_platform::current_platform(true).text_system(), + ), + Some(stringify!(#outer_fn_name)), + bencher, + report.clone(), + ); + #inner_fn_name(&mut cx); + cx.teardown(); + } + }); + report.print(Some(stringify!(#outer_fn_name))); + } + }; + + TokenStream::from(quote! { + #inner_fn + + fn #outer_fn_name(criterion: &mut criterion::Criterion) { + #benchmark + } + + }) +} + +fn error_to_stream(error: syn::Error) -> TokenStream { + TokenStream::from(error.into_compile_error()) +} diff --git a/crates/gpui_macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs index 89d609ae..51d2a8ab 100644 --- a/crates/gpui_macros/src/derive_into_element.rs +++ b/crates/gpui_macros/src/derive_into_element.rs @@ -11,11 +11,11 @@ pub fn derive_into_element(input: TokenStream) -> TokenStream { impl #impl_generics gpui::IntoElement for #type_name #type_generics #where_clause { - type Element = gpui::Component; + type Element = gpui::ViewElement; #[track_caller] fn into_element(self) -> Self::Element { - gpui::Component::new(self) + gpui::ViewElement::new(self) } } }; diff --git a/crates/gpui_macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs index 0f1365be..5f01504c 100644 --- a/crates/gpui_macros/src/gpui_macros.rs +++ b/crates/gpui_macros/src/gpui_macros.rs @@ -1,3 +1,4 @@ +mod bench; mod derive_action; mod derive_app_context; mod derive_into_element; @@ -27,8 +28,8 @@ pub fn register_action(ident: TokenStream) -> TokenStream { register_action::register_action(ident) } -/// #[derive(IntoElement)] is used to create a Component out of anything that implements -/// the `RenderOnce` trait. +/// #[derive(IntoElement)] generates an `IntoElement` impl for any `RenderOnce` +/// type, wrapping it in a `ViewElement` so it can be used as a child. #[proc_macro_derive(IntoElement)] pub fn derive_into_element(input: TokenStream) -> TokenStream { derive_into_element::derive_into_element(input) @@ -188,6 +189,21 @@ pub fn test(args: TokenStream, function: TokenStream) -> TokenStream { test::test(args, function) } +/// `#[gpui::bench]` annotates a Criterion benchmark that runs with GPUI support. +/// +/// Use `#[gpui::bench(inputs = some_iterable())]` on benchmarks that take an +/// additional input argument; the generated benchmark uses Criterion's +/// `bench_with_input`. `group`, `input_name`, and `sample_size` customize the +/// generated input benchmark group. +/// +/// The benchmark crate must add `criterion` and `gpui_platform` to its +/// dev-dependencies and enable gpui's `bench` feature, since the generated code +/// references all three. +#[proc_macro_attribute] +pub fn bench(args: TokenStream, function: TokenStream) -> TokenStream { + bench::bench(args, function) +} + /// When added to a trait, `#[derive_inspector_reflection]` generates a module which provides /// enumeration and lookup by name of all methods that have the shape `fn method(self) -> Self`. /// This is used by the inspector so that it can use the builder methods in `Styled` and diff --git a/crates/gpui_macros/src/styles.rs b/crates/gpui_macros/src/styles.rs index 8f4283de..e9fb5a4a 100644 --- a/crates/gpui_macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -408,56 +408,36 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_2xs(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; - self.style().box_shadow = Some(vec![BoxShadow { - color: hsla(0., 0., 0., 0.05), - offset: point(px(0.), px(1.)), - blur_radius: px(0.), - spread_radius: px(0.), - inset: false, - }]); + self.style().box_shadow = Some(vec![ + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.05)) + ]); self } /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_xs(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; - self.style().box_shadow = Some(vec![BoxShadow { - color: hsla(0., 0., 0., 0.05), - offset: point(px(0.), px(1.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }]); + self.style().box_shadow = Some(vec![ + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.05)).blur_radius(px(2.)) + ]); self } /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_sm(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(1.)), - blur_radius: px(3.), - spread_radius: px(0.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(1.)), - blur_radius: px(2.), - spread_radius: px(-1.), - inset: false, - } + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.1)).blur_radius(px(3.)), + BoxShadow::new(px(0.), px(1.), hsla(0., 0., 0., 0.1)).blur_radius(px(2.)).spread_radius(px(-1.)), ]); self } @@ -465,24 +445,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_md(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(4.)), - blur_radius: px(6.), - spread_radius: px(-1.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(2.)), - blur_radius: px(4.), - spread_radius: px(-2.), - inset: false, - } + BoxShadow::new(px(0.), px(4.), hsla(0., 0., 0., 0.1)).blur_radius(px(6.)).spread_radius(px(-1.)), + BoxShadow::new(px(0.), px(2.), hsla(0., 0., 0., 0.1)).blur_radius(px(4.)).spread_radius(px(-2.)), ]); self } @@ -490,24 +458,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_lg(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(10.)), - blur_radius: px(15.), - spread_radius: px(-3.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(4.)), - blur_radius: px(6.), - spread_radius: px(-4.), - inset: false, - } + BoxShadow::new(px(0.), px(10.), hsla(0., 0., 0., 0.1)).blur_radius(px(15.)).spread_radius(px(-3.)), + BoxShadow::new(px(0.), px(4.), hsla(0., 0., 0., 0.1)).blur_radius(px(6.)).spread_radius(px(-4.)), ]); self } @@ -515,24 +471,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_xl(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; self.style().box_shadow = Some(vec![ - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(20.)), - blur_radius: px(25.), - spread_radius: px(-5.), - inset: false, - }, - BoxShadow { - color: hsla(0., 0., 0., 0.1), - offset: point(px(0.), px(8.)), - blur_radius: px(10.), - spread_radius: px(-6.), - inset: false, - } + BoxShadow::new(px(0.), px(20.), hsla(0., 0., 0., 0.1)).blur_radius(px(25.)).spread_radius(px(-5.)), + BoxShadow::new(px(0.), px(8.), hsla(0., 0., 0., 0.1)).blur_radius(px(10.)).spread_radius(px(-6.)), ]); self } @@ -540,16 +484,12 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { /// Sets the box shadow of the element. /// [Docs](https://tailwindcss.com/docs/box-shadow) #visibility fn shadow_2xl(mut self) -> Self { - use gpui::{BoxShadow, hsla, point, px}; + use gpui::{BoxShadow, hsla, px}; use std::vec; - self.style().box_shadow = Some(vec![BoxShadow { - color: hsla(0., 0., 0., 0.25), - offset: point(px(0.), px(25.)), - blur_radius: px(50.), - spread_radius: px(-12.), - inset: false, - }]); + self.style().box_shadow = Some(vec![ + BoxShadow::new(px(0.), px(25.), hsla(0., 0., 0., 0.25)).blur_radius(px(50.)).spread_radius(px(-12.)) + ]); self } }; diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index 5039ff1e..5bdb8655 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -62,17 +62,10 @@ pub fn current_platform(headless: bool) -> Rc { /// Returns a new [`HeadlessRenderer`] for the current platform, if available. #[cfg(feature = "test-support")] pub fn current_headless_renderer() -> Option> { - #[cfg(target_os = "macos")] - { - Some(Box::new( - gpui_macos::metal_renderer::MetalHeadlessRenderer::new(), - )) - } - - #[cfg(not(target_os = "macos"))] - { - None - } + // This standalone fork benchmarks scene construction without a native renderer. The fork's + // Metal renderer also carries retained-layer and backdrop-blur state that the upstream + // headless renderer does not model, so report that no compatible renderer is available. + None } #[cfg(all(test, target_os = "macos"))] diff --git a/crates/gpui_tokio/Cargo.toml b/crates/gpui_tokio/Cargo.toml index e9d72b8e..5ea34386 100644 --- a/crates/gpui_tokio/Cargo.toml +++ b/crates/gpui_tokio/Cargo.toml @@ -14,6 +14,6 @@ doctest = false [dependencies] anyhow.workspace = true -util.workspace = true gpui.workspace = true +gpui_util.workspace = true tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } diff --git a/crates/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs index f6e1e064..fd71c163 100644 --- a/crates/gpui_tokio/src/gpui_tokio.rs +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -1,7 +1,7 @@ use std::future::Future; use gpui::{App, AppContext, Global, ReadGlobal, Task}; -use util::defer; +use gpui_util::defer; pub use tokio::task::JoinError; diff --git a/crates/gpui_util/Cargo.toml b/crates/gpui_util/Cargo.toml index 5f2267c7..cad692b0 100644 --- a/crates/gpui_util/Cargo.toml +++ b/crates/gpui_util/Cargo.toml @@ -8,5 +8,8 @@ edition.workspace = true log.workspace = true anyhow.workspace = true +[target.'cfg(target_os = "windows")'.dependencies] +which.workspace = true + [lints] workspace = true diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index 25b1a1ee..4dbbc254 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -3,6 +3,7 @@ use std::{ env, + ffi::OsStr, ops::AddAssign, panic::Location, pin::Pin, @@ -13,6 +14,137 @@ use std::{ pub mod arc_cow; +#[cfg(target_os = "windows")] +const CREATE_NO_WINDOW: u32 = 0x0800_0000_u32; + +#[cfg(target_os = "windows")] +pub fn new_std_command(program: impl AsRef) -> std::process::Command { + use std::os::windows::process::CommandExt; + + let mut command = std::process::Command::new(program); + command.creation_flags(CREATE_NO_WINDOW); + command +} + +#[cfg(not(target_os = "windows"))] +pub fn new_std_command(program: impl AsRef) -> std::process::Command { + std::process::Command::new(program) +} + +#[cfg(target_os = "windows")] +pub fn get_windows_system_shell() -> String { + use std::path::PathBuf; + + fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option { + #[cfg(target_pointer_width = "64")] + let env_var = if find_alternate { + "ProgramFiles(x86)" + } else { + "ProgramFiles" + }; + + #[cfg(target_pointer_width = "32")] + let env_var = if find_alternate { + "ProgramW6432" + } else { + "ProgramFiles" + }; + + let install_base_dir = PathBuf::from(std::env::var_os(env_var)?).join("PowerShell"); + install_base_dir + .read_dir() + .ok()? + .filter_map(Result::ok) + .filter(|entry| matches!(entry.file_type(), Ok(ft) if ft.is_dir())) + .filter_map(|entry| { + let dir_name = entry.file_name(); + let dir_name = dir_name.to_string_lossy(); + + let version = if find_preview { + let dash_index = dir_name.find('-')?; + if &dir_name[dash_index + 1..] != "preview" { + return None; + }; + dir_name[..dash_index].parse::().ok()? + } else { + dir_name.parse::().ok()? + }; + + let exe_path = entry.path().join("pwsh.exe"); + if exe_path.exists() { + Some((version, exe_path)) + } else { + None + } + }) + .max_by_key(|(version, _)| *version) + .map(|(_, path)| path) + } + + fn find_pwsh_in_msix(find_preview: bool) -> Option { + let msix_app_dir = + PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps"); + if !msix_app_dir.exists() { + return None; + } + + let prefix = if find_preview { + "Microsoft.PowerShellPreview_" + } else { + "Microsoft.PowerShell_" + }; + msix_app_dir + .read_dir() + .ok()? + .filter_map(|entry| { + let entry = entry.ok()?; + if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) { + return None; + } + + if !entry.file_name().to_string_lossy().starts_with(prefix) { + return None; + } + + let exe_path = entry.path().join("pwsh.exe"); + exe_path.exists().then_some(exe_path) + }) + .next() + } + + fn find_pwsh_in_scoop() -> Option { + let pwsh_exe = + PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe"); + pwsh_exe.exists().then_some(pwsh_exe) + } + + static SYSTEM_SHELL: std::sync::LazyLock = std::sync::LazyLock::new(|| { + let locations = [ + || find_pwsh_in_programfiles(false, false), + || find_pwsh_in_programfiles(true, false), + || find_pwsh_in_msix(false), + || find_pwsh_in_programfiles(false, true), + || find_pwsh_in_msix(true), + || find_pwsh_in_programfiles(true, true), + || find_pwsh_in_scoop(), + || which::which_global("pwsh.exe").ok(), + || which::which_global("powershell.exe").ok(), + ]; + + locations + .into_iter() + .find_map(|f| f()) + .map(|p| p.to_string_lossy().trim().to_owned()) + .inspect(|shell| log::info!("Found powershell in: {}", shell)) + .unwrap_or_else(|| { + log::warn!("Powershell not found, falling back to `cmd`"); + "cmd.exe".to_string() + }) + }); + + (*SYSTEM_SHELL).clone() +} + pub fn post_inc + AddAssign + Copy>(value: &mut T) -> T { let prev = *value; *value += T::from(1); @@ -334,6 +466,65 @@ where } } +/// A build hasher specialized for [`TypeId`](std::any::TypeId) keys. +#[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct TypeIdHashBuilder; + +impl std::hash::BuildHasher for TypeIdHashBuilder { + type Hasher = TypeIdHasher; + + fn build_hasher(&self) -> Self::Hasher { + TypeIdHasher::default() + } +} + +/// A no-op hasher for [`TypeId`](std::any::TypeId), whose hash output is already +/// suitable for use as a hash-table key. +#[derive(Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct TypeIdHasher { + value: u64, +} + +impl std::hash::Hasher for TypeIdHasher { + #[inline] + fn write(&mut self, bytes: &[u8]) { + // TypeId should only hash its first 8 bytes. + if let Some(bytes) = bytes.get(..8) { + bytes + .as_array() + .map(|&array| self.value = u64::from_ne_bytes(array)) + .unwrap_or_else(|| unreachable!("slice was sliced to 8 bytes")); + } else { + debug_panic!( + "expected a 64-bit value, did you use this hasher with something other than a TypeId?" + ); + } + } + + #[inline] + fn finish(&self) -> u64 { + self.value + } +} + +#[test] +fn type_id_hasher() { + use core::any::TypeId; + use core::hash::{Hash, Hasher}; + + fn verify_hashing_with(type_id: TypeId) { + let mut hasher = TypeIdHasher::default(); + type_id.hash(&mut hasher); + assert_ne!(hasher.finish(), 0); + } + + verify_hashing_with(TypeId::of::()); + verify_hashing_with(TypeId::of::<()>()); + verify_hashing_with(TypeId::of::()); + verify_hashing_with(TypeId::of::<&str>()); + verify_hashing_with(TypeId::of::>()); +} + fn poll_log_error_future( future: &mut F, cx: &mut Context, diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml index 05889566..5980fa5e 100644 --- a/crates/gpui_web/Cargo.toml +++ b/crates/gpui_web/Cargo.toml @@ -11,14 +11,14 @@ workspace = true [features] default = ["multithreaded"] -multithreaded = ["dep:wasm_thread", "parking_lot/nightly"] +multithreaded = ["dep:wasm_thread"] [lib] path = "src/gpui_web.rs" [target.'cfg(target_family = "wasm")'.dependencies] gpui.workspace = true -parking_lot.workspace = true +parking_lot = { workspace = true, features = ["nightly"] } gpui_wgpu.workspace = true http_client.workspace = true anyhow.workspace = true diff --git a/crates/gpui_web/assets/fonts/ibm-plex-sans/license.txt b/crates/gpui_web/assets/fonts/ibm-plex-sans/license.txt new file mode 100644 index 00000000..f821ee27 --- /dev/null +++ b/crates/gpui_web/assets/fonts/ibm-plex-sans/license.txt @@ -0,0 +1,92 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/gpui_web/assets/fonts/lilex/OFL.txt b/crates/gpui_web/assets/fonts/lilex/OFL.txt new file mode 100644 index 00000000..ddf53c3e --- /dev/null +++ b/crates/gpui_web/assets/fonts/lilex/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Lilex Project Authors (https://github.com/mishamyrt/Lilex) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/gpui_web/src/dispatcher.rs b/crates/gpui_web/src/dispatcher.rs index 3ee5db76..4dc21097 100644 --- a/crates/gpui_web/src/dispatcher.rs +++ b/crates/gpui_web/src/dispatcher.rs @@ -1,6 +1,5 @@ use gpui::{ PlatformDispatcher, Priority, PriorityQueueReceiver, PriorityQueueSender, RunnableVariant, - ThreadTaskTimings, }; use std::sync::Arc; use std::sync::atomic::AtomicI32; @@ -217,20 +216,6 @@ impl WebDispatcher { } impl PlatformDispatcher for WebDispatcher { - fn get_all_timings(&self) -> Vec { - // TODO-Wasm: should we panic here? - Vec::new() - } - - fn get_current_thread_timings(&self) -> ThreadTaskTimings { - ThreadTaskTimings { - thread_name: None, - thread_id: std::thread::current().id(), - timings: Vec::new(), - total_pushed: 0, - } - } - fn is_main_thread(&self) -> bool { self.on_main_thread() } diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index 170bd9e3..634063c5 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -9,7 +9,7 @@ use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DummyKeyboardMapper, ForegroundExecutor, Keymap, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, - ThermalState, WindowAppearance, WindowParams, + ThermalState, WindowAppearance, WindowKind, WindowParams, popup::PopupNotSupportedError, }; use gpui_wgpu::WgpuContext; use std::{ @@ -226,6 +226,10 @@ impl Platform for WebPlatform { handle: AnyWindowHandle, params: WindowParams, ) -> anyhow::Result> { + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(PopupNotSupportedError.into()); + } + let context_ref = self.wgpu_context.borrow(); let context = context_ref.as_ref().ok_or_else(|| { anyhow::anyhow!("WebGPU context not initialized. Was Platform::run() called?") diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml index 3520e8e6..d91182bc 100644 --- a/crates/gpui_wgpu/Cargo.toml +++ b/crates/gpui_wgpu/Cargo.toml @@ -37,7 +37,7 @@ wgpu.workspace = true font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } [target.'cfg(not(target_family = "wasm"))'.dependencies] -pollster = "0.4.0" +pollster.workspace = true [target.'cfg(target_family = "wasm")'.dependencies] wasm-bindgen.workspace = true diff --git a/crates/gpui_wgpu/benches/layout_line.rs b/crates/gpui_wgpu/benches/layout_line.rs index 8f7f18c0..57265b80 100644 --- a/crates/gpui_wgpu/benches/layout_line.rs +++ b/crates/gpui_wgpu/benches/layout_line.rs @@ -1,4 +1,4 @@ -use criterion::{Criterion, criterion_group, criterion_main}; +use criterion::{Criterion, black_box, criterion_group, criterion_main}; use gpui::{FontFallbacks, FontRun, PlatformTextSystem, font, px}; use gpui_wgpu::CosmicTextSystem; use std::borrow::Cow; @@ -82,15 +82,29 @@ fn bench_layout_line(c: &mut Criterion) { let mut group = c.benchmark_group("layout_line"); group.bench_function("no_fallback", |b| { - b.iter(|| system.layout_line(&text, px(14.0), &runs_no_fallback)) + b.iter(|| { + black_box(system.layout_line(black_box(&text), px(14.0), black_box(&runs_no_fallback))) + }) }); group.bench_function("fallback_chain_ascii_text", |b| { - b.iter(|| system.layout_line(&text, px(14.0), &runs_with_fallback)) + b.iter(|| { + black_box(system.layout_line( + black_box(&text), + px(14.0), + black_box(&runs_with_fallback), + )) + }) }); group.bench_function("fallback_chain_mixed_text", |b| { - b.iter(|| system.layout_line(&mixed_text, px(14.0), &runs_with_mixed_fallback)) + b.iter(|| { + black_box(system.layout_line( + black_box(&mixed_text), + px(14.0), + black_box(&runs_with_mixed_fallback), + )) + }) }); group.finish(); diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index 94e8601b..f6e9dbb3 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -14,7 +14,7 @@ use gpui::{ use itertools::Itertools; use parking_lot::RwLock; use smallvec::SmallVec; -use std::{borrow::Cow, sync::Arc}; +use std::{borrow::Cow, cell::RefCell, sync::Arc}; use swash::{ scale::{Render, ScaleContext, Source, StrikeWith}, zeno::{Format, Vector}, @@ -522,7 +522,18 @@ impl CosmicTextSystemState { spans } else { let loaded_fonts = &self.loaded_fonts; - let covers = |id: FontId, ch: char| charmap_covers(loaded_fonts, id, ch); + let coverage_cache = RefCell::new(HashMap::default()); + let covers = |id: FontId, ch: char| { + let key = (id, ch); + let mut cache = coverage_cache.borrow_mut(); + if let Some(covers) = cache.get(&key).copied() { + return covers; + } + + let covers = charmap_covers(loaded_fonts, id, ch); + cache.insert(key, covers); + covers + }; compute_run_spans(text, offs, run.len, run.font_id, &fallback_chain, &covers) }; diff --git a/crates/gpui_wgpu/src/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl index d3f63ab0..1f9d9625 100644 --- a/crates/gpui_wgpu/src/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -1339,7 +1339,7 @@ fn fs_underline(input: UnderlineVarying) -> @location(0) vec4 { } let underline = b_underlines[input.underline_id]; - if ((underline.wavy & 0xFFu) == 0u) + if (underline.wavy == 0u) { return blend_color(input.color, input.color.a); } @@ -1453,7 +1453,7 @@ fn fs_poly_sprite(input: PolySpriteVarying) -> @location(0) vec4 { let distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii); var color = sample; - if ((sprite.grayscale & 0xFFu) != 0u) { + if (sprite.grayscale != 0u) { let grayscale = dot(color.rgb, GRAYSCALE_FACTORS); color = vec4(vec3(grayscale), sample.a); } diff --git a/crates/gpui_wgpu/src/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs index 44bf36ab..cc6f523d 100644 --- a/crates/gpui_wgpu/src/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -93,6 +93,14 @@ impl WgpuAtlas { lock.tiles_by_key.clear(); lock.pending_uploads.clear(); } + + /// Clears device-backed textures while retaining this atlas's stable identity. + pub fn clear(&self) { + let mut lock = self.0.lock(); + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); + } } impl PlatformAtlas for WgpuAtlas { @@ -121,15 +129,17 @@ impl PlatformAtlas for WgpuAtlas { fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { + let Some(tile) = lock.tiles_by_key.remove(key) else { return; }; + let id = tile.texture_id; let Some(texture_slot) = lock.storage[id.kind].textures.get_mut(id.index as usize) else { return; }; if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); texture.decrement_ref_count(); if texture.is_unreferenced() { lock.pending_uploads @@ -365,7 +375,39 @@ impl WgpuAtlasTexture { #[cfg(test)] mod tests { use super::*; - use gpui::size; + use gpui::{ImageId, RenderImageParams, block_on, size}; + + fn test_device_and_queue() -> anyhow::Result<(Arc, Arc)> { + block_on(async { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await?; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_test_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await?; + Ok((Arc::new(device), Arc::new(queue))) + }) + } #[test] fn atlas_texture_size_never_exceeds_max_texture_size() { @@ -382,4 +424,36 @@ mod tests { assert_eq!(size.width, DevicePixels(1024)); assert_eq!(size.height, DevicePixels(1024)); } + + #[test] + fn remove_deallocates_tile_space_for_reuse() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue); + let insert = |image_id, size: Size| { + let key = AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }); + let byte_count = size.width.0 as usize * size.height.0 as usize * 4; + atlas + .get_or_insert_with(&key, &mut || { + Ok(Some((size, Cow::Owned(vec![0; byte_count])))) + }) + .unwrap() + .unwrap() + }; + let small = size(DevicePixels(64), DevicePixels(64)); + let big = size(DevicePixels(700), DevicePixels(700)); + let keeper = insert(1, small); + let removed = insert(2, big); + assert_eq!(keeper.texture_id, removed.texture_id); + + atlas.remove(&AtlasKey::Image(RenderImageParams { + image_id: ImageId(2), + frame_index: 0, + })); + let replacement = insert(3, big); + assert_eq!(replacement.texture_id, keeper.texture_id); + Ok(()) + } } diff --git a/crates/gpui_wgpu/src/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs index 6e35a475..bdc8c365 100644 --- a/crates/gpui_wgpu/src/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -23,6 +23,34 @@ pub struct CompositorGpuHint { impl WgpuContext { #[cfg(not(target_family = "wasm"))] pub fn new(compositor_gpu: Option) -> anyhow::Result { + Self::new_internal(Self::instance(), None, compositor_gpu, false) + } + + #[cfg(not(target_family = "wasm"))] + pub fn new_for_surface( + instance: wgpu::Instance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + ) -> anyhow::Result { + Self::new_internal(instance, Some(surface), compositor_gpu, false) + } + + #[cfg(not(target_family = "wasm"))] + pub fn new_for_surface_rejecting_software( + instance: wgpu::Instance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + ) -> anyhow::Result { + Self::new_internal(instance, Some(surface), compositor_gpu, true) + } + + #[cfg(not(target_family = "wasm"))] + fn new_internal( + instance: wgpu::Instance, + compatible_surface: Option<&wgpu::Surface<'_>>, + compositor_gpu: Option, + reject_software: bool, + ) -> anyhow::Result { let device_id_filter = match std::env::var("ZED_DEVICE_ID") { Ok(val) => parse_pci_id(&val) .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") @@ -35,19 +63,14 @@ impl WgpuContext { } }; - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, - flags: wgpu::InstanceFlags::default(), - backend_options: wgpu::BackendOptions::default(), - memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), - display: None, - }); - - let adapter = pollster::block_on(Self::select_adapter( - &instance, - device_id_filter, - compositor_gpu.as_ref(), - ))?; + let (adapter, device, queue, dual_source_blending_available) = + pollster::block_on(Self::select_adapter_and_device( + &instance, + device_id_filter, + compositor_gpu.as_ref(), + compatible_surface, + reject_software, + ))?; log::info!( "Selected GPU adapter: {:?} ({:?})", @@ -55,34 +78,6 @@ impl WgpuContext { adapter.get_info().backend ); - let dual_source_blending_available = adapter - .features() - .contains(wgpu::Features::DUAL_SOURCE_BLENDING); - - let mut required_features = wgpu::Features::empty(); - if dual_source_blending_available { - required_features |= wgpu::Features::DUAL_SOURCE_BLENDING; - } else { - log::warn!( - "Dual-source blending not available on this GPU. \ - Subpixel text antialiasing will be disabled." - ); - } - - let (device, queue) = pollster::block_on( - adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("gpui_device"), - required_features, - required_limits: wgpu::Limits::default() - .using_resolution(adapter.limits()) - .using_alignment(adapter.limits()), - memory_hints: wgpu::MemoryHints::MemoryUsage, - trace: wgpu::Trace::Off, - experimental_features: wgpu::ExperimentalFeatures::disabled(), - }), - ) - .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; - let device_lost = Arc::new(AtomicBool::new(false)); device.set_device_lost_callback({ let device_lost = Arc::clone(&device_lost); @@ -104,6 +99,17 @@ impl WgpuContext { }) } + #[cfg(not(target_family = "wasm"))] + pub fn instance() -> wgpu::Instance { + wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }) + } + #[cfg(target_family = "wasm")] pub async fn new_web() -> anyhow::Result { let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { @@ -179,11 +185,13 @@ impl WgpuContext { } #[cfg(not(target_family = "wasm"))] - async fn select_adapter( + async fn select_adapter_and_device( instance: &wgpu::Instance, device_id_filter: Option, compositor_gpu: Option<&CompositorGpuHint>, - ) -> anyhow::Result { + compatible_surface: Option<&wgpu::Surface<'_>>, + reject_software: bool, + ) -> anyhow::Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue, bool)> { let mut adapters: Vec<_> = instance.enumerate_adapters(wgpu::Backends::all()).await; if adapters.is_empty() { @@ -262,14 +270,136 @@ impl WgpuContext { ); } - // Return the first (highest priority) adapter - Ok(adapters.into_iter().next().unwrap()) + for adapter in adapters { + let info = adapter.get_info(); + if reject_software && info.device_type == wgpu::DeviceType::Cpu { + log::info!( + "Skipping software renderer: {} ({:?})", + info.name, + info.backend + ); + continue; + } + + let result = if let Some(surface) = compatible_surface { + Self::try_adapter_with_surface(&adapter, surface).await + } else { + Self::create_device(&adapter).await + }; + match result { + Ok((device, queue, dual_source_blending)) => { + if !dual_source_blending { + log::warn!( + "Dual-source blending not available on this GPU. \ + Subpixel text antialiasing will be disabled." + ); + } + return Ok((adapter, device, queue, dual_source_blending)); + } + Err(error) => { + log::info!( + "Adapter {} ({:?}) failed: {error:#}; trying next", + info.name, + info.backend + ); + } + } + } + + anyhow::bail!("No GPU adapter found that can configure the display surface") + } + + #[cfg(not(target_family = "wasm"))] + async fn create_device( + adapter: &wgpu::Adapter, + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool)> { + let dual_source_blending = adapter + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + let required_features = if dual_source_blending { + wgpu::Features::DUAL_SOURCE_BLENDING + } else { + wgpu::Features::empty() + }; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("gpui_device"), + required_features, + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await + .map_err(|error| anyhow::anyhow!("Failed to create wgpu device: {error}"))?; + Ok((device, queue, dual_source_blending)) + } + + #[cfg(not(target_family = "wasm"))] + async fn try_adapter_with_surface( + adapter: &wgpu::Adapter, + surface: &wgpu::Surface<'_>, + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool)> { + let capabilities = surface.get_capabilities(adapter); + let format = capabilities + .formats + .first() + .copied() + .ok_or_else(|| anyhow::anyhow!("no compatible surface formats"))?; + let alpha_mode = capabilities + .alpha_modes + .first() + .copied() + .ok_or_else(|| anyhow::anyhow!("no compatible alpha modes"))?; + let present_mode = if capabilities + .present_modes + .contains(&wgpu::PresentMode::Fifo) + { + wgpu::PresentMode::Fifo + } else { + capabilities + .present_modes + .first() + .copied() + .ok_or_else(|| anyhow::anyhow!("no compatible present modes"))? + }; + + let (device, queue, dual_source_blending) = Self::create_device(adapter).await?; + let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + surface.configure( + &device, + &wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: 64, + height: 64, + present_mode, + desired_maximum_frame_latency: 2, + alpha_mode, + view_formats: vec![], + }, + ); + if let Some(error) = error_scope.pop().await { + anyhow::bail!("surface configuration failed: {error}"); + } + Ok((device, queue, dual_source_blending)) } pub fn supports_dual_source_blending(&self) -> bool { self.dual_source_blending } + pub fn check_compatible_with_surface(&self, surface: &wgpu::Surface<'_>) -> anyhow::Result<()> { + let capabilities = surface.get_capabilities(&self.adapter); + anyhow::ensure!( + !capabilities.formats.is_empty() && !capabilities.alpha_modes.is_empty(), + "shared GPU adapter is incompatible with the window surface" + ); + Ok(()) + } + /// Returns true if the GPU device was lost (e.g., due to driver crash, suspend/resume). /// When this returns true, the context should be recreated. pub fn device_lost(&self) -> bool { diff --git a/crates/gpui_wgpu/src/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs index 46e34381..5cd19298 100644 --- a/crates/gpui_wgpu/src/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -1,4 +1,4 @@ -use crate::{WgpuAtlas, WgpuContext}; +use crate::{CompositorGpuHint, WgpuAtlas, WgpuContext}; use bytemuck::{Pod, Zeroable}; use gpui::{ AtlasTextureId, BackdropBlur, Background, Bounds, ContentMask, DevicePixels, GlobalElementId, @@ -9,10 +9,12 @@ use gpui::{ use log::warn; #[cfg(not(target_family = "wasm"))] use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use std::cell::RefCell; use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; use std::num::NonZeroU64; use std::ops::Range; +use std::rc::Rc; use std::sync::{Arc, Mutex}; const BACKDROP_BLUR_RADIUS_PER_LEVEL: f32 = 6.0; @@ -35,6 +37,51 @@ fn select_present_mode( .unwrap_or(wgpu::PresentMode::Fifo) } +#[cfg(not(target_family = "wasm"))] +fn recovery_requires_new_context(context_device_lost: Option) -> bool { + context_device_lost.is_none_or(|device_lost| device_lost) +} + +fn surface_usage(surface_usages: wgpu::TextureUsages) -> (wgpu::TextureUsages, bool) { + let supports_copy_src = surface_usages.contains(wgpu::TextureUsages::COPY_SRC); + let usage = if supports_copy_src { + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC + } else { + wgpu::TextureUsages::RENDER_ATTACHMENT + }; + (usage, supports_copy_src) +} + +#[derive(Default)] +struct FrameFailureStreak { + count: u32, + recovery_frame_pending: bool, +} + +impl FrameFailureStreak { + fn record_error(&mut self) -> u32 { + self.count += 1; + self.count + } + + fn begin_recovery(&mut self) { + self.recovery_frame_pending = true; + } + + fn record_no_error(&mut self) { + // Recovery returns before submitting a replacement frame. The next draw + // therefore has no prior error to report, but it is not evidence that + // the GPU recovered successfully. + if !std::mem::take(&mut self.recovery_frame_pending) { + self.count = 0; + } + } + + fn transfer_to(&mut self, replacement: &mut Self) { + *replacement = std::mem::take(self); + } +} + #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct GlobalParams { @@ -129,6 +176,25 @@ pub struct WgpuSurfaceConfig { pub preferred_present_mode: Option, } +#[derive(Clone, Copy, Debug, PartialEq)] +struct DesiredSurfaceState { + size: Size, + transparent: bool, + preferred_present_mode: Option, + is_bgr: bool, +} + +impl DesiredSurfaceState { + #[cfg(not(target_family = "wasm"))] + fn renderer_config(self) -> WgpuSurfaceConfig { + WgpuSurfaceConfig { + size: self.size, + transparent: self.transparent, + preferred_present_mode: self.preferred_present_mode, + } + } +} + struct WgpuPipelines { quads: wgpu::RenderPipeline, shadows: wgpu::RenderPipeline, @@ -187,27 +253,30 @@ enum DrawCommand { }, } -/// Shared GPU context reference, kept for API compatibility with upstream GPUI. -pub type GpuContext = std::rc::Rc>>; +/// Shared GPU context reference, used to coordinate recovery across windows. +pub type GpuContext = Rc>>; -pub struct WgpuRenderer { +#[cfg(not(target_family = "wasm"))] +#[derive(Clone, Copy)] +struct SurfaceHandles { + display: raw_window_handle::RawDisplayHandle, + window: raw_window_handle::RawWindowHandle, +} + +/// Every handle tied to a particular device is dropped as one unit on loss. +#[doc(hidden)] +pub struct WgpuResources { device: Arc, queue: Arc, surface: wgpu::Surface<'static>, - surface_config: wgpu::SurfaceConfiguration, surface_supports_copy_src: bool, pipelines: WgpuPipelines, bind_group_layouts: WgpuBindGroupLayouts, - atlas: Arc, atlas_sampler: wgpu::Sampler, globals_buffer: wgpu::Buffer, - path_globals_offset: u64, - gamma_offset: u64, globals_bind_group: wgpu::BindGroup, path_globals_bind_group: wgpu::BindGroup, instance_buffer: wgpu::Buffer, - instance_buffer_capacity: u64, - storage_buffer_alignment: u64, path_intermediate_texture: Option, path_intermediate_view: Option, path_msaa_texture: Option, @@ -217,6 +286,37 @@ pub struct WgpuRenderer { backdrop_view: Option, backdrop_size: Option>, retained_layers: HashMap, +} + +impl WgpuResources { + fn invalidate_cached_gpu_state(&mut self) { + self.path_intermediate_texture = None; + self.path_intermediate_view = None; + self.path_msaa_texture = None; + self.path_msaa_view = None; + self.path_intermediate_size = None; + self.backdrop_texture = None; + self.backdrop_view = None; + self.backdrop_size = None; + self.retained_layers.clear(); + } +} + +pub struct WgpuRenderer { + #[allow(dead_code)] + context: Option, + #[allow(dead_code)] + compositor_gpu: Option, + resources: Option, + #[cfg(not(target_family = "wasm"))] + surface_handles: Option, + surface_config: wgpu::SurfaceConfiguration, + atlas: Arc, + path_globals_offset: u64, + gamma_offset: u64, + instance_buffer_capacity: u64, + storage_buffer_alignment: u64, + desired: DesiredSurfaceState, rendering_params: RenderingParameters, dual_source_blending: bool, adapter_info: wgpu::AdapterInfo, @@ -224,8 +324,27 @@ pub struct WgpuRenderer { opaque_alpha_mode: wgpu::CompositeAlphaMode, max_texture_size: u32, last_error: Arc>>, - failed_frame_count: u32, + frame_failure_streak: FrameFailureStreak, device_lost: Arc, + needs_redraw: bool, +} + +impl std::ops::Deref for WgpuRenderer { + type Target = WgpuResources; + + fn deref(&self) -> &Self::Target { + self.resources + .as_ref() + .expect("GPU resources not available") + } +} + +impl std::ops::DerefMut for WgpuRenderer { + fn deref_mut(&mut self) -> &mut Self::Target { + self.resources + .as_mut() + .expect("GPU resources not available") + } } impl WgpuRenderer { @@ -240,9 +359,10 @@ impl WgpuRenderer { /// of the returned renderer. #[cfg(not(target_family = "wasm"))] pub fn new( - context: &WgpuContext, + gpu_context: GpuContext, window: &W, config: WgpuSurfaceConfig, + compositor_gpu: Option, ) -> anyhow::Result { let window_handle = window .window_handle() @@ -251,22 +371,49 @@ impl WgpuRenderer { .display_handle() .map_err(|e| anyhow::anyhow!("Failed to get display handle: {e}"))?; - let target = wgpu::SurfaceTargetUnsafe::RawHandle { - raw_display_handle: Some(display_handle.as_raw()), - raw_window_handle: window_handle.as_raw(), + let surface_handles = SurfaceHandles { + display: display_handle.as_raw(), + window: window_handle.as_raw(), }; + let instance = gpu_context + .borrow() + .as_ref() + .map(|context| context.instance.clone()) + .unwrap_or_else(WgpuContext::instance); + // Safety: The caller guarantees that the window handle is valid for the // lifetime of this renderer. In practice, the RawWindow struct is created // from the native window handles and the surface is dropped before the window. let surface = unsafe { - context - .instance - .create_surface_unsafe(target) + instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(surface_handles.display), + raw_window_handle: surface_handles.window, + }) .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))? }; - Self::from_surface(context, surface, config) + if gpu_context.borrow().is_none() { + *gpu_context.borrow_mut() = Some(WgpuContext::new_for_surface( + instance, + &surface, + compositor_gpu, + )?); + } + let context_ref = gpu_context.borrow(); + let context = context_ref.as_ref().expect("context was initialized"); + context.check_compatible_with_surface(&surface)?; + + Self::from_surface_internal( + context, + surface, + config, + Some(Rc::clone(&gpu_context)), + compositor_gpu, + None, + Some(surface_handles), + ) } #[cfg(target_family = "wasm")] @@ -283,10 +430,32 @@ impl WgpuRenderer { Self::from_surface(context, surface, config) } + #[cfg(target_family = "wasm")] fn from_surface( context: &WgpuContext, surface: wgpu::Surface<'static>, config: WgpuSurfaceConfig, + ) -> anyhow::Result { + Self::from_surface_internal( + context, + surface, + config, + None, + None, + None, + #[cfg(not(target_family = "wasm"))] + None, + ) + } + + fn from_surface_internal( + context: &WgpuContext, + surface: wgpu::Surface<'static>, + config: WgpuSurfaceConfig, + gpu_context: Option, + compositor_gpu: Option, + atlas: Option>, + #[cfg(not(target_family = "wasm"))] surface_handles: Option, ) -> anyhow::Result { let surface_caps = surface.get_capabilities(&context.adapter); let preferred_formats = [ @@ -298,26 +467,28 @@ impl WgpuRenderer { .find(|f| surface_caps.formats.contains(f)) .copied() .or_else(|| surface_caps.formats.iter().find(|f| !f.is_srgb()).copied()) - .unwrap_or(surface_caps.formats[0]); + .or_else(|| surface_caps.formats.first().copied()) + .ok_or_else(|| anyhow::anyhow!("Surface reports no supported texture formats"))?; let pick_alpha_mode = - |preferences: &[wgpu::CompositeAlphaMode]| -> wgpu::CompositeAlphaMode { + |preferences: &[wgpu::CompositeAlphaMode]| -> anyhow::Result { preferences .iter() .find(|p| surface_caps.alpha_modes.contains(p)) .copied() - .unwrap_or(surface_caps.alpha_modes[0]) + .or_else(|| surface_caps.alpha_modes.first().copied()) + .ok_or_else(|| anyhow::anyhow!("Surface reports no supported alpha modes")) }; let transparent_alpha_mode = pick_alpha_mode(&[ wgpu::CompositeAlphaMode::PreMultiplied, wgpu::CompositeAlphaMode::Inherit, - ]); + ])?; let opaque_alpha_mode = pick_alpha_mode(&[ wgpu::CompositeAlphaMode::Opaque, wgpu::CompositeAlphaMode::Inherit, - ]); + ])?; let alpha_mode = if config.transparent { transparent_alpha_mode @@ -338,6 +509,11 @@ impl WgpuRenderer { transparent_alpha_mode, opaque_alpha_mode, config, + gpu_context, + compositor_gpu, + atlas, + #[cfg(not(target_family = "wasm"))] + surface_handles, ) } @@ -351,6 +527,10 @@ impl WgpuRenderer { transparent_alpha_mode: wgpu::CompositeAlphaMode, opaque_alpha_mode: wgpu::CompositeAlphaMode, config: WgpuSurfaceConfig, + gpu_context: Option, + compositor_gpu: Option, + atlas: Option>, + #[cfg(not(target_family = "wasm"))] surface_handles: Option, ) -> anyhow::Result { let device = Arc::clone(&context.device); let max_texture_size = device.limits().max_texture_dimension_2d; @@ -368,16 +548,12 @@ impl WgpuRenderer { ); } - let surface_supports_copy_src = surface_usages.contains(wgpu::TextureUsages::COPY_SRC); + let (surface_usage, surface_supports_copy_src) = surface_usage(surface_usages); if !surface_supports_copy_src { warn!("WGPU surface lacks COPY_SRC usage; backdrop blur is disabled"); } let surface_config = wgpu::SurfaceConfiguration { - usage: if surface_supports_copy_src { - wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC - } else { - wgpu::TextureUsages::RENDER_ATTACHMENT - }, + usage: surface_usage, format: surface_format, width: clamped_width.max(1), height: clamped_height.max(1), @@ -401,7 +577,8 @@ impl WgpuRenderer { dual_source_blending, ); - let atlas = Arc::new(WgpuAtlas::new(Arc::clone(&device), Arc::clone(&queue))); + let atlas = atlas + .unwrap_or_else(|| Arc::new(WgpuAtlas::new(Arc::clone(&device), Arc::clone(&queue)))); let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { label: Some("atlas_sampler"), mag_filter: wgpu::FilterMode::Linear, @@ -485,24 +662,18 @@ impl WgpuRenderer { *guard = Some(error.to_string()); })); - Ok(Self { + let resources = WgpuResources { device, queue, surface, - surface_config, surface_supports_copy_src, pipelines, bind_group_layouts, - atlas, atlas_sampler, globals_buffer, - path_globals_offset, - gamma_offset, globals_bind_group, path_globals_bind_group, instance_buffer, - instance_buffer_capacity: INITIAL_INSTANCE_BUFFER_SIZE, - storage_buffer_alignment, path_intermediate_texture: None, path_intermediate_view: None, path_msaa_texture: None, @@ -512,6 +683,26 @@ impl WgpuRenderer { backdrop_view: None, backdrop_size: None, retained_layers: HashMap::default(), + }; + + Ok(Self { + context: gpu_context, + compositor_gpu, + resources: Some(resources), + #[cfg(not(target_family = "wasm"))] + surface_handles, + surface_config, + atlas, + path_globals_offset, + gamma_offset, + instance_buffer_capacity: INITIAL_INSTANCE_BUFFER_SIZE, + storage_buffer_alignment, + desired: DesiredSurfaceState { + size: config.size, + transparent: config.transparent, + preferred_present_mode: config.preferred_present_mode, + is_bgr: false, + }, rendering_params, dual_source_blending, adapter_info, @@ -519,8 +710,9 @@ impl WgpuRenderer { opaque_alpha_mode, max_texture_size, last_error, - failed_frame_count: 0, + frame_failure_streak: FrameFailureStreak::default(), device_lost: context.device_lost_flag(), + needs_redraw: false, }) } @@ -978,6 +1170,10 @@ impl WgpuRenderer { } pub fn update_drawable_size(&mut self, size: Size) { + self.desired.size = size; + if self.resources.is_none() { + return; + } let width = size.width.0 as u32; let height = size.height.0 as u32; @@ -1165,6 +1361,10 @@ impl WgpuRenderer { } pub fn update_transparency(&mut self, transparent: bool) { + self.desired.transparent = transparent; + if self.resources.is_none() { + return; + } let new_alpha_mode = if transparent { self.transparent_alpha_mode } else { @@ -1190,7 +1390,7 @@ impl WgpuRenderer { } pub fn set_subpixel_layout(&mut self, is_bgr: bool) { - self.rendering_params.is_bgr = is_bgr; + self.desired.is_bgr = is_bgr; } #[allow(dead_code)] @@ -1214,19 +1414,33 @@ impl WgpuRenderer { } } - pub fn draw(&mut self, scene: &Scene) { + /// Draws a frame and returns whether a surface buffer was presented. + pub fn draw(&mut self, scene: &Scene) -> bool { + if self.resources.is_none() { + self.needs_redraw = true; + return false; + } let last_error = self.last_error.lock().unwrap().take(); if let Some(error) = last_error { - self.failed_frame_count += 1; + let failed_frame_count = self.frame_failure_streak.record_error(); log::error!( "GPU error during frame (failure {} of 20): {error}", - self.failed_frame_count + failed_frame_count ); - if self.failed_frame_count > 20 { + if failed_frame_count > 20 { panic!("Too many consecutive GPU errors. Last error: {error}"); + } else if failed_frame_count > 5 { + self.resources + .as_mut() + .expect("GPU resources checked above") + .invalidate_cached_gpu_state(); + self.atlas.clear(); + self.needs_redraw = true; + self.frame_failure_streak.begin_recovery(); + return false; } } else { - self.failed_frame_count = 0; + self.frame_failure_streak.record_no_error(); } self.atlas.before_frame(); @@ -1247,19 +1461,19 @@ impl WgpuRenderer { // Textures must be destroyed before the surface can be reconfigured. drop(frame); self.surface.configure(&self.device, &self.surface_config); - return; + return false; } wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { self.surface.configure(&self.device, &self.surface_config); - return; + return false; } wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { - return; + return false; } wgpu::CurrentSurfaceTexture::Validation => { *self.last_error.lock().unwrap() = Some("Surface texture validation error".to_string()); - return; + return false; } }; @@ -1368,11 +1582,12 @@ impl WgpuRenderer { self.instance_buffer_capacity ); frame.present(); - return; + return true; } frame.present(); } + true } fn prepare_retained_layers(&mut self, scene: &Scene) -> Vec { @@ -1407,7 +1622,7 @@ impl WgpuRenderer { } let layer_scene = scene.clone_paint_range(layer.paint_range.clone()); - if !layer_scene.backdrop_blurs.is_empty() { + if Self::retained_scene_contains_backdrop_blurs(&layer_scene) { return None; } @@ -1447,6 +1662,10 @@ impl WgpuRenderer { prepared } + fn retained_scene_contains_backdrop_blurs(scene: &Scene) -> bool { + !scene.backdrop_blurs.is_empty() + } + fn top_level_retained_layer_indices(layers: &[RetainedLayer]) -> Vec { let mut indices = (0..layers.len()).collect::>(); indices.sort_by_key(|&index| { @@ -1619,7 +1838,7 @@ impl WgpuRenderer { gamma_ratios: self.rendering_params.gamma_ratios, grayscale_enhanced_contrast: self.rendering_params.grayscale_enhanced_contrast, subpixel_enhanced_contrast: self.rendering_params.subpixel_enhanced_contrast, - is_bgr: self.rendering_params.is_bgr as u32, + is_bgr: self.desired.is_bgr as u32, _pad: 0, }; let globals = GlobalParams { @@ -2641,37 +2860,120 @@ impl WgpuRenderer { pub fn destroy(&mut self) { // Release surface-bound GPU resources eagerly so the underlying native // window can be destroyed before the renderer itself is dropped. - if let Some(ref texture) = self.path_intermediate_texture { - texture.destroy(); - } - self.path_intermediate_texture = None; - self.path_intermediate_view = None; - if let Some(ref texture) = self.path_msaa_texture { - texture.destroy(); - } - self.path_msaa_texture = None; - self.path_msaa_view = None; - self.path_intermediate_size = None; - if let Some(ref texture) = self.backdrop_texture { - texture.destroy(); - } - self.backdrop_texture = None; - self.backdrop_view = None; - self.backdrop_size = None; - for (_, layer) in self.retained_layers.drain() { - layer.texture.destroy(); - } + self.resources.take(); } /// Returns true if the GPU device was lost and recovery is needed. pub fn device_lost(&self) -> bool { self.device_lost.load(std::sync::atomic::Ordering::SeqCst) } + + /// Returns and clears the flag indicating that cached GPU state was discarded. + pub fn needs_redraw(&mut self) -> bool { + std::mem::take(&mut self.needs_redraw) + } + + /// Recreates this window's device-owned resource graph after device loss. + /// The first window recreates the shared context; later windows adopt it. + #[cfg(not(target_family = "wasm"))] + pub fn recover(&mut self) -> anyhow::Result<()> { + // The current scene was built against atlas IDs from the lost device. + // Always request a fresh scene, including when this recovery attempt fails. + self.needs_redraw = true; + let gpu_context = Rc::clone( + self.context + .as_ref() + .expect("native renderer recovery requires a shared context"), + ); + let handles = self + .surface_handles + .expect("native renderer recovery requires surface handles"); + let needs_new_context = recovery_requires_new_context( + gpu_context.borrow().as_ref().map(WgpuContext::device_lost), + ); + + // Drop every Arc/device child and the old surface before replacing the context. + self.resources.take(); + let surface = if needs_new_context { + *gpu_context.borrow_mut() = None; + std::thread::sleep(std::time::Duration::from_millis(350)); + let instance = WgpuContext::instance(); + let surface = unsafe { + instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(handles.display), + raw_window_handle: handles.window, + })? + }; + let context = WgpuContext::new_for_surface_rejecting_software( + instance, + &surface, + self.compositor_gpu, + )?; + *gpu_context.borrow_mut() = Some(context); + surface + } else { + let context_ref = gpu_context.borrow(); + let context = context_ref.as_ref().expect("context was recovered"); + unsafe { + context + .instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(handles.display), + raw_window_handle: handles.window, + })? + } + }; + let context_ref = gpu_context.borrow(); + let context = context_ref.as_ref().expect("context was recovered"); + let config = self.desired.renderer_config(); + let atlas = Arc::clone(&self.atlas); + let is_bgr = self.desired.is_bgr; + atlas.handle_device_lost(Arc::clone(&context.device), Arc::clone(&context.queue)); + + let mut recovered = Self::from_surface_internal( + context, + surface, + config, + Some(Rc::clone(&gpu_context)), + self.compositor_gpu, + Some(atlas), + Some(handles), + )?; + recovered.desired.is_bgr = is_bgr; + self.frame_failure_streak + .transfer_to(&mut recovered.frame_failure_streak); + recovered.needs_redraw = true; + *self = recovered; + Ok(()) + } } #[cfg(test)] mod tests { use super::*; + use gpui::Corners; + + fn scene_with_retained_primitive( + primitive: impl Into, + ) -> (Scene, RetainedLayer) { + let bounds = Bounds::new( + Point::new(ScaledPixels(0.0), ScaledPixels(0.0)), + Size::new(ScaledPixels(100.0), ScaledPixels(100.0)), + ); + let mut scene = Scene::default(); + scene.insert_primitive(primitive); + let layer = RetainedLayer { + id: GlobalElementId::default(), + content_revision: 1.into(), + content_dirty: true, + bounds, + content_mask: ContentMask { bounds }, + transform: TransformationMatrix::unit(), + opacity: 1.0, + paint_range: 0..scene.paint_operation_count(), + }; + (scene, layer) + } #[test] fn select_present_mode_accepts_auto_modes_without_surface_advertising_them() { @@ -2698,6 +3000,118 @@ mod tests { wgpu::PresentMode::Fifo ); } + + #[test] + fn recovery_state_preserves_requested_surface_and_subpixel_settings() { + let desired = DesiredSurfaceState { + size: Size::new(DevicePixels(1440), DevicePixels(900)), + transparent: true, + preferred_present_mode: Some(wgpu::PresentMode::Mailbox), + is_bgr: true, + }; + + let config = desired.renderer_config(); + assert_eq!(config.size, desired.size); + assert_eq!(config.transparent, desired.transparent); + assert_eq!( + config.preferred_present_mode, + desired.preferred_present_mode + ); + assert!(desired.is_bgr); + } + + #[test] + fn recovery_coordinator_recreates_only_missing_or_lost_shared_contexts() { + assert!(recovery_requires_new_context(None)); + assert!(recovery_requires_new_context(Some(true))); + assert!(!recovery_requires_new_context(Some(false))); + } + + #[test] + fn recovery_gap_does_not_reset_consecutive_frame_failures() { + let mut failures = FrameFailureStreak::default(); + + for expected in 1..=6 { + assert_eq!(failures.record_error(), expected); + if expected > 5 { + failures.begin_recovery(); + } + } + + let mut recovered_failures = FrameFailureStreak::default(); + failures.transfer_to(&mut recovered_failures); + assert_eq!(failures.count, 0); + recovered_failures.record_no_error(); + assert_eq!(recovered_failures.count, 6); + + for expected in 7..=21 { + assert_eq!(recovered_failures.record_error(), expected); + recovered_failures.begin_recovery(); + recovered_failures.record_no_error(); + } + + assert_eq!(recovered_failures.count, 21); + recovered_failures.record_no_error(); + assert_eq!(recovered_failures.count, 0); + } + + #[test] + fn surface_usage_requests_copy_source_only_when_supported() { + let (usage, supports_copy_src) = + surface_usage(wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC); + assert!(supports_copy_src); + assert!(usage.contains(wgpu::TextureUsages::COPY_SRC)); + + let (usage, supports_copy_src) = surface_usage(wgpu::TextureUsages::RENDER_ATTACHMENT); + assert!(!supports_copy_src); + assert!(!usage.contains(wgpu::TextureUsages::COPY_SRC)); + assert!(usage.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)); + } + + #[test] + fn retained_layer_with_backdrop_blur_is_excluded_from_wgpu_cache() { + let bounds = Bounds::new( + Point::new(ScaledPixels(0.0), ScaledPixels(0.0)), + Size::new(ScaledPixels(100.0), ScaledPixels(100.0)), + ); + let (scene, layer) = scene_with_retained_primitive(BackdropBlur { + order: 0, + pad: 0, + bounds, + content_mask: ContentMask { bounds }, + corner_radii: Corners::all(ScaledPixels(8.0)), + blur_radius: ScaledPixels(16.0), + source_origin_x: 0.0, + source_origin_y: 0.0, + source_width: 1.0, + source_height: 1.0, + pad2: 0, + }); + + let retained_scene = scene.clone_paint_range(layer.paint_range); + assert!(WgpuRenderer::retained_scene_contains_backdrop_blurs( + &retained_scene + )); + } + + #[test] + fn retained_layer_without_backdrop_blur_remains_cacheable_by_wgpu() { + let bounds = Bounds::new( + Point::new(ScaledPixels(0.0), ScaledPixels(0.0)), + Size::new(ScaledPixels(100.0), ScaledPixels(100.0)), + ); + let (scene, layer) = scene_with_retained_primitive(Quad { + order: 0, + bounds, + content_mask: ContentMask { bounds }, + ..Default::default() + }); + + let retained_scene = scene.clone_paint_range(layer.paint_range); + assert!(!WgpuRenderer::retained_scene_contains_backdrop_blurs( + &retained_scene + )); + } } struct RenderingParameters { @@ -2705,7 +3119,6 @@ struct RenderingParameters { gamma_ratios: [f32; 4], grayscale_enhanced_contrast: f32, subpixel_enhanced_contrast: f32, - is_bgr: bool, } impl RenderingParameters { @@ -2742,7 +3155,6 @@ impl RenderingParameters { gamma_ratios, grayscale_enhanced_contrast, subpixel_enhanced_contrast, - is_bgr: false, } } } diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index bfb404ae..709fcf0a 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -24,8 +24,10 @@ accesskit.workspace = true accesskit_windows.workspace = true anyhow.workspace = true collections.workspace = true +dunce.workspace = true etagere = "0.2" futures.workspace = true +gpui_util.workspace = true image.workspace = true itertools.workspace = true log.workspace = true @@ -33,12 +35,12 @@ parking_lot.workspace = true rand.workspace = true raw-window-handle = "0.6" smallvec.workspace = true -util.workspace = true uuid.workspace = true windows = { workspace = true, features = [ "UI_Composition", "UI_Composition_Desktop", "System", + "Win32_System_Power", "Win32_System_WinRT_Composition", ] } windows-core.workspace = true diff --git a/crates/gpui_windows/src/direct_manipulation.rs b/crates/gpui_windows/src/direct_manipulation.rs index 6935ef78..20a43672 100644 --- a/crates/gpui_windows/src/direct_manipulation.rs +++ b/crates/gpui_windows/src/direct_manipulation.rs @@ -1,9 +1,9 @@ use std::cell::{Cell, RefCell}; use std::rc::Rc; -use ::util::ResultExt; use anyhow::Result; use gpui::*; +use gpui_util::ResultExt; use windows::Win32::{ Foundation::*, Graphics::{DirectManipulation::*, Gdi::*}, diff --git a/crates/gpui_windows/src/direct_write.rs b/crates/gpui_windows/src/direct_write.rs index 5cdd01f7..ebf99c89 100644 --- a/crates/gpui_windows/src/direct_write.rs +++ b/crates/gpui_windows/src/direct_write.rs @@ -4,9 +4,9 @@ use std::{ mem::ManuallyDrop, }; -use ::util::{ResultExt, maybe}; use anyhow::{Context, Result}; use collections::HashMap; +use gpui_util::{ResultExt, maybe}; use parking_lot::{RwLock, RwLockUpgradableReadGuard}; use windows::{ Win32::{ diff --git a/crates/gpui_windows/src/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs index 3d567ab8..ff40a6d9 100644 --- a/crates/gpui_windows/src/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -98,9 +98,10 @@ impl PlatformAtlas for DirectXAtlas { fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.remove(key).map(|tile| tile.texture_id) else { + let Some(tile) = lock.tiles_by_key.remove(key) else { return; }; + let id = tile.texture_id; let textures = match id.kind { AtlasTextureKind::Monochrome => &mut lock.monochrome_textures, @@ -113,6 +114,7 @@ impl PlatformAtlas for DirectXAtlas { }; if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); texture.decrement_ref_count(); if texture.is_unreferenced() { textures.free_list.push(texture.id.index as usize); @@ -318,3 +320,70 @@ fn etagere_point_to_device(value: etagere::Point) -> Point { y: DevicePixels::from(value.y), } } + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ImageId, RenderImageParams, size}; + use std::borrow::Cow; + use windows::Win32::{ + Foundation::HMODULE, + Graphics::{ + Direct3D::D3D_DRIVER_TYPE_WARP, + Direct3D11::{D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION, D3D11CreateDevice}, + }, + }; + + fn create_atlas() -> Option { + let mut device = None; + let mut context = None; + unsafe { + D3D11CreateDevice( + None, + D3D_DRIVER_TYPE_WARP, + HMODULE::default(), + D3D11_CREATE_DEVICE_BGRA_SUPPORT, + None, + D3D11_SDK_VERSION, + Some(&mut device), + None, + Some(&mut context), + ) + } + .ok()?; + Some(DirectXAtlas::new(&device?, &context?)) + } + + fn insert(atlas: &DirectXAtlas, image_id: usize, size: Size) -> AtlasTile { + let key = AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }); + atlas + .get_or_insert_with(&key, &mut || { + let byte_count = size.width.0 as usize * size.height.0 as usize * 4; + Ok(Some((size, Cow::Owned(vec![0; byte_count])))) + }) + .unwrap() + .unwrap() + } + + #[test] + fn remove_deallocates_tile_space_for_reuse() { + let Some(atlas) = create_atlas() else { + return; + }; + let small = size(DevicePixels(64), DevicePixels(64)); + let big = size(DevicePixels(700), DevicePixels(700)); + let keeper = insert(&atlas, 1, small); + let removed = insert(&atlas, 2, big); + assert_eq!(keeper.texture_id, removed.texture_id); + + atlas.remove(&AtlasKey::Image(RenderImageParams { + image_id: ImageId(2), + frame_index: 0, + })); + let replacement = insert(&atlas, 3, big); + assert_eq!(replacement.texture_id, keeper.texture_id); + } +} diff --git a/crates/gpui_windows/src/directx_devices.rs b/crates/gpui_windows/src/directx_devices.rs index 882e404a..8e65e6e6 100644 --- a/crates/gpui_windows/src/directx_devices.rs +++ b/crates/gpui_windows/src/directx_devices.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result}; +use gpui_util::ResultExt; use itertools::Itertools; -use util::ResultExt; use windows::Win32::{ Foundation::HMODULE, Graphics::{ diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 2040188a..557bc402 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -5,8 +5,8 @@ use std::{ sync::{Arc, OnceLock}, }; -use ::util::ResultExt; use anyhow::{Context, Result}; +use gpui_util::ResultExt; use windows::{ System::DispatcherQueueController, UI::Composition::{ @@ -46,10 +46,15 @@ const BACKDROP_BLUR_OFFSET: f32 = 1.0; const BACKDROP_TEXTURE_SIZE_QUANTUM: i32 = 64; const MAX_STRUCTURED_BUFFER_BYTES: usize = 256 * 1024 * 1024; +fn rounded_backdrop_rebuild_requested(logical_radius: Option) -> bool { + logical_radius.is_some() +} + pub(crate) struct FontInfo { pub gamma_ratios: [f32; 4], pub grayscale_enhanced_contrast: f32, pub subpixel_enhanced_contrast: f32, + pub is_bgr: bool, } pub(crate) struct DirectXRenderer { @@ -60,6 +65,7 @@ pub(crate) struct DirectXRenderer { globals: DirectXGlobalElements, pipelines: DirectXRenderPipelines, direct_composition: Option, + disable_direct_composition: bool, /// Windows.UI.Composition tree used for the rounded host-backdrop blur mode. /// When `Some`, `direct_composition` is `None` (only one composition target /// may exist per HWND) and the swap chain is presented through this tree. @@ -257,6 +263,7 @@ impl DirectXRenderer { globals, pipelines, direct_composition, + disable_direct_composition, rounded_backdrop: None, rounded_backdrop_radius: None, rounded_backdrop_scale: 1.0, @@ -286,6 +293,8 @@ impl DirectXRenderer { viewport_size: [resources.viewport.Width, resources.viewport.Height], grayscale_enhanced_contrast: self.font_info.grayscale_enhanced_contrast, subpixel_enhanced_contrast: self.font_info.subpixel_enhanced_contrast, + is_bgr: self.font_info.is_bgr as u32, + _pad: [0; 3], }], )?; unsafe { @@ -414,11 +423,13 @@ impl DirectXRenderer { } fn handle_device_lost_impl(&mut self, directx_devices: &DirectXDevices) -> Result<()> { - let rounded_active = self.rounded_backdrop.is_some(); - // The rounded backdrop mode still relies on a composition swap chain even - // though it has no `DirectComposition` target, so don't disable DComp - // resources when it is active. - let disable_direct_composition = self.direct_composition.is_none() && !rounded_active; + // The live composition object is torn down before reconstruction and can + // be absent after a failed attempt. Retained configuration is the source + // of truth so a later device-loss retry preserves the requested mode. + let rounded_requested = rounded_backdrop_rebuild_requested(self.rounded_backdrop_radius); + // Rounded backdrop mode is rejected when DirectComposition is disabled, + // so the stored configuration remains the source of truth here. + let disable_direct_composition = self.disable_direct_composition; unsafe { #[cfg(debug_assertions)] @@ -459,7 +470,7 @@ impl DirectXRenderer { let pipelines = DirectXRenderPipelines::new(&devices.device) .context("Creating DirectXRenderPipelines")?; - let direct_composition = if disable_direct_composition || rounded_active { + let direct_composition = if disable_direct_composition || rounded_requested { None } else { let mut composition = @@ -467,18 +478,19 @@ impl DirectXRenderer { composition.set_swap_chain(&resources.swap_chain)?; Some(composition) }; - let rounded_backdrop = if rounded_active { + let rounded_backdrop = if rounded_requested { let device_radius = self.rounded_backdrop_radius.unwrap_or(0.0) * self.rounded_backdrop_scale; - RoundedBackdrop::new( - self.hwnd, - &resources.swap_chain, - self.width, - self.height, - device_radius, + Some( + RoundedBackdrop::new( + self.hwnd, + &resources.swap_chain, + self.width, + self.height, + device_radius, + ) + .context("Rebuilding rounded backdrop after device lost")?, ) - .context("Rebuilding rounded backdrop after device lost") - .log_err() } else { None }; @@ -703,6 +715,10 @@ impl DirectXRenderer { } pub(crate) fn update_transparency(&mut self, transparent: bool) { + if self.disable_direct_composition { + return; + } + // The rounded backdrop mode already composes the swap chain with per-pixel // alpha through its own Windows.UI.Composition target; never create a // competing DirectComposition target for the same HWND. @@ -737,6 +753,10 @@ impl DirectXRenderer { ) -> Result<()> { match logical_radius { Some(radius) => { + if self.disable_direct_composition { + anyhow::bail!("rounded backdrop blur requires DirectComposition"); + } + self.rounded_backdrop_radius = Some(radius); self.rounded_backdrop_scale = scale_factor; let device_radius = radius * scale_factor; @@ -766,7 +786,7 @@ impl DirectXRenderer { } None => { self.rounded_backdrop_radius = None; - if self.rounded_backdrop.take().is_some() { + if self.rounded_backdrop.take().is_some() && !self.disable_direct_composition { // We tore down the DComp target when entering rounded mode; // rebuild it so plain transparency keeps working. self.rebuild_direct_composition() @@ -1632,6 +1652,7 @@ impl DirectXRenderer { gamma_ratios: gpui::get_gamma_correction_ratios(render_params.GetGamma()), grayscale_enhanced_contrast: render_params.GetGrayscaleEnhancedContrast(), subpixel_enhanced_contrast: render_params.GetEnhancedContrast(), + is_bgr: render_params.GetPixelGeometry() == DWRITE_PIXEL_GEOMETRY_BGR, } }) } @@ -2199,6 +2220,8 @@ struct GlobalParams { viewport_size: [f32; 2], grayscale_enhanced_contrast: f32, subpixel_enhanced_contrast: f32, + is_bgr: u32, + _pad: [u32; 3], } #[derive(Debug, Default)] @@ -3329,6 +3352,23 @@ mod amd { } } +#[cfg(test)] +mod tests { + use super::{GlobalParams, rounded_backdrop_rebuild_requested}; + + #[test] + fn global_params_preserve_hlsl_constant_buffer_alignment() { + assert_eq!(std::mem::size_of::(), 48); + } + + #[test] + fn rounded_backdrop_rebuild_uses_retained_configuration() { + assert!(!rounded_backdrop_rebuild_requested(None)); + assert!(rounded_backdrop_rebuild_requested(Some(0.0))); + assert!(rounded_backdrop_rebuild_requested(Some(12.0))); + } +} + mod dxgi { use windows::{ Win32::Graphics::Dxgi::{IDXGIAdapter1, IDXGIDevice}, diff --git a/crates/gpui_windows/src/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs index a5cfd9dc..6b8d3ff2 100644 --- a/crates/gpui_windows/src/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -1,30 +1,29 @@ use std::{ + ffi::c_void, + ptr::NonNull, sync::atomic::{AtomicBool, Ordering}, thread::{ThreadId, current}, - time::{Duration, Instant}, + time::Duration, }; use anyhow::Context; -use util::ResultExt; -use windows::{ +use gpui_util::ResultExt; +use windows::Win32::{ + Foundation::{FILETIME, LPARAM, WPARAM}, + Media::{timeBeginPeriod, timeEndPeriod}, System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, - }, - Win32::{ - Foundation::{LPARAM, WPARAM}, - Media::{timeBeginPeriod, timeEndPeriod}, - System::Threading::{ - GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority, - THREAD_PRIORITY_TIME_CRITICAL, - }, - UI::WindowsAndMessaging::PostMessageW, + CloseThreadpoolTimer, CloseThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWork, + GetCurrentThread, PTP_CALLBACK_INSTANCE, PTP_TIMER, PTP_WORK, SetThreadPriority, + SetThreadpoolTimer, SubmitThreadpoolWork, THREAD_PRIORITY_TIME_CRITICAL, + TP_CALLBACK_ENVIRON_V3, TP_CALLBACK_PRIORITY, TP_CALLBACK_PRIORITY_HIGH, + TP_CALLBACK_PRIORITY_LOW, TP_CALLBACK_PRIORITY_NORMAL, }, + UI::WindowsAndMessaging::PostMessageW, }; use crate::{HWND, SafeHwnd, WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD}; use gpui::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueSender, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, TimerResolutionGuard, + PlatformDispatcher, Priority, PriorityQueueSender, RunnableVariant, TimerResolutionGuard, }; pub(crate) struct WindowsDispatcher { @@ -53,80 +52,57 @@ impl WindowsDispatcher { } } - fn dispatch_on_threadpool(&self, priority: WorkItemPriority, runnable: RunnableVariant) { - let handler = { - let mut task_wrapper = Some(runnable); - WorkItemHandler::new(move |_| { - let runnable = task_wrapper.take().unwrap(); - Self::execute_runnable(runnable); - Ok(()) - }) + fn dispatch_on_threadpool(&self, priority: TP_CALLBACK_PRIORITY, runnable: RunnableVariant) { + let environ = TP_CALLBACK_ENVIRON_V3 { + Version: 3, + CallbackPriority: priority, + Size: size_of::() as u32, + ..Default::default() }; - ThreadPool::RunWithPriorityAsync(&handler, priority).log_err(); + // If the thread pool never runs our callback, the matching `from_raw` is never called, which leaks the runnable. + // Dropping the scheduled runnable would cancel its task and make the next poll of any awaiter panic. Since we expect + // the scenario to usually happen during shutdown, this leak is acceptable. + let context = runnable.into_raw().as_ptr() as *mut c_void; + + unsafe { + if let Ok(work) = + CreateThreadpoolWork(Some(run_work_callback), Some(context), Some(&environ)) + { + SubmitThreadpoolWork(work); + } + } } fn dispatch_on_threadpool_after(&self, runnable: RunnableVariant, duration: Duration) { - let handler = { - let mut task_wrapper = Some(runnable); - TimerElapsedHandler::new(move |_| { - let runnable = task_wrapper.take().unwrap(); - Self::execute_runnable(runnable); - Ok(()) - }) - }; - ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err(); + let context = runnable.into_raw().as_ptr() as *mut c_void; + + unsafe { + if let Ok(timer) = CreateThreadpoolTimer(Some(run_timer_callback), Some(context), None) + { + // Negative FILETIME expresses a relative delay in 100ns ticks. + let ticks = (duration.as_nanos() / 100).min(i64::MAX as u128) as i64; + let due = (-ticks) as u64; + let due_time = FILETIME { + dwLowDateTime: due as u32, + dwHighDateTime: (due >> 32) as u32, + }; + SetThreadpoolTimer(timer, Some(&due_time), 0, None); + } + } } #[inline(always)] pub(crate) fn execute_runnable(runnable: RunnableVariant) { - let start = Instant::now(); - let location = runnable.metadata().location; - let mut timing = TaskTiming { - location, - start, - end: None, - }; - gpui::profiler::add_task_timing(timing); - + let spawned = runnable.metadata().spawned; + gpui::profiler::update_running_task(spawned, location); runnable.run(); - - let end = Instant::now(); - timing.end = Some(end); - - gpui::profiler::add_task_timing(timing); + gpui::profiler::save_task_timing(); } } impl PlatformDispatcher for WindowsDispatcher { - fn get_all_timings(&self) -> Vec { - let global_thread_timings = GLOBAL_THREAD_TIMINGS.lock(); - ThreadTaskTimings::convert(&global_thread_timings) - } - - fn get_current_thread_timings(&self) -> gpui::ThreadTaskTimings { - THREAD_TIMINGS.with(|timings| { - let timings = timings.lock(); - let thread_name = timings.thread_name.clone(); - let total_pushed = timings.total_pushed; - let timings = &timings.timings; - - let mut vec = Vec::with_capacity(timings.len()); - - let (s1, s2) = timings.as_slices(); - vec.extend_from_slice(s1); - vec.extend_from_slice(s2); - - gpui::ThreadTaskTimings { - thread_name, - thread_id: std::thread::current().id(), - timings: vec, - total_pushed, - } - }) - } - fn is_main_thread(&self) -> bool { current().id() == self.main_thread_id } @@ -136,9 +112,9 @@ impl PlatformDispatcher for WindowsDispatcher { Priority::RealtimeAudio => { panic!("RealtimeAudio priority should use spawn_realtime, not dispatch") } - Priority::High => WorkItemPriority::High, - Priority::Medium => WorkItemPriority::Normal, - Priority::Low => WorkItemPriority::Low, + Priority::High => TP_CALLBACK_PRIORITY_HIGH, + Priority::Medium => TP_CALLBACK_PRIORITY_NORMAL, + Priority::Low => TP_CALLBACK_PRIORITY_LOW, }; self.dispatch_on_threadpool(priority, runnable); } @@ -181,12 +157,7 @@ impl PlatformDispatcher for WindowsDispatcher { // SAFETY: always safe to call let thread_handle = unsafe { GetCurrentThread() }; - // SAFETY: thread_handle is a valid handle to a thread - unsafe { SetPriorityClass(thread_handle, HIGH_PRIORITY_CLASS) } - .context("thread priority class") - .log_err(); - - // SAFETY: thread_handle is a valid handle to a thread + // SAFETY: thread_handle is a valid handle to the current thread unsafe { SetThreadPriority(thread_handle, THREAD_PRIORITY_TIME_CRITICAL) } .context("thread priority") .log_err(); @@ -199,8 +170,28 @@ impl PlatformDispatcher for WindowsDispatcher { unsafe { timeBeginPeriod(1); } - util::defer(Box::new(|| unsafe { + gpui_util::defer(Box::new(|| unsafe { timeEndPeriod(1); })) } } + +unsafe extern "system" fn run_work_callback( + _instance: PTP_CALLBACK_INSTANCE, + context: *mut c_void, + work: PTP_WORK, +) { + let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) }; + WindowsDispatcher::execute_runnable(runnable); + unsafe { CloseThreadpoolWork(work) }; +} + +unsafe extern "system" fn run_timer_callback( + _instance: PTP_CALLBACK_INSTANCE, + context: *mut c_void, + timer: PTP_TIMER, +) { + let runnable = unsafe { RunnableVariant::from_raw(NonNull::new_unchecked(context as *mut ())) }; + WindowsDispatcher::execute_runnable(runnable); + unsafe { CloseThreadpoolTimer(timer) }; +} diff --git a/crates/gpui_windows/src/display.rs b/crates/gpui_windows/src/display.rs index 3b81dc63..bb75bf7d 100644 --- a/crates/gpui_windows/src/display.rs +++ b/crates/gpui_windows/src/display.rs @@ -1,7 +1,7 @@ +use gpui_util::ResultExt; use itertools::Itertools; use smallvec::SmallVec; use std::rc::Rc; -use util::ResultExt; use uuid::Uuid; use windows::{ Win32::{ diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 3cb961ad..2ddd772c 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -1,7 +1,7 @@ use std::{rc::Rc, sync::atomic::Ordering}; -use ::util::ResultExt; use anyhow::Context as _; +use gpui_util::ResultExt; use windows::{ Win32::{ Foundation::*, @@ -761,25 +761,28 @@ impl WindowsWindowInner { this.state.cursor_visible.store(true, Ordering::Release); } + // Windows does not always send key-up events to a window that loses focus. Refresh the + // modifier snapshot before notifying activation listeners so they cannot observe stale + // Alt/Shift state after an Alt-Tab round trip. + if activated { + this.state.last_reported_modifiers.set(None); + this.state.last_reported_capslock.set(None); + + if let Some(mut func) = this.state.callbacks.input.take() { + func(PlatformInput::ModifiersChanged(ModifiersChangedEvent { + modifiers: current_modifiers(), + capslock: current_capslock(), + })); + this.state.callbacks.input.set(Some(func)); + } + } + self.executor .spawn(async move { if let Some(mut func) = this.state.callbacks.active_status_change.take() { func(activated); this.state.callbacks.active_status_change.set(Some(func)); } - - if activated { - this.state.last_reported_modifiers.set(None); - this.state.last_reported_capslock.set(None); - - if let Some(mut func) = this.state.callbacks.input.take() { - func(PlatformInput::ModifiersChanged(ModifiersChangedEvent { - modifiers: current_modifiers(), - capslock: current_capslock(), - })); - this.state.callbacks.input.set(Some(func)); - } - } }) .detach(); @@ -895,7 +898,7 @@ impl WindowsWindowInner { } fn handle_hit_test_msg(&self, handle: HWND, lparam: LPARAM) -> Option { - if !self.is_movable || self.state.is_fullscreen() { + if self.state.is_fullscreen() { return None; } @@ -906,16 +909,14 @@ impl WindowsWindowInner { .callbacks .hit_test_window_control .set(Some(callback)); - if let Some(area) = area { - match area { - WindowControlArea::Drag => Some(HTCAPTION as _), - WindowControlArea::Close => return Some(HTCLOSE as _), - WindowControlArea::Max => return Some(HTMAXBUTTON as _), - WindowControlArea::Min => return Some(HTMINBUTTON as _), - } - } else { - None - } + area.and_then(|area| { + window_control_hit_target( + area, + self.is_movable, + self.is_resizable, + self.is_minimizable, + ) + }) } else { None }; @@ -936,7 +937,11 @@ impl WindowsWindowInner { }; unsafe { ScreenToClient(handle, &mut cursor_point).ok().log_err() }; - if !self.state.is_maximized() && 0 <= cursor_point.y && cursor_point.y <= frame_y { + if self.is_resizable + && !self.state.is_maximized() + && 0 <= cursor_point.y + && cursor_point.y <= frame_y + { // x-axis actually goes from -frame_x to 0 return Some(if cursor_point.x <= 0 { HTTOPLEFT @@ -945,7 +950,7 @@ impl WindowsWindowInner { unsafe { GetWindowRect(handle, &mut rect) }.log_err(); // right and bottom bounds of RECT are exclusive, thus `-1` let right = rect.right - rect.left - 1; - // the bounds include the padding frames, so accomodate for both of them + // the bounds include the padding frames, so accommodate for both of them if right - 2 * frame_x <= cursor_point.x { HTTOPRIGHT } else { @@ -1062,11 +1067,12 @@ impl WindowsWindowInner { && let Some(last_pressed) = last_pressed { let handled = match (wparam.0 as u32, last_pressed) { - (HTMINBUTTON, HTMINBUTTON) => { + (HTMINBUTTON, HTMINBUTTON) if self.is_minimizable => { unsafe { ShowWindowAsync(handle, SW_MINIMIZE).ok().log_err() }; true } - (HTMAXBUTTON, HTMAXBUTTON) => { + (HTMINBUTTON, HTMINBUTTON) => true, + (HTMAXBUTTON, HTMAXBUTTON) if self.is_resizable => { if self.state.is_maximized() { unsafe { ShowWindowAsync(handle, SW_NORMAL).ok().log_err() }; } else { @@ -1074,6 +1080,7 @@ impl WindowsWindowInner { } true } + (HTMAXBUTTON, HTMAXBUTTON) => true, (HTCLOSE, HTCLOSE) => { unsafe { PostMessageW(Some(handle), WM_CLOSE, WPARAM::default(), LPARAM::default()) @@ -1215,12 +1222,13 @@ impl WindowsWindowInner { { panic!("Device lost: {err}"); } + self.state.force_render_after_recovery.set(true); Some(0) } fn handle_dm_pointer_hit_test(&self, wparam: WPARAM) -> Option { self.state.direct_manipulation.on_pointer_hit_test(wparam); - Some(0) + None } #[inline] @@ -1239,10 +1247,12 @@ impl WindowsWindowInner { self.state.callbacks.input.set(Some(func)); } - // we are instructing gpui to force render a frame, this will - // re-populate all the gpu textures for us so we can resume drawing in - // case we disabled drawing earlier due to a device loss - self.state.renderer.borrow_mut().mark_drawable(); + let force_render = force_render || self.state.force_render_after_recovery.take(); + if force_render { + // A forced render rebuilds the scene with fresh atlas textures and resumes drawing + // after device-loss recovery. + self.state.renderer.borrow_mut().mark_drawable(); + } request_frame(RequestFrameOptions { require_presentation: false, force_render, @@ -1648,6 +1658,25 @@ pub(crate) fn current_capslock() -> Capslock { Capslock { on } } +fn window_control_hit_target( + area: WindowControlArea, + is_movable: bool, + is_resizable: bool, + is_minimizable: bool, +) -> Option { + match area { + WindowControlArea::Drag if is_movable => Some(HTCAPTION as _), + WindowControlArea::Drag => None, + WindowControlArea::Close => Some(HTCLOSE as _), + WindowControlArea::Max if is_resizable => Some(HTMAXBUTTON as _), + WindowControlArea::Max if is_movable => Some(HTCAPTION as _), + WindowControlArea::Max => Some(HTNOWHERE as _), + WindowControlArea::Min if is_minimizable => Some(HTMINBUTTON as _), + WindowControlArea::Min if is_movable => Some(HTCAPTION as _), + WindowControlArea::Min => Some(HTNOWHERE as _), + } +} + // there is some additional non-visible space when talking about window // borders on Windows: // - SM_CXSIZEFRAME: The resize handle. @@ -1686,3 +1715,42 @@ fn notify_frame_changed(handle: HWND) { .log_err(); } } + +#[cfg(test)] +mod tests { + use super::window_control_hit_target; + use gpui::WindowControlArea; + use windows::Win32::UI::WindowsAndMessaging::{ + HTCAPTION, HTCLOSE, HTMAXBUTTON, HTMINBUTTON, HTNOWHERE, + }; + + const CAPTION: isize = HTCAPTION as isize; + const CLOSE: isize = HTCLOSE as isize; + const MAX_BUTTON: isize = HTMAXBUTTON as isize; + const MIN_BUTTON: isize = HTMINBUTTON as isize; + const NOWHERE: isize = HTNOWHERE as isize; + + #[test] + fn window_control_hit_targets_respect_window_capabilities() { + use WindowControlArea::{Close, Drag, Max, Min}; + + let cases: &[(WindowControlArea, bool, bool, bool, Option)] = &[ + (Drag, false, true, true, None), + (Close, false, false, false, Some(CLOSE)), + (Max, true, false, true, Some(CAPTION)), + (Max, false, false, true, Some(NOWHERE)), + (Min, true, true, false, Some(CAPTION)), + (Min, false, true, false, Some(NOWHERE)), + (Max, false, true, false, Some(MAX_BUTTON)), + (Min, false, false, true, Some(MIN_BUTTON)), + ]; + + for &(area, movable, resizable, minimizable, expected) in cases { + assert_eq!( + window_control_hit_target(area, movable, resizable, minimizable), + expected, + "unexpected hit target for area={area:?} movable={movable} resizable={resizable} minimizable={minimizable}" + ); + } + } +} diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 68b8424e..8c16b26f 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -9,9 +9,9 @@ use std::{ }, }; -use ::util::{ResultExt, paths::SanitizedPath}; use anyhow::{Context as _, Result, anyhow}; use futures::channel::oneshot::{self, Receiver}; +use gpui_util::{ResultExt, get_windows_system_shell, new_std_command}; use itertools::Itertools; use parking_lot::RwLock; use smallvec::SmallVec; @@ -21,7 +21,7 @@ use windows::{ Foundation::*, Graphics::{Direct3D11::ID3D11Device, Gdi::*}, Security::Credentials::*, - System::{Com::*, LibraryLoader::*, Ole::*, SystemInformation::*}, + System::{Com::*, LibraryLoader::*, Ole::*, Power::*, SystemInformation::*}, UI::{Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*}, }, core::*, @@ -45,6 +45,7 @@ pub struct WindowsPlatform { /// as resizing them has failed, causing us to have lost at least the render target. invalidate_devices: Arc, handle: HWND, + suspend_resume_notification: RefCell>, disable_direct_composition: bool, } @@ -77,6 +78,7 @@ struct PlatformCallbacks { will_open_app_menu: Cell>>, validate_app_menu_command: Cell bool>>>, keyboard_layout_change: Cell>>, + system_wake: Cell>>, } impl WindowsPlatformState { @@ -193,6 +195,7 @@ impl WindowsPlatform { foreground_executor, text_system, direct_write_text_system, + suspend_resume_notification: RefCell::new(None), disable_direct_composition, drop_target_helper, invalidate_devices: Arc::new(AtomicBool::new(false)), @@ -414,6 +417,17 @@ impl Platform for WindowsPlatform { self.inner .with_callback(|callbacks| &callbacks.quit, |callback| callback()); + + // Bypass the CRT exit logic, which runs atexit handlers before calling ExitProcess. + // aws-lc registers an atexit handler that intentionally acquires a lock without releasing it. + // aws-lc also has thread_local objects which acquire this lock in their destructor. + // Destructors for thread_locals run under the loader lock, so there is a race condition + // where, if a thread exits after atexit handlers have run, the TLS destructors will block + // indefinitely on this lock while holding the loader lock. Since ExitProcess also requires + // the loader lock, process teardown will deadlock. + unsafe { + windows::Win32::System::Threading::ExitProcess(0); + } } fn quit(&self) { @@ -456,11 +470,10 @@ impl Platform for WindowsPlatform { clippy::disallowed_methods, reason = "We are restarting ourselves, using std command thus is fine" )] - let restart_process = - ::util::command::new_std_command(::util::shell::get_windows_system_shell()) - .arg("-command") - .arg(script) - .spawn(); + let restart_process = new_std_command(get_windows_system_shell()) + .arg("-command") + .arg(script) + .spawn(); match restart_process { Ok(_) => unsafe { PostQuitMessage(0) }, @@ -618,6 +631,21 @@ impl Platform for WindowsPlatform { self.inner.state.callbacks.reopen.set(Some(callback)); } + fn on_system_wake(&self, callback: Box) { + self.inner.state.callbacks.system_wake.set(Some(callback)); + let mut notification = self.suspend_resume_notification.borrow_mut(); + if notification.is_none() { + *notification = unsafe { + // SAFETY: self.handle is the platform window receiving WM_POWERBROADCAST. + RegisterSuspendResumeNotification( + HANDLE(self.handle.0), + DEVICE_NOTIFY_WINDOW_HANDLE, + ) + .log_err() + }; + } + } + fn set_menus(&self, menus: Vec

, _keymap: &Keymap) { *self.inner.state.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect(); } @@ -713,6 +741,15 @@ impl Platform for WindowsPlatform { } fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task> { + // CredWriteW rejects larger blobs with the opaque RPC error + // 0x800706F7 "The stub received bad data", so fail with a clear + // message instead. + if password.len() > CRED_MAX_CREDENTIAL_BLOB_SIZE as usize { + return Task::ready(Err(anyhow!( + "credential for {url} is {} bytes, which exceeds the Windows Credential Manager limit of {CRED_MAX_CREDENTIAL_BLOB_SIZE} bytes", + password.len() + ))); + } let password = password.to_vec(); let mut username = username.encode_utf16().chain(Some(0)).collect_vec(); let mut target_name = windows_credentials_target_name(url) @@ -872,6 +909,7 @@ impl WindowsPlatformInner { | WM_GPUI_DOCK_MENU_ACTION | WM_GPUI_KEYBOARD_LAYOUT_CHANGED | WM_GPUI_GPU_DEVICE_LOST => self.handle_gpui_events(msg, wparam, lparam), + WM_POWERBROADCAST => self.handle_power_broadcast(wparam), _ => None, }; if let Some(result) = handled { @@ -1008,6 +1046,13 @@ impl WindowsPlatformInner { Some(0) } + fn handle_power_broadcast(&self, wparam: WPARAM) -> Option { + if is_system_wake_event(wparam.0) { + self.with_callback(|callbacks| &callbacks.system_wake, |callback| callback()); + } + Some(1) + } + fn handle_device_lost(&self, lparam: LPARAM) -> Option { let directx_devices = lparam.0 as *const DirectXDevices; let directx_devices = unsafe { &*directx_devices }; @@ -1018,9 +1063,17 @@ impl WindowsPlatformInner { } } +fn is_system_wake_event(wparam: usize) -> bool { + wparam as u32 == PBT_APMRESUMEAUTOMATIC +} + impl Drop for WindowsPlatform { fn drop(&mut self) { unsafe { + if let Some(notification) = self.suspend_resume_notification.borrow_mut().take() { + // SAFETY: notification was returned by RegisterSuspendResumeNotification. + UnregisterSuspendResumeNotification(notification).log_err(); + } DestroyWindow(self.handle) .context("Destroying platform window") .log_err(); @@ -1175,8 +1228,8 @@ fn file_save_dialog( .context("failed to canonicalize directory") .log_err() { - let full_path = SanitizedPath::new(&full_path); - let full_path_string = full_path.to_string(); + let full_path = dunce::simplified(&full_path); + let full_path_string = full_path.display().to_string(); let path_item: IShellItem = unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? }; unsafe { @@ -1356,7 +1409,15 @@ unsafe extern "system" fn window_procedure( } let inner = unsafe { &*ptr }; let result = if let Some(inner) = inner.upgrade() { - inner.handle_msg(hwnd, msg, wparam, lparam) + if cfg!(debug_assertions) { + let inner = std::panic::AssertUnwindSafe(inner); + match std::panic::catch_unwind(|| { inner }.handle_msg(hwnd, msg, wparam, lparam)) { + Ok(result) => result, + Err(_) => std::process::abort(), + } + } else { + inner.handle_msg(hwnd, msg, wparam, lparam) + } } else { unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } }; @@ -1371,8 +1432,16 @@ unsafe extern "system" fn window_procedure( #[cfg(test)] mod tests { + use super::is_system_wake_event; use crate::{read_from_clipboard, write_to_clipboard}; use gpui::ClipboardItem; + use windows::Win32::UI::WindowsAndMessaging::{PBT_APMRESUMEAUTOMATIC, PBT_APMSUSPEND}; + + #[test] + fn only_automatic_resume_is_a_system_wake_event() { + assert!(is_system_wake_event(PBT_APMRESUMEAUTOMATIC as usize)); + assert!(!is_system_wake_event(PBT_APMSUSPEND as usize)); + } #[test] fn test_clipboard() { diff --git a/crates/gpui_windows/src/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl index 831333ab..29976952 100644 --- a/crates/gpui_windows/src/shaders.hlsl +++ b/crates/gpui_windows/src/shaders.hlsl @@ -5,6 +5,8 @@ cbuffer GlobalParams: register(b0) { float2 global_viewport_size; float grayscale_enhanced_contrast; float subpixel_enhanced_contrast; + uint is_bgr; + uint3 global_pad; }; Texture2D t_sprite: register(t0); @@ -384,6 +386,18 @@ float4 gradient_color(Background background, break; } } + + // Dither to reduce banding in gradients (especially dark or translucent ones). + // Triangular-distributed noise breaks up 8-bit quantization steps without bias. + { + float2 seed = position * 0.6180339887; + float r1 = frac(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453); + float r2 = frac(sin(dot(seed, float2(39.3460, 11.135))) * 24634.6345); + float tri = r1 + r2 - 1.0; + color.rgb += tri * 2.0 / 255.0; + color.a += tri * 3.0 / 255.0; + } + break; } case 2: { @@ -406,11 +420,11 @@ float4 gradient_color(Background background, // checkerboard float size = background.gradient_angle_or_pattern_height; float2 relative_position = position - bounds.origin; - + float x_index = floor(relative_position.x / size); float y_index = floor(relative_position.y / size); float should_be_colored = (x_index + y_index) % 2.0; - + color = solid_color; color.a *= saturate(should_be_colored); break; @@ -1334,6 +1348,9 @@ MonochromeSpriteVertexOutput subpixel_sprite_vertex(uint vertex_id: SV_VertexID, SubpixelSpriteFragmentOutput subpixel_sprite_fragment(MonochromeSpriteFragmentInput input) { float3 sample = t_sprite.Sample(s_sprite, input.tile_position).rgb; + if (is_bgr) { + sample = sample.bgr; + } float3 alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, subpixel_enhanced_contrast, gamma_ratios); SubpixelSpriteFragmentOutput output; @@ -1396,7 +1413,7 @@ float4 polychrome_sprite_fragment(PolychromeSpriteFragmentInput input): SV_Targe float distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii); float4 color = sample; - if ((sprite.grayscale & 0xFFu) != 0u) { + if (sprite.grayscale != 0u) { float3 grayscale = dot(color.rgb, GRAYSCALE_FACTORS); color = float4(grayscale, sample.a); } diff --git a/crates/gpui_windows/src/system_settings.rs b/crates/gpui_windows/src/system_settings.rs index 53214f40..8cce544b 100644 --- a/crates/gpui_windows/src/system_settings.rs +++ b/crates/gpui_windows/src/system_settings.rs @@ -3,7 +3,7 @@ use std::{ ffi::{c_uint, c_void}, }; -use ::util::ResultExt; +use gpui_util::ResultExt; use windows::Win32::UI::WindowsAndMessaging::{ SPI_GETWHEELSCROLLCHARS, SPI_GETWHEELSCROLLLINES, SYSTEM_PARAMETERS_INFO_ACTION, SYSTEM_PARAMETERS_INFO_UPDATE_FLAGS, SystemParametersInfoW, diff --git a/crates/gpui_windows/src/util.rs b/crates/gpui_windows/src/util.rs index fe5093de..1de3d9de 100644 --- a/crates/gpui_windows/src/util.rs +++ b/crates/gpui_windows/src/util.rs @@ -1,7 +1,7 @@ use std::sync::OnceLock; -use ::util::ResultExt; use anyhow::Context; +use gpui_util::ResultExt; use windows::{ UI::{ Color, diff --git a/crates/gpui_windows/src/vsync.rs b/crates/gpui_windows/src/vsync.rs index 73c32cf9..51fbdf6c 100644 --- a/crates/gpui_windows/src/vsync.rs +++ b/crates/gpui_windows/src/vsync.rs @@ -4,7 +4,7 @@ use std::{ }; use anyhow::{Context, Result}; -use util::ResultExt; +use gpui_util::ResultExt; use windows::Win32::{ Foundation::HWND, Graphics::Dwm::{DWM_TIMING_INFO, DwmFlush, DwmGetCompositionTimingInfo}, diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 86f0da52..1f292cc3 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -10,9 +10,9 @@ use std::{ time::{Duration, Instant}, }; -use ::util::ResultExt; use anyhow::{Context as _, Result}; use futures::channel::oneshot::{self, Receiver}; +use gpui_util::ResultExt; use raw_window_handle as rwh; use smallvec::SmallVec; use windows::{ @@ -66,6 +66,9 @@ pub struct WindowsWindowState { pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, + /// Set after device-loss recovery so the next draw bypasses cached scene data that may still + /// reference atlas textures from the old device. + pub force_render_after_recovery: Cell, pub click_state: ClickState, pub current_cursor: Cell>, @@ -91,6 +94,8 @@ pub(crate) struct WindowsWindowInner { pub(crate) handle: AnyWindowHandle, pub(crate) hide_title_bar: bool, pub(crate) is_movable: bool, + pub(crate) is_resizable: bool, + pub(crate) is_minimizable: bool, pub(crate) executor: ForegroundExecutor, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, @@ -163,6 +168,7 @@ impl WindowsWindowState { last_reported_capslock: Cell::new(last_reported_capslock), hovered: Cell::new(hovered), renderer: RefCell::new(renderer), + force_render_after_recovery: Cell::new(false), click_state, current_cursor: Cell::new(current_cursor), cursor_visible, @@ -258,6 +264,8 @@ impl WindowsWindowInner { handle: context.handle, hide_title_bar: context.hide_title_bar, is_movable: context.is_movable, + is_resizable: context.is_resizable, + is_minimizable: context.is_minimizable, executor: context.executor.clone(), validation_number: context.validation_number, main_receiver: context.main_receiver.clone(), @@ -380,6 +388,8 @@ struct WindowCreateContext { hide_title_bar: bool, display: WindowsDisplay, is_movable: bool, + is_resizable: bool, + is_minimizable: bool, min_size: Option>, executor: ForegroundExecutor, current_cursor: Option, @@ -401,6 +411,12 @@ impl WindowsWindow { params: WindowParams, creation_info: WindowCreationInfo, ) -> Result { + // Native anchored popups are not implemented on Windows yet. Rejecting them explicitly + // lets callers fall back to GPUI's in-window popovers. + if let WindowKind::AnchoredPopup(_) = params.kind { + return Err(popup::PopupNotSupportedError.into()); + } + let WindowCreationInfo { icon, executor, @@ -482,6 +498,8 @@ impl WindowsWindow { hide_title_bar, display, is_movable: params.is_movable, + is_resizable: params.is_resizable, + is_minimizable: params.is_minimizable, min_size: params.window_min_size, executor, current_cursor, @@ -1718,8 +1736,12 @@ fn set_window_composition_attribute(hwnd: HWND, color: Option, state: u32 .log_err() { let func_name = PCSTR::from_raw(c"SetWindowCompositionAttribute".as_ptr() as *const u8); + let Some(raw_set_window_composition_attribute) = GetProcAddress(user32, func_name) + else { + return; + }; let set_window_composition_attribute: SetWindowCompositionAttributeType = - std::mem::transmute(GetProcAddress(user32, func_name)); + std::mem::transmute(raw_set_window_composition_attribute); let mut color = color.unwrap_or_default(); // state 4 = ACCENT_ENABLE_ACRYLICBLURBEHIND, 5 = ACCENT_ENABLE_HOSTBACKDROP. let is_blur = state == 4 || state == 5; diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 74fec9ef..f41fa0a3 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -58,7 +58,10 @@ impl LocalExecutor { let (runnable, task) = spawn_local_with_source_location( future, move |runnable| dispatch(runnable), - RunnableMeta { location }, + RunnableMeta { + location, + spawned: crate::SpawnTime(Instant::now()), + }, ); runnable.schedule(); Task(TaskState::Spawned(task)) @@ -180,7 +183,10 @@ impl BackgroundExecutor { let scheduler = Arc::downgrade(&self.scheduler); let location = Location::caller(); let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) + .metadata(RunnableMeta { + location, + spawned: crate::SpawnTime(Instant::now()), + }) .spawn( move |_| future, move |runnable| { @@ -210,7 +216,10 @@ impl BackgroundExecutor { })); let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) + .metadata(RunnableMeta { + location, + spawned: crate::SpawnTime(Instant::now()), + }) .spawn( move |_| future, move |runnable| { diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index a81a359b..19867b23 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -56,18 +56,26 @@ impl Priority { } } +/// The moment a runnable was spawned. +#[derive(Clone, Copy, Debug)] +pub struct SpawnTime(pub std::time::Instant); + /// Metadata attached to runnables for debugging and profiling. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct RunnableMeta { /// The source location where the task was spawned. pub location: &'static Location<'static>, + /// The moment the task was spawned. + pub spawned: SpawnTime, } -impl std::fmt::Debug for RunnableMeta { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("RunnableMeta") - .field("location", &self.location) - .finish() +impl RunnableMeta { + #[track_caller] + pub fn new_with_callers_location() -> Self { + Self { + location: Location::caller(), + spawned: SpawnTime(std::time::Instant::now()), + } } } @@ -166,7 +174,9 @@ where drop(executor); while let Ok(runnable) = runnable_receiver.recv() { - runnable.run(); + // Keep the dedicated thread alive if one task panics. The + // standard panic hook reports the panic before this returns. + let _ = panic::catch_unwind(AssertUnwindSafe(|| runnable.run())); } }) .expect("failed to spawn dedicated thread"); diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 86922f2a..ed02b13c 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -21,7 +21,7 @@ use std::{ panic::{self, AssertUnwindSafe}, pin::Pin, sync::{ - Arc, + Arc, Weak, atomic::{AtomicBool, Ordering::SeqCst}, }, task::{Context, Poll, RawWaker, RawWakerVTable, Waker}, @@ -161,14 +161,11 @@ impl TestScheduler { /// Create a local executor for this scheduler. pub fn foreground(self: &Arc) -> LocalExecutor { let session_id = self.allocate_session_id(); - // Detached local tasks can leave runnables or wakers behind; scheduling - // must not keep this test scheduler alive after callers drop it. - let scheduler = Arc::downgrade(self); - LocalExecutor::new(session_id, self.clone(), move |runnable| { - if let Some(scheduler) = scheduler.upgrade() { - scheduler.schedule_local(session_id, runnable); - } - }) + LocalExecutor::new( + session_id, + self.clone(), + schedule_local_dispatch(Arc::downgrade(self), session_id), + ) } /// Create a background executor for this scheduler @@ -655,15 +652,23 @@ impl Scheduler for TestScheduler { >, ) -> Task> { let session_id = self.allocate_session_id(); - // Dedicated local tasks use the same weak scheduling contract as - // foreground tasks so detached children cannot retain the scheduler. - let scheduler = Arc::downgrade(&self); - let executor = LocalExecutor::new(session_id, self, move |runnable| { - if let Some(scheduler) = scheduler.upgrade() { - scheduler.schedule_local(session_id, runnable); - } - }); - executor.spawn(f(executor.clone())) + let weak_scheduler = Arc::downgrade(&self); + let spawner = LocalExecutor::new( + session_id, + self, + schedule_local_dispatch(weak_scheduler.clone(), session_id), + ); + spawner.spawn(async move { + let scheduler = weak_scheduler + .upgrade() + .expect("dedicated tasks are only polled while their scheduler is still alive"); + let executor = LocalExecutor::new( + session_id, + scheduler, + schedule_local_dispatch(weak_scheduler, session_id), + ); + f(executor).await + }) } fn as_test(&self) -> Option<&TestScheduler> { @@ -671,6 +676,17 @@ impl Scheduler for TestScheduler { } } +fn schedule_local_dispatch( + scheduler: Weak, + session_id: SessionId, +) -> impl Fn(Runnable) + Send + Sync + 'static { + move |runnable| { + if let Some(scheduler) = scheduler.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + } +} + #[derive(Clone, Debug)] pub struct TestSchedulerConfig { pub seed: u64, diff --git a/crates/scheduler/src/tests.rs b/crates/scheduler/src/tests.rs index fa852ac1..2ee4a7ce 100644 --- a/crates/scheduler/src/tests.rs +++ b/crates/scheduler/src/tests.rs @@ -72,6 +72,20 @@ fn test_scheduler_drops_with_stalled_detached_background_task() { drop(sender); } +#[test] +fn test_scheduler_drops_with_never_polled_dedicated_task() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + let weak_scheduler = Arc::downgrade(&scheduler); + + scheduler + .background() + .spawn_dedicated(|_executor| async move {}) + .detach(); + + drop(scheduler); + assert!(weak_scheduler.upgrade().is_none()); +} + #[test] fn test_scheduler_drops_with_queued_detached_foreground_task() { let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); @@ -243,6 +257,10 @@ fn test_timer_ordering() { } #[test] +#[allow( + clippy::await_holding_refcell_ref, + reason = "the test verifies that foreground tasks may intentionally retain a local borrow while suspended" +)] fn test_foreground_task_can_hold_mut_borrow_across_await() { TestScheduler::once(async |scheduler| { let foreground = scheduler.foreground(); @@ -911,6 +929,27 @@ fn test_spawn_dedicated_thread_closure_panic_reaches_caller() { ); } +#[test] +fn test_spawn_dedicated_child_panic_does_not_stop_thread() { + let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); + + let result = block_on(spawn_dedicated_thread( + SessionId::new(1), + scheduler, + |executor| async move { + executor + .spawn(async { + panic!("dedicated child task exploded"); + }) + .detach(); + + executor.spawn(async { 42 }).await + }, + )); + + assert_eq!(result, 42); +} + #[test] fn test_spawn_dedicated_determinism_under_many() { use parking_lot::Mutex; diff --git a/rust-toolchain.toml b/rust-toolchain.toml index e7cc2242..2dd2a0e6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,8 +1,9 @@ [toolchain] -channel = "1.92" +channel = "1.95.0" profile = "minimal" -components = [ "rustfmt", "clippy" ] +components = [ "rustfmt", "clippy", "rust-analyzer", "rust-src" ] targets = [ "wasm32-wasip2", # extensions + "wasm32-unknown-unknown", # gpui on the web "x86_64-unknown-linux-musl", # remote server ]