diff --git a/Cargo.lock b/Cargo.lock index c18d1438f6..21840b968b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,95 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +dependencies = [ + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c5c87e8d94f2ec10cce590aadff24c76f576dab5502d45d0aed9fc3065d4451" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel 2.5.0", + "async-executor", + "async-task", + "atspi", + "futures-lite 2.6.1", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -60,6 +149,12 @@ dependencies = [ "equator", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -69,6 +164,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "anstream" version = "0.6.21" @@ -178,27 +279,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "async-fs", - "async-net", - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.2", - "serde", - "serde_repr", - "url", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "zbus", -] - [[package]] name = "ashpd" version = "0.13.9" @@ -206,9 +286,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13bdf0fd848239dcd5e64eeeee35dbc00378ebcc6f3aa4ead0a305eec83d0cfb" dependencies = [ "enumflags2", + "futures-channel", "futures-util", "getrandom 0.4.2", "serde", + "serde_repr", + "wayland-backend", + "wayland-client", + "wayland-protocols", "zbus", ] @@ -414,6 +499,20 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "async-tar" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1937db2d56578aa3919b9bdb0e5100693fd7d1c0f145c53eb81fbb03e217550" +dependencies = [ + "async-std", + "filetime", + "libc", + "pin-project", + "redox_syscall 0.2.16", + "xattr", +] + [[package]] name = "async-task" version = "4.7.1" @@ -433,15 +532,15 @@ dependencies = [ [[package]] name = "async_zip" -version = "0.0.17" +version = "0.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b9f7252833d5ed4b00aa9604b563529dd5e11de9c23615de2dcdf91eb87b52" +checksum = "0d8c50d65ce1b0e0cb65a785ff615f78860d7754290647d3b983208daa4f85e6" dependencies = [ "async-compression", "crc32fast", "futures-lite 2.6.1", "pin-project", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] @@ -456,6 +555,43 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atspi" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] + +[[package]] +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -485,6 +621,28 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "backtrace" version = "0.3.76" @@ -500,12 +658,38 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags 2.10.0", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.1", + "shlex", + "syn", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -553,9 +737,6 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -dependencies = [ - "serde_core", -] [[package]] name = "bitstream-io" @@ -587,13 +768,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + [[package]] name = "block2" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2", + "objc2 0.6.3", ] [[package]] @@ -609,6 +799,16 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "bstr" version = "1.12.0" @@ -669,6 +869,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "calloop" version = "0.14.4" @@ -694,6 +903,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbc" version = "0.1.2" @@ -733,6 +948,21 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-expr" version = "0.15.8" @@ -773,10 +1003,38 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.1", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "cipher" version = "0.4.4" @@ -789,10 +1047,65 @@ dependencies = [ ] [[package]] -name = "circular-buffer" -version = "1.2.0" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14c638459986b83c2b885179bd4ea6a2cbb05697b001501a56adb3a3d230803b" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation 0.1.2", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] [[package]] name = "cocoa" @@ -802,14 +1115,28 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ "bitflags 2.10.0", "block", - "cocoa-foundation", + "cocoa-foundation 0.2.0", "core-foundation 0.10.0", - "core-graphics", + "core-graphics 0.24.0", "foreign-types", "libc", "objc", ] +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + [[package]] name = "cocoa-foundation" version = "0.2.0" @@ -824,23 +1151,24 @@ dependencies = [ "objc", ] -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width 0.1.14", -] - [[package]] name = "codespan-reporting" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ - "unicode-width 0.2.2", + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "indexmap", + "rustc-hash 2.1.1", ] [[package]] @@ -855,6 +1183,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "command-fds" version = "0.3.2" @@ -871,6 +1209,7 @@ version = "0.4.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef8a506ec4b81c460798f572caead636d57d3d7e940f998160f52bd254bf2d23" dependencies = [ + "bzip2", "compression-core", "deflate64", "flate2", @@ -892,6 +1231,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "const-random" version = "0.1.18" @@ -914,9 +1263,21 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.4.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "core-foundation" @@ -944,6 +1305,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.24.0" @@ -957,6 +1331,19 @@ dependencies = [ "libc", ] +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.1.3" @@ -981,9 +1368,9 @@ dependencies = [ [[package]] name = "core-graphics2" -version = "0.4.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e4583956b9806b69f73fcb23aee05eb3620efc282972f08f6a6db7504f8334d" +checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" dependencies = [ "bitflags 2.10.0", "block", @@ -999,23 +1386,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.0", - "core-graphics", + "core-graphics 0.24.0", "foreign-types", "libc", ] [[package]] name = "core-video" -version = "0.4.3" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45e71d5be22206bed53c3c3cb99315fc4c3d31b8963808c6bc4538168c4f8ef" +checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" dependencies = [ "block", "core-foundation 0.10.0", "core-graphics2", "io-surface", "libc", - "metal 0.29.0", + "metal", ] [[package]] @@ -1029,9 +1416,9 @@ dependencies = [ [[package]] name = "cosmic-text" -version = "0.17.2" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d8c4e3a1d02f5269ed15c2d70b4647167856f66f228dcdf99050ab77bbb5a56" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" dependencies = [ "bitflags 2.10.0", "fontdb", @@ -1069,6 +1456,51 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -1121,20 +1553,14 @@ dependencies = [ [[package]] name = "ctor" -version = "0.4.3" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec09e802f5081de6157da9a75701d6c713d8dc3ba52571fd4bd25f412644e8a6" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2931af7e13dc045d8e9d26afccc6fa115d64e115c9c84b1166288b46f6782c2" - [[package]] name = "data-url" version = "0.3.2" @@ -1149,22 +1575,36 @@ checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" [[package]] name = "derive_more" -version = "0.99.20" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ - "convert_case", + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn", + "unicode-xid", ] [[package]] -name = "diff" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "digest" @@ -1179,45 +1619,31 @@ dependencies = [ [[package]] name = "dirs" -version = "4.0.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" 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", + "dirs-sys", ] [[package]] name = "dirs-sys" -version = "0.3.7" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -1225,9 +1651,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.3", ] [[package]] @@ -1265,21 +1691,6 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" -[[package]] -name = "dtor" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97cbdf2ad6846025e8e25df05171abfb30e3ababa12ee0a0e44b9bbe570633a8" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7454e41ff9012c00d53cf7f475c5e3afa3b91b7c90568495495e8d9bf47a1055" - [[package]] name = "dunce" version = "1.0.5" @@ -1716,6 +2127,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1728,9 +2145,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -1743,9 +2160,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1766,15 +2183,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -1783,9 +2200,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-lite" @@ -1817,9 +2234,9 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1828,21 +2245,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1852,7 +2269,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1932,19 +2348,6 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -[[package]] -name = "git2" -version = "0.20.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" -dependencies = [ - "bitflags 2.10.0", - "libc", - "libgit2-sys", - "log", - "url", -] - [[package]] name = "gl_generator" version = "0.14.0" @@ -1956,6 +2359,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.17" @@ -1983,9 +2392,9 @@ dependencies = [ [[package]] name = "glow" -version = "0.16.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -2002,35 +2411,18 @@ dependencies = [ "gl_generator", ] -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.10.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.10.0", -] - [[package]] name = "gpu-allocator" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ + "ash", + "hashbrown 0.16.1", "log", "presser", - "thiserror 1.0.69", - "windows 0.58.0", + "thiserror 2.0.17", + "windows 0.62.2", ] [[package]] @@ -2054,118 +2446,195 @@ dependencies = [ ] [[package]] -name = "gpui-ce" -version = "0.3.3" +name = "gpui" +version = "0.2.2" dependencies = [ + "accesskit", "anyhow", - "as-raw-xcb-connection", - "ashpd 0.11.1", "async-channel 2.5.0", "async-task", "backtrace", + "bindgen", "bitflags 2.10.0", "block", - "bytemuck", - "calloop", - "calloop-wayland-source", "cbindgen", "chrono", - "circular-buffer", - "cocoa", - "cocoa-foundation", + "cocoa 0.26.0", + "cocoa-foundation 0.2.0", + "collections", "core-foundation 0.10.0", "core-foundation-sys", - "core-graphics", + "core-graphics 0.24.0", "core-text", "core-video", - "cosmic-text", "ctor", "derive_more", - "dispatch2", "embed-resource", "env_logger", "etagere", - "filedescriptor", - "flume", "foreign-types", "futures", "futures-concurrency", - "gpui-ce-macros", - "gpui_collections", - "gpui_http_client", - "gpui_refineable", - "gpui_sum_tree", + "getrandom 0.3.4", + "gpui_macros", + "gpui_platform", + "gpui_shared_string", "gpui_util", - "gpui_util_macros", + "gpui_web", + "hdrhistogram", + "http_client", "image", "inventory", "itertools 0.14.0", - "libc", "log", "lyon", "mach2", - "metal 0.29.0", - "naga 29.0.0", + "media", + "metal", "num_cpus", "objc", - "objc2", - "objc2-metal", - "oo7", - "open", + "objc2 0.6.3", + "objc2-metal 0.3.2", "parking", "parking_lot", "pathfinder_geometry", "pin-project", "pollster 0.4.0", "postage", - "pretty_assertions", "profiling", "proptest", "rand 0.9.2", "raw-window-handle", + "refineable", + "regex", + "reqwest_client", "resvg", + "scheduler", "schemars", "seahash", - "semver", "serde", "serde_json", "slotmap", "smallvec", - "smol", "spin 0.10.0", "stacksafe", - "strum 0.27.2", - "swash", + "strum", + "sum_tree", "taffy", "thiserror 2.0.17", + "ttf-parser", "unicode-segmentation", "url", "usvg", + "util_macros", "uuid", "waker-fn", + "wasm-bindgen", + "web-time", + "windows 0.61.3", + "zed-font-kit", + "zed-scap", +] + +[[package]] +name = "gpui_linux" +version = "0.1.0" +dependencies = [ + "accesskit", + "accesskit_unix", + "anyhow", + "as-raw-xcb-connection", + "ashpd", + "bitflags 2.10.0", + "bytemuck", + "calloop", + "calloop-wayland-source", + "collections", + "filedescriptor", + "futures", + "gpui", + "gpui_wgpu", + "http_client", + "image", + "itertools 0.14.0", + "libc", + "log", + "oo7", + "open", + "parking_lot", + "pathfinder_geometry", + "pollster 0.4.0", + "profiling", + "raw-window-handle", + "smallvec", + "smol", + "strum", + "swash", + "url", + "util", + "uuid", "wayland-backend", "wayland-client", "wayland-cursor", "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", - "web-time", - "wgpu", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-numerics", - "windows-registry 0.5.3", "x11-clipboard", "x11rb", "xkbcommon", - "zed-font-kit", + "zed-scap", "zed-xim", ] [[package]] -name = "gpui-ce-macros" +name = "gpui_macos" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_macos", + "anyhow", + "async-task", + "block", + "cbindgen", + "cocoa 0.26.0", + "collections", + "core-foundation 0.10.0", + "core-foundation-sys", + "core-graphics 0.24.0", + "core-text", + "core-video", + "ctor", + "derive_more", + "dispatch2", + "etagere", + "foreign-types", + "futures", + "gpui", + "image", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "media", + "metal", + "objc", + "objc2-app-kit 0.3.2", + "parking_lot", + "pathfinder_geometry", + "raw-window-handle", + "semver", + "smallvec", + "strum", + "util", + "uuid", + "zed-font-kit", +] + +[[package]] +name = "gpui_macros" +version = "0.1.0" +dependencies = [ + "gpui", "heck 0.5.0", "proc-macro2", "quote", @@ -2173,144 +2642,135 @@ dependencies = [ ] [[package]] -name = "gpui_collections" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae39dc6d3d201be97e4bc08d96dbef2bc5b5c3d5734e05786e8cc3043342351c" +name = "gpui_platform" +version = "0.1.0" dependencies = [ - "indexmap", - "rustc-hash 2.1.1", + "console_error_panic_hook", + "gpui", + "gpui_linux", + "gpui_macos", + "gpui_web", + "gpui_windows", ] [[package]] -name = "gpui_derive_refineable" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "644de174341a87b3478bd65b66bca38af868bcf2b2e865700523734f83cfc664" +name = "gpui_shared_string" +version = "0.1.0" dependencies = [ - "proc-macro2", - "quote", - "syn", + "schemars", + "serde", + "smol_str", ] [[package]] -name = "gpui_http_client" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23822b0a6d2c5e6a42507980a0ab3848610ea908942c8ef98187f646f690335e" +name = "gpui_tokio" +version = "0.1.0" dependencies = [ "anyhow", - "async-compression", - "async-fs", - "bytes", - "derive_more", - "futures", - "gpui_util", - "http", - "http-body", - "log", - "parking_lot", - "serde", - "serde_json", - "sha2", - "tempfile", - "url", - "zed-async-tar", - "zed-reqwest", -] - -[[package]] -name = "gpui_perf" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40a0961dcf598955130e867f4b731150a20546427b41b1a63767c1037a86d77" -dependencies = [ - "gpui_collections", - "serde", - "serde_json", -] - -[[package]] -name = "gpui_refineable" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "258cb099254e9468181aee5614410fba61db4ae115fc1d51b4a0b985f60d6641" -dependencies = [ - "gpui_derive_refineable", -] - -[[package]] -name = "gpui_sum_tree" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4f3bedd573fafafa13d1200b356c588cf094fb2786e3684bb3f5ea59b549fa9" -dependencies = [ - "arrayvec", - "log", - "rayon", + "gpui", + "tokio", + "util", ] [[package]] name = "gpui_util" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68faea25903ae524de9af83990b9aa51bcbc8dd085929ac0aea7fd41905e05c3" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" dependencies = [ "anyhow", - "async-fs", - "async_zip", - "command-fds", - "dirs 4.0.0", - "dunce", - "futures", - "futures-lite 1.13.0", - "git2", - "globset", - "gpui_collections", - "gpui_util_macros", - "itertools 0.14.0", - "libc", "log", - "nix 0.29.0", - "rand 0.9.2", - "regex", - "rust-embed", - "schemars", - "serde", - "serde_json", - "serde_json_lenient", - "shlex", - "smol", - "take-until", - "tempfile", - "tendril", - "unicase", - "walkdir", - "which", ] [[package]] -name = "gpui_util_macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c28f65ef47fb97e21e82fd4dd75ccc2506eda010c846dc8054015ea234f1a22" +name = "gpui_web" +version = "0.1.0" dependencies = [ - "gpui_perf", - "quote", - "syn", + "anyhow", + "console_error_panic_hook", + "futures", + "gpui", + "gpui_wgpu", + "http_client", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "smallvec", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + +[[package]] +name = "gpui_wgpu" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytemuck", + "collections", + "cosmic-text", + "criterion", + "etagere", + "gpui", + "gpui_util", + "itertools 0.14.0", + "js-sys", + "log", + "parking_lot", + "pollster 0.4.0", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu", + "zed-font-kit", +] + +[[package]] +name = "gpui_windows" +version = "0.1.0" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "collections", + "etagere", + "futures", + "gpui", + "image", + "itertools 0.14.0", + "log", + "parking_lot", + "rand 0.9.2", + "raw-window-handle", + "smallvec", + "util", + "uuid", + "windows 0.61.3", + "windows-core 0.61.2", + "windows-numerics 0.2.0", + "windows-registry 0.5.3", + "zed-scap", ] [[package]] name = "grid" -version = "0.18.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" [[package]] name = "h2" -version = "0.4.12" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -2350,6 +2810,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2371,9 +2840,35 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -2465,6 +2960,40 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "anyhow", + "async-compression", + "async-fs", + "async-tar", + "bytes", + "derive_more", + "futures", + "http", + "http-body", + "log", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "sha2", + "tempfile", + "url", + "util", +] + +[[package]] +name = "http_client_tls" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "rustls", + "rustls-platform-verifier", +] + [[package]] name = "httparse" version = "1.10.1" @@ -2473,9 +3002,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.7.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2487,7 +3016,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2495,16 +3023,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -2512,13 +3039,12 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -2543,7 +3069,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -2779,9 +3305,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is-docker" @@ -2792,6 +3318,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -2808,6 +3345,15 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -2856,6 +3402,22 @@ dependencies = [ "syn", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni-sys" version = "0.3.0" @@ -2874,10 +3436,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.90" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14dc6f6450b3f6d4ed5b16327f38fed626d375a886159ca555bd7822c0c3a5a6" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -2952,6 +3516,12 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + [[package]] name = "libc" version = "0.2.182" @@ -2968,18 +3538,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libgit2-sys" -version = "0.18.3+1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" -dependencies = [ - "cc", - "libc", - "libz-sys", - "pkg-config", -] - [[package]] name = "libloading" version = "0.8.9" @@ -3007,24 +3565,24 @@ dependencies = [ "redox_syscall 0.5.18", ] -[[package]] -name = "libz-sys" -version = "1.1.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "linebender_resource_handle" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "link-section" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "014e440054ce8170890229eeef5bcda955305e056ec713de40ed366944483f09" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -3190,6 +3748,21 @@ dependencies = [ "digest", ] +[[package]] +name = "media" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "anyhow", + "bindgen", + "core-foundation 0.10.0", + "core-video", + "ctor", + "foreign-types", + "metal", + "objc", +] + [[package]] name = "memchr" version = "2.7.6" @@ -3216,28 +3789,13 @@ dependencies = [ [[package]] name = "metal" -version = "0.29.0" +version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ecfd3296f8c56b7c1f6fbac3c71cefa9d78ce009850c45000015f206dc7fa21" +checksum = "c7047791b5bc903b8cd963014b355f71dc9864a9a0b727057676c1dcae5cbc15" dependencies = [ "bitflags 2.10.0", "block", - "core-graphics-types 0.1.3", - "foreign-types", - "log", - "objc", - "paste", -] - -[[package]] -name = "metal" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e" -dependencies = [ - "bitflags 2.10.0", - "block", - "core-graphics-types 0.1.3", + "core-graphics-types 0.2.0", "foreign-types", "log", "objc", @@ -3278,9 +3836,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "wasi", @@ -3299,38 +3857,15 @@ dependencies = [ [[package]] name = "naga" -version = "24.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e" -dependencies = [ - "arrayvec", - "bit-set 0.8.0", - "bitflags 2.10.0", - "cfg_aliases", - "codespan-reporting 0.11.1", - "hexf-parse", - "indexmap", - "log", - "rustc-hash 1.1.0", - "spirv", - "strum 0.26.3", - "termcolor", - "thiserror 2.0.17", - "unicode-xid", -] - -[[package]] -name = "naga" -version = "29.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85b4372fed0bd362d646d01b6926df0e837859ccc522fed720c395e0460f29c8" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bit-set 0.9.1", "bitflags 2.10.0", "cfg-if", "cfg_aliases", - "codespan-reporting 0.13.1", + "codespan-reporting", "half", "hashbrown 0.16.1", "hexf-parse", @@ -3340,6 +3875,7 @@ dependencies = [ "num-traits", "once_cell", "rustc-hash 1.1.0", + "spirv", "thiserror 2.0.17", "unicode-ident", ] @@ -3355,9 +3891,9 @@ dependencies = [ [[package]] name = "ndk-sys" -version = "0.5.0+25.2.9519653" +version = "0.6.0+11769913" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" dependencies = [ "jni-sys", ] @@ -3408,6 +3944,24 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -3527,6 +4081,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", ] [[package]] @@ -3538,6 +4120,44 @@ dependencies = [ "objc2-encode", ] +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "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 = [ + "objc2 0.6.3", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3546,7 +4166,19 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.10.0", "dispatch2", - "objc2", + "objc2 0.6.3", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -3555,6 +4187,18 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "libc", + "objc2 0.5.2", +] + [[package]] name = "objc2-foundation" version = "0.3.2" @@ -3562,7 +4206,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.10.0", - "objc2", + "objc2 0.6.3", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", ] [[package]] @@ -3572,11 +4229,55 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.10.0", - "block2", + "block2 0.6.2", "dispatch2", - "objc2", + "objc2 0.6.3", "objc2-core-foundation", - "objc2-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.10.0", + "block2 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.10.0", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", ] [[package]] @@ -3607,7 +4308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ "aes", - "ashpd 0.13.9", + "ashpd", "async-fs", "async-io", "async-lock", @@ -3635,6 +4336,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.3.2" @@ -3648,9 +4355,9 @@ dependencies = [ [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "option-ext" @@ -3753,6 +4460,59 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "perf" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "collections", + "serde", + "serde_json", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand 2.3.0", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -3808,6 +4568,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.17.16" @@ -3916,16 +4704,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" -[[package]] -name = "pretty_assertions" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" -dependencies = [ - "diff", - "yansi", -] - [[package]] name = "prettyplease" version = "0.2.37" @@ -3998,13 +4776,13 @@ dependencies = [ [[package]] name = "proptest" version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", "bitflags 2.10.0", "num-traits", + "proptest-macro", "rand 0.9.2", "rand_chacha 0.9.0", "rand_xorshift", @@ -4014,6 +4792,17 @@ dependencies = [ "unarray", ] +[[package]] +name = "proptest-macro" +version = "0.5.0" +source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +dependencies = [ + "convert_case 0.11.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "psm" version = "0.1.30" @@ -4054,6 +4843,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff6510e86862b57b210fd8cbe8ed3f0d7d600b9c2863cd4549a2e033c66e956" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.37.5" @@ -4063,6 +4861,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4085,9 +4893,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ "bytes", "getrandom 0.3.4", @@ -4275,6 +5083,18 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "rayon" version = "1.11.0" @@ -4336,13 +5156,13 @@ dependencies = [ [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 1.0.69", + "thiserror 2.0.17", ] [[package]] @@ -4365,6 +5185,14 @@ dependencies = [ "syn", ] +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "derive_refineable", +] + [[package]] name = "regex" version = "1.12.2" @@ -4400,18 +5228,39 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "anyhow", + "bytes", + "futures", + "gpui_util", + "http_client", + "http_client_tls", + "log", + "regex", + "serde", + "tokio", + "zed-reqwest", +] + [[package]] name = "resvg" version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ + "gif", + "image-webp", "log", "pico-args", "rgb", "svgtypes", "tiny-skia", "usvg", + "zune-jpeg", ] [[package]] @@ -4445,9 +5294,9 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rust-embed" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "025908b8682a26ba8d12f6f2d66b987584a4a87bc024abc5bbc12553a8cd178a" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -4456,9 +5305,9 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6065f1a4392b71819ec1ea1df1120673418bf386f50de1d6f54204d836d4349c" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" dependencies = [ "proc-macro2", "quote", @@ -4469,9 +5318,9 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "8.7.2" +version = "8.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6cc0c81648b20b70c491ff8cce00c1c3b223bb8ed2b5d41f0e54c6c4c0a3594" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" dependencies = [ "globset", "sha2", @@ -4533,10 +5382,12 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.33" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751e04a496ca00bb97a5e043158d23d66b5aabf2e1d5aa2a0aaebb1aafe6f82c" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ + "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4547,9 +5398,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4568,20 +5419,48 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.103.7" +name = "rustls-platform-verifier" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10b3f4191e8a80e6b43eebabfac91e5dcecebb27a71f04e820c47ec41d314bf" +checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ + "core-foundation 0.10.0", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs 0.26.11", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4640,13 +5519,28 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.2", + "web-time", +] + [[package]] name = "schemars" version = "1.0.4" @@ -4685,6 +5579,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4693,9 +5610,9 @@ checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "security-framework" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.0", @@ -4706,9 +5623,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -4875,6 +5792,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4983,15 +5909,19 @@ name = "smol_str" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5014,9 +5944,9 @@ dependencies = [ [[package]] name = "spirv" -version = "0.3.0+sdk-1.3.268.0" +version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ "bitflags 2.10.0", ] @@ -5076,35 +6006,13 @@ dependencies = [ "float-cmp", ] -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - [[package]] name = "strum" 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]] @@ -5125,6 +6033,18 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "heapless", + "log", + "rayon", + "tracing", + "ztracing", +] + [[package]] name = "sval" version = "2.15.0" @@ -5270,6 +6190,20 @@ dependencies = [ "libc", ] +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -5306,9 +6240,9 @@ dependencies = [ [[package]] name = "taffy" -version = "0.9.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13e5d13f79d558b5d353a98072ca8ca0e99da429467804de959aa8c83c9a004" +checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" dependencies = [ "arrayvec", "grid", @@ -5322,6 +6256,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bdb6fa0dfa67b38c1e66b7041ba9dcf23b99d8121907cd31c807a332f7a0bbb" +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5401,6 +6347,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.10.3" @@ -5460,6 +6415,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.10.0" @@ -5477,9 +6442,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ "bytes", "libc", @@ -5491,9 +6456,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.2" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -5501,9 +6466,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -5513,9 +6478,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -5548,7 +6513,7 @@ dependencies = [ "toml_datetime 0.7.3", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -5580,7 +6545,7 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -5592,7 +6557,7 @@ dependencies = [ "indexmap", "toml_datetime 0.7.3", "toml_parser", - "winnow", + "winnow 0.7.13", ] [[package]] @@ -5601,7 +6566,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ - "winnow", + "winnow 0.7.13", ] [[package]] @@ -5618,9 +6583,9 @@ checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -5673,6 +6638,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -5779,12 +6770,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" @@ -5821,7 +6806,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -5860,6 +6845,55 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "anyhow", + "async-fs", + "async_zip", + "collections", + "command-fds", + "dirs", + "dunce", + "futures", + "futures-lite 1.13.0", + "globset", + "gpui_util", + "itertools 0.14.0", + "libc", + "log", + "mach2", + "nix 0.29.0", + "percent-encoding", + "regex", + "rust-embed", + "schemars", + "serde", + "serde_json", + "serde_json_lenient", + "shlex", + "smol", + "take-until", + "tempfile", + "tendril", + "unicase", + "url", + "walkdir", + "which", +] + +[[package]] +name = "util_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "perf", + "quote", + "syn", +] + [[package]] name = "uuid" version = "1.18.1" @@ -5884,6 +6918,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.12.0" @@ -5920,12 +6960,6 @@ dependencies = [ "sval_serde", ] -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.0" @@ -6018,9 +7052,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.113" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60722a937f594b7fde9adb894d7c092fc1bb6612897c46368d18e7a20208eff2" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -6031,23 +7065,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.63" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a89f4650b770e4521aa6573724e2aed4704372151bd0de9d16a3bbabb87441a" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.113" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fac8c6395094b6b91c4af293f4c79371c163f9a6f56184d2c9a85f5a95f3950" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6055,9 +7085,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.113" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3fabce6159dc20728033842636887e4877688ae94382766e00b180abac9d60" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -6068,9 +7098,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.113" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de0e091bdb824da87dc01d967388880d017a0a9bc4f3bdc0d86ee9f9336e3bb5" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -6110,6 +7140,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm_thread" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" +dependencies = [ + "futures", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -6204,7 +7246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54cb1e9dc49da91950bdfd8b848c49330536d9d1fb03d4bfec8cae50caa50ae3" dependencies = [ "proc-macro2", - "quick-xml", + "quick-xml 0.37.5", "quote", ] @@ -6222,9 +7264,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.90" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705eceb4ce901230f8625bd1d665128056ccbe4b7408faa625eec1ba80f59a97" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -6240,6 +7282,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" +dependencies = [ + "webpki-root-certs 1.0.7", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.10" @@ -6248,18 +7308,21 @@ checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" [[package]] name = "wgpu" -version = "24.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", "bitflags 2.10.0", + "bytemuck", + "cfg-if", "cfg_aliases", "document-features", + "hashbrown 0.16.1", "js-sys", "log", - "naga 24.0.0", + "naga", "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "smallvec", @@ -6274,84 +7337,132 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "24.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "arrayvec", - "bit-vec 0.8.0", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.10.0", + "bytemuck", "cfg_aliases", "document-features", + "hashbrown 0.16.1", "indexmap", "log", - "naga 24.0.0", + "naga", "once_cell", "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.17", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-windows-linux-android", "wgpu-hal", + "wgpu-naga-bridge", "wgpu-types", ] +[[package]] +name = "wgpu-core-deps-apple" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +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" +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" +dependencies = [ + "wgpu-hal", +] + [[package]] name = "wgpu-hal" -version = "24.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set 0.8.0", + "bit-set 0.9.1", "bitflags 2.10.0", - "block", + "block2 0.6.2", "bytemuck", + "cfg-if", "cfg_aliases", - "core-graphics-types 0.1.3", "glow", "glutin_wgl_sys", - "gpu-alloc", "gpu-allocator", "gpu-descriptor", + "hashbrown 0.16.1", "js-sys", "khronos-egl", "libc", "libloading", "log", - "metal 0.31.0", - "naga 24.0.0", + "naga", "ndk-sys", - "objc", + "objc2 0.6.3", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", "once_cell", "ordered-float", "parking_lot", + "portable-atomic", + "portable-atomic-util", "profiling", "range-alloc", "raw-window-handle", + "raw-window-metal", "renderdoc-sys", - "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.17", "wasm-bindgen", + "wayland-sys", "web-sys", + "wgpu-naga-bridge", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" +dependencies = [ + "naga", "wgpu-types", - "windows 0.58.0", - "windows-core 0.58.0", ] [[package]] name = "wgpu-types" -version = "24.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c" +version = "29.0.3" +source = "git+https://github.com/zed-industries/wgpu.git?rev=357a0c56e0070480ad9daea5d2eaa83150b79e88#357a0c56e0070480ad9daea5d2eaa83150b79e88" dependencies = [ "bitflags 2.10.0", + "bytemuck", "js-sys", "log", + "raw-window-handle", "web-sys", ] @@ -6400,11 +7511,11 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows" -version = "0.58.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ - "windows-core 0.58.0", + "windows-core 0.57.0", "windows-targets 0.52.6", ] @@ -6414,11 +7525,36 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror 2.0.17", + "windows 0.61.3", + "windows-future 0.2.1", ] [[package]] @@ -6431,15 +7567,23 @@ dependencies = [ ] [[package]] -name = "windows-core" -version = "0.58.0" +name = "windows-collections" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", - "windows-result 0.2.0", - "windows-strings 0.1.0", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", "windows-targets 0.52.6", ] @@ -6456,6 +7600,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -6464,14 +7621,25 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] name = "windows-implement" -version = "0.58.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", @@ -6491,9 +7659,9 @@ dependencies = [ [[package]] name = "windows-interface" -version = "0.58.0" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", @@ -6533,6 +7701,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-registry" version = "0.4.0" @@ -6557,9 +7735,9 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.2.0" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ "windows-targets 0.52.6", ] @@ -6574,13 +7752,12 @@ dependencies = [ ] [[package]] -name = "windows-strings" -version = "0.1.0" +name = "windows-result" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-result 0.2.0", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -6602,12 +7779,21 @@ dependencies = [ ] [[package]] -name = "windows-sys" -version = "0.48.0" +name = "windows-strings" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-targets 0.48.5", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", ] [[package]] @@ -6648,17 +7834,17 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" 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", + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -6704,10 +7890,19 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "windows-threading" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" [[package]] name = "windows_aarch64_gnullvm" @@ -6723,9 +7918,9 @@ checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" [[package]] name = "windows_aarch64_msvc" @@ -6741,9 +7936,9 @@ checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" [[package]] name = "windows_i686_gnu" @@ -6771,9 +7966,9 @@ checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" [[package]] name = "windows_i686_msvc" @@ -6789,9 +7984,9 @@ checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" [[package]] name = "windows_x86_64_gnu" @@ -6807,9 +8002,9 @@ checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" [[package]] name = "windows_x86_64_gnullvm" @@ -6825,9 +8020,9 @@ checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" -version = "0.48.5" +version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" [[package]] name = "windows_x86_64_msvc" @@ -6850,6 +8045,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.55.0" @@ -6975,6 +8179,16 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + [[package]] name = "x11-clipboard" version = "0.9.3" @@ -7014,6 +8228,18 @@ dependencies = [ "libc", ] +[[package]] +name = "xcb" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4c580d8205abb0a5cf4eb7e927bd664e425b6c3263f9c5310583da96970cf6" +dependencies = [ + "bitflags 1.3.2", + "libc", + "quick-xml 0.30.0", + "x11", +] + [[package]] name = "xcursor" version = "0.3.10" @@ -7066,12 +8292,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" -[[package]] -name = "yansi" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" - [[package]] name = "yazi" version = "0.2.1" @@ -7142,12 +8362,36 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.13", "zbus_macros", "zbus_names", "zvariant", ] +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + [[package]] name = "zbus_macros" version = "5.13.2" @@ -7165,40 +8409,38 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.1" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow", + "winnow 1.0.3", "zvariant", ] [[package]] -name = "zed-async-tar" -version = "0.5.0-zed" +name = "zbus_xml" +version = "5.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf4b5f655e29700e473cb1acd914ab112b37b62f96f7e642d5fc6a0c02eb881" +checksum = "a8067892e940ed1727dea64690378601603b31d62dfde019a5335fbb7c0e0ed9" dependencies = [ - "async-std", - "filetime", - "libc", - "pin-project", - "redox_syscall 0.2.16", - "xattr", + "quick-xml 0.39.4", + "serde", + "zbus_names", + "zvariant", ] [[package]] name = "zed-font-kit" version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=110523127440aefb11ce0cf280ae7c5071337ec5#110523127440aefb11ce0cf280ae7c5071337ec5" +source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" dependencies = [ "bitflags 2.10.0", "byteorder", "core-foundation 0.10.0", - "core-graphics", + "core-graphics 0.24.0", "core-text", - "dirs 5.0.1", + "dirs", "dwrote", "float-ord", "freetype-sys", @@ -7215,10 +8457,9 @@ dependencies = [ [[package]] name = "zed-reqwest" version = "0.12.15-zed" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2d05756ff48539950c3282ad7acf3817ad3f08797c205ad1c34a2ce03b9970" +source = "git+https://github.com/zed-industries/reqwest.git?rev=c15662463bda39148ba154100dd44d3fba5873a4#c15662463bda39148ba154100dd44d3fba5873a4" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -7262,6 +8503,27 @@ dependencies = [ "windows-registry 0.4.0", ] +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=4afea48c3b002197176fb19cd0f9b180dd36eaac#4afea48c3b002197176fb19cd0f9b180dd36eaac" +dependencies = [ + "anyhow", + "cocoa 0.25.0", + "core-graphics-helmer-fork", + "log", + "objc", + "rand 0.8.5", + "screencapturekit", + "screencapturekit-sys", + "sysinfo", + "tao-core-video-sys", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + [[package]] name = "zed-xim" version = "0.4.0-zed" @@ -7375,6 +8637,33 @@ dependencies = [ "syn", ] +[[package]] +name = "zlog" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "anyhow", + "chrono", + "collections", + "log", +] + +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" +dependencies = [ + "tracing", + "tracing-subscriber", + "zlog", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed#876ec5a8a074ba83cce2129ed4d76b59c05a37e9" + [[package]] name = "zune-core" version = "0.4.12" @@ -7401,25 +8690,24 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.9.2" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", "serde_bytes", - "url", - "winnow", + "winnow 1.0.3", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.9.2" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -7430,13 +8718,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", "syn", - "winnow", + "winnow 1.0.3", ] diff --git a/Cargo.toml b/Cargo.toml index a204aa6488..e097992c8a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,233 +1,151 @@ [workspace] members = [ - ".", - "tooling/macros", + "./crates/gpui/", + "./crates/gpui_web/", + "./crates/gpui_wgpu/", + "./crates/gpui_linux/", + "./crates/gpui_macos/", + "./crates/gpui_tokio/", + "./crates/gpui_macros/", + "./crates/gpui_windows/", + "./crates/gpui_platform/", + "./crates/gpui_shared_string/", ] default-members = [ - ".", + "./crates/gpui/", ] resolver = "3" -[package] -name = "gpui-ce" -version = "0.3.3" +[workspace.package] edition = "2024" authors = ["Nathan Sobo "] description = "A community fork of Zed's GPU-accelerated UI framework" -repository = "https://github.com/gpui-ce/gpui-ce" publish = true license = "Apache-2.0" homepage = "https://gpui-ce.github.io/" readme = "README.md" -keywords = ["desktop", "gui", "immediate"] -categories = ["gui"] -autoexamples = false -[features] -default = ["font-kit", "wayland", "x11", "windows-manifest"] -test-support = [ - "leak-detection", - "backtrace", - "collections/test-support", - "util/test-support", - "http_client/test-support", - "wayland", - "x11", -] -inspector = ["gpui-ce-macros/inspector"] -leak-detection = ["backtrace"] -runtime_shaders = [] -wayland = [ - "bitflags", - "ashpd/wayland", - "font-kit", - "calloop-wayland-source", - "wayland-backend", - "wayland-client", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-plasma", - "wayland-protocols-wlr", - "filedescriptor", - "xkbcommon", - "open", -] -x11 = [ - "ashpd", - "font-kit", - "as-raw-xcb-connection", - "x11rb", - "xkbcommon", - "xim", - "x11-clipboard", - "filedescriptor", - "open", -] -windows-manifest = [] - -[lib] -name = "gpui" -path = "src/gpui.rs" -doctest = false - -[dependencies] +[workspace.dependencies] +accesskit = "0.24.0" +accesskit_macos = "0.26.0" +accesskit_unix = "0.21.0" +accesskit_windows = "0.32.1" anyhow = "1.0.86" -async-task = "4.7" -backtrace = { version = "0.3", optional = true } -circular-buffer = "1.0" -collections = { package = "gpui_collections", version = "0.2.2" } -ctor = "0.4.0" -derive_more = "0.99.17" -etagere = "0.2" -futures = "0.3" -futures-concurrency = "7" -gpui-ce-macros = { path = "./tooling/macros", features = ["inspector"] } -http_client = { package = "gpui_http_client", version = "0.2.2" } +backtrace = "0.3" +bitflags = "2.6.0" +collections = { git = "https://github.com/zed-industries/zed", version = "0.1.0" } +ctor = "1.0.6" +derive_more = { version = "2.1.1", features = [ + "add", + "add_assign", + "deref", + "deref_mut", + "display", + "from_str", + "mul", + "mul_assign", + "not", +] } +futures = "0.3.32" +futures-concurrency = "7.7.1" +http_client = { git = "https://github.com/zed-industries/zed" } image = "0.25.1" inventory = "0.3.19" itertools = "0.14.0" -libc = "0.2" log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } -lyon = "1.0" -num_cpus = "1.13" -parking = "2.0.0" parking_lot = "0.12.1" -pin-project = "1.1.10" postage = { version = "0.5", features = ["futures-traits"] } +proptest = { git = "https://github.com/proptest-rs/proptest", rev = "3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b", features = ["attr-macro"] } +chrono = { version = "0.4", features = ["serde"] } profiling = "1" -proptest = "1" rand = "0.9" -raw-window-handle = "0.6" -refineable = { package = "gpui_refineable", version = "0.2.2" } -resvg = { version = "0.45.0", default-features = false, features = [ - "text", - "system-fonts", - "memmap-fonts", -] } +regex = "1.5" +refineable = { git = "https://github.com/zed-industries/zed" } +scheduler = { git = "https://github.com/zed-industries/zed" } +util_macros = { git = "https://github.com/zed-industries/zed" } schemars = { version = "1.0", features = ["indexmap2"] } -seahash = "4.1" -semver = { version = "1.0", features = ["serde"] } serde = { version = "1.0.221", features = ["derive", "rc"] } serde_json = { version = "1.0.144", features = ["preserve_order", "raw_value"] } slotmap = "1.0.6" -smallvec = { version = "1.6", features = ["union"] } -smol = "2.0" -spin = "0.10.0" +smallvec = { version = "1.6", features = ["union", "const_new"] } +async-channel = "2.5.0" stacksafe = "0.1" strum = { version = "0.27.2", features = ["derive"] } -sum_tree = { package = "gpui_sum_tree", version = "0.2.2" } -taffy = "=0.9.0" +sum_tree = { git = "https://github.com/zed-industries/zed" } thiserror = "2.0.12" -util = { package = "gpui_util", version = "0.2.2" } -util_macros = { package = "gpui_util_macros", version = "0.2.2" } -usvg = { version = "0.45.0", default-features = false } -url = "2" +hdrhistogram = "7" +pollster = "0.4.0" +url = "2.2" uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } -waker-fn = "1.2.0" -web-time = "1" -async-channel = "2" -pollster = "0.4" -chrono = "0.4" - -[target.'cfg(target_os = "macos")'.dependencies] -block = "0.1" +web-time = "1.1.0" cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" core-foundation = "=0.10.0" core-foundation-sys = "0.8.6" -core-graphics = "0.24" -core-text = "21" -core-video = { version = "0.4.3", features = ["metal"] } -dispatch2 = "0.3.1" -flume = "0.11" -font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", optional = true } -foreign-types = "0.5" -mach2 = "0.5" -metal = "0.29" +core-video = { version = "0.5.2", features = ["metal"] } +media = { git = "https://github.com/zed-industries/zed" } objc = "0.2" -objc2 = { version = "0.6", optional = true } -objc2-metal = { version = "0.3", optional = true } - -[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies] -pathfinder_geometry = "0.5" - -[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] -flume = "0.11" -oo7 = { version = "0.6", default-features = false, features = [ - "async-std", - "native_crypto", +mach2 = "0.5" +metal = "0.33" +scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } +env_logger = "0.11" +unicode-segmentation = "1.10" +reqwest_client = { git = "https://github.com/zed-industries/zed" } +wasm-bindgen = "0.2.120" +heck = "0.5" +proc-macro2 = "1.0.93" +syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] } +quote = "1.0.9" +ashpd = { version = "0.13", default-features = false, features = [ + "async-io", + "notification", + "open_uri", + "file_chooser", + "settings", + "trash" ] } -wgpu = "24" -cosmic-text = "0.17.0" -swash = "0.2.6" -bytemuck = "1" +libc = "0.2" +smol = "2.0" +util = { git = "https://github.com/zed-industries/zed" } +wgpu = { git = "https://github.com/zed-industries/wgpu.git", rev = "357a0c56e0070480ad9daea5d2eaa83150b79e88" } +criterion = { version = "0.5", features = ["html_reports"] } +objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] } +semver = { version = "1.0", features = ["serde"] } +windows-core = "0.61" +tokio = { version = "1" } +gpui_util = { git = "https://github.com/zed-industries/zed" } -ashpd = { version = "0.11", default-features = false, features = ["async-std"], optional = true } -font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "110523127440aefb11ce0cf280ae7c5071337ec5", package = "zed-font-kit", version = "0.14.1-zed", features = [ - "source-fontconfig-dlopen", -], optional = true } +gpui = { path = "./crates/gpui/" } +gpui_platform = { path = "./crates/gpui_platform/" } +gpui_linux = { path = "./crates/gpui_linux/" } +gpui_macos = { path = "./crates/gpui_macos/" } +gpui_windows = { path = "./crates/gpui_windows/" } +gpui_web = { path = "./crates/gpui_web/" } -calloop = "0.14.3" -filedescriptor = { version = "0.8.2", optional = true } -open = { version = "5.2.0", optional = true } +gpui_wgpu = { path = "./crates/gpui_wgpu/" } -calloop-wayland-source = { version = "0.4.1", optional = true } -wayland-backend = { version = "0.3.3", features = [ - "client_system", - "dlopen", -], optional = true } -wayland-client = { version = "0.31.11", optional = true } -wayland-cursor = { version = "0.31.11", optional = true } -wayland-protocols = { version = "0.32.9", features = [ - "client", - "staging", - "unstable", -], optional = true } -wayland-protocols-plasma = { version = "0.3.9", features = [ - "client", -], optional = true } -wayland-protocols-wlr = { version = "0.3.9", features = [ - "client", -], optional = true } +gpui_macros = { path = "./crates/gpui_macros/" } +gpui_shared_string = { path = "./crates/gpui_shared_string/" } +gpui_tokio = { path = "./crates/gpui_tokio/" } -as-raw-xcb-connection = { version = "1", optional = true } -x11rb = { version = "0.13.1", features = [ - "allow-unsafe-code", - "xkb", - "randr", - "xinput", - "cursor", - "resource_manager", - "sync", - "dri3", -], optional = true } -xkbcommon = { version = "0.8.0", features = [ - "wayland", - "x11", -], optional = true } -xim = { git = "https://github.com/zed-industries/xim-rs.git", rev = "16f35a2c881b815a2b6cdfd6687988e84f8447d8", features = [ - "x11rb-xcb", - "x11rb-client", -], package = "zed-xim", version = "0.4.0-zed", optional = true } -x11-clipboard = { version = "0.9.3", optional = true } -bitflags = { version = "2.6.0", optional = true } - -[target.'cfg(target_os = "windows")'.dependencies] -flume = "0.11" -rand = "0.9" -windows = { version = "0.61", features = [ +[workspace.dependencies.windows] +version = "0.61" +features = [ "Foundation_Numerics", + "Globalization_DateTimeFormatting", "Storage_Search", "Storage_Streams", "System_Threading", "UI_ViewManagement", "Wdk_System_SystemServices", + "Win32_Foundation", "Win32_Globalization", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", "Win32_Graphics_Direct3D_Fxc", "Win32_Graphics_DirectComposition", "Win32_Graphics_DirectWrite", + "Win32_Graphics_DirectManipulation", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi", "Win32_Graphics_Dxgi_Common", @@ -242,6 +160,7 @@ windows = { version = "0.61", features = [ "Win32_System_Com", "Win32_System_Com_StructuredStorage", "Win32_System_Console", + "Win32_System_Diagnostics_Debug", "Win32_System_DataExchange", "Win32_System_IO", "Win32_System_LibraryLoader", @@ -249,6 +168,7 @@ windows = { version = "0.61", features = [ "Win32_System_Ole", "Win32_System_Performance", "Win32_System_Pipes", + "Win32_System_RestartManager", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", @@ -258,195 +178,18 @@ windows = { version = "0.61", features = [ "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Input_Pointer", "Win32_UI_Shell", "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", -] } -windows-core = "0.61" -windows-numerics = "0.2" -windows-registry = "0.5" + "Win32_Media", +] -[dev-dependencies] -backtrace = "0.3" -collections = { package = "gpui_collections", version = "0.2.2", features = ["test-support"] } -env_logger = "0.11" -http_client = { package = "gpui_http_client", version = "0.2.2", features = ["test-support"] } -lyon = { version = "1.0", features = ["extra"] } -pretty_assertions = { version = "1.3.0", features = ["unstable"] } -proptest = "1" -rand = "0.9" -unicode-segmentation = "1.10" -util = { package = "gpui_util", version = "0.2.2", features = ["test-support"] } - -[target.'cfg(target_os = "windows")'.build-dependencies] -embed-resource = "3.0" -windows-registry = "0.5" - -[target.'cfg(target_os = "macos")'.build-dependencies] -cbindgen = { version = "0.28.0", default-features = false } - -[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.build-dependencies] -naga = { version = "29.0", features = ["wgsl-in"] } - -# ============================================================================ -# Learn Examples - Educational examples for learning GPUI -# ============================================================================ - -[[example]] -name = "interactive_elements" -path = "examples/learn/interactive_elements.rs" - -[[example]] -name = "creating_components" -path = "examples/learn/creating_components.rs" - -[[example]] -name = "layout" -path = "examples/learn/layout.rs" - -[[example]] -name = "styling" -path = "examples/learn/styling.rs" - -[[example]] -name = "async_tasks" -path = "examples/learn/async_tasks.rs" - -[[example]] -name = "custom_drawing" -path = "examples/learn/custom_drawing.rs" - -[[example]] -name = "animation" -path = "examples/learn/animation.rs" - -[[example]] -name = "text" -path = "examples/learn/text.rs" - -# ============================================================================ -# Bench Examples - Performance benchmarks -# ============================================================================ - -[[example]] -name = "data_table" -path = "examples/bench/data_table.rs" - -[[example]] -name = "paths_bench" -path = "examples/bench/paths_bench.rs" - -[[example]] -name = "pattern" -path = "examples/bench/pattern.rs" - -[[example]] -name = "shadow" -path = "examples/bench/shadow.rs" - -# ============================================================================ -# Legacy Examples - Original examples (to be updated) -# ============================================================================ - -[[example]] -name = "focus_visible" -path = "examples/legacy/focus_visible.rs" - -[[example]] -name = "gif_viewer" -path = "examples/legacy/gif_viewer.rs" - -[[example]] -name = "gradient" -path = "examples/legacy/gradient.rs" - -[[example]] -name = "hello_world" -path = "examples/legacy/hello_world.rs" - -[[example]] -name = "image_loading" -path = "examples/legacy/image_loading.rs" - -[[example]] -name = "input" -path = "examples/legacy/input.rs" - -[[example]] -name = "layer_shell" -path = "examples/legacy/layer_shell.rs" - -[[example]] -name = "on_window_close_quit" -path = "examples/legacy/on_window_close_quit.rs" - -[[example]] -name = "opacity" -path = "examples/legacy/opacity.rs" - -[[example]] -name = "scrollable" -path = "examples/legacy/scrollable.rs" - -[[example]] -name = "svg" -path = "examples/legacy/svg/svg.rs" - -[[example]] -name = "tab_stop" -path = "examples/legacy/tab_stop.rs" - -[[example]] -name = "tree" -path = "examples/legacy/tree.rs" - -[[example]] -name = "uniform_list" -path = "examples/legacy/uniform_list.rs" - -[[example]] -name = "window" -path = "examples/legacy/window.rs" - -[[example]] -name = "window_positioning" -path = "examples/legacy/window_positioning.rs" - -[[example]] -name = "window_shadow" -path = "examples/legacy/window_shadow.rs" - -[profile.dev] -split-debuginfo = "unpacked" -incremental = false -codegen-units = 16 - -[profile.dev.build-override] -codegen-units = 16 - -[profile.dev.package] -quote = { opt-level = 3 } -syn = { opt-level = 3 } -proc-macro2 = { opt-level = 3 } -taffy = { opt-level = 3 } -resvg = { opt-level = 3 } - -[profile.release] -debug = "limited" -lto = "thin" -codegen-units = 1 - -[profile.release-fast] -inherits = "release" -debug = "full" -lto = false -codegen-units = 16 - -[lints.rust] +[workspace.lints.rust] unexpected_cfgs = { level = "allow" } -[lints.clippy] +[workspace.lints.clippy] dbg_macro = "deny" todo = "deny" declare_interior_mutable_const = "deny" @@ -459,4 +202,3 @@ single_range_in_vec_init = "allow" too_many_arguments = "allow" large_enum_variant = "allow" nonminimal_bool = "allow" - diff --git a/README.md b/README.md index ab0e448753..a2d016f7f4 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,19 @@ A community fork of [GPUI](https://gpui.rs), Zed's GPU-accelerated UI framework. ```toml [dependencies] gpui = { package = "gpui-ce", version = "0.3" } +gpui_platform = { git = "https://github.com/gpui-ce/gpui-ce" } # for test support... [dev-dependencies] gpui = { package = "gpui-ce", version = "0.3", features = ["test-support"] } ``` +or for using the git version + +```toml +gpui = { package = "gpui", git = "https://github.com/gpui-ce/gpui-ce" } +``` + Then use `gpui::{import}` as normal. --- @@ -24,7 +31,7 @@ todo: rewrite below... GPUI is a hybrid immediate and retained mode, GPU accelerated, UI framework for Rust, designed to support a wide variety of applications. -Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. +Everything in GPUI starts with an `Application`. You can create one with `gpui_platform::application()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. ### Dependencies @@ -75,4 +82,3 @@ In addition to the systems above, GPUI provides a range of smaller services that - The `[gpui::test]` macro provides a convenient way to write tests for your GPUI applications. Tests also have their own kind of context, a `TestAppContext` which provides ways of simulating common platform input. See `app::test_context` and `test` modules for more details. Currently, the best way to learn about these APIs is to read the Zed source code or drop a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog). - diff --git a/build.rs b/build.rs deleted file mode 100644 index fb31fc5f9b..0000000000 --- a/build.rs +++ /dev/null @@ -1,421 +0,0 @@ -#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] - -use std::env; - -fn main() { - println!("cargo::rustc-check-cfg=cfg(gles)"); - - let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); - - match target_os.as_str() { - "macos" => { - #[cfg(target_os = "macos")] - macos::build(); - } - "windows" => { - #[cfg(target_os = "windows")] - windows::build(); - } - _ => {} - } -} - -#[cfg(target_os = "macos")] -mod macos { - use std::{ - env, - path::{Path, PathBuf}, - }; - - use cbindgen::Config; - - pub fn build() { - let header_path = generate_shader_bindings(); - - #[cfg(feature = "runtime_shaders")] - emit_stitched_shaders(&header_path); - #[cfg(not(feature = "runtime_shaders"))] - compile_metal_shaders(&header_path); - } - - fn generate_shader_bindings() -> PathBuf { - let output_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("scene.h"); - let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - - let mut config = Config { - include_guard: Some("SCENE_H".into()), - language: cbindgen::Language::C, - no_includes: true, - ..Default::default() - }; - config.export.include.extend([ - "Bounds".into(), - "Corners".into(), - "Edges".into(), - "Size".into(), - "Pixels".into(), - "PointF".into(), - "Hsla".into(), - "ContentMask".into(), - "Uniforms".into(), - "AtlasTile".into(), - "PathRasterizationInputIndex".into(), - "PathVertex_ScaledPixels".into(), - "PathRasterizationVertex".into(), - "ShadowInputIndex".into(), - "Shadow".into(), - "QuadInputIndex".into(), - "Underline".into(), - "UnderlineInputIndex".into(), - "Quad".into(), - "BorderStyle".into(), - "SpriteInputIndex".into(), - "MonochromeSprite".into(), - "PolychromeSprite".into(), - "PathSprite".into(), - "SurfaceInputIndex".into(), - "SurfaceBounds".into(), - "TransformationMatrix".into(), - ]); - config.no_includes = true; - config.enumeration.prefix_with_name = true; - - let mut builder = cbindgen::Builder::new(); - - // Source files that define types used in shaders - let src_paths = [ - crate_dir.join("src/scene.rs"), - crate_dir.join("src/geometry.rs"), - crate_dir.join("src/color.rs"), - crate_dir.join("src/window.rs"), - crate_dir.join("src/platform.rs"), - crate_dir.join("src/platform/mac/metal_renderer.rs"), - ]; - - for src_path in &src_paths { - println!("cargo:rerun-if-changed={}", src_path.display()); - builder = builder.with_src(src_path); - } - - builder - .with_config(config) - .generate() - .expect("Unable to generate bindings") - .write_to_file(&output_path); - - output_path - } - - /// To enable runtime compilation, we need to "stitch" the shaders file with the generated header - /// so that it is self-contained. - #[cfg(feature = "runtime_shaders")] - fn emit_stitched_shaders(header_path: &Path) { - fn stitch_header(header: &Path, shader_path: &Path) -> std::io::Result { - let header_contents = std::fs::read_to_string(header)?; - let shader_contents = std::fs::read_to_string(shader_path)?; - let stitched_contents = format!("{header_contents}\n{shader_contents}"); - let out_path = - PathBuf::from(env::var("OUT_DIR").unwrap()).join("stitched_shaders.metal"); - std::fs::write(&out_path, stitched_contents)?; - Ok(out_path) - } - let shader_source_path = "./src/platform/mac/shaders.metal"; - let shader_path = PathBuf::from(shader_source_path); - stitch_header(header_path, &shader_path).unwrap(); - println!("cargo:rerun-if-changed={}", &shader_source_path); - } - - #[cfg(not(feature = "runtime_shaders"))] - fn compile_metal_shaders(header_path: &Path) { - use std::process::{self, Command}; - let shader_path = "./src/platform/mac/shaders.metal"; - let air_output_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.air"); - let metallib_output_path = - PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metallib"); - println!("cargo:rerun-if-changed={}", shader_path); - - let output = Command::new("xcrun") - .args([ - "-sdk", - "macosx", - "metal", - "-gline-tables-only", - "-mmacosx-version-min=10.15.7", - "-MO", - "-c", - shader_path, - "-include", - (header_path.to_str().unwrap()), - "-o", - ]) - .arg(&air_output_path) - .output() - .unwrap(); - - if !output.status.success() { - println!( - "cargo::error=metal shader compilation failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - process::exit(1); - } - - let output = Command::new("xcrun") - .args(["-sdk", "macosx", "metallib"]) - .arg(air_output_path) - .arg("-o") - .arg(metallib_output_path) - .output() - .unwrap(); - - if !output.status.success() { - println!( - "cargo::error=metallib compilation failed:\n{}", - String::from_utf8_lossy(&output.stderr) - ); - process::exit(1); - } - } -} - -#[cfg(target_os = "windows")] -mod windows { - pub fn build() { - #[cfg(not(debug_assertions))] - shader_compilation::compile_shaders(); - - #[cfg(feature = "windows-manifest")] - embed_resource(); - } - - #[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"); - println!("cargo:rerun-if-changed={}", manifest.display()); - println!("cargo:rerun-if-changed={}", rc_file.display()); - embed_resource::compile(rc_file, embed_resource::NONE) - .manifest_required() - .unwrap(); - } - - #[cfg(not(debug_assertions))] - mod shader_compilation { - use std::{ - fs, - io::Write, - path::{Path, PathBuf}, - process::{self, Command}, - }; - - pub fn compile_shaders() { - let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) - .join("src/platform/windows/shaders.hlsl"); - let out_dir = std::env::var("OUT_DIR").unwrap(); - - println!("cargo:rerun-if-changed={}", shader_path.display()); - - let fxc_path = find_fxc_compiler(); - - let modules = [ - "quad", - "shadow", - "path_rasterization", - "path_sprite", - "underline", - "monochrome_sprite", - "subpixel_sprite", - "polychrome_sprite", - ]; - - let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir); - if Path::new(&rust_binding_path).exists() { - fs::remove_file(&rust_binding_path) - .expect("Failed to remove existing Rust binding file"); - } - for module in modules { - compile_shader_for_module( - module, - &out_dir, - &fxc_path, - shader_path.to_str().unwrap(), - &rust_binding_path, - ); - } - - { - let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) - .join("src/platform/windows/color_text_raster.hlsl"); - compile_shader_for_module( - "emoji_rasterization", - &out_dir, - &fxc_path, - shader_path.to_str().unwrap(), - &rust_binding_path, - ); - } - } - - fn find_latest_windows_sdk_binary( - binary: &str, - ) -> Result, Box> { - let key = windows_registry::LOCAL_MACHINE - .open("SOFTWARE\\WOW6432Node\\Microsoft\\Microsoft SDKs\\Windows\\v10.0")?; - - let install_folder: String = key.get_string("InstallationFolder")?; - let install_folder_bin = Path::new(&install_folder).join("bin"); - - let mut versions: Vec<_> = std::fs::read_dir(&install_folder_bin)? - .flatten() - .filter(|entry| entry.path().is_dir()) - .filter_map(|entry| entry.file_name().into_string().ok()) - .collect(); - - versions.sort_by_key(|s| { - s.split('.') - .filter_map(|p| p.parse().ok()) - .collect::>() - }); - - let arch = match std::env::consts::ARCH { - "x86_64" => "x64", - "aarch64" => "arm64", - _ => Err(format!( - "Unsupported architecture: {}", - std::env::consts::ARCH - ))?, - }; - - if let Some(highest_version) = versions.last() { - return Ok(Some( - install_folder_bin - .join(highest_version) - .join(arch) - .join(binary), - )); - } - - Ok(None) - } - - fn find_fxc_compiler() -> String { - if let Ok(path) = std::env::var("GPUI_FXC_PATH") - && Path::new(&path).exists() - { - return path; - } - - if let Ok(output) = std::process::Command::new("where.exe") - .arg("fxc.exe") - .output() - && output.status.success() - { - let path = String::from_utf8_lossy(&output.stdout); - return path.trim().to_string(); - } - - if let Ok(Some(path)) = find_latest_windows_sdk_binary("fxc.exe") { - return path.to_string_lossy().into_owned(); - } - - panic!("Failed to find fxc.exe"); - } - - fn compile_shader_for_module( - module: &str, - out_dir: &str, - fxc_path: &str, - shader_path: &str, - rust_binding_path: &str, - ) { - let output_file = format!("{}/{}_vs.h", out_dir, module); - let const_name = format!("{}_VERTEX_BYTES", module.to_uppercase()); - compile_shader_impl( - fxc_path, - &format!("{module}_vertex"), - &output_file, - &const_name, - shader_path, - "vs_4_1", - ); - generate_rust_binding(&const_name, &output_file, rust_binding_path); - - let output_file = format!("{}/{}_ps.h", out_dir, module); - let const_name = format!("{}_FRAGMENT_BYTES", module.to_uppercase()); - compile_shader_impl( - fxc_path, - &format!("{module}_fragment"), - &output_file, - &const_name, - shader_path, - "ps_4_1", - ); - generate_rust_binding(&const_name, &output_file, rust_binding_path); - } - - fn compile_shader_impl( - fxc_path: &str, - entry_point: &str, - output_path: &str, - var_name: &str, - shader_path: &str, - target: &str, - ) { - let output = Command::new(fxc_path) - .args([ - "/T", - target, - "/E", - entry_point, - "/Fh", - output_path, - "/Vn", - var_name, - "/O3", - shader_path, - ]) - .output(); - - match output { - Ok(result) => { - if result.status.success() { - return; - } - println!( - "cargo::error=Shader compilation failed for {}:\n{}", - entry_point, - String::from_utf8_lossy(&result.stderr) - ); - process::exit(1); - } - Err(e) => { - println!("cargo::error=Failed to run fxc for {}: {}", entry_point, e); - process::exit(1); - } - } - } - - fn generate_rust_binding(const_name: &str, head_file: &str, output_path: &str) { - let header_content = fs::read_to_string(head_file).expect("Failed to read header file"); - let const_definition = { - let global_var_start = header_content.find("const BYTE").unwrap(); - let global_var = &header_content[global_var_start..]; - let equal = global_var.find('=').unwrap(); - global_var[equal + 1..].trim() - }; - let rust_binding = format!( - "const {}: &[u8] = &{}\n", - const_name, - const_definition.replace('{', "[").replace('}', "]") - ); - let mut options = fs::OpenOptions::new() - .create(true) - .append(true) - .open(output_path) - .expect("Failed to open Rust binding file"); - options - .write_all(rust_binding.as_bytes()) - .expect("Failed to write Rust binding file"); - } - } -} diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml new file mode 100644 index 0000000000..88fc23004b --- /dev/null +++ b/crates/gpui/Cargo.toml @@ -0,0 +1,303 @@ +[package] +name = "gpui" +version = "0.2.2" +edition.workspace = true +authors = ["Nathan Sobo "] +description = "Zed's GPU-accelerated UI framework" +repository = "https://github.com/zed-industries/zed" +publish = true +license = "Apache-2.0" +homepage = "https://gpui.rs" +readme = "README.md" +keywords = ["desktop", "gui", "immediate"] +categories = ["gui"] + + +[lints] +workspace = true + +[features] +default = ["font-kit", "wayland", "x11", "windows-manifest"] +test-support = [ + "leak-detection", + "collections/test-support", + "http_client/test-support", + "wayland", + "x11", + "proptest", +] +inspector = ["gpui_macros/inspector"] +leak-detection = ["backtrace"] +wayland = [ + "bitflags", +] +x11 = [ + "scap?/x11", +] +screen-capture = [ + "scap", +] +windows-manifest = ["dep:embed-resource"] +input-latency-histogram = ["dep:hdrhistogram"] + +[lib] +path = "src/gpui.rs" +doctest = false + +[dependencies] +accesskit.workspace = true +anyhow.workspace = true +async-task = "4.7" +backtrace = { workspace = true, optional = true } +bitflags = { workspace = true, optional = true } + +collections.workspace = true +ctor.workspace = true +derive_more.workspace = true +etagere = "0.2" +futures.workspace = true +futures-concurrency.workspace = true +gpui_macros.workspace = true +gpui_shared_string.workspace = true +http_client.workspace = true +image.workspace = true +inventory.workspace = true +itertools.workspace = true +log.workspace = true +num_cpus = "1.13" +parking = "2.0.0" +parking_lot.workspace = true +postage.workspace = true +proptest = { workspace = true, optional = true } +chrono.workspace = true +profiling.workspace = true +rand.workspace = true +raw-window-handle = "0.6" +regex.workspace = true +refineable.workspace = true +scheduler.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 } +ttf-parser = "0.25" +util_macros.workspace = true +schemars.workspace = true +seahash = "4.1" +serde.workspace = true +serde_json.workspace = true +slotmap.workspace = true +smallvec.workspace = true +async-channel.workspace = true +stacksafe.workspace = true +strum.workspace = true +sum_tree.workspace = true +taffy = "=0.10.1" +thiserror.workspace = true +gpui_util.workspace = true +hdrhistogram = { workspace = true, optional = true } +waker-fn = "1.2.0" +lyon = "1.0" +pin-project = "1.1.10" +spin = "0.10.0" +pollster.workspace = true +url.workspace = true +uuid.workspace = true +web-time.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +getrandom = { version = "0.3.4", features = ["wasm_js"] } +uuid = { workspace = true, features = ["js"] } + + +[target.'cfg(target_os = "macos")'.dependencies] +block = "0.1" +cocoa.workspace = true +cocoa-foundation.workspace = true +core-foundation.workspace = true +core-foundation-sys.workspace = true +core-graphics = "0.24" +core-video.workspace = true +core-text = "21" +# 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 = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } +foreign-types = "0.5" +log.workspace = true +media.workspace = true +objc.workspace = true +objc2 = { version = "0.6", optional = true } +objc2-metal = { version = "0.3", optional = true } +mach2.workspace = true +#TODO: replace with "objc2" +metal.workspace = true + +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))'.dependencies] +pathfinder_geometry = "0.5" + +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))'.dependencies] +scap = { workspace = true, optional = true } + + + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.61", features = ["Win32_Foundation"] } + + +[dev-dependencies] +backtrace.workspace = true +collections = { workspace = true, features = ["test-support"] } +env_logger.workspace = true +gpui_platform = { workspace = true, features = ["font-kit", "wayland", "x11"] } +gpui_util = { workspace = true } +lyon = { version = "1.0", features = ["extra"] } +proptest = { workspace = true } +rand.workspace = true +scheduler = { workspace = true, features = ["test-support"] } +unicode-segmentation = { workspace = true } + +[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")'.dev-dependencies] +wasm-bindgen = { workspace = true } +gpui_web.workspace = true + +[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 } + + +# ============================================================================ +# Learn Examples - Educational examples for learning GPUI +# ============================================================================ + +[[example]] +name = "interactive_elements" +path = "examples/learn/interactive_elements.rs" + +[[example]] +name = "creating_components" +path = "examples/learn/creating_components.rs" + +[[example]] +name = "layout" +path = "examples/learn/layout.rs" + +[[example]] +name = "styling" +path = "examples/learn/styling.rs" + +[[example]] +name = "async_tasks" +path = "examples/learn/async_tasks.rs" + +[[example]] +name = "custom_drawing" +path = "examples/learn/custom_drawing.rs" + +[[example]] +name = "animation" +path = "examples/learn/animation.rs" + +[[example]] +name = "text" +path = "examples/learn/text.rs" + +# ============================================================================ +# Bench Examples - Performance benchmarks +# ============================================================================ + +[[example]] +name = "data_table" +path = "examples/bench/data_table.rs" + +[[example]] +name = "paths_bench" +path = "examples/bench/paths_bench.rs" + +[[example]] +name = "pattern" +path = "examples/bench/pattern.rs" + +[[example]] +name = "shadow" +path = "examples/bench/shadow.rs" + +# ============================================================================ +# Legacy Examples - Original examples (to be updated) +# ============================================================================ + +[[example]] +name = "focus_visible" +path = "examples/legacy/focus_visible.rs" + +[[example]] +name = "gif_viewer" +path = "examples/legacy/gif_viewer.rs" + +[[example]] +name = "gradient" +path = "examples/legacy/gradient.rs" + +[[example]] +name = "hello_world" +path = "examples/legacy/hello_world.rs" + +[[example]] +name = "image_loading" +path = "examples/legacy/image_loading.rs" + +[[example]] +name = "input" +path = "examples/legacy/input.rs" + +[[example]] +name = "layer_shell" +path = "examples/legacy/layer_shell.rs" + +[[example]] +name = "on_window_close_quit" +path = "examples/legacy/on_window_close_quit.rs" + +[[example]] +name = "opacity" +path = "examples/legacy/opacity.rs" + +[[example]] +name = "scrollable" +path = "examples/legacy/scrollable.rs" + +[[example]] +name = "svg" +path = "examples/legacy/svg/svg.rs" + +[[example]] +name = "tab_stop" +path = "examples/legacy/tab_stop.rs" + +[[example]] +name = "tree" +path = "examples/legacy/tree.rs" + +[[example]] +name = "uniform_list" +path = "examples/legacy/uniform_list.rs" + +[[example]] +name = "window" +path = "examples/legacy/window.rs" + +[[example]] +name = "window_positioning" +path = "examples/legacy/window_positioning.rs" + +[[example]] +name = "window_shadow" +path = "examples/legacy/window_shadow.rs" diff --git a/crates/gpui/LICENSE-APACHE b/crates/gpui/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui/README.md b/crates/gpui/README.md new file mode 100644 index 0000000000..6d4a37932f --- /dev/null +++ b/crates/gpui/README.md @@ -0,0 +1,67 @@ +# Welcome to GPUI! + +GPUI is a hybrid immediate and retained mode, GPU accelerated, UI framework +for Rust, designed to support a wide variety of applications. + +## Getting Started + +GPUI is still in active development as we work on the Zed code editor, and is still pre-1.0. There will often be breaking changes between versions. You'll also need to use the latest version of stable Rust and be on macOS or Linux. Add the following to your `Cargo.toml`: + +```toml +gpui = { version = "*" } +``` + +- [Ownership and data flow](_ownership_and_data_flow) +- [Accessibility](_accessibility) + +Everything in GPUI starts with an `Application`. You can create one with `Application::new()`, and kick off your application by passing a callback to `Application::run()`. Inside this callback, you can create a new window with `App::open_window()`, and register your first root view. See [gpui.rs](https://www.gpui.rs/) for a complete example. + +### Dependencies + +GPUI has various system dependencies that it needs in order to work. + +#### macOS + +On macOS, GPUI uses Metal for rendering. In order to use Metal, you need to do the following: + +- Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store, or from the [Apple Developer](https://developer.apple.com/download/all/) website. Note this requires a developer account. + +> Ensure you launch Xcode after installing, and install the macOS components, which is the default option. + +- Install [Xcode command line tools](https://developer.apple.com/xcode/resources/) + + ```sh + xcode-select --install + ``` + +- Ensure that the Xcode command line tools are using your newly installed copy of Xcode: + + ```sh + sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer + ``` + +## The Big Picture + +GPUI offers three different [registers]() depending on your needs: + +- State management and communication with `Entity`'s. Whenever you need to store application state that communicates between different parts of your application, you'll want to use GPUI's entities. Entities are owned by GPUI and are only accessible through an owned smart pointer similar to an `Rc`. See the `app::context` module for more information. + +- High level, declarative UI with views. All UI in GPUI starts with a view. A view is simply an `Entity` that can be rendered, by implementing the `Render` trait. At the start of each frame, GPUI will call this render method on the root view of a given window. Views build a tree of `elements`, lay them out and style them with a tailwind-style API, and then give them to GPUI to turn into pixels. See the `div` element for an all purpose swiss-army knife of rendering. + +- Low level, imperative UI with Elements. Elements are the building blocks of UI in GPUI, and they provide a nice wrapper around an imperative API that provides as much flexibility and control as you need. Elements have total control over how they and their child elements are rendered and can be used for making efficient views into large lists, implement custom layouting for a code editor, and anything else you can think of. See the `element` module for more information. + +Each of these registers has one or more corresponding contexts that can be accessed from all GPUI services. This context is your main interface to GPUI, and is used extensively throughout the framework. + +## Other Resources + +In addition to the systems above, GPUI provides a range of smaller services that are useful for building complex applications: + +- Actions are user-defined structs that are used for converting keystrokes into logical operations in your UI. Use this for implementing keyboard shortcuts, such as cmd-q. See the `action` module for more information. + +- Platform services, such as `quit the app` or `open a URL` are available as methods on the `app::App`. + +- An async executor that is integrated with the platform's event loop. See the `executor` module for more information., + +- The `[gpui::test]` macro provides a convenient way to write tests for your GPUI applications. Tests also have their own kind of context, a `TestAppContext` which provides ways of simulating common platform input. See `app::test_context` and `test` modules for more details. + +Currently, the best way to learn about these APIs is to read the Zed source code or drop a question in the [Zed Discord](https://zed.dev/community-links). We're working on improving the documentation, creating more examples, and will be publishing more guides to GPUI on our [blog](https://zed.dev/blog). diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs new file mode 100644 index 0000000000..b1bfd2194f --- /dev/null +++ b/crates/gpui/build.rs @@ -0,0 +1,23 @@ +#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] + +fn main() { + println!("cargo::rustc-check-cfg=cfg(gles)"); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + + if target_os == "windows" { + #[cfg(feature = "windows-manifest")] + embed_resource(); + } +} + +#[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"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rerun-if-changed={}", rc_file.display()); + embed_resource::compile(rc_file, embed_resource::NONE) + .manifest_required() + .unwrap(); +} diff --git a/docs/contexts.md b/crates/gpui/docs/contexts.md similarity index 100% rename from docs/contexts.md rename to crates/gpui/docs/contexts.md diff --git a/docs/key_dispatch.md b/crates/gpui/docs/key_dispatch.md similarity index 100% rename from docs/key_dispatch.md rename to crates/gpui/docs/key_dispatch.md diff --git a/examples/bench/data_table.rs b/crates/gpui/examples/bench/data_table.rs similarity index 98% rename from examples/bench/data_table.rs rename to crates/gpui/examples/bench/data_table.rs index dd1a443a9d..04a7e8dd0a 100644 --- a/examples/bench/data_table.rs +++ b/crates/gpui/examples/bench/data_table.rs @@ -1,9 +1,9 @@ use std::{ops::Range, rc::Rc, time::Duration}; use gpui::{ - App, Application, Bounds, Context, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, - Render, SharedString, UniformListScrollHandle, Window, WindowBounds, WindowOptions, canvas, - div, point, prelude::*, px, rgb, size, uniform_list, + canvas, div, point, prelude::*, px, rgb, size, uniform_list, App, Application, Bounds, Context, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, SharedString, + UniformListScrollHandle, Window, WindowBounds, WindowOptions, }; const TOTAL_ITEMS: usize = 10000; @@ -447,7 +447,7 @@ impl Render for DataTable { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { cx.open_window( WindowOptions { focus: true, diff --git a/examples/bench/paths_bench.rs b/crates/gpui/examples/bench/paths_bench.rs similarity index 90% rename from examples/bench/paths_bench.rs rename to crates/gpui/examples/bench/paths_bench.rs index a801889ae8..e2beb307b8 100644 --- a/examples/bench/paths_bench.rs +++ b/crates/gpui/examples/bench/paths_bench.rs @@ -1,7 +1,7 @@ use gpui::{ - Application, Background, Bounds, ColorSpace, Context, Path, PathBuilder, Pixels, Render, - TitlebarOptions, Window, WindowBounds, WindowOptions, canvas, div, linear_color_stop, - linear_gradient, point, prelude::*, px, rgb, size, + canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, rgb, size, Application, + Background, Bounds, ColorSpace, Context, Path, PathBuilder, Pixels, Render, TitlebarOptions, + Window, WindowBounds, WindowOptions, }; const DEFAULT_WINDOW_WIDTH: Pixels = px(1024.0); @@ -69,7 +69,7 @@ impl Render for PaintingViewer { } fn main() { - Application::new().run(|cx| { + gpui_platform::application().run(|cx| { cx.open_window( WindowOptions { titlebar: Some(TitlebarOptions { diff --git a/examples/bench/pattern.rs b/crates/gpui/examples/bench/pattern.rs similarity index 94% rename from examples/bench/pattern.rs rename to crates/gpui/examples/bench/pattern.rs index 8be6642fda..e9c8884c6c 100644 --- a/examples/bench/pattern.rs +++ b/crates/gpui/examples/bench/pattern.rs @@ -1,6 +1,6 @@ use gpui::{ - App, AppContext, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, - linear_color_stop, linear_gradient, pattern_slash, prelude::*, px, rgb, size, + div, linear_color_stop, linear_gradient, pattern_slash, prelude::*, px, rgb, size, App, + AppContext, Application, Bounds, Context, Window, WindowBounds, WindowOptions, }; struct PatternExample; @@ -99,7 +99,7 @@ impl Render for PatternExample { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/bench/shadow.rs b/crates/gpui/examples/bench/shadow.rs similarity index 98% rename from examples/bench/shadow.rs rename to crates/gpui/examples/bench/shadow.rs index a66e9f7889..92a2f13366 100644 --- a/examples/bench/shadow.rs +++ b/crates/gpui/examples/bench/shadow.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, - WindowOptions, current_platform, div, hsla, point, prelude::*, px, relative, rgb, size, + current_platform, div, hsla, point, prelude::*, px, relative, rgb, size, App, Application, + Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, WindowOptions, }; struct Shadow {} @@ -569,7 +569,7 @@ impl Render for Shadow { } fn main() { - Application::with_platform(current_platform(false)).run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(1000.0), px(800.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/learn/animation.rs b/crates/gpui/examples/learn/animation.rs similarity index 98% rename from examples/learn/animation.rs rename to crates/gpui/examples/learn/animation.rs index 8b89315bc2..e462623029 100644 --- a/examples/learn/animation.rs +++ b/crates/gpui/examples/learn/animation.rs @@ -15,9 +15,9 @@ use std::time::Duration; use anyhow::Result; use gpui::colors::Colors; use gpui::{ + bounce, div, ease_in_out, linear, percentage, prelude::*, px, rgb, size as gpui_size, svg, Animation, AnimationExt as _, App, Application, AssetSource, Bounds, Context, Hsla, - SharedString, Transformation, Window, WindowBounds, WindowOptions, bounce, div, ease_in_out, - linear, percentage, prelude::*, px, rgb, size as gpui_size, svg, + SharedString, Transformation, Window, WindowBounds, WindowOptions, }; struct Assets {} @@ -261,7 +261,7 @@ fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> i } fn main() { - Application::new() + gpui_platform::application() .with_assets(Assets {}) .run(|cx: &mut App| { let bounds = Bounds::centered(None, gpui_size(px(500.), px(650.)), cx); diff --git a/examples/learn/async_tasks.rs b/crates/gpui/examples/learn/async_tasks.rs similarity index 96% rename from examples/learn/async_tasks.rs rename to crates/gpui/examples/learn/async_tasks.rs index 00900b0b3f..966737ba27 100644 --- a/examples/learn/async_tasks.rs +++ b/crates/gpui/examples/learn/async_tasks.rs @@ -14,8 +14,8 @@ use std::time::Duration; use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Context, Entity, Render, Task, Window, WindowBounds, WindowOptions, - div, prelude::*, px, rgb, size, + div, prelude::*, px, rgb, size, App, Application, Bounds, Context, Entity, Render, Task, + Window, WindowBounds, WindowOptions, }; // Example 1: Simple Foreground Task @@ -138,24 +138,22 @@ impl CancellableTaskDemo { self.counting_task = None; cx.notify(); } else { - self.counting_task = Some(cx.spawn(async move |this, cx| { - loop { - cx.background_spawn(async { - std::thread::sleep(Duration::from_millis(100)); + self.counting_task = Some(cx.spawn(async move |this, cx| loop { + cx.background_spawn(async { + std::thread::sleep(Duration::from_millis(100)); + }) + .await; + + let should_continue = this + .update(cx, |this, cx| { + this.counter += 1; + cx.notify(); + true }) - .await; + .unwrap_or(false); - let should_continue = this - .update(cx, |this, cx| { - this.counter += 1; - cx.notify(); - true - }) - .unwrap_or(false); - - if !should_continue { - break; - } + if !should_continue { + break; } })); cx.notify(); @@ -546,7 +544,7 @@ fn progress_bar(colors: &Colors, progress: u32) -> impl IntoElement { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(550.), px(850.)), cx); cx.open_window( WindowOptions { diff --git a/examples/learn/creating_components.rs b/crates/gpui/examples/learn/creating_components.rs similarity index 98% rename from examples/learn/creating_components.rs rename to crates/gpui/examples/learn/creating_components.rs index 6de5d31b2a..075733d5aa 100644 --- a/examples/learn/creating_components.rs +++ b/crates/gpui/examples/learn/creating_components.rs @@ -13,8 +13,8 @@ mod example_prelude; use example_prelude::init_example; use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Context, Entity, IntoElement, Render, RenderOnce, Window, - WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, + div, prelude::*, px, rgb, size, App, Application, Bounds, Context, Entity, IntoElement, Render, + RenderOnce, Window, WindowBounds, WindowOptions, }; // ============================================================================ @@ -419,7 +419,7 @@ impl Render for CreatingComponentsExample { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(700.), px(400.)), cx); cx.open_window( WindowOptions { diff --git a/examples/learn/custom_drawing.rs b/crates/gpui/examples/learn/custom_drawing.rs similarity index 98% rename from examples/learn/custom_drawing.rs rename to crates/gpui/examples/learn/custom_drawing.rs index 84f5dc41d2..730eb97665 100644 --- a/examples/learn/custom_drawing.rs +++ b/crates/gpui/examples/learn/custom_drawing.rs @@ -9,9 +9,9 @@ use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Context, Hsla, MouseButton, MouseDownEvent, MouseMoveEvent, - MouseUpEvent, Path, PathBuilder, Pixels, Point, Render, Rgba, Window, WindowBounds, - WindowOptions, canvas, div, fill, point, prelude::*, px, rgb, size, + canvas, div, fill, point, prelude::*, px, rgb, size, App, Application, Bounds, Context, Hsla, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Path, PathBuilder, Pixels, Point, + Render, Rgba, Window, WindowBounds, WindowOptions, }; #[path = "../prelude.rs"] @@ -464,7 +464,7 @@ fn section( } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(550.), px(800.)), cx); cx.open_window( WindowOptions { diff --git a/examples/learn/interactive_elements.rs b/crates/gpui/examples/learn/interactive_elements.rs similarity index 98% rename from examples/learn/interactive_elements.rs rename to crates/gpui/examples/learn/interactive_elements.rs index 6ddbfc2fc8..359e1faa0f 100644 --- a/examples/learn/interactive_elements.rs +++ b/crates/gpui/examples/learn/interactive_elements.rs @@ -13,9 +13,9 @@ mod example_prelude; use example_prelude::init_example; use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, ClickEvent, Context, Entity, Half, Hsla, IntoElement, MouseButton, - MouseMoveEvent, Pixels, Point, Render, Window, WindowBounds, WindowOptions, div, prelude::*, - px, rgb, size, + div, prelude::*, px, rgb, size, App, Application, Bounds, ClickEvent, Context, Entity, Half, + Hsla, IntoElement, MouseButton, MouseMoveEvent, Pixels, Point, Render, Window, WindowBounds, + WindowOptions, }; // ============================================================================ @@ -552,7 +552,7 @@ impl Render for InteractiveElementsExample { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(700.), px(650.)), cx); cx.open_window( WindowOptions { diff --git a/examples/learn/layout.rs b/crates/gpui/examples/learn/layout.rs similarity index 98% rename from examples/learn/layout.rs rename to crates/gpui/examples/learn/layout.rs index c13f2e81fb..62b17a256b 100644 --- a/examples/learn/layout.rs +++ b/crates/gpui/examples/learn/layout.rs @@ -12,8 +12,8 @@ mod example_prelude; use example_prelude::init_example; use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Context, Div, Hsla, Render, Rgba, Window, WindowBounds, - WindowOptions, div, prelude::*, px, size, + div, prelude::*, px, size, App, Application, Bounds, Context, Div, Hsla, Render, Rgba, Window, + WindowBounds, WindowOptions, }; // Helper: Colored block for visualization @@ -484,7 +484,7 @@ fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> i } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx| { let bounds = Bounds::centered(None, size(px(650.), px(700.)), cx); cx.open_window( WindowOptions { diff --git a/examples/learn/styling.rs b/crates/gpui/examples/learn/styling.rs similarity index 98% rename from examples/learn/styling.rs rename to crates/gpui/examples/learn/styling.rs index 6bff1d197c..64b6842ef5 100644 --- a/examples/learn/styling.rs +++ b/crates/gpui/examples/learn/styling.rs @@ -8,8 +8,8 @@ use gpui::colors::Colors; use gpui::{ - App, Application, Bounds, Context, FocusHandle, Hsla, KeyBinding, Menu, MenuItem, Render, Rgba, - Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, + actions, div, prelude::*, px, rgb, size, App, Application, Bounds, Context, FocusHandle, Hsla, + KeyBinding, Menu, MenuItem, Render, Rgba, Window, WindowBounds, WindowOptions, }; actions!(styling_example, [Quit, Tab, TabPrev]); @@ -480,7 +480,7 @@ fn color_swatch(colors: &Colors, name: &'static str, color: Rgba) -> impl IntoEl } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { cx.activate(true); cx.on_action(|_: &Quit, cx| cx.quit()); cx.bind_keys([ diff --git a/examples/learn/text.rs b/crates/gpui/examples/learn/text.rs similarity index 98% rename from examples/learn/text.rs rename to crates/gpui/examples/learn/text.rs index 99ce86860d..de020a26fb 100644 --- a/examples/learn/text.rs +++ b/crates/gpui/examples/learn/text.rs @@ -14,9 +14,9 @@ mod example_prelude; use example_prelude::init_example; use gpui::{ - App, Application, Bounds, Context, FontStyle, FontWeight, Hsla, Render, StyledText, - TextOverflow, Window, WindowBounds, WindowOptions, colors::Colors, current_platform, div, - prelude::*, px, relative, rgb, size, + colors::Colors, current_platform, div, prelude::*, px, relative, rgb, size, App, Application, + Bounds, Context, FontStyle, FontWeight, Hsla, Render, StyledText, TextOverflow, Window, + WindowBounds, WindowOptions, }; // Text Styling Examples @@ -567,7 +567,7 @@ fn section(colors: &Colors, title: &'static str, content: impl IntoElement) -> i } fn main() { - Application::with_platform(current_platform(false)).run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(650.), px(900.)), cx); cx.open_window( WindowOptions { diff --git a/examples/legacy/focus_visible.rs b/crates/gpui/examples/legacy/focus_visible.rs similarity index 97% rename from examples/legacy/focus_visible.rs rename to crates/gpui/examples/legacy/focus_visible.rs index d7c15396f0..2000007e99 100644 --- a/examples/legacy/focus_visible.rs +++ b/crates/gpui/examples/legacy/focus_visible.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, - Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, + actions, div, prelude::*, px, size, App, Application, Bounds, Context, Div, ElementId, + FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, }; actions!(example, [Tab, TabPrev, Quit]); @@ -192,7 +192,7 @@ impl Render for Example { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/examples/legacy/gif_viewer.rs b/crates/gpui/examples/legacy/gif_viewer.rs similarity index 86% rename from examples/legacy/gif_viewer.rs rename to crates/gpui/examples/legacy/gif_viewer.rs index 3206a2fb7a..3fd0f804fe 100644 --- a/examples/legacy/gif_viewer.rs +++ b/crates/gpui/examples/legacy/gif_viewer.rs @@ -1,4 +1,4 @@ -use gpui::{App, Application, Context, Render, Window, WindowOptions, div, img, prelude::*}; +use gpui::{div, img, prelude::*, App, Application, Context, Render, Window, WindowOptions}; use std::path::PathBuf; struct GifViewer { @@ -24,7 +24,7 @@ impl Render for GifViewer { fn main() { env_logger::init(); - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let gif_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/image/black-cat-typing.gif"); diff --git a/examples/legacy/gradient.rs b/crates/gpui/examples/legacy/gradient.rs similarity index 97% rename from examples/legacy/gradient.rs rename to crates/gpui/examples/legacy/gradient.rs index 30fb3090a3..45b89c696a 100644 --- a/examples/legacy/gradient.rs +++ b/crates/gpui/examples/legacy/gradient.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, ColorSpace, Context, Half, Render, Window, WindowOptions, canvas, - div, linear_color_stop, linear_gradient, point, prelude::*, px, size, + canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, size, App, Application, + Bounds, ColorSpace, Context, Half, Render, Window, WindowOptions, }; struct GradientViewer { @@ -243,7 +243,7 @@ impl Render for GradientViewer { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { cx.open_window( WindowOptions { focus: true, diff --git a/examples/legacy/hello_world.rs b/crates/gpui/examples/legacy/hello_world.rs similarity index 94% rename from examples/legacy/hello_world.rs rename to crates/gpui/examples/legacy/hello_world.rs index 7011093474..98c6e3038f 100644 --- a/examples/legacy/hello_world.rs +++ b/crates/gpui/examples/legacy/hello_world.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div, - prelude::*, px, rgb, size, + div, prelude::*, px, rgb, size, App, Application, Bounds, Context, SharedString, Window, + WindowBounds, WindowOptions, }; struct HelloWorld { @@ -87,7 +87,7 @@ impl Render for HelloWorld { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/legacy/image/app-icon.png b/crates/gpui/examples/legacy/image/app-icon.png similarity index 100% rename from examples/legacy/image/app-icon.png rename to crates/gpui/examples/legacy/image/app-icon.png diff --git a/examples/legacy/image/arrow_circle.svg b/crates/gpui/examples/legacy/image/arrow_circle.svg similarity index 100% rename from examples/legacy/image/arrow_circle.svg rename to crates/gpui/examples/legacy/image/arrow_circle.svg diff --git a/examples/legacy/image/black-cat-typing.gif b/crates/gpui/examples/legacy/image/black-cat-typing.gif similarity index 100% rename from examples/legacy/image/black-cat-typing.gif rename to crates/gpui/examples/legacy/image/black-cat-typing.gif diff --git a/examples/legacy/image/color.svg b/crates/gpui/examples/legacy/image/color.svg similarity index 100% rename from examples/legacy/image/color.svg rename to crates/gpui/examples/legacy/image/color.svg diff --git a/examples/legacy/image/image.rs b/crates/gpui/examples/legacy/image/image.rs similarity index 95% rename from examples/legacy/image/image.rs rename to crates/gpui/examples/legacy/image/image.rs index 34a510f76d..a8e282ce8e 100644 --- a/examples/legacy/image/image.rs +++ b/crates/gpui/examples/legacy/image/image.rs @@ -4,9 +4,9 @@ use std::sync::Arc; use anyhow::Result; use gpui::{ - App, AppContext, Application, AssetSource, Bounds, Context, ImageSource, KeyBinding, Menu, - MenuItem, Point, SharedString, SharedUri, TitlebarOptions, Window, WindowBounds, WindowOptions, - actions, div, img, prelude::*, px, rgb, size, + actions, div, img, prelude::*, px, rgb, size, App, AppContext, Application, AssetSource, + Bounds, Context, ImageSource, KeyBinding, Menu, MenuItem, Point, SharedString, SharedUri, + TitlebarOptions, Window, WindowBounds, WindowOptions, }; use reqwest_client::ReqwestClient; @@ -150,7 +150,7 @@ fn main() { let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - Application::new() + gpui_platform::application() .with_assets(Assets { base: manifest_dir.join("examples"), }) diff --git a/examples/legacy/image_loading.rs b/crates/gpui/examples/legacy/image_loading.rs similarity index 95% rename from examples/legacy/image_loading.rs rename to crates/gpui/examples/legacy/image_loading.rs index 399bd2634f..f85c4e1d9d 100644 --- a/examples/legacy/image_loading.rs +++ b/crates/gpui/examples/legacy/image_loading.rs @@ -1,10 +1,10 @@ use std::{path::Path, sync::Arc, time::Duration}; use gpui::{ - Animation, AnimationExt, App, Application, Asset, AssetLogger, AssetSource, Bounds, Context, - Hsla, ImageAssetLoader, ImageCacheError, ImgResourceLoader, LOADING_DELAY, Length, RenderImage, - Resource, SharedString, Window, WindowBounds, WindowOptions, black, div, img, prelude::*, - pulsating_between, px, red, size, + black, div, img, prelude::*, pulsating_between, px, red, size, Animation, AnimationExt, App, + Application, Asset, AssetLogger, AssetSource, Bounds, Context, Hsla, ImageAssetLoader, + ImageCacheError, ImgResourceLoader, Length, RenderImage, Resource, SharedString, Window, + WindowBounds, WindowOptions, LOADING_DELAY, }; struct Assets {} @@ -193,7 +193,7 @@ impl Render for ImageLoadingExample { fn main() { env_logger::init(); - Application::new() + gpui_platform::application() .with_assets(Assets {}) .run(|cx: &mut App| { let options = WindowOptions { diff --git a/examples/legacy/input.rs b/crates/gpui/examples/legacy/input.rs similarity index 99% rename from examples/legacy/input.rs rename to crates/gpui/examples/legacy/input.rs index aac56bdf1d..0ea91ca75e 100644 --- a/examples/legacy/input.rs +++ b/crates/gpui/examples/legacy/input.rs @@ -682,7 +682,7 @@ impl Render for InputExample { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.bind_keys([ KeyBinding::new("backspace", Backspace, None), diff --git a/examples/legacy/layer_shell.rs b/crates/gpui/examples/legacy/layer_shell.rs similarity index 81% rename from examples/legacy/layer_shell.rs rename to crates/gpui/examples/legacy/layer_shell.rs index 51577b1b26..cbf9840610 100644 --- a/examples/legacy/layer_shell.rs +++ b/crates/gpui/examples/legacy/layer_shell.rs @@ -11,22 +11,20 @@ mod example { use std::time::{Duration, SystemTime, UNIX_EPOCH}; use gpui::{ - App, Application, Bounds, Context, FontWeight, Size, Window, WindowBackgroundAppearance, - WindowBounds, WindowKind, WindowOptions, div, layer_shell::*, point, prelude::*, px, rems, - rgba, white, + div, layer_shell::*, point, prelude::*, px, rems, rgba, white, App, Application, Bounds, + Context, FontWeight, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowKind, + WindowOptions, }; struct LayerShellExample; impl LayerShellExample { fn new(cx: &mut Context) -> Self { - cx.spawn(async move |this, cx| { - loop { - let _ = this.update(cx, |_, cx| cx.notify()); - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - } + cx.spawn(async move |this, cx| loop { + let _ = this.update(cx, |_, cx| cx.notify()); + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; }) .detach(); @@ -60,7 +58,7 @@ mod example { } pub fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { cx.open_window( WindowOptions { titlebar: None, diff --git a/examples/legacy/on_window_close_quit.rs b/crates/gpui/examples/legacy/on_window_close_quit.rs similarity index 91% rename from examples/legacy/on_window_close_quit.rs rename to crates/gpui/examples/legacy/on_window_close_quit.rs index 9a2b2f2fee..577217e415 100644 --- a/examples/legacy/on_window_close_quit.rs +++ b/crates/gpui/examples/legacy/on_window_close_quit.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, FocusHandle, KeyBinding, Window, WindowBounds, - WindowOptions, actions, div, prelude::*, px, rgb, size, + actions, div, prelude::*, px, rgb, size, App, Application, Bounds, Context, FocusHandle, + KeyBinding, Window, WindowBounds, WindowOptions, }; actions!(example, [CloseWindow]); @@ -35,7 +35,7 @@ impl Render for ExampleWindow { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let mut bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.bind_keys([KeyBinding::new("cmd-w", CloseWindow, None)]); diff --git a/examples/legacy/opacity.rs b/crates/gpui/examples/legacy/opacity.rs similarity index 96% rename from examples/legacy/opacity.rs rename to crates/gpui/examples/legacy/opacity.rs index b6c01fc3cf..e31f4e0be1 100644 --- a/examples/legacy/opacity.rs +++ b/crates/gpui/examples/legacy/opacity.rs @@ -2,8 +2,8 @@ use std::{fs, path::PathBuf}; use anyhow::Result; use gpui::{ - App, Application, AssetSource, Bounds, BoxShadow, ClickEvent, Context, SharedString, Task, - Window, WindowBounds, WindowOptions, div, hsla, img, point, prelude::*, px, rgb, size, svg, + div, hsla, img, point, prelude::*, px, rgb, size, svg, App, Application, AssetSource, Bounds, + BoxShadow, ClickEvent, Context, SharedString, Task, Window, WindowBounds, WindowOptions, }; struct Assets { @@ -156,7 +156,7 @@ impl Render for HelloWorld { } fn main() { - Application::new() + gpui_platform::application() .with_assets(Assets { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), }) diff --git a/examples/legacy/scrollable.rs b/crates/gpui/examples/legacy/scrollable.rs similarity index 92% rename from examples/legacy/scrollable.rs rename to crates/gpui/examples/legacy/scrollable.rs index b668c19c40..4a3845004a 100644 --- a/examples/legacy/scrollable.rs +++ b/crates/gpui/examples/legacy/scrollable.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, - size, + div, prelude::*, px, size, App, Application, Bounds, Context, Window, WindowBounds, + WindowOptions, }; struct Scrollable {} @@ -45,7 +45,7 @@ impl Render for Scrollable { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/legacy/svg/dragon.svg b/crates/gpui/examples/legacy/svg/dragon.svg similarity index 100% rename from examples/legacy/svg/dragon.svg rename to crates/gpui/examples/legacy/svg/dragon.svg diff --git a/examples/legacy/svg/svg.rs b/crates/gpui/examples/legacy/svg/svg.rs similarity index 91% rename from examples/legacy/svg/svg.rs rename to crates/gpui/examples/legacy/svg/svg.rs index 81792af999..5cddc06d59 100644 --- a/examples/legacy/svg/svg.rs +++ b/crates/gpui/examples/legacy/svg/svg.rs @@ -3,8 +3,8 @@ use std::path::PathBuf; use anyhow::Result; use gpui::{ - App, Application, AssetSource, Bounds, Context, SharedString, Window, WindowBounds, - WindowOptions, current_platform, div, prelude::*, px, rgb, size, svg, + current_platform, div, prelude::*, px, rgb, size, svg, App, Application, AssetSource, Bounds, + Context, SharedString, Window, WindowBounds, WindowOptions, }; struct Assets { @@ -68,7 +68,7 @@ impl Render for SvgExample { } fn main() { - Application::with_platform(current_platform(false)) + gpui_platform::application() .with_assets(Assets { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), }) diff --git a/examples/legacy/tab_stop.rs b/crates/gpui/examples/legacy/tab_stop.rs similarity index 95% rename from examples/legacy/tab_stop.rs rename to crates/gpui/examples/legacy/tab_stop.rs index 4d99da1a07..534ef4c101 100644 --- a/examples/legacy/tab_stop.rs +++ b/crates/gpui/examples/legacy/tab_stop.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, - Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, + actions, div, prelude::*, px, size, App, Application, Bounds, Context, Div, ElementId, + FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, }; actions!(example, [Tab, TabPrev]); @@ -134,7 +134,7 @@ impl Render for Example { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/examples/legacy/tree.rs b/crates/gpui/examples/legacy/tree.rs similarity index 89% rename from examples/legacy/tree.rs rename to crates/gpui/examples/legacy/tree.rs index 1bd4592003..de1b253593 100644 --- a/examples/legacy/tree.rs +++ b/crates/gpui/examples/legacy/tree.rs @@ -3,8 +3,8 @@ use std::sync::LazyLock; use gpui::{ - App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, - size, + div, prelude::*, px, size, App, Application, Bounds, Context, Window, WindowBounds, + WindowOptions, }; struct Tree {} @@ -32,7 +32,7 @@ impl Render for Tree { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/legacy/uniform_list.rs b/crates/gpui/examples/legacy/uniform_list.rs similarity index 88% rename from examples/legacy/uniform_list.rs rename to crates/gpui/examples/legacy/uniform_list.rs index fc5d92bdd4..5c470d4b55 100644 --- a/examples/legacy/uniform_list.rs +++ b/crates/gpui/examples/legacy/uniform_list.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, - rgb, size, uniform_list, + div, prelude::*, px, rgb, size, uniform_list, App, Application, Bounds, Context, Window, + WindowBounds, WindowOptions, }; struct UniformListExample {} @@ -36,7 +36,7 @@ impl Render for UniformListExample { } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/legacy/window.rs b/crates/gpui/examples/legacy/window.rs similarity index 97% rename from examples/legacy/window.rs rename to crates/gpui/examples/legacy/window.rs index 4c275d4cb7..e7c49f63e4 100644 --- a/examples/legacy/window.rs +++ b/crates/gpui/examples/legacy/window.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Application, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Window, WindowBounds, - WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, + actions, div, prelude::*, px, rgb, size, App, Application, Bounds, Context, KeyBinding, + PromptButton, PromptLevel, Window, WindowBounds, WindowKind, WindowOptions, }; struct SubWindow { @@ -241,7 +241,7 @@ impl Render for WindowDemo { actions!(window, [Quit]); fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( diff --git a/examples/legacy/window_positioning.rs b/crates/gpui/examples/legacy/window_positioning.rs similarity index 96% rename from examples/legacy/window_positioning.rs rename to crates/gpui/examples/legacy/window_positioning.rs index ca6cd535d6..3adfe22850 100644 --- a/examples/legacy/window_positioning.rs +++ b/crates/gpui/examples/legacy/window_positioning.rs @@ -1,7 +1,7 @@ use gpui::{ - App, Application, Bounds, Context, DisplayId, Hsla, Pixels, SharedString, Size, Window, - WindowBackgroundAppearance, WindowBounds, WindowKind, WindowOptions, div, point, prelude::*, - px, rgb, + div, point, prelude::*, px, rgb, App, Application, Bounds, Context, DisplayId, Hsla, Pixels, + SharedString, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowKind, + WindowOptions, }; struct WindowContent { @@ -68,7 +68,7 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds) -> Window } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { // Create several new windows, positioned in the top right corner of each screen let size = Size { width: px(350.), diff --git a/examples/legacy/window_shadow.rs b/crates/gpui/examples/legacy/window_shadow.rs similarity index 97% rename from examples/legacy/window_shadow.rs rename to crates/gpui/examples/legacy/window_shadow.rs index 469017da79..4fa3b33702 100644 --- a/examples/legacy/window_shadow.rs +++ b/crates/gpui/examples/legacy/window_shadow.rs @@ -1,8 +1,8 @@ use gpui::{ - App, Application, Bounds, Context, CursorStyle, Decorations, HitboxBehavior, Hsla, MouseButton, + black, canvas, div, green, point, prelude::*, px, rgb, size, transparent_black, white, App, + Application, Bounds, Context, CursorStyle, Decorations, HitboxBehavior, Hsla, MouseButton, Pixels, Point, ResizeEdge, Size, Window, WindowBackgroundAppearance, WindowBounds, - WindowDecorations, WindowOptions, black, canvas, div, green, point, prelude::*, px, rgb, size, - transparent_black, white, + WindowDecorations, WindowOptions, }; struct WindowShadow {} @@ -203,7 +203,7 @@ fn resize_edge(pos: Point, shadow_size: Pixels, size: Size) -> O } fn main() { - Application::new().run(|cx: &mut App| { + gpui_platform::application().run(|cx: &mut App| { let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/examples/prelude.rs b/crates/gpui/examples/prelude.rs similarity index 91% rename from examples/prelude.rs rename to crates/gpui/examples/prelude.rs index c16408b5ca..63b6c50f89 100644 --- a/examples/prelude.rs +++ b/crates/gpui/examples/prelude.rs @@ -8,7 +8,7 @@ //! use example_prelude::init_example; //! ``` -use gpui::{App, KeyBinding, Menu, MenuItem, SharedString, actions}; +use gpui::{actions, App, KeyBinding, Menu, MenuItem, SharedString}; actions!(example, [Quit, CloseWindow]); @@ -33,7 +33,7 @@ pub fn init_example(cx: &mut App, name: impl Into) { }]); // Quit the app when all windows are closed - cx.on_window_closed(|cx| { + cx.on_window_closed(|cx, _| { if cx.windows().is_empty() { cx.quit(); } diff --git a/resources/windows/gpui.manifest.xml b/crates/gpui/resources/windows/gpui.manifest.xml similarity index 92% rename from resources/windows/gpui.manifest.xml rename to crates/gpui/resources/windows/gpui.manifest.xml index c3a99d23ff..d11e1a4b4d 100644 --- a/resources/windows/gpui.manifest.xml +++ b/crates/gpui/resources/windows/gpui.manifest.xml @@ -16,6 +16,7 @@ true/pm PerMonitorV2 + SegmentHeap diff --git a/resources/windows/gpui.rc b/crates/gpui/resources/windows/gpui.rc similarity index 100% rename from resources/windows/gpui.rc rename to crates/gpui/resources/windows/gpui.rc diff --git a/crates/gpui/src/_accessibility.rs b/crates/gpui/src/_accessibility.rs new file mode 100644 index 0000000000..155dd6506e --- /dev/null +++ b/crates/gpui/src/_accessibility.rs @@ -0,0 +1,243 @@ +//! # Accessibility in GPUI +//! +//! "Accessibility" refers to the ability of your application to be used by all +//! users, regardless of disability status. There are many aspects, all important, including: +//! - Ensuring sufficient text contrast. +//! - Providing a mechanism to disable animations. +//! - Providing a mechanism to increase text sizes. +//! - etc. +//! +//! This guide is focused on **programmatic accessibility**. This allows +//! assistive technology, such as screen readers or Braille displays, to inspect +//! and interact with your app. +//! +//! GPUI integrates with [AccessKit] to provide programmatic accessibility +//! features (referred to as simply "accessibility" for the rest of this guide). +//! +//! A minimal example can be found in the `examples/a11y` directory. +//! +//! ## Background +//! +//! Accessibility support is based on two key capabilities: +//! - Exposing information about the current UI state to assistive technology. +//! - Responding to actions requested by assistive technology. +//! +//! For example, a screen reader might want to announce to the user that a new +//! button has appeared. The user may then want to use a voice control program +//! to press that button. +//! +//! ### IDs in GPUI - [`ElementId`] and [`GlobalElementId`] +//! +//! In GPUI, each [`Element`] can have an [`id`][Element::id]: +//! ```rust +//! # use gpui::*; +//! let div_with_id = div().id("my-id").child(text!("hello")); +//! +//! // IDs are optional +//! let div_without_id = div().child(text!("hello")); +//! ``` +//! +//! [`Element`]s with IDs are also assigned a [`GlobalElementId`]. This global +//! ID is formed by composing all the non-`None` IDs of its ancestors. For +//! example: +//! ```rust +//! # use gpui::*; +//! let inner = div().id("inner-id"); +//! let middle = div().child(inner); // no ID +//! let outer = div().id("outer-id").child(middle); +//! ``` +//! In this example, `inner`s global ID is (roughly speaking) `["outer-id", +//! "inner-id"]`. +//! +//! Since `middle` doesn't have an ID itself, it has no global ID. +//! +//! [`GlobalElementId`]s should be unique per-frame. Duplicate global IDs in the +//! same frame will likely cause bugs. +//! +//! ### IDs and accessibility +//! +//! When GPUI renders a frame, it walks your UI tree, and finds nodes with +//! global IDs, and informs assistive technology about this node. +//! +//! In order for nodes to be reported, they must also have a non-`None` +//! [`role`][Element::a11y_role]. This is used to inform assistive technology +//! what *sort* of node it is (button, label, table, etc.). You can use +//! [`div().id(...).role()`][StatefulInteractiveElement::role] to set the role. +//! +//! Nodes with the same global ID *across frames* are considered to be "the +//! same" node. For example: +//! ```rust +//! # use gpui::*; +//! // The UI in frame 1 +//! let frame_1 = div() +//! .id("parent") +//! .role(Role::Button) +//! .child( +//! div() +//! .id("id-1") +//! .role(Role::Label) +//! .child(text!("hello")) +//! ); +//! +//! // The UI on the next frame +//! let frame_2 = div() +//! .id("parent") +//! .role(Role::Button) +//! .child( +//! div() +//! .id("id-2") // <- different ID +//! .role(Role::Label) +//! .child(text!("hello")) +//! ); +//! ``` +//! Logically, the UI has not changed. But the screen reader has no way of +//! knowing that both child [`div`]s are "the same". So assistive technology +//! will interpret this as one node being removed, and another node being added. +//! This can be very disorienting for users, since announcements typically only +//! happen when something has *meaningfully* changed. +//! +//! In other words, by controlling the ID of an element, you can control whether +//! a change to a UI element is considered meaningful. You can also control +//! whether elements are reported to assistive technology *at all* by setting +//! the [`role`][Element::a11y_role], since nodes with no role are not reported. +//! +//! #### IDs and text +//! +//! Special care must be taken when dealing with text. +//! +//! GPUI provides the [`text!`] macro, which wraps strings in the [`Text`] type, +//! but automatically derives an ID. Usually, this is what you want. However, +//! the way it generates its ID is subtle and perhaps surprising. +//! +//! The ID of an invocation of the [`text!`] macro is derived from the +//! **location in the source code of that invocation**. For example: +//! +//! ```rust +//! # use gpui::*; +//! let a = text!("a"); +//! let b = text!("b"); +//! +//! // Different source locations, different IDs +//! assert_ne!(a.id(), b.id()); +//! +//! // However: +//! +//! fn make_text(s: &str) -> Text { text!(s) } +//! +//! let a = make_text("a"); +//! let b = make_text("b"); +//! +//! // Both `a` and `b` are produced by the same `text!` invocation, so the IDs +//! // are the same +//! assert_eq!(a.id(), b.id()); +//! ``` +//! This can produce surprising behaviour. For example, this footgun: +//! ```rust +//! # use gpui::*; +//! let todos = vec!["eat lunch", "drink water", "go to gym"]; +//! let todo_divs = todos.into_iter().map(|todo| { +//! text!(todo) +//! }); +//! +//! div() +//! .id("todo-list") +//! .role(Role::Document) +//! .children(todo_divs); // ERROR: multiple nodes with the same global ID +//! ``` +//! +//! Here, when we map the iterator, since we have only written [`text!`] once, +//! there is only one ID. And since they have the same ancestors and the same +//! ID, they will have the same global ID. In release builds, this will mean +//! some nodes get silently dropped! +//! +//! To fix this, you can set an ID: +//! ```rust +//! # use gpui::*; +//! let todos = vec!["eat lunch", "drink water", "go to gym"]; +//! let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| { +//! text!(todo).with_id(index) // OR `text(id = index, todo)` +//! }); +//! +//! div() +//! .id("todo-list") +//! .role(Role::Document) +//! .children(todo_divs); +//! ``` +//! Another possible solution is to wrap the [`text!`] in another node that +//! *does* have a unique global ID. For example: +//! ```rust +//! # use gpui::*; +//! let todos = vec!["eat lunch", "drink water", "go to gym"]; +//! let todo_divs = todos.into_iter().enumerate().map(|(index, todo)| { +//! div().id(index).child(text!(todo)) +//! }); +//! +//! div() +//! .id("todo-list") +//! .role(Role::Document) +//! .children(todo_divs); +//! ``` +//! Since the AccessKit [`NodeId`][accesskit::NodeId] is derived from the global +//! ID, and the global ID takes into account the IDs of all ancestors, this +//! works too. +//! +//! Occasionally, you will need to create a [`Text`] element with *no* ID. You +//! can achieve this with [`Text::new_inaccessible`]. If you are creating a +//! custom UI component (e.g. a button), you may want this so that you can set a +//! label property on a parent [`div`] without duplicating the text in the +//! accessibility tree. +//! +//! ### Handling actions +//! +//! Assistive technology can dispatch actions to the UI. While many users of +//! assistive technology use traditional input devices (e.g. a keyboard), some +//! use more specialized systems. For example, users with limited mobility may +//! use voice control to interact with your app. +//! +//! When a user dispatches an action, it is dispatched *to a specific node*. It +//! is your responsibility to tell the UI elements how they should respond when +//! a request comes in. +//! +//! Note, these actions are **totally unrelated** to GPUI's [`Action`] trait. +//! AccessKit exposes [`accesskit::Action`]. In GPUI, this is re-exported as +//! [`AccessibleAction`]. +//! +//! To respond to an accessible action, use +//! [`div().on_a11y_action()`][InteractiveElement::on_a11y_action]: +//! ```rust,ignore +//! div() +//! .id("my-slider") +//! .role(Role::Slider) +//! .on_a11y_action(AccessibleAction::Increment, |_extra, _window, _cx| { +//! position += 1; +//! cx.notify(); +//! }) +//! .child(my_cool_slider()); +//! ``` +//! +//! Note that some common actions are automatically registered. For example, +//! [`.on_click()`][StatefulInteractiveElement::on_click] adds an +//! [`AccessibleAction::Click`] handler that calls the click handler. +//! +//! ## Further reading +//! +//! Designing high-quality accessible interfaces can be challenging, in the same +//! way that designing high-quality traditional interfaces can be. The +//! following pages have useful information: +//! +//! - [AccessKit]: The cross-platform accessibility toolkit GPUI uses +//! internally. +//! - [MDN WAI-ARIA basics][mdn-aria]: Introduction to roles, properties, and +//! states. +//! - [ARIA Authoring Practices Guide][apg]: W3C patterns for accessible +//! widgets. +//! +//! Note that, while GPUI mimics web APIs, it doesn't necessarily behave +//! *exactly* as a web browser would with the same attributes. +//! +//! [AccessKit]: https://accesskit.dev/ +//! [mdn-aria]: https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/Accessibility/WAI-ARIA_basics +//! [apg]: https://www.w3.org/WAI/ARIA/apg/ + +#[cfg(doc)] +use crate::*; // so I don't have to qualify every type :) diff --git a/src/_ownership_and_data_flow.rs b/crates/gpui/src/_ownership_and_data_flow.rs similarity index 100% rename from src/_ownership_and_data_flow.rs rename to crates/gpui/src/_ownership_and_data_flow.rs diff --git a/src/action.rs b/crates/gpui/src/action.rs similarity index 100% rename from src/action.rs rename to crates/gpui/src/action.rs diff --git a/src/app.rs b/crates/gpui/src/app.rs similarity index 93% rename from src/app.rs rename to crates/gpui/src/app.rs index 5b5d78b3ca..c8f1032fbd 100644 --- a/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1,4 +1,4 @@ -use crate::scheduler::Instant; +use scheduler::Instant; use std::{ any::{TypeId, type_name}, cell::{BorrowMutError, Cell, Ref, RefCell, RefMut}, @@ -26,6 +26,7 @@ pub use async_context::*; use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use context::*; pub use entity_map::*; +use gpui_util::{ResultExt, debug_panic}; #[cfg(any(test, feature = "test-support"))] pub use headless_app_context::*; use http_client::{HttpClient, Url}; @@ -34,7 +35,6 @@ use smallvec::SmallVec; pub use test_app::*; #[cfg(any(test, feature = "test-support"))] pub use test_context::*; -use util::{ResultExt, debug_panic}; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test_context::*; @@ -49,7 +49,8 @@ use crate::{ PlatformKeyboardMapper, Point, Priority, PromptBuilder, PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextRenderingMode, TextSystem, - ThermalState, Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, + ThermalState, Window, WindowAppearance, WindowButtonLayout, WindowHandle, WindowId, + WindowInvalidator, colors::{Colors, GlobalColors}, hash, init_app_menus, }; @@ -141,11 +142,6 @@ pub struct Application(Rc); /// Represents an application before it is fully launched. Once your app is /// configured, you'll start the app with `App::run`. impl Application { - /// Builds an app with the default platform for the current OS. - pub fn new() -> Self { - Self::with_platform(crate::current_platform(false)) - } - /// Builds an app with a caller-provided platform implementation. pub fn with_platform(platform: Rc) -> Self { Self(App::new_app( @@ -155,6 +151,21 @@ impl Application { )) } + /// Builds an app with accessibility (AccessKit) integration forcibly + /// disabled. + /// + /// In this mode, accessibility APIs (e.g. + /// [`div().role()`][crate::StatefulInteractiveElement::role]) silently + /// no-op. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview of + /// the features this disables. + pub fn new_inaccessible(platform: Rc) -> Self { + let this = Self::with_platform(platform); + this.0.borrow_mut().accessibility_force_disabled = true; + this + } + /// Assigns the source of assets for the application. pub fn with_assets(self, asset_source: impl AssetSource) -> Self { let mut context_lock = self.0.borrow_mut(); @@ -245,7 +256,7 @@ type Listener = Box bool + 'static>; pub(crate) type KeystrokeObserver = Box bool + 'static>; type QuitHandler = Box LocalBoxFuture<'static, ()> + 'static>; -type WindowClosedHandler = Box; +type WindowClosedHandler = Box; type ReleaseListener = Box; type NewEntityListener = Box, &mut App) + 'static>; @@ -261,6 +272,22 @@ pub enum QuitMode { Explicit, } +/// Controls when GPUI hides the mouse cursor in response to keyboard input. +/// +/// Restoration on mouse motion is handled by the platform layer; this enum +/// only describes the policy for *triggering* a hide. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] +pub enum CursorHideMode { + /// Never hide the cursor automatically. + Never, + /// Hide on character-producing key presses (typing). + OnTyping, + /// Hide on character-producing key presses, *and* when a key binding + /// resolves to an action that consumes the keystroke. + #[default] + OnTypingAndAction, +} + #[doc(hidden)] #[derive(Clone, PartialEq, Eq)] pub struct SystemWindowTab { @@ -642,6 +669,7 @@ pub struct App { pub(crate) window_invalidators_by_entity: FxHashMap>, pub(crate) tracked_entities: FxHashMap>, + pub(crate) current_window_by_entity: FxHashMap, #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) inspector_renderer: Option, #[cfg(any(feature = "inspector", debug_assertions))] @@ -652,6 +680,10 @@ pub struct App { pub(crate) window_update_stack: Vec, pub(crate) mode: GpuiMode, + pub(crate) cursor_hide_mode: CursorHideMode, + /// Whether the app was created by [`Application::new_inaccessible`]. No + /// accesskit APIs will be called when this flag is set. + pub(crate) accessibility_force_disabled: bool, flushing_effects: bool, pending_updates: usize, quit_mode: QuitMode, @@ -719,6 +751,7 @@ impl App { observers: SubscriberSet::new(), tracked_entities: FxHashMap::default(), window_invalidators_by_entity: FxHashMap::default(), + current_window_by_entity: FxHashMap::default(), event_listeners: SubscriberSet::new(), release_listeners: SubscriberSet::new(), keystroke_observers: SubscriberSet::new(), @@ -739,6 +772,8 @@ impl App { inspector_element_registry: InspectorElementRegistry::default(), quit_mode: QuitMode::default(), quitting: false, + cursor_hide_mode: CursorHideMode::default(), + accessibility_force_disabled: false, #[cfg(any(test, feature = "test-support", debug_assertions))] name: None, @@ -879,6 +914,27 @@ impl App { self.platform.quit(); } + /// Returns the current policy for hiding the cursor in response to + /// keyboard input. + pub fn cursor_hide_mode(&self) -> CursorHideMode { + self.cursor_hide_mode + } + + /// Sets the policy controlling when GPUI hides the cursor in response + /// to keyboard input. + pub fn set_cursor_hide_mode(&mut self, mode: CursorHideMode) { + self.cursor_hide_mode = mode; + } + + /// Returns whether the cursor is currently visible according to the + /// platform. This will report `false` after a keyboard input has hidden + /// the cursor and the user has not yet moved the mouse to restore it. + /// + /// See [`App::set_cursor_hide_mode`]. + pub fn is_cursor_visible(&self) -> bool { + self.platform.is_cursor_visible() + } + /// Schedules all windows in the application to be redrawn. This can be called /// multiple times in an update cycle and still result in a single redraw. pub fn refresh_windows(&mut self) { @@ -956,6 +1012,8 @@ impl App { .entry(*entity) .or_default() .insert(window_handle.id, invalidator.clone()); + self.current_window_by_entity + .insert(*entity, window_handle.id); } tracked_entities.clear(); tracked_entities.extend(entities.iter().copied()); @@ -1182,6 +1240,11 @@ impl App { self.platform.window_appearance() } + /// Returns the window button layout configuration when supported. + pub fn button_layout(&self) -> Option { + self.platform.button_layout() + } + /// Reads data from the platform clipboard. pub fn read_from_clipboard(&self) -> Option { self.platform.read_from_clipboard() @@ -1457,6 +1520,8 @@ impl App { for (entity_id, mut entity) in dropped { self.observers.remove(&entity_id); self.event_listeners.remove(&entity_id); + self.window_invalidators_by_entity.remove(&entity_id); + self.current_window_by_entity.remove(&entity_id); for release_callback in self.release_listeners.remove(&entity_id) { release_callback(entity.as_mut(), self); } @@ -1533,6 +1598,13 @@ impl App { tid: TypeId, window: Option, ) { + // Seed the entity's current window from its creation context so + // `with_window` resolves correctly before the entity has ever been + // rendered. + if let Some(id) = window { + self.current_window_by_entity.insert(entity.entity_id(), id); + } + self.new_entity_observers.clone().retain(&tid, |observer| { if let Some(id) = window { self.update_window_id(id, { @@ -1547,7 +1619,28 @@ impl App { }); } - fn update_window_id(&mut self, id: WindowId, update: F) -> Result + /// Run `f` against the entity's *current* window — the most recently + /// rendered window that referenced the entity, or its creation window if + /// it has yet to be rendered. Returns `None` if the entity has no + /// current window, or if that window has been closed, or if it is + /// already on the update stack. + pub fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + let window_id = *self.current_window_by_entity.get(&entity_id)?; + self.update_window_id(window_id, |_, window, cx| f(window, cx)) + .ok() + } + + fn ensure_window(&mut self, entity_id: EntityId, window: WindowId) { + self.current_window_by_entity + .entry(entity_id) + .or_insert(window); + } + + pub(crate) fn update_window_id(&mut self, id: WindowId, update: F) -> Result where F: FnOnce(AnyView, &mut Window, &mut App) -> T, { @@ -1564,9 +1657,21 @@ impl App { if window.removed { cx.window_handles.remove(&id); cx.windows.remove(id); + if let Some(tracked) = cx.tracked_entities.remove(&id) { + for entity_id in tracked { + if let Some(windows) = + cx.window_invalidators_by_entity.get_mut(&entity_id) + { + windows.remove(&id); + } + if cx.current_window_by_entity.get(&entity_id) == Some(&id) { + cx.current_window_by_entity.remove(&entity_id); + } + } + } cx.window_closed_observers.clone().retain(&(), |callback| { - callback(cx); + callback(cx, id); true }); @@ -1679,8 +1784,7 @@ impl App { self.globals_by_type .get(&TypeId::of::()) .map(|any_state| any_state.downcast_ref::().unwrap()) - .with_context(|| format!("no state of type {} exists", type_name::())) - .unwrap() + .unwrap_or_else(|| panic!("no state of type {} exists", type_name::())) } /// Access the global of the given type if a value has been assigned. @@ -1698,8 +1802,7 @@ impl App { self.globals_by_type .get_mut(&global_type) .and_then(|any_state| any_state.downcast_mut::()) - .with_context(|| format!("no state of type {} exists", type_name::())) - .unwrap() + .unwrap_or_else(|| panic!("no state of type {} exists", type_name::())) } /// Access the global of the given type mutably. A default value is assigned if a global of this type has not @@ -1734,7 +1837,7 @@ impl App { *self .globals_by_type .remove(&global_type) - .unwrap_or_else(|| panic!("no global added for {}", std::any::type_name::())) + .unwrap_or_else(|| panic!("no global added for {}", type_name::())) .downcast() .unwrap() } @@ -2040,7 +2143,10 @@ impl App { /// Register a callback to be invoked when a window is closed /// The window is no longer accessible at the point this callback is invoked. - pub fn on_window_closed(&self, mut on_closed: impl FnMut(&mut App) + 'static) -> Subscription { + pub fn on_window_closed( + &self, + mut on_closed: impl FnMut(&mut App, WindowId) + 'static, + ) -> Subscription { let (subscription, activate) = self.window_closed_observers.insert((), Box::new(on_closed)); activate(); subscription @@ -2279,13 +2385,27 @@ impl App { .or_default(), ); - if window_invalidators.is_empty() { + // `window_invalidators_by_entity` is monotonic, so an entry alone + // doesn't mean the window is currently rendering the entity. Filter + // through `tracked_entities` to keep invalidation tight to windows + // that actually display this entity right now. + let live_invalidators: SmallVec<[WindowInvalidator; 2]> = window_invalidators + .iter() + .filter(|(window_id, _)| { + self.tracked_entities + .get(window_id) + .is_some_and(|set| set.contains(&entity_id)) + }) + .map(|(_, invalidator)| invalidator.clone()) + .collect(); + + if live_invalidators.is_empty() { if self.pending_notifications.insert(entity_id) { self.pending_effects .push_back(Effect::Notify { emitter: entity_id }); } } else { - for invalidator in window_invalidators.values() { + for invalidator in &live_invalidators { invalidator.invalidate_view(entity_id, self); } } @@ -2356,13 +2476,12 @@ impl AppContext for App { let entity = build_entity(&mut Context::new_context(cx, slot.downgrade())); cx.push_effect(Effect::EntityCreated { - entity: handle.clone().into_any(), + entity: handle.into_any(), tid: TypeId::of::(), window: cx.window_update_stack.last().cloned(), }); - cx.entities.insert(slot, entity); - handle + cx.entities.insert(slot, entity) }) } @@ -2422,6 +2541,14 @@ impl AppContext for App { self.update_window_id(handle.id, update) } + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + App::with_window(self, entity_id, f) + } + fn read_window( &self, window: &WindowHandle, @@ -2578,10 +2705,6 @@ pub struct KeystrokeEvent { struct NullHttpClient; impl HttpClient for NullHttpClient { - fn type_name(&self) -> &'static str { - "NullHttpClient" - } - fn send( &self, _req: http_client::Request, diff --git a/src/app/async_context.rs b/crates/gpui/src/app/async_context.rs similarity index 86% rename from src/app/async_context.rs rename to crates/gpui/src/app/async_context.rs index e8cca03047..6c5246f33d 100644 --- a/src/app/async_context.rs +++ b/crates/gpui/src/app/async_context.rs @@ -1,8 +1,8 @@ use crate::{ AnyView, AnyWindowHandle, App, AppCell, AppContext, BackgroundExecutor, BorrowAppContext, - Entity, EventEmitter, Focusable, ForegroundExecutor, Global, GpuiBorrow, PromptButton, - PromptLevel, Render, Reservation, Result, Subscription, Task, VisualContext, Window, - WindowHandle, + Entity, EntityId, EventEmitter, Focusable, ForegroundExecutor, Global, GpuiBorrow, + PromptButton, PromptLevel, Render, Reservation, Result, Subscription, Task, VisualContext, + Window, WindowHandle, }; use anyhow::{Context as _, bail}; use derive_more::{Deref, DerefMut}; @@ -94,6 +94,19 @@ impl AppContext for AsyncApp { lock.update_window(window, f) } + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + let app = self.app.upgrade()?; + let mut lock = app.try_borrow_mut().ok()?; + if lock.quitting { + return None; + } + lock.with_window(entity_id, f) + } + fn read_window( &self, window: &WindowHandle, @@ -253,10 +266,10 @@ impl AsyncApp { &self, entity: &WeakEntity, f: Callback, - ) -> util::Deferred> { + ) -> gpui_util::Deferred> { let entity = entity.clone(); let mut cx = self.clone(); - util::defer(move || { + gpui_util::defer(move || { entity.update(&mut cx, f).ok(); }) } @@ -365,7 +378,21 @@ impl AppContext for AsyncWindowContext { where T: 'static, { - self.app.new(build_entity) + let mut build_entity = Some(build_entity); + match self.app.update_window(self.window, |_, _, cx| { + cx.new( + build_entity + .take() + .expect("build_entity is taken exactly once"), + ) + }) { + Ok(entity) => entity, + Err(_) => self.app.new( + build_entity + .take() + .expect("update_window returned Err without invoking the closure"), + ), + } } fn reserve_entity(&mut self) -> Reservation { @@ -377,7 +404,19 @@ impl AppContext for AsyncWindowContext { reservation: Reservation, build_entity: impl FnOnce(&mut Context) -> T, ) -> Entity { - self.app.insert_entity(reservation, build_entity) + let mut args = Some((reservation, build_entity)); + match self.app.update_window(self.window, |_, _, cx| { + let (reservation, build_entity) = args.take().expect("args are taken exactly once"); + cx.insert_entity(reservation, build_entity) + }) { + Ok(entity) => entity, + Err(_) => { + let (reservation, build_entity) = args + .take() + .expect("update_window returned Err without invoking the closure"); + self.app.insert_entity(reservation, build_entity) + } + } } fn update_entity( @@ -409,6 +448,14 @@ impl AppContext for AsyncWindowContext { self.app.update_window(window, update) } + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + self.app.with_window(entity_id, f) + } + fn read_window( &self, window: &WindowHandle, @@ -457,9 +504,12 @@ impl VisualContext for AsyncWindowContext { view: &Entity, update: impl FnOnce(&mut T, &mut Window, &mut Context) -> R, ) -> Result { - self.app.update_window(self.window, |_, window, cx| { - view.update(cx, |entity, cx| update(entity, window, cx)) - }) + let view = view.clone(); + self.app + .with_window(view.entity_id(), |window, app| { + view.update(app, |entity, cx| update(entity, window, cx)) + }) + .context("entity has no current window") } fn replace_root_view( diff --git a/src/app/context.rs b/crates/gpui/src/app/context.rs similarity index 92% rename from src/app/context.rs rename to crates/gpui/src/app/context.rs index 28d30ab37e..0d1ee47ce4 100644 --- a/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -5,6 +5,7 @@ use crate::{ }; use anyhow::Result; use futures::FutureExt; +use gpui_util::Deferred; use std::{ any::{Any, TypeId}, borrow::{Borrow, BorrowMut}, @@ -12,7 +13,6 @@ use std::{ ops, sync::Arc, }; -use util::Deferred; use super::{App, AsyncWindowContext, Entity, KeystrokeEvent}; @@ -278,7 +278,7 @@ impl<'a, T: 'static> Context<'a, T> { ) -> Deferred { let this = self.weak_entity(); let mut cx = self.to_async(); - util::defer(move || { + gpui_util::defer(move || { this.update(&mut cx, f).ok(); }) } @@ -307,9 +307,13 @@ impl<'a, T: 'static> Context<'a, T> { window: &Window, f: impl FnOnce(&mut T, &mut Window, &mut Context) + 'static, ) { - let view = self.entity(); - window.defer(self, move |window, cx| { - view.update(cx, |view, cx| f(view, window, cx)) + let view = self.weak_entity(); + let entity_id = self.entity_id(); + self.ensure_window(entity_id, window.handle.id); + self.app.defer(move |cx| { + cx.with_window(entity_id, |window, cx| { + view.update(cx, |view, cx| f(view, window, cx)).ok(); + }); }); } @@ -326,25 +330,21 @@ impl<'a, T: 'static> Context<'a, T> { { let observed_id = observed.entity_id(); let observed = observed.downgrade(); - let window_handle = window.handle; let observer = self.weak_entity(); + let observer_id = self.entity_id(); + self.ensure_window(observer_id, window.handle.id); self.new_observer( observed_id, Box::new(move |cx| { - window_handle - .update(cx, |_, window, cx| { - if let Some((observer, observed)) = - observer.upgrade().zip(observed.upgrade()) - { - observer.update(cx, |observer, cx| { - on_notify(observer, observed, window, cx); - }); - true - } else { - false - } - }) - .unwrap_or(false) + let Some((observer, observed)) = observer.upgrade().zip(observed.upgrade()) else { + return false; + }; + cx.with_window(observer_id, |window, cx| { + observer.update(cx, |observer, cx| { + on_notify(observer, observed, window, cx); + }); + }); + true }), ) } @@ -363,28 +363,25 @@ impl<'a, T: 'static> Context<'a, T> { Evt: 'static, { let emitter = emitter.downgrade(); - let window_handle = window.handle; let subscriber = self.weak_entity(); + let subscriber_id = self.entity_id(); + self.ensure_window(subscriber_id, window.handle.id); self.new_subscription( emitter.entity_id(), ( TypeId::of::(), Box::new(move |event, cx| { - window_handle - .update(cx, |_, window, cx| { - if let Some((subscriber, emitter)) = - subscriber.upgrade().zip(emitter.upgrade()) - { - let event = event.downcast_ref().expect("invalid event type"); - subscriber.update(cx, |subscriber, cx| { - on_event(subscriber, &emitter, event, window, cx); - }); - true - } else { - false - } - }) - .unwrap_or(false) + let Some((subscriber, emitter)) = subscriber.upgrade().zip(emitter.upgrade()) + else { + return false; + }; + let event = event.downcast_ref().expect("invalid event type"); + cx.with_window(subscriber_id, |window, cx| { + subscriber.update(cx, |subscriber, cx| { + on_event(subscriber, &emitter, event, window, cx); + }); + }); + true }), ), ) @@ -479,6 +476,24 @@ impl<'a, T: 'static> Context<'a, T> { subscription } + /// Registers a callback to be invoked when the window button layout changes. + pub fn observe_button_layout_changed( + &self, + window: &mut Window, + mut callback: impl FnMut(&mut T, &mut Window, &mut Context) + 'static, + ) -> Subscription { + let view = self.weak_entity(); + let (subscription, activate) = window.button_layout_observers.insert( + (), + Box::new(move |window, cx| { + view.update(cx, |view, cx| callback(view, window, cx)) + .is_ok() + }), + ); + activate(); + subscription + } + /// Register a callback to be invoked when a keystroke is received by the application /// in any window. Note that this fires after all other action and event mechanisms have resolved /// and that this API will not be invoked if the event's propagation is stopped. @@ -817,6 +832,15 @@ impl AppContext for Context<'_, T> { self.app.update_window(window, update) } + #[inline] + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + self.app.with_window(entity_id, f) + } + #[inline] fn read_window( &self, diff --git a/src/app/entity_map.rs b/crates/gpui/src/app/entity_map.rs similarity index 91% rename from src/app/entity_map.rs rename to crates/gpui/src/app/entity_map.rs index 78c9a7f5c3..e4e9f3b58a 100644 --- a/src/app/entity_map.rs +++ b/crates/gpui/src/app/entity_map.rs @@ -20,7 +20,7 @@ use std::{ }; use super::Context; -use crate::local_util::atomic_incr_if_not_zero; +use crate::util::atomic_incr_if_not_zero; #[cfg(any(test, feature = "leak-detection"))] use collections::HashMap; @@ -795,14 +795,13 @@ impl WeakEntity { update: impl FnOnce(&mut T, &mut Window, &mut Context) -> R, ) -> Result where - C: VisualContext, + C: AppContext, { - let window = cx.window_handle(); let entity = self.upgrade().context("entity released")?; - - window.update(cx, |_, window, cx| { - entity.update(cx, |entity, cx| update(entity, window, cx)) + cx.with_window(entity.entity_id(), |window, app| { + entity.update(app, |entity, cx| update(entity, window, cx)) }) + .context("entity has no current window") } /// Reads the entity referenced by this handle with the given function if @@ -894,6 +893,9 @@ pub(crate) struct HandleId { /// created, all participating strong entities in this cycle will effectively /// leak as they cannot be released anymore. /// +/// Cycles can also happen if an entity owns a task or subscription that it +/// itself owns a strong reference to the entity again. +/// /// # Usage /// /// You can use `WeakEntity::assert_released` or `AnyWeakEntity::assert_released` @@ -919,7 +921,7 @@ pub(crate) struct HandleId { /// ``` /// /// This will capture and display backtraces for each leaked handle, helping you -/// identify where handles were created but not released. +/// identify where leaked handles were created. /// /// # How It Works /// @@ -962,7 +964,7 @@ impl LeakDetector { entity_id: EntityId, type_name: Option<&'static str>, ) -> HandleId { - let id = util::post_inc(&mut self.next_handle_id); + let id = gpui_util::post_inc(&mut self.next_handle_id); let handle_id = HandleId { id }; let handles = self .entity_handles @@ -1002,11 +1004,13 @@ impl LeakDetector { /// otherwise it suggests setting the environment variable to get more info. pub fn assert_released(&mut self, entity_id: EntityId) { use std::fmt::Write as _; + if let Some(data) = self.entity_handles.remove(&entity_id) { let mut out = String::new(); for (_, backtrace) in data.handles { if let Some(mut backtrace) = backtrace { backtrace.resolve(); + let backtrace = BacktraceFormatter(backtrace); writeln!(out, "Leaked handle:\n{:?}", backtrace).unwrap(); } else { writeln!( @@ -1016,7 +1020,7 @@ impl LeakDetector { .unwrap(); } } - panic!("{out}"); + panic!("Handles for {} leaked:\n{out}", data.type_name); } } @@ -1054,6 +1058,7 @@ impl LeakDetector { if let Some(backtrace) = backtrace { let mut backtrace = backtrace.clone(); backtrace.resolve(); + let backtrace = BacktraceFormatter(backtrace); writeln!( out, "Leaked handle for entity {} ({entity_id:?}):\n{:?}", @@ -1091,6 +1096,7 @@ impl Drop for LeakDetector { for (_handle, backtrace) in data.handles { if let Some(mut backtrace) = backtrace { backtrace.resolve(); + let backtrace = BacktraceFormatter(backtrace); writeln!( out, "Leaked handle for entity {} ({entity_id:?}):\n{:?}", @@ -1111,6 +1117,71 @@ impl Drop for LeakDetector { } } +#[cfg(any(test, feature = "leak-detection"))] +struct BacktraceFormatter(backtrace::Backtrace); + +#[cfg(any(test, feature = "leak-detection"))] +impl fmt::Debug for BacktraceFormatter { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + use backtrace::{BacktraceFmt, BytesOrWideString, PrintFmt}; + + let style = if fmt.alternate() { + PrintFmt::Full + } else { + PrintFmt::Short + }; + + // When printing paths we try to strip the cwd if it exists, otherwise + // we just print the path as-is. Note that we also only do this for the + // short format, because if it's full we presumably want to print + // everything. + let cwd = std::env::current_dir(); + let mut print_path = move |fmt: &mut fmt::Formatter<'_>, path: BytesOrWideString<'_>| { + let path = path.into_path_buf(); + if style != PrintFmt::Full { + if let Ok(cwd) = &cwd { + if let Ok(suffix) = path.strip_prefix(cwd) { + return fmt::Display::fmt(&suffix.display(), fmt); + } + } + } + fmt::Display::fmt(&path.display(), fmt) + }; + + let mut f = BacktraceFmt::new(fmt, style, &mut print_path); + f.add_context()?; + let mut strip = true; + for frame in self.0.frames() { + if let [symbol, ..] = frame.symbols() + && let Some(name) = symbol.name() + && let Some(filename) = name.as_str() + { + match filename { + "test::run_test_in_process" + | "scheduler::executor::spawn_local_with_source_location::impl$1::poll > > > >,alloc::alloc::Global> > >" => { + strip = true + } + "gpui::app::entity_map::LeakDetector::handle_created" => { + strip = false; + continue; + } + "zed::main" => { + strip = true; + f.frame().backtrace_frame(frame)?; + } + _ => {} + } + } + if strip { + continue; + } + f.frame().backtrace_frame(frame)?; + } + f.finish()?; + Ok(()) + } +} + #[cfg(test)] mod test { use crate::EntityMap; diff --git a/src/app/headless_app_context.rs b/crates/gpui/src/app/headless_app_context.rs similarity index 96% rename from src/app/headless_app_context.rs rename to crates/gpui/src/app/headless_app_context.rs index 90dc8c8f0c..b21e64fa36 100644 --- a/src/app/headless_app_context.rs +++ b/crates/gpui/src/app/headless_app_context.rs @@ -10,7 +10,7 @@ use crate::{ AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, Bounds, - Context, Entity, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer, + Context, Entity, EntityId, ForegroundExecutor, Global, Pixels, PlatformHeadlessRenderer, PlatformTextSystem, Render, Reservation, Size, Task, TestDispatcher, TestPlatform, TextSystem, Window, WindowBounds, WindowHandle, WindowOptions, app::{GpuiBorrow, GpuiMode}, @@ -246,6 +246,15 @@ impl AppContext for HeadlessAppContext { lock.update_window(window, f) } + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + let mut lock = self.app.borrow_mut(); + lock.with_window(entity_id, f) + } + fn read_window( &self, window: &WindowHandle, diff --git a/src/app/test_app.rs b/crates/gpui/src/app/test_app.rs similarity index 100% rename from src/app/test_app.rs rename to crates/gpui/src/app/test_app.rs diff --git a/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs similarity index 90% rename from src/app/test_context.rs rename to crates/gpui/src/app/test_context.rs index d8f459df3c..9e32c5dc2d 100644 --- a/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -1,9 +1,9 @@ use crate::{ Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, AsyncApp, AvailableSpace, BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, DrawPhase, Drawable, - Element, Empty, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers, - ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, - Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, + Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, + Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, + Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, window::ElementArenaScope, }; @@ -84,6 +84,15 @@ impl AppContext for TestAppContext { lock.update_window(window, f) } + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + let mut lock = self.app.borrow_mut(); + lock.with_window(entity_id, f) + } + fn read_window( &self, window: &WindowHandle, @@ -193,12 +202,6 @@ impl TestAppContext { &self.foreground_executor } - #[expect(clippy::wrong_self_convention)] - fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { - let mut cx = self.app.borrow_mut(); - cx.new(build_entity) - } - /// Gives you an `&mut App` for the duration of the closure pub fn update(&self, f: impl FnOnce(&mut App) -> R) -> R { let mut cx = self.app.borrow_mut(); @@ -333,6 +336,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 true if there's a path selection dialog 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) { @@ -735,6 +752,16 @@ impl VisualTestContext { self.cx.test_window(self.window).0.lock().title.clone() } + /// Read the document path off the window (set by `Window#set_document_path`) + pub fn document_path(&mut self) -> Option { + self.cx + .test_window(self.window) + .0 + .lock() + .document_path + .clone() + } + /// Simulate a sequence of keystrokes `cx.simulate_keystrokes("cmd-p escape")` /// Automatically runs until parked. pub fn simulate_keystrokes(&mut self, keystrokes: &str) { @@ -930,7 +957,9 @@ impl VisualTestContext { impl AppContext for VisualTestContext { fn new(&mut self, build_entity: impl FnOnce(&mut Context) -> T) -> Entity { - self.cx.new(build_entity) + self.window + .update(&mut self.cx, |_, _, cx| cx.new(build_entity)) + .expect("window was unexpectedly closed") } fn reserve_entity(&mut self) -> crate::Reservation { @@ -942,7 +971,11 @@ impl AppContext for VisualTestContext { reservation: crate::Reservation, build_entity: impl FnOnce(&mut Context) -> T, ) -> Entity { - self.cx.insert_entity(reservation, build_entity) + self.window + .update(&mut self.cx, |_, _, cx| { + cx.insert_entity(reservation, build_entity) + }) + .expect("window was unexpectedly closed") } fn update_entity( @@ -977,6 +1010,14 @@ impl AppContext for VisualTestContext { self.cx.update_window(window, f) } + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + self.cx.with_window(entity_id, f) + } + fn read_window( &self, window: &WindowHandle, @@ -1027,11 +1068,14 @@ impl VisualContext for VisualTestContext { view: &Entity, update: impl FnOnce(&mut V, &mut Window, &mut Context) -> R, ) -> R { - self.window - .update(&mut self.cx, |_, window, cx| { - view.update(cx, |v, cx| update(v, window, cx)) + let view = view.clone(); + self.cx + .app + .borrow_mut() + .with_window(view.entity_id(), |window, app| { + view.update(app, |v, cx| update(v, window, cx)) }) - .expect("window was unexpectedly closed") + .expect("entity has no current window; use `update` instead of `update_in`") } fn replace_root_view( @@ -1068,3 +1112,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/src/app/visual_test_context.rs b/crates/gpui/src/app/visual_test_context.rs similarity index 96% rename from src/app/visual_test_context.rs rename to crates/gpui/src/app/visual_test_context.rs index f0fbf47f1f..b54802a299 100644 --- a/src/app/visual_test_context.rs +++ b/crates/gpui/src/app/visual_test_context.rs @@ -1,9 +1,9 @@ use crate::{ Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetSource, BackgroundExecutor, - Bounds, ClipboardItem, Context, Entity, ForegroundExecutor, Global, InputEvent, Keystroke, - Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, - Render, Result, Size, Task, TestDispatcher, TextSystem, VisualTestPlatform, Window, - WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, + Bounds, ClipboardItem, Context, Entity, EntityId, ForegroundExecutor, Global, InputEvent, + Keystroke, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, + Platform, Point, Render, Result, Size, Task, TestDispatcher, TextSystem, VisualTestPlatform, + Window, WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, }; use anyhow::anyhow; use image::RgbaImage; @@ -446,6 +446,15 @@ impl AppContext for VisualTestAppContext { lock.update_window(window, f) } + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option { + let mut lock = self.app.borrow_mut(); + lock.with_window(entity_id, f) + } + fn read_window( &self, window: &WindowHandle, diff --git a/src/arena.rs b/crates/gpui/src/arena.rs similarity index 100% rename from src/arena.rs rename to crates/gpui/src/arena.rs diff --git a/src/asset_cache.rs b/crates/gpui/src/asset_cache.rs similarity index 100% rename from src/asset_cache.rs rename to crates/gpui/src/asset_cache.rs diff --git a/src/assets.rs b/crates/gpui/src/assets.rs similarity index 74% rename from src/assets.rs rename to crates/gpui/src/assets.rs index 231a571ecd..cb8b47ed01 100644 --- a/src/assets.rs +++ b/crates/gpui/src/assets.rs @@ -77,8 +77,13 @@ impl RenderImage { /// Get the size of this image, in pixels. pub fn size(&self, frame_index: usize) -> Size { - let (width, height) = self.data[frame_index].buffer().dimensions(); - size(width.into(), height.into()) + self.data + .get(frame_index) + .map(|frame| { + let (width, height) = frame.buffer().dimensions(); + size(width.into(), height.into()) + }) + .unwrap_or_default() } /// Get the size of this image, in pixels for display, adjusted for the scale factor. @@ -89,7 +94,10 @@ impl RenderImage { /// Get the delay of this frame from the previous pub fn delay(&self, frame_index: usize) -> Delay { - self.data[frame_index].delay() + self.data + .get(frame_index) + .map(|frame| frame.delay()) + .unwrap_or(Delay::from_numer_denom_ms(100, 1)) } /// Get the number of frames for this image. @@ -102,7 +110,24 @@ impl fmt::Debug for RenderImage { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("ImageData") .field("id", &self.id) - .field("size", &self.size(0)) + .field("size", &self.data.first().map(|f| f.buffer().dimensions())) .finish() } } + +#[cfg(test)] +mod tests { + use super::*; + use smallvec::SmallVec; + + #[test] + fn empty_render_image_does_not_panic() { + let image = RenderImage::new(SmallVec::new()); + assert_eq!(image.frame_count(), 0); + assert_eq!(image.size(0), Size::default()); + assert_eq!(image.as_bytes(0), None); + assert_eq!(image.render_size(0), Size::default()); + assert_eq!(image.delay(0), Delay::from_numer_denom_ms(100, 1)); + let _ = format!("{image:?}"); + } +} diff --git a/src/bounds_tree.rs b/crates/gpui/src/bounds_tree.rs similarity index 95% rename from src/bounds_tree.rs rename to crates/gpui/src/bounds_tree.rs index 9cf86a2cc9..e95e32477b 100644 --- a/src/bounds_tree.rs +++ b/crates/gpui/src/bounds_tree.rs @@ -3,6 +3,7 @@ use std::{ cmp, fmt::Debug, ops::{Add, Sub}, + ptr::NonNull, }; /// Maximum children per internal node (R-tree style branching factor). @@ -30,7 +31,7 @@ where /// Reusable stack for tree traversal during insertion. insert_path: Vec, /// Reusable stack for search operations. - search_stack: Vec, + search_stack: Vec>>, } /// A node in the bounds tree. @@ -150,12 +151,14 @@ where // Slow path: search the tree self.search_stack.clear(); - self.search_stack.push(root_idx); + self.search_stack.push(NonNull::from(&self.nodes[root_idx])); let mut max_found = 0u32; - while let Some(node_idx) = self.search_stack.pop() { - let node = &self.nodes[node_idx]; + while let Some(node) = self.search_stack.pop() { + // SAFETY: `node` is guaranteed to be valid as the `nodes` stack is unmodified in this function + // and the `search_stack` only contains pointers from this function call. + let node = unsafe { node.as_ref() }; // Pruning: skip if this subtree can't improve our result if node.max_order <= max_found { @@ -174,11 +177,14 @@ where NodeKind::Internal { children } => { // Children are maintained with highest max_order at the end. // Push in forward order to highest (last) is popped first. - for &child_idx in children.as_slice() { - if self.nodes[child_idx].max_order > max_found { - self.search_stack.push(child_idx); - } - } + self.search_stack.extend( + children + .as_slice() + .iter() + .map(|&child_idx| &self.nodes[child_idx]) + .filter(|node| node.max_order > max_found) + .map(NonNull::from), + ); } } } diff --git a/src/color.rs b/crates/gpui/src/color.rs similarity index 97% rename from src/color.rs rename to crates/gpui/src/color.rs index 75585bcd90..be8169e81d 100644 --- a/src/color.rs +++ b/crates/gpui/src/color.rs @@ -283,6 +283,32 @@ pub struct Hsla { pub a: f32, } +#[cfg(feature = "proptest")] +mod property { + use super::Hsla; + use proptest::prelude::*; + + impl Hsla { + /// Proptest [`Strategy`] that produces opaque colors (i.e. alpha = 1). + /// + /// For truly arbitrary colors, use the [`Arbitrary`] implementation. + pub fn opaque_strategy() -> impl Strategy { + (0.0f32..=1.0, 0.0f32..=1.0, 0.0f32..=1.0).prop_map(|(h, s, l)| Hsla { h, s, l, a: 1. }) + } + } + + impl Arbitrary for Hsla { + type Strategy = BoxedStrategy; + type Parameters = (); + + fn arbitrary_with((): Self::Parameters) -> Self::Strategy { + (0.0f32..=1.0, 0.0f32..=1.0, 0.0f32..=1.0, 0.0f32..=1.0) + .prop_map(|(h, s, l, a)| Hsla { h, s, l, a }) + .boxed() + } + } +} + impl PartialEq for Hsla { fn eq(&self, other: &Self) -> bool { self.h diff --git a/src/colors.rs b/crates/gpui/src/colors.rs similarity index 100% rename from src/colors.rs rename to crates/gpui/src/colors.rs diff --git a/src/element.rs b/crates/gpui/src/element.rs similarity index 92% rename from src/element.rs rename to crates/gpui/src/element.rs index b5c3bcc3fb..e05d6e97f4 100644 --- a/src/element.rs +++ b/crates/gpui/src/element.rs @@ -34,7 +34,7 @@ use crate::{ App, ArenaBox, AvailableSpace, Bounds, Context, DispatchNodeId, ElementId, FocusHandle, InspectorElementId, LayoutId, Pixels, Point, SharedString, Size, Style, Window, - local_util::FluentBuilder, window::with_element_arena, + util::FluentBuilder, window::with_element_arena, }; use derive_more::{Deref, DerefMut}; use std::{ @@ -103,6 +103,22 @@ pub trait Element: 'static + IntoElement { cx: &mut App, ); + /// Returns the accessible role for this element, if any. + /// Elements that return `None` are not included in the accessibility tree. + /// + /// Note: inclusion in accessibility tree requires non-`None` [`id`][Element::id]. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn a11y_role(&self) -> Option { + None + } + + /// Write accessibility properties to the given node. + /// Called only when `a11y_role()` returns `Some`. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn write_a11y_info(&self, _node: &mut accesskit::Node) {} + /// Convert this element into a dynamically-typed [`AnyElement`]. fn into_any(self) -> AnyElement { AnyElement::new(self) @@ -302,6 +318,15 @@ impl Display for GlobalElementId { } } +impl GlobalElementId { + pub(crate) fn accesskit_node_id(&self) -> accesskit::NodeId { + use std::hash::{Hash, Hasher}; + let mut hasher = std::hash::DefaultHasher::default(); + self.hash(&mut hasher); + accesskit::NodeId(hasher.finish()) + } +} + trait ElementObject { fn inner_element(&mut self) -> &mut dyn Any; @@ -431,6 +456,26 @@ impl Drawable { } let bounds = window.layout_bounds(layout_id); + let mut pushed_a11y_node = false; + if window.a11y.is_active() { + if let Some(global_id) = global_id.as_ref() { + if let Some(role) = self.element.a11y_role() { + let node_id = global_id.accesskit_node_id(); + let mut node = accesskit::Node::new(role); + let scale = window.scale_factor(); + node.set_bounds(accesskit::Rect { + x0: (bounds.origin.x.0 * scale) as f64, + y0: (bounds.origin.y.0 * scale) as f64, + x1: ((bounds.origin.x.0 + bounds.size.width.0) * scale) as f64, + y1: ((bounds.origin.y.0 + bounds.size.height.0) * scale) as f64, + }); + self.element.write_a11y_info(&mut node); + window.a11y.node_bounds.insert(node_id, bounds); + pushed_a11y_node = window.a11y.nodes.push(node_id, node); + } + } + } + let node_id = window.next_frame.dispatch_tree.push_node(); let prepaint = self.element.prepaint( global_id.as_ref(), @@ -442,6 +487,10 @@ impl Drawable { ); window.next_frame.dispatch_tree.pop_node(); + if pushed_a11y_node { + window.a11y.nodes.pop(); + } + if global_id.is_some() { window.element_id_stack.pop(); } diff --git a/src/elements/anchored.rs b/crates/gpui/src/elements/anchored.rs similarity index 64% rename from src/elements/anchored.rs rename to crates/gpui/src/elements/anchored.rs index f92593ef8d..29260061bb 100644 --- a/src/elements/anchored.rs +++ b/crates/gpui/src/elements/anchored.rs @@ -1,7 +1,7 @@ use smallvec::SmallVec; use crate::{ - AnyElement, App, Axis, Bounds, Corner, Display, Edges, Element, GlobalElementId, + Anchor, AnyElement, App, Axis, Bounds, Display, Edges, Element, GlobalElementId, InspectorElementId, IntoElement, LayoutId, ParentElement, Pixels, Point, Position, Size, Style, Window, point, px, }; @@ -15,7 +15,7 @@ pub struct AnchoredState { /// will avoid overflowing the window bounds. pub struct Anchored { children: SmallVec<[AnyElement; 2]>, - anchor_corner: Corner, + anchor: Anchor, fit_mode: AnchoredFitMode, anchor_position: Option>, position_mode: AnchoredPositionMode, @@ -27,7 +27,7 @@ pub struct Anchored { pub fn anchored() -> Anchored { Anchored { children: SmallVec::new(), - anchor_corner: Corner::TopLeft, + anchor: Anchor::TopLeft, fit_mode: AnchoredFitMode::SwitchAnchor, anchor_position: None, position_mode: AnchoredPositionMode::Window, @@ -37,8 +37,8 @@ pub fn anchored() -> Anchored { impl Anchored { /// Sets which corner of the anchored element should be anchored to the current position. - pub fn anchor(mut self, anchor: Corner) -> Self { - self.anchor_corner = anchor; + pub fn anchor(mut self, anchor: Anchor) -> Self { + self.anchor = anchor; self } @@ -132,19 +132,17 @@ impl Element for Anchored { return; } - let mut child_min = point(Pixels::MAX, Pixels::MAX); - let mut child_max = Point::default(); - for child_layout_id in &request_layout.child_layout_ids { - let child_bounds = window.layout_bounds(*child_layout_id); - child_min = child_min.min(&child_bounds.origin); - child_max = child_max.max(&child_bounds.bottom_right()); - } - let size: Size = (child_max - child_min).into(); + let children_bounds = request_layout + .child_layout_ids + .iter() + .map(|id| window.layout_bounds(*id)) + .reduce(|acc, bounds| acc.union(&bounds)) + .unwrap(); let (origin, mut desired) = self.position_mode.get_position_and_bounds( self.anchor_position, - self.anchor_corner, - size, + self.anchor, + children_bounds.size, bounds, self.offset, ); @@ -155,25 +153,25 @@ impl Element for Anchored { }; if self.fit_mode == AnchoredFitMode::SwitchAnchor { - let mut anchor_corner = self.anchor_corner; + let mut anchor = self.anchor; if desired.left() < limits.left() || desired.right() > limits.right() { - let switched = Bounds::from_corner_and_size( - anchor_corner.other_side_corner_along(Axis::Horizontal), + let switched = Bounds::from_anchor_and_size( + anchor.other_side_along(Axis::Horizontal), origin, - size, + children_bounds.size, ); if !(switched.left() < limits.left() || switched.right() > limits.right()) { - anchor_corner = anchor_corner.other_side_corner_along(Axis::Horizontal); + anchor = anchor.other_side_along(Axis::Horizontal); desired = switched } } if desired.top() < limits.top() || desired.bottom() > limits.bottom() { - let switched = Bounds::from_corner_and_size( - anchor_corner.other_side_corner_along(Axis::Vertical), + let switched = Bounds::from_anchor_and_size( + anchor.other_side_along(Axis::Vertical), origin, - size, + children_bounds.size, ); if !(switched.top() < limits.top() || switched.bottom() > limits.bottom()) { desired = switched; @@ -264,7 +262,7 @@ impl AnchoredPositionMode { fn get_position_and_bounds( &self, anchor_position: Option>, - anchor_corner: Corner, + anchor: Anchor, size: Size, bounds: Bounds, offset: Option>, @@ -274,14 +272,13 @@ impl AnchoredPositionMode { match self { AnchoredPositionMode::Window => { let anchor_position = anchor_position.unwrap_or(bounds.origin); - let bounds = - Bounds::from_corner_and_size(anchor_corner, anchor_position + offset, size); + let bounds = Bounds::from_anchor_and_size(anchor, anchor_position + offset, size); (anchor_position, bounds) } AnchoredPositionMode::Local => { let anchor_position = anchor_position.unwrap_or_default(); - let bounds = Bounds::from_corner_and_size( - anchor_corner, + let bounds = Bounds::from_anchor_and_size( + anchor, bounds.origin + anchor_position + offset, size, ); @@ -290,3 +287,112 @@ impl AnchoredPositionMode { } } } + +#[cfg(test)] +mod tests { + use crate::{ + Context, Pixels, PlatformInput, Point, TestAppContext, Window, deferred, div, point, + prelude::*, px, size, + }; + + struct AnchoredTestView { + position: Point, + } + + impl Render for AnchoredTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child( + div() + .id("scroll-container") + .overflow_y_scroll() + .size_full() + .child(div().h(px(2000.)).w_full()) + .child( + deferred( + super::anchored() + .snap_to_window() + .position(self.position) + .child( + div() + .id("menu") + .debug_selector(|| "MENU".into()) + .w(px(200.)) + .h(px(300.)), + ), + ) + .with_priority(1), + ), + ) + } + } + + #[gpui::test] + fn test_anchored_position_without_scroll(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView { + position: point(px(100.), px(100.)), + }); + + cx.run_until_parked(); + + let menu_bounds = window + .update(cx, |_, window, _| { + window.rendered_frame.debug_bounds.get("MENU").copied() + }) + .unwrap() + .expect("MENU debug bounds not found"); + + assert_eq!(menu_bounds.origin, point(px(100.), px(100.))); + assert_eq!(menu_bounds.size, size(px(200.), px(300.))); + } + + #[gpui::test] + fn test_anchored_position_when_scrolled(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView { + position: point(px(100.), px(100.)), + }); + + cx.run_until_parked(); + + window + .update(cx, |_, window, cx| { + let event = gpui::ScrollWheelEvent { + position: point(px(400.), px(300.)), + delta: gpui::ScrollDelta::Pixels(point(px(0.), px(-1000.))), + ..Default::default() + }; + window.dispatch_event(PlatformInput::ScrollWheel(event), cx); + }) + .unwrap(); + + cx.run_until_parked(); + + let menu_bounds = window + .update(cx, |_, window, _| { + window.rendered_frame.debug_bounds.get("MENU").copied() + }) + .unwrap() + .expect("MENU debug bounds not found"); + + assert_eq!(menu_bounds.origin, point(px(100.), px(100.))); + assert_eq!(menu_bounds.size, size(px(200.), px(300.))); + } + + #[gpui::test] + fn test_anchored_snaps_to_window(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(800.), px(600.)), |_, _| AnchoredTestView { + position: point(px(100.), px(500.)), + }); + + cx.run_until_parked(); + + let menu_bounds = window + .update(cx, |_, window, _| { + window.rendered_frame.debug_bounds.get("MENU").copied() + }) + .unwrap() + .expect("MENU debug bounds not found"); + + assert_eq!(menu_bounds.origin, point(px(100.), px(300.))); + assert_eq!(menu_bounds.size, size(px(200.), px(300.))); + } +} diff --git a/src/elements/animation.rs b/crates/gpui/src/elements/animation.rs similarity index 99% rename from src/elements/animation.rs rename to crates/gpui/src/elements/animation.rs index 882d2d01e2..8a42c8bd49 100644 --- a/src/elements/animation.rs +++ b/crates/gpui/src/elements/animation.rs @@ -1,4 +1,4 @@ -use crate::scheduler::Instant; +use scheduler::Instant; use std::{rc::Rc, time::Duration}; use crate::{ diff --git a/src/elements/canvas.rs b/crates/gpui/src/elements/canvas.rs similarity index 100% rename from src/elements/canvas.rs rename to crates/gpui/src/elements/canvas.rs diff --git a/src/elements/deferred.rs b/crates/gpui/src/elements/deferred.rs similarity index 100% rename from src/elements/deferred.rs rename to crates/gpui/src/elements/deferred.rs diff --git a/src/elements/div.rs b/crates/gpui/src/elements/div.rs similarity index 89% rename from src/elements/div.rs rename to crates/gpui/src/elements/div.rs index 8a3505ee19..5538d3d92a 100644 --- a/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -15,19 +15,19 @@ //! and Tailwind-like styling that you can use to build your own custom elements. Div is //! constructed by combining these two systems into an all-in-one element. -#[cfg(any(target_os = "linux", target_os = "macos"))] use crate::PinchEvent; 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, + 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, }; use collections::HashMap; +use gpui_util::ResultExt; use refineable::Refineable; use smallvec::SmallVec; use stacksafe::{StackSafe, stacksafe}; @@ -42,7 +42,6 @@ use std::{ sync::Arc, time::Duration, }; -use util::ResultExt; use super::ImageCacheProvider; @@ -357,11 +356,7 @@ impl Interactivity { /// Bind the given callback to pinch gesture events during the bubble phase. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn on_pinch(&mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) { self.pinch_listeners .push(Box::new(move |event, phase, hitbox, window, cx| { @@ -373,11 +368,7 @@ impl Interactivity { /// Bind the given callback to pinch gesture events during the capture phase. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] pub fn capture_pinch( &mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, @@ -675,15 +666,9 @@ impl Interactivity { self.hitbox_behavior = HitboxBehavior::BlockMouseExceptScroll; } - #[cfg(any(target_os = "linux", target_os = "macos"))] fn has_pinch_listeners(&self) -> bool { !self.pinch_listeners.is_empty() } - - #[cfg(not(any(target_os = "linux", target_os = "macos")))] - fn has_pinch_listeners(&self) -> bool { - false - } } /// A trait for elements that want to use the standard GPUI event handlers that don't @@ -753,7 +738,7 @@ pub trait InteractiveElement: Sized { fn key_context(mut self, key_context: C) -> Self where C: TryInto, - E: Debug, + E: std::fmt::Display, { if let Some(key_context) = key_context.try_into().log_err() { self.interactivity().key_context = Some(key_context); @@ -957,11 +942,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to pinch gesture events during the bubble phase. /// The fluent API equivalent to [`Interactivity::on_pinch`]. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] fn on_pinch(mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static) -> Self { self.interactivity().on_pinch(listener); self @@ -970,11 +951,7 @@ pub trait InteractiveElement: Sized { /// Bind the given callback to pinch gesture events during the capture phase. /// The fluent API equivalent to [`Interactivity::capture_pinch`]. /// - /// Note: This event is only available on macOS and Wayland (Linux). - /// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. - /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. - #[cfg(any(target_os = "linux", target_os = "macos"))] fn capture_pinch( mut self, listener: impl Fn(&PinchEvent, &mut Window, &mut App) + 'static, @@ -1198,6 +1175,124 @@ pub trait InteractiveElement: Sized { /// A trait for elements that want to use the standard GPUI interactivity features /// that require state. pub trait StatefulInteractiveElement: InteractiveElement { + /// Set the accessible role for this element. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn role(mut self, role: accesskit::Role) -> Self { + debug_assert!( + role != accesskit::Role::GenericContainer, + "GenericContainer is filtered out of the a11y tree and has no effect" + ); + self.interactivity().override_role = Some(role); + self + } + + /// Set the accessible label for this element. + fn aria_label(mut self, label: impl Into) -> Self { + self.interactivity().aria_label = Some(label.into()); + self + } + + /// Set the selected state for this element. + fn aria_selected(mut self, selected: bool) -> Self { + self.interactivity().aria_selected = Some(selected); + self + } + + /// Set the expanded state for this element. + fn aria_expanded(mut self, expanded: bool) -> Self { + self.interactivity().aria_expanded = Some(expanded); + self + } + + /// Set the toggled state for this element. + fn aria_toggled(mut self, toggled: accesskit::Toggled) -> Self { + self.interactivity().aria_toggled = Some(toggled); + self + } + + /// Set the numeric value for this element. + fn aria_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_numeric_value = Some(value); + self + } + + /// Set the minimum numeric value for this element. + fn aria_min_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_min_numeric_value = Some(value); + self + } + + /// Set the maximum numeric value for this element. + fn aria_max_numeric_value(mut self, value: f64) -> Self { + self.interactivity().aria_max_numeric_value = Some(value); + self + } + + /// Set the orientation of this element. + fn aria_orientation(mut self, orientation: accesskit::Orientation) -> Self { + self.interactivity().aria_orientation = Some(orientation); + self + } + + /// Set the heading level of this element. + fn aria_level(mut self, level: usize) -> Self { + self.interactivity().aria_level = Some(level); + self + } + + /// Set the position in set of this element. + fn aria_position_in_set(mut self, position: usize) -> Self { + self.interactivity().aria_position_in_set = Some(position); + self + } + + /// Set the size of set for this element. + fn aria_size_of_set(mut self, size: usize) -> Self { + self.interactivity().aria_size_of_set = Some(size); + self + } + + /// Set the row index for this element. + fn aria_row_index(mut self, index: usize) -> Self { + self.interactivity().aria_row_index = Some(index); + self + } + + /// Set the column index for this element. + fn aria_column_index(mut self, index: usize) -> Self { + self.interactivity().aria_column_index = Some(index); + self + } + + /// Set the row count for this element. + fn aria_row_count(mut self, count: usize) -> Self { + self.interactivity().aria_row_count = Some(count); + self + } + + /// Set the column count for this element. + fn aria_column_count(mut self, count: usize) -> Self { + self.interactivity().aria_column_count = Some(count); + self + } + + /// Register a handler for an accessibility action on this element. + /// The handler is called when a screen reader requests the given action. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + fn on_a11y_action( + mut self, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut crate::Window, &mut crate::App) + + 'static, + ) -> Self { + self.interactivity() + .a11y_action_listeners + .push((action, Box::new(listener))); + self + } + /// Set this element to focusable. fn focusable(mut self) -> Self { self.interactivity().focusable = true; @@ -1223,15 +1318,6 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } - /// Set the space to be reserved for rendering the scrollbar. - /// - /// This will only affect the layout of the element when overflow for this element is set to - /// `Overflow::Scroll`. - fn scrollbar_width(mut self, width: impl Into) -> Self { - self.interactivity().base_style.scrollbar_width = Some(width.into()); - self - } - /// Track the scroll state of this element with the given handle. fn track_scroll(mut self, scroll_handle: &ScrollHandle) -> Self { self.interactivity().tracked_scroll_handle = Some(scroll_handle.clone()); @@ -1367,7 +1453,6 @@ pub(crate) type MouseMoveListener = pub(crate) type ScrollWheelListener = Box; -#[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) type PinchListener = Box; @@ -1507,6 +1592,18 @@ impl Element for Div { self.interactivity.source_location() } + fn a11y_role(&self) -> Option { + // Nodes with `GenericContainer` should never be reported to accesskit. + // Equivalent to an HTML div with no role. + self.interactivity + .override_role + .filter(|role| *role != accesskit::Role::GenericContainer) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.interactivity.write_a11y_info(node); + } + #[stacksafe] fn request_layout( &mut self, @@ -1725,7 +1822,6 @@ pub struct Interactivity { pub(crate) mouse_pressure_listeners: Vec, pub(crate) mouse_move_listeners: Vec, pub(crate) scroll_wheel_listeners: Vec, - #[cfg(any(target_os = "linux", target_os = "macos"))] pub(crate) pinch_listeners: Vec, pub(crate) key_down_listeners: Vec, pub(crate) key_up_listeners: Vec, @@ -1744,6 +1840,25 @@ pub struct Interactivity { pub(crate) tab_group: bool, pub(crate) tab_stop: bool, + pub(crate) a11y_action_listeners: + Vec<(accesskit::Action, crate::window::a11y::A11yActionListener)>, + pub(crate) override_role: Option, + pub(crate) aria_label: Option, + pub(crate) aria_selected: Option, + pub(crate) aria_expanded: Option, + pub(crate) aria_toggled: Option, + pub(crate) aria_numeric_value: Option, + pub(crate) aria_min_numeric_value: Option, + pub(crate) aria_max_numeric_value: Option, + pub(crate) aria_orientation: Option, + pub(crate) aria_level: Option, + pub(crate) aria_position_in_set: Option, + pub(crate) aria_size_of_set: Option, + pub(crate) aria_row_index: Option, + pub(crate) aria_column_index: Option, + pub(crate) aria_row_count: Option, + pub(crate) aria_column_count: Option, + #[cfg(any(feature = "inspector", debug_assertions))] pub(crate) source_location: Option<&'static core::panic::Location<'static>>, @@ -1864,6 +1979,16 @@ impl Interactivity { if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { window.set_focus_handle(focus_handle, cx); + + if window.a11y.is_active() { + if let Some(global_id) = global_id { + let node_id = global_id.accesskit_node_id(); + window.a11y.focus_ids.insert(node_id, focus_handle.id); + if focus_handle.is_focused(window) && window.a11y.nodes.has_node(node_id) { + window.a11y.nodes.set_focus(node_id); + } + } + } } window.with_optional_element_state::( global_id, @@ -2088,6 +2213,22 @@ impl Interactivity { } self.paint_keyboard_listeners(window, cx); + + if window.a11y.is_active() { + if let Some(global_id) = global_id { + if !self.a11y_action_listeners.is_empty() { + let node_id = global_id.accesskit_node_id(); + for (action, listener) in + self.a11y_action_listeners.drain(..) + { + window.on_a11y_action( + node_id, action, listener, + ); + } + } + } + } + f(&style, window, cx); if let Some(_hitbox) = hitbox { @@ -2297,7 +2438,6 @@ impl Interactivity { }) } - #[cfg(any(target_os = "linux", target_os = "macos"))] for listener in self.pinch_listeners.drain(..) { let hitbox = hitbox.clone(); window.on_mouse_event(move |event: &PinchEvent, phase, window, cx| { @@ -2892,6 +3032,63 @@ impl Interactivity { style } + + pub(crate) fn write_a11y_info(&self, node: &mut accesskit::Node) { + if let Some(label) = &self.aria_label { + node.set_label(label.to_string()); + } + if let Some(selected) = self.aria_selected { + node.set_selected(selected); + } + if let Some(expanded) = self.aria_expanded { + node.set_expanded(expanded); + } + if let Some(toggled) = self.aria_toggled { + node.set_toggled(toggled); + } + if let Some(value) = self.aria_numeric_value { + node.set_numeric_value(value); + } + if let Some(value) = self.aria_min_numeric_value { + node.set_min_numeric_value(value); + } + if let Some(value) = self.aria_max_numeric_value { + node.set_max_numeric_value(value); + } + if let Some(orientation) = self.aria_orientation { + node.set_orientation(orientation); + } + if let Some(level) = self.aria_level { + node.set_level(level); + } + if let Some(position) = self.aria_position_in_set { + node.set_position_in_set(position); + } + if let Some(size) = self.aria_size_of_set { + node.set_size_of_set(size); + } + if let Some(index) = self.aria_row_index { + node.set_row_index(index); + } + if let Some(index) = self.aria_column_index { + node.set_column_index(index); + } + if let Some(count) = self.aria_row_count { + node.set_row_count(count); + } + if let Some(count) = self.aria_column_count { + node.set_column_count(count); + } + if !self.click_listeners.is_empty() { + node.add_action(accesskit::Action::Click); + } + if self.tracked_focus_handle.is_some() || self.focusable { + node.add_action(accesskit::Action::Focus); + } + for (action, _) in &self.a11y_action_listeners { + node.add_action(*action); + } + } } /// The per-frame state of an interactive element. Used for tracking stateful interactions like clicks @@ -3093,21 +3290,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; + 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, @@ -3186,11 +3391,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(); } @@ -3287,6 +3495,14 @@ where self.element.source_location() } + fn a11y_role(&self) -> Option { + self.element.a11y_role() + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + self.element.write_a11y_info(node); + } + fn request_layout( &mut self, id: Option<&GlobalElementId>, @@ -3603,6 +3819,112 @@ impl ScrollHandle { #[cfg(test)] mod tests { use super::*; + use crate::{AppContext as _, Context, InputEvent, MouseMoveEvent, TestAppContext}; + use std::rc::Weak; + + 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, + } + + 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()), + ) + .into_any_element(), + captured_active_tooltip: self.captured_active_tooltip.clone(), + } + } + } #[test] fn scroll_handle_aligns_wide_children_to_left_edge() { @@ -3641,4 +3963,96 @@ mod tests { assert_eq!(handle.offset().y, px(-25.)); } + + fn setup_tooltip_owner_test() -> ( + 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, + } + }); + 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(); + + 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_is_released_when_its_owner_disappears() { + let (mut test_app, any_window, captured_active_tooltip) = setup_tooltip_owner_test(); + + let weak_active_tooltip = captured_active_tooltip.borrow().clone().unwrap(); + let active_tooltip = weak_active_tooltip.upgrade().unwrap(); + + test_app.dispatcher.advance_clock(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()); + } } diff --git a/src/elements/image_cache.rs b/crates/gpui/src/elements/image_cache.rs similarity index 100% rename from src/elements/image_cache.rs rename to crates/gpui/src/elements/image_cache.rs diff --git a/src/elements/img.rs b/crates/gpui/src/elements/img.rs similarity index 79% rename from src/elements/img.rs rename to crates/gpui/src/elements/img.rs index e3ffe42449..6f01182f9e 100644 --- a/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -6,12 +6,13 @@ use crate::{ }; use anyhow::Result; -use crate::scheduler::Instant; use futures::Future; +use gpui_util::ResultExt; use image::{ AnimationDecoder, DynamicImage, Frame, ImageError, ImageFormat, Rgba, codecs::{gif::GifDecoder, webp::WebPDecoder}, }; +use scheduler::Instant; use smallvec::SmallVec; use std::{ fs, @@ -23,7 +24,6 @@ use std::{ time::Duration, }; use thiserror::Error; -use util::ResultExt; use super::{Stateful, StatefulInteractiveElement}; @@ -294,7 +294,7 @@ impl Element for Img { }) }); - let frame_index = state.as_ref().map(|state| state.frame_index).unwrap_or(0); + let mut frame_index = state.as_ref().map(|state| state.frame_index).unwrap_or(0); let layout_id = self.interactivity.request_layout( global_id, @@ -312,26 +312,36 @@ impl Element for Img { cx, ) { Some(Ok(data)) => { - if let Some(state) = &mut state { - let frame_count = data.frame_count(); - if frame_count > 1 { - let current_time = Instant::now(); - if let Some(last_frame_time) = state.last_frame_time { - let elapsed = current_time - last_frame_time; - let frame_duration = - Duration::from(data.delay(state.frame_index)); + let frame_count = data.frame_count(); + let max_frame_index = frame_count.saturating_sub(1); - if elapsed >= frame_duration { - state.frame_index = - (state.frame_index + 1) % frame_count; - state.last_frame_time = - Some(current_time - (elapsed - frame_duration)); + if let Some(state) = &mut state { + state.frame_index = state.frame_index.min(max_frame_index); + if frame_count > 1 { + if window.is_window_active() { + let current_time = Instant::now(); + if let Some(last_frame_time) = state.last_frame_time { + let elapsed = current_time - last_frame_time; + let frame_duration = + Duration::from(data.delay(state.frame_index)); + + if elapsed >= frame_duration { + state.frame_index = + (state.frame_index + 1) % frame_count; + state.last_frame_time = + Some(current_time - (elapsed - frame_duration)); + } + } else { + state.last_frame_time = Some(current_time); } } else { - state.last_frame_time = Some(current_time); + state.last_frame_time = None; } + } else { + state.last_frame_time = None; } state.started_loading = None; + frame_index = state.frame_index; } let image_size = data.render_size(frame_index); @@ -365,7 +375,10 @@ impl Element for Img { }; } - if global_id.is_some() && data.frame_count() > 1 { + if global_id.is_some() + && data.frame_count() > 1 + && window.is_window_active() + { window.request_animation_frame(); } } @@ -466,6 +479,9 @@ impl Element for Img { window, cx, ) { + if data.frame_count() == 0 { + return; + } let new_bounds = self .style .object_fit @@ -643,12 +659,26 @@ impl Asset for ImageAssetLoader { let mut frames = SmallVec::new(); for frame in decoder.into_frames() { - let mut frame = frame?; - // Convert from RGBA to BGRA. - for pixel in frame.buffer_mut().chunks_exact_mut(4) { - pixel.swap(0, 2); + match frame { + Ok(mut frame) => { + // Convert from RGBA to BGRA. + for pixel in frame.buffer_mut().chunks_exact_mut(4) { + pixel.swap(0, 2); + } + frames.push(frame); + } + Err(err) => { + log::debug!( + "Skipping GIF frame in {source:?} due to decode error: {err}" + ); + } } - frames.push(frame); + } + + if frames.is_empty() { + return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!( + "GIF could not be decoded: all frames failed ({source:?})" + )))); } frames @@ -661,12 +691,26 @@ impl Asset for ImageAssetLoader { let mut frames = SmallVec::new(); for frame in decoder.into_frames() { - let mut frame = frame?; - // Convert from RGBA to BGRA. - for pixel in frame.buffer_mut().chunks_exact_mut(4) { - pixel.swap(0, 2); + match frame { + Ok(mut frame) => { + // Convert from RGBA to BGRA. + for pixel in frame.buffer_mut().chunks_exact_mut(4) { + pixel.swap(0, 2); + } + frames.push(frame); + } + Err(err) => { + log::debug!( + "Skipping WebP frame in {source:?} due to decode error: {err}" + ); + } } - frames.push(frame); + } + + if frames.is_empty() { + return Err(ImageCacheError::Other(Arc::new(anyhow::anyhow!( + "WebP could not be decoded: all frames failed ({source:?})" + )))); } frames @@ -697,7 +741,7 @@ impl Asset for ImageAssetLoader { Ok(Arc::new(RenderImage::new(data))) } else { svg_renderer - .render_single_frame(&bytes, 1.0, true) + .render_single_frame(&bytes, 1.0) .map_err(Into::into) } } @@ -757,3 +801,63 @@ impl From for ImageCacheError { Self::Image(Arc::new(value)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ParentElement as _, TestAppContext, canvas, div, point, px, size}; + use image::{Frame, ImageBuffer, Rgba}; + + const TEST_IMG_ID: &str = "test-img"; + + fn test_image(frame_count: usize) -> Arc { + let frame = Frame::new(ImageBuffer::from_pixel(1, 1, Rgba([0, 0, 0, 0]))); + Arc::new(RenderImage::new(SmallVec::from_iter( + (0..frame_count).map(|_| frame.clone()), + ))) + } + + /// Overwrites the cached `frame_index` of the sibling `img` during paint. + fn seed_frame_index(frame_index: usize) -> impl IntoElement { + canvas( + |_, _, _| (), + move |_, _, window, _| { + window.with_global_id(TEST_IMG_ID.into(), |id, window| { + window.with_element_state::(id, |state, _| { + let mut state = state.expect("img state should be initialized"); + state.frame_index = frame_index; + ((), state) + }); + }); + }, + ) + } + + #[gpui::test] + fn zero_frame_image_does_not_panic_on_paint(cx: &mut TestAppContext) { + cx.add_empty_window() + .draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| { + img(ImageSource::Render(test_image(0))).into_any_element() + }); + } + + #[gpui::test] + fn stale_frame_index_is_clamped_when_image_changes(cx: &mut TestAppContext) { + let window = cx.add_empty_window(); + + // Assert that a cached frame_index from a previous multi-frame image + // does not cause an out-of-bounds panic when the image is replaced + // with one that has fewer frames. + window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| { + div() + .child(img(ImageSource::Render(test_image(5))).id(TEST_IMG_ID)) + .child(seed_frame_index(4)) + .into_any_element() + }); + window.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, _| { + img(ImageSource::Render(test_image(1))) + .id(TEST_IMG_ID) + .into_any_element() + }); + } +} diff --git a/src/elements/list.rs b/crates/gpui/src/elements/list.rs similarity index 55% rename from src/elements/list.rs rename to crates/gpui/src/elements/list.rs index b84241e9e0..5a729dcc5f 100644 --- a/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -71,11 +71,27 @@ struct StateInner { scroll_handler: Option>, scrollbar_drag_start_height: Option, measuring_behavior: ListMeasuringBehavior, - pending_scroll: Option, + pending_scroll: Option, + follow_state: FollowState, +} + +/// Deferred scroll adjustment applied after the scroll-top item has been remeasured. +/// +/// An absolute pending scroll preserves the same pixel offset into the item, which keeps +/// visible text stable while content is appended to or removed from that item. A +/// proportional pending scroll preserves the same fractional position within the item, +/// which is useful when the whole list is being resized and each item scales similarly. +#[derive(Clone)] +enum PendingScroll { + /// Preserve the same pixel offset into the item after it is remeasured. + Absolute { item_ix: usize, offset: Pixels }, + /// Preserve the same fractional offset into the item after it is remeasured. + Proportional(PendingScrollFraction), } /// Keeps track of a fractional scroll position within an item for restoration /// after remeasurement. +#[derive(Clone)] struct PendingScrollFraction { /// The index of the item to scroll within. item_ix: usize, @@ -83,6 +99,66 @@ struct PendingScrollFraction { fraction: f32, } +/// Determines how remeasurement preserves the scroll position when the scroll-top item +/// changes height. +enum ScrollAnchor { + /// Preserve the same pixel offset into the scroll-top item. + Absolute, + /// Preserve the same fractional position within the scroll-top item. + Proportional, +} + +/// Controls whether the list automatically follows new content at the end. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FollowMode { + /// Normal scrolling — no automatic following. + #[default] + Normal, + /// The list should auto-scroll along with the tail, when scrolled to bottom. + Tail, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +enum FollowState { + #[default] + Normal, + Tail { + is_following: bool, + }, +} + +impl FollowState { + fn is_following(&self) -> bool { + matches!(self, FollowState::Tail { is_following: true }) + } + + fn has_stopped_following(&self) -> bool { + matches!( + self, + FollowState::Tail { + is_following: false + } + ) + } + + fn start_following(&mut self) { + if let FollowState::Tail { + is_following: false, + } = self + { + *self = FollowState::Tail { is_following: true }; + } + } + + fn stop_following(&mut self) { + if let FollowState::Tail { is_following: true } = self { + *self = FollowState::Tail { + is_following: false, + }; + } + } +} + /// Whether the list is scrolling from top to bottom or bottom to top. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ListAlignment { @@ -102,6 +178,9 @@ pub struct ListScrollEvent { /// Whether the list has been scrolled. pub is_scrolled: bool, + + /// Whether the list is currently in follow-tail mode (auto-scrolling to end). + pub is_following_tail: bool, } /// The sizing behavior to apply during layout. @@ -165,6 +244,7 @@ pub struct ListPrepaintState { #[derive(Clone)] enum ListItem { Unmeasured { + size_hint: Option>, focus_handle: Option, }, Measured { @@ -182,9 +262,16 @@ impl ListItem { } } + fn size_hint(&self) -> Option> { + match self { + ListItem::Measured { size, .. } => Some(*size), + ListItem::Unmeasured { size_hint, .. } => *size_hint, + } + } + fn focus_handle(&self) -> Option { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle.clone() } } @@ -192,7 +279,7 @@ impl ListItem { fn contains_focused(&self, window: &Window, cx: &App) -> bool { match self { - ListItem::Unmeasured { focus_handle } | ListItem::Measured { focus_handle, .. } => { + ListItem::Unmeasured { focus_handle, .. } | ListItem::Measured { focus_handle, .. } => { focus_handle .as_ref() .is_some_and(|handle| handle.contains_focused(window, cx)) @@ -208,6 +295,7 @@ struct ListItemSummary { unrendered_count: usize, height: Pixels, has_focus_handles: bool, + has_unknown_height: bool, } #[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] @@ -236,6 +324,7 @@ impl ListState { scrollbar_drag_start_height: None, measuring_behavior: ListMeasuringBehavior::default(), pending_scroll: None, + follow_state: FollowState::default(), }))); this.splice(0..0, item_count); this @@ -270,37 +359,79 @@ impl ListState { /// Use this when item heights may have changed (e.g., font size changes) /// but the number and identity of items remains the same. pub fn remeasure(&self) { + let count = self.item_count(); + self.remeasure_items_with_scroll_anchor(0..count, ScrollAnchor::Proportional); + } + + /// Mark items in `range` as needing remeasurement while preserving + /// the current scroll position. Unlike [`Self::splice`], this does + /// not change the number of items or blow away `logical_scroll_top`. + /// + /// Use this when an item's content has changed and its rendered + /// height may be different (e.g., streaming text, tool results + /// loading), but the item itself still exists at the same index. + pub fn remeasure_items(&self, range: Range) { + self.remeasure_items_with_scroll_anchor(range, ScrollAnchor::Absolute); + } + + fn remeasure_items_with_scroll_anchor(&self, range: Range, scroll_anchor: ScrollAnchor) { let state = &mut *self.0.borrow_mut(); - let new_items = state.items.iter().map(|item| ListItem::Unmeasured { - focus_handle: item.focus_handle(), - }); - - // If there's a `logical_scroll_top`, we need to keep track of it as a - // `PendingScrollFraction`, so we can later preserve that scroll - // position proportionally to the item, in case the item's height - // changes. if let Some(scroll_top) = state.logical_scroll_top { - let mut cursor = state.items.cursor::(()); - cursor.seek(&Count(scroll_top.item_ix), Bias::Right); - - if let Some(item) = cursor.item() { - if let Some(size) = item.size() { - let fraction = if size.height.0 > 0.0 { - (scroll_top.offset_in_item.0 / size.height.0).clamp(0.0, 1.0) - } else { - 0.0 - }; - - state.pending_scroll = Some(PendingScrollFraction { + if range.contains(&scroll_top.item_ix) { + state.pending_scroll = match scroll_anchor { + ScrollAnchor::Absolute => Some(PendingScroll::Absolute { item_ix: scroll_top.item_ix, - fraction, - }); - } + offset: scroll_top.offset_in_item, + }), + ScrollAnchor::Proportional => { + // If the scroll-top item falls within the remeasured range, + // store a fractional offset so the layout can restore the + // proportional scroll position after the item is re-rendered + // at its new height. + let mut cursor = state.items.cursor::(()); + cursor.seek(&Count(scroll_top.item_ix), Bias::Right); + + cursor + .item() + .and_then(|item| { + item.size().map(|size| { + let fraction = if size.height.0 > 0.0 { + (scroll_top.offset_in_item.0 / size.height.0) + .clamp(0.0, 1.0) + } else { + 0.0 + }; + + PendingScroll::Proportional(PendingScrollFraction { + item_ix: scroll_top.item_ix, + fraction, + }) + }) + }) + .or_else(|| state.pending_scroll.clone()) + } + }; } } - state.items = SumTree::from_iter(new_items, ()); + // Rebuild the tree, replacing items in the range with + // Unmeasured copies that keep their focus handles. + let new_items = { + let mut cursor = state.items.cursor::(()); + let mut new_items = cursor.slice(&Count(range.start), Bias::Right); + let invalidated = cursor.slice(&Count(range.end), Bias::Right); + new_items.extend( + invalidated.iter().map(|item| ListItem::Unmeasured { + size_hint: item.size_hint(), + focus_handle: item.focus_handle(), + }), + (), + ); + new_items.append(cursor.suffix(), ()); + new_items + }; + state.items = new_items; state.measuring_behavior.reset(); } @@ -309,6 +440,25 @@ impl ListState { self.0.borrow().items.summary().count } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable or the total content height is not yet known. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let bounds = state.last_layout_bounds?; + let summary = state.items.summary(); + if summary.has_unknown_height { + return None; + } + let padding = state.last_padding.unwrap_or_default(); + let content_height = summary.height + padding.top + padding.bottom; + let scroll_max = (content_height - bounds.size.height).max(px(0.)); + if scroll_max <= px(0.) { + return None; + } + let scroll_top = state.scroll_top(&state.logical_scroll_top()); + Some(scroll_top >= scroll_max) + } + /// Inform the list state that the items in `old_range` have been replaced /// by `count` new items that must be recalculated. pub fn splice(&self, old_range: Range, count: usize) { @@ -334,7 +484,10 @@ impl ListState { new_items.extend( focus_handles.into_iter().map(|focus_handle| { spliced_count += 1; - ListItem::Unmeasured { focus_handle } + ListItem::Unmeasured { + size_hint: None, + focus_handle, + } }), (), ); @@ -377,6 +530,11 @@ impl ListState { let current_offset = self.logical_scroll_top(); let state = &mut *self.0.borrow_mut(); + + if distance < px(0.) { + state.follow_state.stop_following(); + } + let mut cursor = state.items.cursor::(()); cursor.seek(&Count(current_offset.item_ix), Bias::Right); @@ -394,6 +552,54 @@ impl ListState { }); } + /// Scroll the list to the very end (past the last item). + /// + /// Unlike [`scroll_to_reveal_item`], this uses the total item count as the + /// anchor, so the list's layout pass will walk backwards from the end and + /// always show the bottom of the last item — even when that item is still + /// growing (e.g. during streaming). + pub fn scroll_to_end(&self) { + let state = &mut *self.0.borrow_mut(); + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + + /// Set the follow mode for the list. In `Tail` mode, the list + /// will auto-scroll to the end and re-engage after the user + /// scrolls back to the bottom. In `Normal` mode, no automatic + /// following occurs. + pub fn set_follow_mode(&self, mode: FollowMode) { + let state = &mut *self.0.borrow_mut(); + + match mode { + FollowMode::Normal => { + state.follow_state = FollowState::Normal; + } + FollowMode::Tail => { + state.follow_state = FollowState::Tail { is_following: true }; + if matches!(mode, FollowMode::Tail) { + let item_count = state.items.summary().count; + state.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + } + } + } + } + + /// Returns whether the list is currently actively following the + /// tail (snapping to the end on each layout). + pub fn is_following_tail(&self) -> bool { + matches!( + self.0.borrow().follow_state, + FollowState::Tail { is_following: true } + ) + } + /// Scroll the list to the given offset pub fn scroll_to(&self, mut scroll_top: ListOffset) { let state = &mut *self.0.borrow_mut(); @@ -403,6 +609,10 @@ impl ListState { scroll_top.offset_in_item = px(0.); } + if scroll_top.item_ix < item_count { + state.follow_state.stop_following(); + } + state.logical_scroll_top = Some(scroll_top); } @@ -484,6 +694,16 @@ impl ListState { self.0.borrow_mut().scrollbar_drag_start_height.take(); } + /// Returns `true` if the scrollbar is currently being dragged. + /// + /// This is set between [`scrollbar_drag_started`](Self::scrollbar_drag_started) + /// and [`scrollbar_drag_ended`](Self::scrollbar_drag_ended) calls. Useful for + /// consumers that need to distinguish scrollbar drags from wheel/trackpad scrolls, + /// e.g. to suppress auto-scroll behavior during manual positioning. + pub fn is_scrollbar_dragging(&self) -> bool { + self.0.borrow().scrollbar_drag_start_height.is_some() + } + /// Set the offset from the scrollbar pub fn set_offset_from_scrollbar(&self, point: Point) { self.0.borrow_mut().set_offset_from_scrollbar(point); @@ -493,28 +713,26 @@ impl ListState { /// This value remains constant while dragging to prevent the scrollbar from moving away unexpectedly. pub fn max_offset_for_scrollbar(&self) -> Point { let state = self.0.borrow(); - let bounds = state.last_layout_bounds.unwrap_or_default(); - - let height = state - .scrollbar_drag_start_height - .unwrap_or_else(|| state.items.summary().height); - - point(Pixels::ZERO, Pixels::ZERO.max(height - bounds.size.height)) + point(Pixels::ZERO, state.max_scroll_offset()) } - /// Returns the current scroll offset adjusted for the scrollbar + /// Returns the current scroll offset adjusted for the scrollbar. + /// + /// The returned offset has a negative `y` component representing + /// how far the content has scrolled. pub fn scroll_px_offset_for_scrollbar(&self) -> Point { let state = &self.0.borrow(); + + if state.logical_scroll_top.is_none() && state.alignment == ListAlignment::Bottom { + return Point::new(px(0.), -state.max_scroll_offset()); + } + let logical_scroll_top = state.logical_scroll_top(); let mut cursor = state.items.cursor::(()); let summary: ListItemSummary = cursor.summary(&Count(logical_scroll_top.item_ix), Bias::Right); - let content_height = state.items.summary().height; - let drag_offset = - // if dragging the scrollbar, we want to offset the point if the height changed - content_height - state.scrollbar_drag_start_height.unwrap_or(content_height); - let offset = summary.height + logical_scroll_top.offset_in_item - drag_offset; + let offset = summary.height + logical_scroll_top.offset_in_item; Point::new(px(0.), -offset) } @@ -523,9 +741,55 @@ impl ListState { pub fn viewport_bounds(&self) -> Bounds { self.0.borrow().last_layout_bounds.unwrap_or_default() } + + /// Returns whether the item is entirely above the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_above_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(true); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.bottom() <= viewport_bounds.top()) + } + + /// Returns whether the item is entirely below the viewport, or `None` if + /// the list has not measured enough layout to know. + pub fn item_is_below_viewport(&self, ix: usize) -> Option { + let viewport_bounds = self.viewport_bounds(); + if viewport_bounds.size.height == px(0.0) { + return None; + } + + let scroll_top = self.logical_scroll_top(); + if ix < scroll_top.item_ix { + // Rows before the logical scroll top have no item bounds, but + // their position relative to the viewport is known from scroll state. + return Some(false); + } + + let item_bounds = self.bounds_for_item(ix)?; + Some(item_bounds.top() >= viewport_bounds.bottom()) + } } impl StateInner { + fn max_scroll_offset(&self) -> Pixels { + let bounds = self.last_layout_bounds.unwrap_or_default(); + let height = self + .scrollbar_drag_start_height + .unwrap_or_else(|| self.items.summary().height); + (height - bounds.size.height).max(px(0.)) + } + fn visible_range( items: &SumTree, height: Pixels, @@ -574,6 +838,10 @@ impl StateInner { }); } + if delta.y > px(0.) { + self.follow_state.stop_following(); + } + if let Some(handler) = self.scroll_handler.as_mut() { let visible_range = Self::visible_range(&self.items, height, scroll_top); handler( @@ -581,6 +849,10 @@ impl StateInner { visible_range, count: self.items.summary().count, is_scrolled: self.logical_scroll_top.is_some(), + is_following_tail: matches!( + self.follow_state, + FollowState::Tail { is_following: true } + ), }, window, cx, @@ -670,6 +942,15 @@ impl StateInner { let mut rendered_height = padding.top; let mut max_item_width = px(0.); let mut scroll_top = self.logical_scroll_top(); + + if self.follow_state.is_following() { + scroll_top = ListOffset { + item_ix: self.items.summary().count, + offset_in_item: px(0.), + }; + self.logical_scroll_top = Some(scroll_top); + } + let mut rendered_focused_item = false; let available_item_space = size( @@ -700,14 +981,26 @@ impl StateInner { size = Some(element_size); // If there's a pending scroll adjustment for the scroll-top - // item, apply it, ensuring proportional scroll position is - // maintained after re-measuring. + // item, apply it. if ix == 0 { if let Some(pending_scroll) = self.pending_scroll.take() { - if pending_scroll.item_ix == scroll_top.item_ix { - scroll_top.offset_in_item = - Pixels(pending_scroll.fraction * element_size.height.0); - self.logical_scroll_top = Some(scroll_top); + match pending_scroll { + PendingScroll::Absolute { item_ix, offset } + if item_ix == scroll_top.item_ix => + { + scroll_top.offset_in_item = offset.min(element_size.height); + self.logical_scroll_top = Some(scroll_top); + } + PendingScroll::Proportional(pending_scroll) + if pending_scroll.item_ix == scroll_top.item_ix => + { + // Ensuring proportional scroll position is + // maintained after re-measuring. + scroll_top.offset_in_item = + Pixels(pending_scroll.fraction * element_size.height.0); + self.logical_scroll_top = Some(scroll_top); + } + _ => {} } } } @@ -815,6 +1108,18 @@ impl StateInner { new_items.append(cursor.suffix(), ()); self.items = new_items; + // If follow_tail mode is on but the user scrolled away + // (is_following is false), check whether the current scroll + // position has returned to the bottom. + if self.follow_state.has_stopped_following() { + let padding = self.last_padding.unwrap_or_default(); + let total_height = self.items.summary().height + padding.top + padding.bottom; + let scroll_offset = self.scroll_top(&scroll_top); + if scroll_offset + available_height >= total_height - px(1.0) { + self.follow_state.start_following(); + } + } + // If none of the visible items are focused, check if an off-screen item is focused // and include it to be rendered after the visible items so keyboard interaction continues // to work for it. @@ -944,12 +1249,29 @@ impl StateInner { let height = bounds.size.height; let padding = self.last_padding.unwrap_or_default(); - let content_height = self.items.summary().height; + // Scrollbar drag positions are computed from the content height + // captured at drag start, so map them back using the same height. + let content_height = self + .scrollbar_drag_start_height + .unwrap_or_else(|| self.items.summary().height); let scroll_max = (content_height + padding.top + padding.bottom - height).max(px(0.)); - let drag_offset = - // if dragging the scrollbar, we want to offset the point if the height changed - content_height - self.scrollbar_drag_start_height.unwrap_or(content_height); - let new_scroll_top = (point.y - drag_offset).abs().max(px(0.)).min(scroll_max); + let new_scroll_top = (-point.y).max(px(0.)).min(scroll_max); + + // If content grew during the drag, the frozen bottom is below the + // live bottom. Treat dragging to the frozen end as resuming tail follow. + let dragged_to_end = + 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 }; + let item_count = self.items.summary().count; + self.logical_scroll_top = Some(ListOffset { + item_ix: item_count, + offset_in_item: px(0.), + }); + return; + } + + self.follow_state.stop_following(); if self.alignment == ListAlignment::Bottom && new_scroll_top == scroll_max { self.logical_scroll_top = None; @@ -1097,6 +1419,7 @@ impl Element for List { { let new_items = SumTree::from_iter( state.items.iter().map(|item| ListItem::Unmeasured { + size_hint: None, focus_handle: item.focus_handle(), }), (), @@ -1183,12 +1506,20 @@ impl sum_tree::Item for ListItem { fn summary(&self, _: ()) -> Self::Summary { match self { - ListItem::Unmeasured { focus_handle } => ListItemSummary { + ListItem::Unmeasured { + size_hint, + focus_handle, + } => ListItemSummary { count: 1, rendered_count: 0, unrendered_count: 1, - height: px(0.), + height: if let Some(size) = size_hint { + size.height + } else { + px(0.) + }, has_focus_handles: focus_handle.is_some(), + has_unknown_height: size_hint.is_none(), }, ListItem::Measured { size, focus_handle, .. @@ -1198,6 +1529,7 @@ impl sum_tree::Item for ListItem { unrendered_count: 0, height: size.height, has_focus_handles: focus_handle.is_some(), + has_unknown_height: false, }, } } @@ -1214,6 +1546,7 @@ impl sum_tree::ContextLessSummary for ListItemSummary { self.unrendered_count += summary.unrendered_count; self.height += summary.height; self.has_focus_handles |= summary.has_focus_handles; + self.has_unknown_height |= summary.has_unknown_height; } } @@ -1257,8 +1590,8 @@ mod test { use std::rc::Rc; use crate::{ - self as gpui, AppContext, Context, Element, IntoElement, ListState, Render, Styled, - TestAppContext, Window, div, list, point, px, size, + self as gpui, AppContext, Context, Element, FollowMode, IntoElement, ListState, Render, + Styled, TestAppContext, Window, div, list, point, px, size, }; #[gpui::test] @@ -1349,6 +1682,114 @@ mod test { assert_eq!(offset.offset_in_item, px(0.)); } + struct TestListView(ListState); + impl Render for TestListView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(20.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + #[gpui::test] + fn test_item_viewport_queries_return_none_before_layout(_cx: &mut TestAppContext) { + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + assert_eq!(state.item_is_above_viewport(0), None); + assert_eq!(state.item_is_below_viewport(0), None); + } + + #[gpui::test] + fn test_item_viewport_queries_before_logical_scroll_top(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(1), Some(true)); + assert_eq!(state.item_is_below_viewport(1), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_inside_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(2), Some(false)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_above_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(20.), + }); + 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(2), Some(true)); + assert_eq!(state.item_is_below_viewport(2), Some(false)); + } + + #[gpui::test] + fn test_item_viewport_queries_measured_item_below_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)); + } + + #[gpui::test] + fn test_item_viewport_queries_after_scroll_to_end_before_layout(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let state = ListState::new(5, crate::ListAlignment::Top, px(10.)).measure_all(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(20.)), |_, cx| { + cx.new(|_| TestListView(state.clone())).into_any_element() + }); + + state.scroll_to_end(); + + assert_eq!(state.logical_scroll_top().item_ix, state.item_count()); + assert_eq!(state.item_is_above_viewport(0), Some(true)); + assert_eq!(state.item_is_below_viewport(0), Some(false)); + } + #[gpui::test] fn test_measure_all_after_width_change(cx: &mut TestAppContext) { let cx = cx.add_empty_window(); @@ -1449,4 +1890,583 @@ mod test { assert_eq!(offset.item_ix, 2); assert_eq!(offset.offset_in_item, px(20.)); } + + #[gpui::test] + fn test_remeasure_item_preserves_scroll_offset(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 state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + 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(200); + state.remeasure_items(5..6); + + 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(40.)); + } + + #[gpui::test] + fn test_follow_tail_stays_at_bottom_as_items_grow(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items, each 50px tall → 500px total content, 200px viewport. + // With follow-tail on, the list should always show the bottom. + let item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + 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 |_, _, _| { + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let state_clone = state.clone(); + let item_height_clone = item_height.clone(); + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state_clone, + item_height: item_height_clone, + }) + }); + + state.set_follow_mode(FollowMode::Tail); + + // First paint — items are 50px, total 500px, viewport 200px. + // Follow-tail should anchor to the end. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + // The scroll should be at the bottom: the last visible items fill the + // 200px viewport from the end of 500px of content (offset 300px). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + + // Simulate items growing (e.g. streaming content makes each item taller). + // 10 items × 80px = 800px total. + item_height.set(80); + state.remeasure(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After growth, follow-tail should have re-anchored to the new end. + // 800px total − 200px viewport = 600px offset → item 7 at offset 40px, + // but follow-tail anchors to item_count (10), and layout walks back to + // fill 200px, landing at item 7 (7 × 80 = 560, 800 − 560 = 240 > 200, + // so item 8: 8 × 80 = 640, 800 − 640 = 160 < 200 → keeps walking → + // item 7: offset = 800 − 200 = 600, item_ix = 600/80 = 7, remainder 40). + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 7); + assert_eq!(offset.offset_in_item, px(40.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_user_scroll(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + state.set_follow_mode(FollowMode::Tail); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the user scrolling up. + // This should disengage follow-tail. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(100.))), + ..Default::default() + }); + + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the user scrolls toward the start" + ); + } + + #[gpui::test] + fn test_follow_tail_disengages_on_scrollbar_reposition(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + // Paint with follow-tail — scroll anchored to the bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Simulate the scrollbar moving the viewport to the middle. + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!( + !state.is_following_tail(), + "follow-tail should disengage when the scrollbar manually repositions the list" + ); + + // A subsequent draw should preserve the user's manual position instead + // of snapping back to the end. + 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, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_scrollbar_drag_with_growing_content(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + let last_item_height = Rc::new(Cell::new(50usize)); + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView { + state: ListState, + last_item_height: Rc>, + } + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let last_item_height = self.last_item_height.clone(); + list(self.state.clone(), move |index, _, _| { + let height = if index == 9 { + last_item_height.get() + } else { + 50 + }; + div().h(px(height as f32)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| { + cx.new(|_| TestView { + state: state.clone(), + last_item_height: last_item_height.clone(), + }) + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.scrollbar_drag_started(); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + let scrollbar_offset_before_growth = state.scroll_px_offset_for_scrollbar(); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + + last_item_height.set(550); + state.remeasure_items(9..10); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + assert_eq!(state.max_offset_for_scrollbar().y, px(300.)); + assert_eq!( + state.scroll_px_offset_for_scrollbar(), + scrollbar_offset_before_growth + ); + + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + } + + #[gpui::test] + fn test_set_follow_tail_snaps_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + // Scroll to the middle of the list (item 3). + state.scroll_to(gpui::ListOffset { + item_ix: 3, + offset_in_item: px(0.), + }); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 3); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(!state.is_following_tail()); + + // Enable follow-tail — this should immediately snap the scroll anchor + // to the end, like the user just sent a prompt. + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + // After paint, scroll should be at the bottom. + // 500px total − 200px viewport = 300px offset → item 6, offset 0. + let offset = state.logical_scroll_top(); + assert_eq!(offset.item_ix, 6); + assert_eq!(offset.offset_in_item, px(0.)); + assert!(state.is_following_tail()); + } + + #[gpui::test] + fn test_bottom_aligned_scrollbar_offset_at_end(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + const ITEMS: usize = 10; + const ITEM_SIZE: f32 = 50.0; + + let state = ListState::new( + ITEMS, + crate::ListAlignment::Bottom, + px(ITEMS as f32 * ITEM_SIZE), + ); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(ITEM_SIZE)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(100.)), |_, cx| { + cx.new(|_| TestView(state.clone())).into_any_element() + }); + + // Bottom-aligned lists start pinned to the end: logical_scroll_top returns + // item_ix == item_count, meaning no explicit scroll position has been set. + assert_eq!(state.logical_scroll_top().item_ix, ITEMS); + + let max_offset = state.max_offset_for_scrollbar(); + let scroll_offset = state.scroll_px_offset_for_scrollbar(); + + assert_eq!( + -scroll_offset.y, max_offset.y, + "scrollbar offset ({}) should equal max offset ({}) when list is pinned to bottom", + -scroll_offset.y, max_offset.y, + ); + } + + /// When the user scrolls away from the bottom during follow_tail, + /// follow_tail suspends. If they scroll back to the bottom, the + /// next paint should re-engage follow_tail using fresh measurements. + #[gpui::test] + fn test_follow_tail_reengages_when_scrolled_back_to_bottom(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Scroll up — follow_tail should suspend (not fully disengage). + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(50.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + // Scroll back down to the bottom. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-10000.))), + ..Default::default() + }); + + // After a paint, follow_tail should re-engage because the + // layout confirmed we're at the true bottom. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom" + ); + } + + /// When an item is spliced to unmeasured (0px) while follow_tail + /// is suspended, the re-engagement check should still work correctly + #[gpui::test] + fn test_follow_tail_reengagement_not_fooled_by_unmeasured_items(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 20 items × 50px = 1000px total, 200px viewport, 1000px + // overdraw so all items get measured during the follow_tail + // paint (matching realistic production settings). + let state = ListState::new(20, crate::ListAlignment::Top, px(1000.)); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Scroll up a meaningful amount — suspends follow_tail. + // 20 items × 50px = 1000px. viewport 200px. scroll_max = 800px. + // Scrolling up 200px puts us at 600px, clearly not at bottom. + cx.simulate_event(ScrollWheelEvent { + position: point(px(50.), px(100.)), + delta: ScrollDelta::Pixels(point(px(0.), px(200.))), + ..Default::default() + }); + assert!(!state.is_following_tail()); + + // Invalidate the last item (simulates EntryUpdated calling + // remeasure_items). This makes items.summary().height + // temporarily wrong (0px for the invalidated item). + state.remeasure_items(19..20); + + // Paint — layout re-measures the invalidated item with its true + // height. The re-engagement check uses these fresh measurements. + // Since we scrolled 200px up from the 800px max, we're at + // ~600px — NOT at the bottom, so follow_tail should NOT + // re-engage. + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!( + !state.is_following_tail(), + "follow_tail should not falsely re-engage due to an unmeasured item \ + reducing items.summary().height" + ); + } + + #[gpui::test] + fn test_follow_tail_reengages_after_scrollbar_disengagement(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + + // 10 items × 50px = 500px total, 200px viewport, scroll_max = 300px. + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + // Drag the scrollbar up to the middle — follow_tail should suspend. + state.set_offset_from_scrollbar(point(px(0.), px(-150.))); + assert!(!state.is_following_tail()); + + // Drag the scrollbar back to the bottom — follow_tail should re-engage + // on the next paint. + state.set_offset_from_scrollbar(point(px(0.), px(-300.))); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + assert!( + state.is_following_tail(), + "follow_tail should re-engage after scrolling back to the bottom via the scrollbar" + ); + } + + #[gpui::test] + fn test_follow_tail_reengages_after_scrollbar_drag_to_bottom_while_growing( + cx: &mut TestAppContext, + ) { + let cx = cx.add_empty_window(); + + let state = ListState::new(10, crate::ListAlignment::Top, px(0.)).measure_all(); + + struct TestView(ListState); + impl Render for TestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + list(self.0.clone(), |_, _, _| { + div().h(px(50.)).w_full().into_any() + }) + .w_full() + .h_full() + } + } + + let view = cx.update(|_, cx| cx.new(|_| TestView(state.clone()))); + + state.set_follow_mode(FollowMode::Tail); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + assert!(state.is_following_tail()); + + state.scrollbar_drag_started(); + + state.splice(10..10, 10); + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.clone().into_any_element() + }); + + state.set_offset_from_scrollbar(point(px(0.), px(-300.))); + state.scrollbar_drag_ended(); + + cx.draw(point(px(0.), px(0.)), size(px(100.), px(200.)), |_, _| { + view.into_any_element() + }); + + assert!( + state.is_following_tail(), + "follow_tail should re-engage when the user drags the scrollbar to \ + the bottom of its track, even when content has grown during the drag \ + (so frozen_bottom < live_bottom)" + ); + } } diff --git a/src/elements/mod.rs b/crates/gpui/src/elements/mod.rs similarity index 100% rename from src/elements/mod.rs rename to crates/gpui/src/elements/mod.rs diff --git a/src/elements/surface.rs b/crates/gpui/src/elements/surface.rs similarity index 100% rename from src/elements/surface.rs rename to crates/gpui/src/elements/surface.rs diff --git a/src/elements/svg.rs b/crates/gpui/src/elements/svg.rs similarity index 99% rename from src/elements/svg.rs rename to crates/gpui/src/elements/svg.rs index c4999655b8..a29b106c0e 100644 --- a/src/elements/svg.rs +++ b/crates/gpui/src/elements/svg.rs @@ -5,7 +5,7 @@ use crate::{ Interactivity, IntoElement, LayoutId, Pixels, Point, Radians, SharedString, Size, StyleRefinement, Styled, TransformationMatrix, Window, point, px, radians, size, }; -use util::ResultExt; +use gpui_util::ResultExt; /// An SVG element. pub struct Svg { diff --git a/src/elements/text.rs b/crates/gpui/src/elements/text.rs similarity index 74% rename from src/elements/text.rs rename to crates/gpui/src/elements/text.rs index e6abbe3252..82d23c83b7 100644 --- a/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -6,17 +6,250 @@ use crate::{ WrappedLineLayout, register_tooltip_mouse_handlers, set_tooltip_on_window, }; use anyhow::Context as _; +use gpui_util::ResultExt; use itertools::Itertools; use smallvec::SmallVec; use std::{ borrow::Cow, cell::{Cell, RefCell}, mem, - ops::Range, + ops::{Deref, DerefMut, Range}, rc::Rc, sync::Arc, }; -use util::ResultExt; + +/// An [`Element`] that renders text. +/// +/// In general, [`Text`] objects should be created via the [`text`] macro: +/// ```rust +/// # use gpui::*; +/// # fn render() -> impl IntoElement { +/// div().child(text!("hello")) +/// # } +/// ``` +/// ## IDs and Accessibility +/// +/// [`Text`] elements have an ID. This ID is primarily used to produce nodes in +/// the accessibility tree, which allows the text to be visible to screen +/// readers and other assistive technologies. +/// +/// This ID is stable across frames. If the same text, with the same ID, is +/// present in two consecutive frames, no updates are reported to the screen +/// reader. If the text changes, but the ID stays the same, then the screen +/// reader will be notified that a text node's content has changed. **However**, +/// if the ID changes, then the screen reader will be notified that a node has +/// been removed, and a new node has been added. +/// +/// When using the [`text`] macro, each invocation of the macro will get a +/// unique ID, derived from its position in the source code (filename, line, and +/// column). For example: +/// ```rust +/// # use gpui::*; +/// let x = text!("hello"); +/// let y = text!("hello"); +/// // not equal, because different `text!` invocations produced them +/// assert_ne!(x.id(), y.id()); +/// +/// fn make_text(s: &str) -> Text { text!(s) } +/// let x = make_text("hello"); +/// let y = make_text("hello"); +/// // equal, because the same `text!` invocation produced them +/// assert_eq!(x.id(), y.id()); +/// ``` +/// When the contents of an invocation of [`text`] do not change, this +/// distinction is less relevant (with the caveat that you still need to take +/// care to ensure that duplicate IDs do not appear). +/// +/// However, when a [`text`] invocation's argument *does* change, you should +/// consider whether this change should be reported as a node "updating its +/// contents", or an old node being destroyed and a new node being created. +#[derive(Debug, Clone)] +pub struct Text { + id: Option, + text: SharedString, +} + +impl Text { + /// Create a new [`Text`] element with a specific ID. + /// + /// If you want a unique ID to be assigned automatically, use the [`text`] + /// macro. The docs for [`Text`] have more detail about choosing IDs. + #[inline] + pub const fn new(id: ElementId, text: SharedString) -> Self { + Self { id: Some(id), text } + } + + /// Create a new [`Text`] element that is inaccessible to screen readers. + /// + /// In order for text to be accessible to screen readers, it must have an ID + /// provided. If you want text to be accessible, either use [`text`] to have + /// an ID automatically assigned, or use [`Text::new`] to manually assign an + /// ID. + /// + /// This function is intended for use inside custom UI components, where + /// accessible properties may be set on parent containers. + #[inline] + pub const fn new_inaccessible(text: SharedString) -> Self { + Self { id: None, text } + } + + /// The ID of this [`Text`] element. + #[inline] + pub const fn id(&self) -> Option<&ElementId> { + self.id.as_ref() + } + + /// Produce a new [`Text`] with the given `id`. + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// The text that this [`Text`] element will display. + #[inline] + pub const fn text(&self) -> &SharedString { + &self.text + } +} + +impl Deref for Text { + type Target = SharedString; + fn deref(&self) -> &Self::Target { + &self.text + } +} + +impl DerefMut for Text { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.text + } +} + +/// Trivial hash function for the location information produced by the [`text`] +/// macro. Not covered by semver guarantees. Performance is not particularly +/// significant because it's only used on small strings in const contexts. +#[doc(hidden)] +pub const fn __hash_text_macro_location_unstable_do_not_use(s: &'static str) -> u64 { + const BASIS: u64 = 0xcbf29ce484222325; + const PRIME: u64 = 0x100000001b3; + + let bytes = s.as_bytes(); + let mut hash = BASIS; + let mut i = 0; + while i < bytes.len() { + hash ^= bytes[i] as u64; + hash = hash.wrapping_mul(PRIME); + i += 1; + } + hash +} + +/// Create a new [`Text`] element. +/// +/// ```rust +/// # use gpui::*; +/// let a = text!("hello"); +/// let b = text!(id = "farewell-message", "hello"); +/// +/// ``` +/// +/// Text created with this macro is *accessible*. The macro generates an ID +/// based on the source location. See the docs for [`Text`] for a more in-depth +/// explanation of the significance of the ID of a [`Text`] element. +#[macro_export] +macro_rules! text { + (id = $id:expr, $text:expr) => {{ $crate::Text::new($id.into(), $text.into()) }}; + ($text:expr) => {{ + const ID: &'static str = concat!(file!(), "/", line!(), ":", column!()); + const HASH: u64 = $crate::__hash_text_macro_location_unstable_do_not_use(ID); + $crate::Text::new($crate::ElementId::Integer(HASH), $text.into()) + }}; +} + +impl IntoElement for Text { + type Element = Self; + #[inline] + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Text { + type RequestLayoutState = TextLayout; + type PrepaintState = (); + + fn id(&self) -> Option { + self.id.clone() + } + + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn a11y_role(&self) -> Option { + if self.id.is_some() { + Some(accesskit::Role::Label) + } else { + None + } + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.set_value(self.text.to_string()); + } + + fn request_layout( + &mut self, + id: Option<&GlobalElementId>, + inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + ::request_layout(&mut self.text, id, inspector_id, 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 { + ::prepaint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + 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, + ) { + ::paint( + &mut self.text, + id, + inspector_id, + bounds, + request_layout, + prepaint, + window, + cx, + ); + } +} impl Element for &'static str { type RequestLayoutState = TextLayout; @@ -159,6 +392,7 @@ pub struct StyledText { text: SharedString, runs: Option>, delayed_highlights: Option, HighlightStyle)>>, + delayed_font_family_overrides: Option, SharedString)>>, layout: TextLayout, } @@ -169,6 +403,7 @@ impl StyledText { text: text.into(), runs: None, delayed_highlights: None, + delayed_font_family_overrides: None, layout: TextLayout::default(), } } @@ -242,9 +477,54 @@ impl StyledText { runs } + /// Override the font family for specific byte ranges of the text. + /// + /// This is resolved lazily at layout time, so the overrides are applied + /// on top of the inherited text style from the parent element. + /// Can be combined with [`with_highlights`](Self::with_highlights). + /// + /// The overrides must be sorted by range start and non-overlapping. + /// Each override range must fall on character boundaries. + pub fn with_font_family_overrides( + mut self, + overrides: impl IntoIterator, SharedString)>, + ) -> Self { + self.delayed_font_family_overrides = Some( + overrides + .into_iter() + .inspect(|(range, _)| { + debug_assert!(self.text.is_char_boundary(range.start)); + debug_assert!(self.text.is_char_boundary(range.end)); + }) + .collect(), + ); + self + } + + fn apply_font_family_overrides( + runs: &mut [TextRun], + overrides: &[(Range, SharedString)], + ) { + let mut byte_offset = 0; + let mut override_idx = 0; + for run in runs.iter_mut() { + let run_end = byte_offset + run.len; + while override_idx < overrides.len() && overrides[override_idx].0.end <= byte_offset { + override_idx += 1; + } + if override_idx < overrides.len() { + let (ref range, ref family) = overrides[override_idx]; + if byte_offset >= range.start && run_end <= range.end { + run.font.family = family.clone(); + } + } + byte_offset = run_end; + } + } + /// Set the text runs for this piece of text. pub fn with_runs(mut self, runs: Vec) -> Self { - let mut text = &**self.text; + let mut text = &*self.text; for run in &runs { text = text.get(run.len..).unwrap_or_else(|| { #[cfg(debug_assertions)] @@ -278,12 +558,19 @@ impl Element for StyledText { window: &mut Window, cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { - let runs = self.runs.take().or_else(|| { + let font_family_overrides = self.delayed_font_family_overrides.take(); + let mut runs = self.runs.take().or_else(|| { self.delayed_highlights.take().map(|delayed_highlights| { Self::compute_runs(&self.text, &window.text_style(), delayed_highlights) }) }); + if let Some(ref overrides) = font_family_overrides { + let runs = + runs.get_or_insert_with(|| vec![window.text_style().to_run(self.text.len())]); + Self::apply_font_family_overrides(runs, overrides); + } + let layout_id = self.layout.layout(self.text.clone(), runs, window, cx); (layout_id, ()) } @@ -345,9 +632,11 @@ impl TextLayout { ) -> LayoutId { let text_style = window.text_style(); let font_size = text_style.font_size.to_pixels(window.rem_size()); - let line_height = text_style - .line_height - .to_pixels(font_size.into(), window.rem_size()); + let line_height = window.pixel_snap( + text_style + .line_height + .to_pixels(font_size.into(), window.rem_size()), + ); let runs = if let Some(runs) = runs { runs @@ -399,14 +688,27 @@ impl TextLayout { } let mut line_wrapper = cx.text_system().line_wrapper(text_style.font(), font_size); - let (text, runs) = if let Some(truncate_width) = truncate_width { - line_wrapper.truncate_line( - text.clone(), - truncate_width, - &truncation_affix, - &runs, - truncate_from, - ) + let (text, runs) = if truncate_width.is_some() { + if let Some(max_lines) = text_style.line_clamp + && let Some(wrap_width) = wrap_width + { + line_wrapper.truncate_wrapped_line( + text.clone(), + wrap_width, + max_lines, + &truncation_affix, + &runs, + truncate_from, + ) + } else { + line_wrapper.truncate_line( + text.clone(), + truncate_width.unwrap_or(Pixels::MAX), + &truncation_affix, + &runs, + truncate_from, + ) + } } else { (text.clone(), Cow::Borrowed(&*runs)) }; @@ -738,6 +1040,14 @@ impl Element for InteractiveText { None } + fn a11y_role(&self) -> Option { + Some(accesskit::Role::Label) + } + + fn write_a11y_info(&self, node: &mut accesskit::Node) { + node.set_value(self.text.text.to_string()); + } + fn request_layout( &mut self, _id: Option<&GlobalElementId>, @@ -940,6 +1250,8 @@ impl IntoElement for InteractiveText { #[cfg(test)] mod tests { + use super::*; + #[test] fn test_into_element_for() { use crate::{ParentElement as _, SharedString, div}; @@ -950,4 +1262,23 @@ mod tests { let _ = div().child(Cow::Borrowed("Cow")); let _ = div().child(SharedString::from("SharedString")); } + + #[test] + fn text_macro_id() { + // one call to `text!` = one id + fn make_text_stable_id(happy: bool) -> Text { + text!(if happy { "happy" } else { "sad" }) + } + + // two calls to `text!` = two ids + fn make_text_unstable_id(happy: bool) -> Text { + if happy { text!("happy") } else { text!("sad") } + } + + assert_eq!(make_text_stable_id(false).id, make_text_stable_id(true).id); + assert_ne!( + make_text_unstable_id(false).id, + make_text_unstable_id(true).id + ); + } } diff --git a/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs similarity index 98% rename from src/elements/uniform_list.rs rename to crates/gpui/src/elements/uniform_list.rs index a7486f0c00..0a3314573f 100644 --- a/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -8,7 +8,7 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, Entity, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, - StyleRefinement, Styled, Window, point, size, + StyleRefinement, Styled, Window, point, px, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc, usize}; @@ -236,6 +236,18 @@ impl UniformListScrollHandle { } } + /// Whether the list is scrolled to the end, or `None` if the list is + /// not scrollable. + pub fn is_scrolled_to_end(&self) -> Option { + let state = self.0.borrow(); + let max_offset = state.base_handle.max_offset(); + if max_offset.y <= px(0.) { + return None; + } + let offset = state.base_handle.offset(); + Some(-offset.y >= max_offset.y) + } + /// Scroll to the bottom of the list. pub fn scroll_to_bottom(&self) { self.scroll_to_item(usize::MAX, ScrollStrategy::Bottom); diff --git a/src/executor.rs b/crates/gpui/src/executor.rs similarity index 73% rename from src/executor.rs rename to crates/gpui/src/executor.rs index b92520126c..7f8d7da0d4 100644 --- a/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,23 +1,18 @@ -use crate::scheduler::Instant; -use crate::scheduler::Scheduler; use crate::{App, PlatformDispatcher, PlatformScheduler}; use futures::channel::mpsc; use futures::prelude::*; -use std::{ - fmt::Debug, future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, - time::Duration, -}; -use util::TryFutureExt; +use gpui_util::{TryFutureExt, TryFutureExtBacktrace}; +use scheduler::Instant; +use scheduler::Scheduler; +use std::{future::Future, marker::PhantomData, mem, pin::Pin, rc::Rc, sync::Arc, time::Duration}; -pub use crate::scheduler::{ - FallibleTask, ForegroundExecutor as SchedulerForegroundExecutor, Priority, -}; +pub use scheduler::{FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task}; /// A pointer to the executor that is currently running, /// for spawning background tasks. #[derive(Clone)] pub struct BackgroundExecutor { - inner: crate::scheduler::BackgroundExecutor, + inner: scheduler::BackgroundExecutor, dispatcher: Arc, } @@ -25,90 +20,41 @@ pub struct BackgroundExecutor { /// for spawning tasks on the main thread. #[derive(Clone)] pub struct ForegroundExecutor { - inner: crate::scheduler::ForegroundExecutor, + inner: scheduler::LocalExecutor, dispatcher: Arc, not_send: PhantomData>, } -/// Task is a primitive that allows work to happen in the background. +/// Extension trait for `Task>` that adds `detach_and_log_err` with an `&App` context. /// -/// It implements [`Future`] so you can `.await` on it. -/// -/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows -/// the task to continue running, but with no way to return a value. -#[must_use] -#[derive(Debug)] -pub struct Task(crate::scheduler::Task); - -impl Task { - /// Creates a new task that will resolve with the value. - pub fn ready(val: T) -> Self { - Task(crate::scheduler::Task::ready(val)) - } - - /// Returns true if the task has completed or was created with `Task::ready`. - pub fn is_ready(&self) -> bool { - self.0.is_ready() - } - - /// Detaching a task runs it to completion in the background. - pub fn detach(self) { - self.0.detach() - } - - /// Wraps a scheduler Task. - pub fn from_scheduler(task: crate::scheduler::Task) -> Self { - Task(task) - } - - /// Converts this task into a fallible task that returns `Option`. - /// - /// Unlike the standard `Task`, a [`FallibleTask`] will return `None` - /// if the task was cancelled. - /// - /// # Example - /// - /// ```ignore - /// // Background task that gracefully handles cancellation: - /// cx.background_spawn(async move { - /// let result = foreground_task.fallible().await; - /// if let Some(value) = result { - /// // Process the value - /// } - /// // If None, task was cancelled - just exit gracefully - /// }).detach(); - /// ``` - pub fn fallible(self) -> FallibleTask { - self.0.fallible() - } +/// This trait is automatically implemented for all `Task>` types. +pub trait TaskExt { + /// Run the task to completion in the background and log any errors that occur. + fn detach_and_log_err(self, cx: &App); + /// Like [`Self::detach_and_log_err`], but uses `{:?}` formatting on failure so `anyhow::Error` + /// values emit their full backtrace. Prefer `detach_and_log_err` unless a backtrace is wanted. + fn detach_and_log_err_with_backtrace(self, cx: &App); } -impl Task> +impl TaskExt for Task> where T: 'static, - E: 'static + Debug, + E: 'static + std::fmt::Display + std::fmt::Debug, { - /// Run the task to completion in the background and log any errors that occur. #[track_caller] - pub fn detach_and_log_err(self, cx: &App) { + fn detach_and_log_err(self, cx: &App) { let location = core::panic::Location::caller(); cx.foreground_executor() .spawn(self.log_tracked_err(*location)) .detach(); } -} -impl std::future::Future for Task { - type Output = T; - - fn poll( - self: std::pin::Pin<&mut Self>, - cx: &mut std::task::Context<'_>, - ) -> std::task::Poll { - // SAFETY: Task is a repr(transparent) wrapper around the scheduler Task, - // and we're just projecting the pin through to the inner task. - let inner = unsafe { self.map_unchecked_mut(|t| &mut t.0) }; - inner.poll(cx) + #[track_caller] + fn detach_and_log_err_with_backtrace(self, cx: &App) { + let location = *core::panic::Location::caller(); + cx.foreground_executor() + .spawn(self.log_tracked_err_with_backtrace(location)) + .detach(); } } @@ -126,15 +72,15 @@ impl BackgroundExecutor { let scheduler: Arc = Arc::new(PlatformScheduler::new(dispatcher.clone())); Self { - inner: crate::scheduler::BackgroundExecutor::new(scheduler), + inner: scheduler::BackgroundExecutor::new(scheduler), dispatcher, } } - /// Returns the underlying crate::scheduler::BackgroundExecutor. + /// Returns the underlying scheduler::BackgroundExecutor. /// /// This is used by Ex to pass the executor to thread/worktree code. - pub fn scheduler_executor(&self) -> crate::scheduler::BackgroundExecutor { + pub fn scheduler_executor(&self) -> scheduler::BackgroundExecutor { self.inner.clone() } @@ -161,66 +107,12 @@ impl BackgroundExecutor { R: Send + 'static, { if priority == Priority::RealtimeAudio { - Task::from_scheduler(self.inner.spawn_realtime(future)) + self.inner.spawn_realtime(future) } else { - Task::from_scheduler(self.inner.spawn_with_priority(priority, future)) + self.inner.spawn_with_priority(priority, future) } } - /// Enqueues the given future to be run to completion on a background thread and blocking the current task on it. - /// - /// This allows to spawn background work that borrows from its scope. Note that the supplied future will run to - /// completion before the current task is resumed, even if the current task is slated for cancellation. - pub async fn await_on_background(&self, future: impl Future + Send) -> R - where - R: Send, - { - use crate::RunnableMeta; - use parking_lot::{Condvar, Mutex}; - - struct NotifyOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for NotifyOnDrop<'_> { - fn drop(&mut self) { - *self.0.1.lock() = true; - self.0.0.notify_all(); - } - } - - struct WaitOnDrop<'a>(&'a (Condvar, Mutex)); - - impl Drop for WaitOnDrop<'_> { - fn drop(&mut self) { - let mut done = self.0.1.lock(); - if !*done { - self.0.0.wait(&mut done); - } - } - } - - let dispatcher = self.dispatcher.clone(); - let location = core::panic::Location::caller(); - - let pair = &(Condvar::new(), Mutex::new(false)); - let _wait_guard = WaitOnDrop(pair); - - let (runnable, task) = unsafe { - async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn_unchecked( - move |_| async { - let _notify_guard = NotifyOnDrop(pair); - future.await - }, - move |runnable| { - dispatcher.dispatch(runnable, Priority::default()); - }, - ) - }; - runnable.schedule(); - task.await - } - /// Scoped lets you start a number of tasks and waits /// for all of them to complete before returning. pub async fn scoped<'scope, F>(&self, scheduler: F) @@ -340,7 +232,7 @@ impl BackgroundExecutor { /// In tests, returns the rng used by the dispatcher. #[cfg(any(test, feature = "test-support"))] - pub fn rng(&self) -> crate::scheduler::SharedRng { + pub fn rng(&self) -> scheduler::SharedRng { self.dispatcher.as_test().unwrap().scheduler().rng() } @@ -386,18 +278,29 @@ impl ForegroundExecutor { ) } else { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + let inner = platform_scheduler.foreground_executor(); + return Self { + inner, + dispatcher, + not_send: PhantomData, + }; }; #[cfg(not(any(test, feature = "test-support")))] - let (scheduler, session_id): (Arc, _) = { + let inner = { let platform_scheduler = Arc::new(PlatformScheduler::new(dispatcher.clone())); - let session_id = platform_scheduler.allocate_session_id(); - (platform_scheduler, session_id) + platform_scheduler.foreground_executor() }; - let inner = crate::scheduler::ForegroundExecutor::new(session_id, scheduler); + #[cfg(any(test, feature = "test-support"))] + let inner = { + let scheduler_for_dispatch = Arc::downgrade(&scheduler); + scheduler::LocalExecutor::new(session_id, scheduler, move |runnable| { + if let Some(scheduler) = scheduler_for_dispatch.upgrade() { + scheduler.schedule_local(session_id, runnable); + } + }) + }; Self { inner, @@ -412,7 +315,7 @@ impl ForegroundExecutor { where R: 'static, { - Task::from_scheduler(self.inner.spawn(future.boxed_local())) + self.inner.spawn(future.boxed_local()) } /// Enqueues the given Task to run on the main thread with the given priority. @@ -426,7 +329,7 @@ impl ForegroundExecutor { R: 'static, { // Priority is ignored for foreground tasks - they run in order on the main thread - Task::from_scheduler(self.inner.spawn(future)) + self.inner.spawn(future) } /// Used by the test harness to run an async test in a synchronous fashion. @@ -472,7 +375,7 @@ impl ForegroundExecutor { } #[doc(hidden)] - pub fn scheduler_executor(&self) -> SchedulerForegroundExecutor { + pub fn scheduler_executor(&self) -> SchedulerLocalExecutor { self.inner.clone() } } diff --git a/src/geometry.rs b/crates/gpui/src/geometry.rs similarity index 94% rename from src/geometry.rs rename to crates/gpui/src/geometry.rs index 76157a06a5..e5951a1296 100644 --- a/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -826,24 +826,45 @@ where }; Bounds { origin, size } } +} +impl Bounds +where + T: Sub + Half + Clone + Debug + Default + PartialEq, +{ /// Constructs a `Bounds` from a corner point and size. The specified corner will be placed at /// the specified origin. - pub fn from_corner_and_size(corner: Corner, origin: Point, size: Size) -> Bounds { + pub fn from_anchor_and_size(corner: Anchor, origin: Point, size: Size) -> Bounds { let origin = match corner { - Corner::TopLeft => origin, - Corner::TopRight => Point { + Anchor::TopLeft => origin, + Anchor::TopRight => Point { x: origin.x - size.width.clone(), y: origin.y, }, - Corner::BottomLeft => Point { + Anchor::BottomLeft => Point { x: origin.x, y: origin.y - size.height.clone(), }, - Corner::BottomRight => Point { + Anchor::BottomRight => Point { x: origin.x - size.width.clone(), y: origin.y - size.height.clone(), }, + Anchor::TopCenter => Point { + x: origin.x - size.width.half(), + y: origin.y, + }, + Anchor::BottomCenter => Point { + x: origin.x - size.width.half(), + y: origin.y - size.height.clone(), + }, + Anchor::LeftCenter => Point { + x: origin.x, + y: origin.y - size.height.half(), + }, + Anchor::RightCenter => Point { + x: origin.x - size.width.clone(), + y: origin.y - size.height.half(), + }, }; Bounds { origin, size } @@ -864,6 +885,43 @@ where } } +impl Bounds +where + T: Add + Half + Clone + Debug + Default + PartialEq, +{ + /// Returns the top center point of the bounds. + pub fn top_center(&self) -> Point { + Point { + x: self.origin.x.clone() + self.size.width.half(), + y: self.origin.y.clone(), + } + } + + /// Returns the bottom center point of the bounds. + pub fn bottom_center(&self) -> Point { + Point { + x: self.origin.x.clone() + self.size.width.half(), + y: self.origin.y.clone() + self.size.height.clone(), + } + } + + /// Returns the left center point of the bounds. + pub fn left_center(&self) -> Point { + Point { + x: self.origin.x.clone(), + y: self.origin.y.clone() + self.size.height.half(), + } + } + + /// Returns the right center point of the bounds. + pub fn right_center(&self) -> Point { + Point { + x: self.origin.x.clone() + self.size.width.clone(), + y: self.origin.y.clone() + self.size.height.half(), + } + } +} + impl Bounds where T: PartialOrd + Add + Clone + Debug + Default + PartialEq, @@ -1334,7 +1392,12 @@ where y: self.origin.y.clone() + self.size.height.clone(), } } +} +impl Bounds +where + T: Add + Half + Clone + Debug + Default + PartialEq, +{ /// Returns the requested corner point of the bounds. /// /// # Returns @@ -1344,20 +1407,24 @@ where /// # Examples /// /// ``` - /// use gpui::{Bounds, Corner, Point, Size}; + /// use gpui::{Bounds, Anchor, Point, Size}; /// let bounds = Bounds { /// origin: Point { x: 0, y: 0 }, /// size: Size { width: 10, height: 20 }, /// }; - /// let bottom_left = bounds.corner(Corner::BottomLeft); + /// let bottom_left = bounds.corner(Anchor::BottomLeft); /// assert_eq!(bottom_left, Point { x: 0, y: 20 }); /// ``` - pub fn corner(&self, corner: Corner) -> Point { + pub fn corner(&self, corner: Anchor) -> Point { match corner { - Corner::TopLeft => self.origin.clone(), - Corner::TopRight => self.top_right(), - Corner::BottomLeft => self.bottom_left(), - Corner::BottomRight => self.bottom_right(), + Anchor::TopLeft => self.origin.clone(), + Anchor::TopRight => self.top_right(), + Anchor::BottomLeft => self.bottom_left(), + Anchor::BottomRight => self.bottom_right(), + Anchor::TopCenter => self.top_center(), + Anchor::BottomCenter => self.bottom_center(), + Anchor::LeftCenter => self.left_center(), + Anchor::RightCenter => self.right_center(), } } } @@ -2093,9 +2160,9 @@ impl From for Edges { } } -/// Identifies a corner of a 2d box. +/// Identifies a reference point on a 2D box, used to anchor positioned elements. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Corner { +pub enum Anchor { /// The top left corner TopLeft, /// The top right corner @@ -2104,24 +2171,36 @@ pub enum Corner { BottomLeft, /// The bottom right corner BottomRight, + /// The top center position + TopCenter, + /// The bottom center position + BottomCenter, + /// The left center position + LeftCenter, + /// The right center position + RightCenter, } -impl Corner { +impl Anchor { /// Returns the directly opposite corner. /// /// # Examples /// /// ``` - /// # use gpui::Corner; - /// assert_eq!(Corner::TopLeft.opposite_corner(), Corner::BottomRight); + /// # use gpui::Anchor; + /// assert_eq!(Anchor::TopLeft.opposite(), Anchor::BottomRight); /// ``` #[must_use] - pub fn opposite_corner(self) -> Self { + pub fn opposite(self) -> Self { match self { - Corner::TopLeft => Corner::BottomRight, - Corner::TopRight => Corner::BottomLeft, - Corner::BottomLeft => Corner::TopRight, - Corner::BottomRight => Corner::TopLeft, + Anchor::TopLeft => Anchor::BottomRight, + Anchor::TopRight => Anchor::BottomLeft, + Anchor::BottomLeft => Anchor::TopRight, + Anchor::BottomRight => Anchor::TopLeft, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::LeftCenter => Anchor::RightCenter, + Anchor::RightCenter => Anchor::LeftCenter, } } @@ -2130,27 +2209,44 @@ impl Corner { /// # Examples /// /// ``` - /// # use gpui::{Axis, Corner}; - /// let result = Corner::TopLeft.other_side_corner_along(Axis::Horizontal); - /// assert_eq!(result, Corner::TopRight); + /// # use gpui::{Axis, Anchor}; + /// let result = Anchor::TopLeft.other_side_along(Axis::Horizontal); + /// assert_eq!(result, Anchor::TopRight); /// ``` #[must_use] - pub fn other_side_corner_along(self, axis: Axis) -> Self { + pub fn other_side_along(self, axis: Axis) -> Self { match axis { Axis::Vertical => match self { - Corner::TopLeft => Corner::BottomLeft, - Corner::TopRight => Corner::BottomRight, - Corner::BottomLeft => Corner::TopLeft, - Corner::BottomRight => Corner::TopRight, + Anchor::TopLeft => Anchor::BottomLeft, + Anchor::TopRight => Anchor::BottomRight, + Anchor::BottomLeft => Anchor::TopLeft, + Anchor::BottomRight => Anchor::TopRight, + Anchor::TopCenter => Anchor::BottomCenter, + Anchor::BottomCenter => Anchor::TopCenter, + Anchor::LeftCenter => Anchor::LeftCenter, + Anchor::RightCenter => Anchor::RightCenter, }, Axis::Horizontal => match self { - Corner::TopLeft => Corner::TopRight, - Corner::TopRight => Corner::TopLeft, - Corner::BottomLeft => Corner::BottomRight, - Corner::BottomRight => Corner::BottomLeft, + Anchor::TopLeft => Anchor::TopRight, + Anchor::TopRight => Anchor::TopLeft, + Anchor::BottomLeft => Anchor::BottomRight, + Anchor::BottomRight => Anchor::BottomLeft, + Anchor::TopCenter => Anchor::TopCenter, + Anchor::BottomCenter => Anchor::BottomCenter, + Anchor::LeftCenter => Anchor::RightCenter, + Anchor::RightCenter => Anchor::LeftCenter, }, } } + + /// Returns true if at the center. + #[inline] + pub fn is_center(&self) -> bool { + matches!( + self, + Self::TopCenter | Self::BottomCenter | Self::LeftCenter | Self::RightCenter + ) + } } /// Represents the corners of a box in a 2D space, such as border radius. @@ -2172,7 +2268,7 @@ pub struct Corners { impl Corners where - T: Clone + Debug + Default + PartialEq, + T: Add + Half + Clone + Debug + Default + PartialEq, { /// Constructs `Corners` where all sides are set to the same specified value. /// @@ -2207,31 +2303,60 @@ where } } - /// Returns the requested corner. + /// Returns the requested corner value, supporting all eight corner positions. + /// + /// For the four basic corners (TopLeft, TopRight, BottomLeft, BottomRight), + /// this returns the corresponding field value directly. + /// + /// For the center positions (TopCenter, BottomCenter, LeftCenter, RightCenter), + /// this calculates the average of the two adjacent corners. /// /// # Returns /// - /// A `Point` representing the corner requested by the parameter. + /// A value of type `T` representing the corner requested by the parameter. /// /// # Examples /// + /// Basic corner positions: + /// /// ``` - /// # use gpui::{Corner, Corners}; + /// # use gpui::{Anchor, Corners}; /// let corners = Corners { - /// top_left: 1, - /// top_right: 2, - /// bottom_left: 3, - /// bottom_right: 4 + /// top_left: 10, + /// top_right: 20, + /// bottom_left: 30, + /// bottom_right: 40 /// }; - /// assert_eq!(corners.corner(Corner::BottomLeft), 3); + /// assert_eq!(corners.corner(Anchor::TopLeft), 10); + /// assert_eq!(corners.corner(Anchor::BottomRight), 40); + /// ``` + /// + /// Center positions (calculated as average of adjacent corners): + /// + /// ``` + /// # use gpui::{Anchor, Corners}; + /// let corners = Corners { + /// top_left: 10, + /// top_right: 20, + /// bottom_left: 30, + /// bottom_right: 40 + /// }; + /// assert_eq!(corners.corner(Anchor::TopCenter), 15); + /// assert_eq!(corners.corner(Anchor::BottomCenter), 35); + /// assert_eq!(corners.corner(Anchor::LeftCenter), 20); + /// assert_eq!(corners.corner(Anchor::RightCenter), 30); /// ``` #[must_use] - pub fn corner(&self, corner: Corner) -> T { + pub fn corner(&self, corner: Anchor) -> T { match corner { - Corner::TopLeft => self.top_left.clone(), - Corner::TopRight => self.top_right.clone(), - Corner::BottomLeft => self.bottom_left.clone(), - Corner::BottomRight => self.bottom_right.clone(), + Anchor::TopLeft => self.top_left.clone(), + Anchor::TopRight => self.top_right.clone(), + Anchor::BottomLeft => self.bottom_left.clone(), + Anchor::BottomRight => self.bottom_right.clone(), + Anchor::TopCenter => (self.top_left.clone() + self.top_right.clone()).half(), + Anchor::BottomCenter => (self.bottom_left.clone() + self.bottom_right.clone()).half(), + Anchor::LeftCenter => (self.top_left.clone() + self.bottom_left.clone()).half(), + Anchor::RightCenter => (self.top_right.clone() + self.bottom_right.clone()).half(), } } } @@ -2337,7 +2462,7 @@ impl + Ord + Clone + Debug + Default + PartialEq> Corner /// /// # Returns /// - /// Corner radii values clamped to fit. + /// Anchor radii values clamped to fit. #[must_use] pub fn clamp_radii_for_quad_size(self, size: Size) -> Corners { let max = cmp::min(size.width, size.height) / 2.; diff --git a/src/global.rs b/crates/gpui/src/global.rs similarity index 100% rename from src/global.rs rename to crates/gpui/src/global.rs diff --git a/src/gpui.rs b/crates/gpui/src/gpui.rs similarity index 90% rename from src/gpui.rs rename to crates/gpui/src/gpui.rs index 8acc91ca0f..12aeebfb95 100644 --- a/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -5,8 +5,8 @@ #![allow(unused_mut)] // False positives in platform specific code extern crate self as gpui; -extern crate gpui_ce_macros as gpui_macros; - +#[doc(hidden)] +pub static GPUI_MANIFEST_DIR: &'static str = env!("CARGO_MANIFEST_DIR"); #[macro_use] mod action; mod app; @@ -30,7 +30,6 @@ mod inspector; mod interactive; mod key_dispatch; mod keymap; -mod local_util; mod path_builder; mod platform; pub mod prelude; @@ -40,9 +39,6 @@ pub mod profiler; #[expect(missing_docs)] pub mod queue; mod scene; -/// The scheduler module provides task scheduling, execution, and timing primitives. -pub mod scheduler; -mod shared_string; mod shared_uri; mod style; mod styled; @@ -53,12 +49,15 @@ mod taffy; #[cfg(any(test, feature = "test-support"))] pub mod test; mod text_system; +mod util; mod view; mod window; #[cfg(any(test, feature = "test-support"))] pub use proptest; +#[cfg(doc)] +pub mod _accessibility; #[cfg(doc)] pub mod _ownership_and_data_flow; @@ -78,6 +77,9 @@ mod seal { pub trait Sealed {} } +pub use accesskit; +pub use accesskit::Action as AccessibleAction; +pub use accesskit::{Orientation, Role, Toggled}; pub use action::*; pub use anyhow::Result; pub use app::*; @@ -92,16 +94,16 @@ pub use executor::*; pub use geometry::*; pub use global::*; pub use gpui_macros::{ - AppContext, IntoElement, Render, VisualContext, derive_inspector_reflection, register_action, - test, + AppContext, IntoElement, Render, VisualContext, property_test, register_action, test, }; +pub use gpui_shared_string::*; +pub use gpui_util::arc_cow::ArcCow; pub use http_client; pub use input::*; pub use inspector::*; pub use interactive::*; use key_dispatch::*; pub use keymap::*; -pub use local_util::{FutureExt, Timeout, command}; pub use path_builder::*; pub use platform::*; pub use profiler::*; @@ -109,7 +111,6 @@ pub use profiler::*; pub use queue::{PriorityQueueReceiver, PriorityQueueSender}; pub use refineable::*; pub use scene::*; -pub use shared_string::*; pub use shared_uri::*; use std::{any::Any, future::Future}; pub use style::*; @@ -122,10 +123,12 @@ pub use taffy::{AvailableSpace, LayoutId}; #[cfg(any(test, feature = "test-support"))] pub use test::*; pub use text_system::*; -pub use util::arc_cow::ArcCow; +pub use util::{FutureExt, Timeout}; pub use view::*; pub use window::*; +pub use pollster::block_on; + /// The context trait, allows the different contexts in GPUI to be used /// interchangeably for certain operations. pub trait AppContext { @@ -173,6 +176,16 @@ pub trait AppContext { where F: FnOnce(AnyView, &mut Window, &mut App) -> T; + /// Run `f` against the entity's *current* window — the most recently + /// rendered window that referenced the entity. Returns `None` if the + /// entity has no current window or that window is unavailable. See + /// [`App::with_window`] for the underlying lookup. + fn with_window( + &mut self, + entity_id: EntityId, + f: impl FnOnce(&mut Window, &mut App) -> R, + ) -> Option; + /// Read a window off of the application context. fn read_window( &self, diff --git a/src/input.rs b/crates/gpui/src/input.rs similarity index 96% rename from src/input.rs rename to crates/gpui/src/input.rs index c9c0a85cad..10ca46501d 100644 --- a/src/input.rs +++ b/crates/gpui/src/input.rs @@ -187,4 +187,9 @@ impl InputHandler for ElementInputHandler { self.view .update(cx, |view, cx| view.accepts_text_input(window, cx)) } + + fn prefers_ime_for_printable_keys(&mut self, window: &mut Window, cx: &mut App) -> bool { + self.view + .update(cx, |view, cx| view.accepts_text_input(window, cx)) + } } diff --git a/src/inspector.rs b/crates/gpui/src/inspector.rs similarity index 100% rename from src/inspector.rs rename to crates/gpui/src/inspector.rs diff --git a/src/interactive.rs b/crates/gpui/src/interactive.rs similarity index 97% rename from src/interactive.rs rename to crates/gpui/src/interactive.rs index 3d3ddb49f7..0c7f2f9c97 100644 --- a/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -473,10 +473,7 @@ impl Default for ScrollDelta { /// A pinch gesture event from the platform, generated when the user performs /// a pinch-to-zoom gesture (typically on a trackpad). /// -/// Note: This event is only available on macOS and Wayland (Linux). -/// On Windows, pinch gestures are simulated as scroll wheel events with Ctrl held. #[derive(Clone, Debug, Default)] -#[cfg(any(target_os = "linux", target_os = "macos"))] pub struct PinchEvent { /// The position of the pinch center on the window. pub position: Point, @@ -493,20 +490,15 @@ pub struct PinchEvent { pub phase: TouchPhase, } -#[cfg(any(target_os = "linux", target_os = "macos"))] impl Sealed for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl InputEvent for PinchEvent { fn to_platform_input(self) -> PlatformInput { PlatformInput::Pinch(self) } } -#[cfg(any(target_os = "linux", target_os = "macos"))] impl GestureEvent for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl MouseEvent for PinchEvent {} -#[cfg(any(target_os = "linux", target_os = "macos"))] impl Deref for PinchEvent { type Target = Modifiers; @@ -675,7 +667,6 @@ pub enum PlatformInput { /// The scroll wheel was used. ScrollWheel(ScrollWheelEvent), /// A pinch gesture was performed. - #[cfg(any(target_os = "linux", target_os = "macos"))] Pinch(PinchEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), @@ -693,7 +684,6 @@ impl PlatformInput { PlatformInput::MousePressure(event) => Some(event), PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), } @@ -710,7 +700,6 @@ impl PlatformInput { PlatformInput::MousePressure(_) => None, PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(_) => None, PlatformInput::FileDrop(_) => None, } diff --git a/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs similarity index 100% rename from src/key_dispatch.rs rename to crates/gpui/src/key_dispatch.rs diff --git a/src/keymap.rs b/crates/gpui/src/keymap.rs similarity index 100% rename from src/keymap.rs rename to crates/gpui/src/keymap.rs diff --git a/src/keymap/binding.rs b/crates/gpui/src/keymap/binding.rs similarity index 100% rename from src/keymap/binding.rs rename to crates/gpui/src/keymap/binding.rs diff --git a/src/keymap/context.rs b/crates/gpui/src/keymap/context.rs similarity index 100% rename from src/keymap/context.rs rename to crates/gpui/src/keymap/context.rs diff --git a/src/path_builder.rs b/crates/gpui/src/path_builder.rs similarity index 100% rename from src/path_builder.rs rename to crates/gpui/src/path_builder.rs diff --git a/src/platform.rs b/crates/gpui/src/platform.rs similarity index 79% rename from src/platform.rs rename to crates/gpui/src/platform.rs index 9ec4a982ec..fe93222a50 100644 --- a/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -2,21 +2,6 @@ mod app_menu; mod keyboard; mod keystroke; -#[cfg(target_os = "macos")] -pub(crate) mod mac; - -#[cfg(any(target_os = "linux", target_os = "freebsd"))] -pub(crate) mod linux; - -#[cfg(target_os = "windows")] -pub(crate) mod windows; - -#[cfg(any(target_os = "linux", target_os = "freebsd", target_family = "wasm"))] -pub(crate) mod wgpu; - -#[cfg(target_family = "wasm")] -pub(crate) mod web; - #[cfg(all(target_os = "linux", feature = "wayland"))] #[expect(missing_docs)] pub mod layer_shell; @@ -43,17 +28,17 @@ pub(crate) type PlatformScreenCaptureFrame = (); #[cfg(all(target_os = "macos", feature = "screen-capture"))] pub(crate) type PlatformScreenCaptureFrame = core_video::image_buffer::CVImageBuffer; -use crate::scheduler::Instant; -pub use crate::scheduler::RunnableMeta; 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; +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +use anyhow::bail; use async_task::Runnable; use futures::channel::oneshot; #[cfg(any(test, feature = "test-support"))] @@ -61,6 +46,8 @@ use image::RgbaImage; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, Frame}; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use scheduler::Instant; +pub use scheduler::RunnableMeta; use schemars::JsonSchema; use seahash::SeaHasher; use serde::{Deserialize, Serialize}; @@ -93,81 +80,6 @@ pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream} #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] pub use visual_test::VisualTestPlatform; -#[cfg(target_os = "macos")] -pub use mac::MacPlatform; - -#[cfg(target_os = "windows")] -pub use windows::WindowsPlatform; - -/// Returns the default [`Platform`] for the current OS. -pub fn current_platform(headless: bool) -> Rc { - #[cfg(target_os = "macos")] - { - Rc::new(mac::MacPlatform::new(headless)) - } - - #[cfg(target_os = "windows")] - { - Rc::new( - windows::WindowsPlatform::new(headless).expect("failed to initialize Windows platform"), - ) - } - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - { - linux::current_platform(headless) - } - - #[cfg(target_family = "wasm")] - { - let _ = headless; - Rc::new(web::WebPlatform::new(true)) - } -} - -/// Returns a background executor for the current platform. -pub fn background_executor() -> crate::BackgroundExecutor { - current_platform(true).background_executor() -} - -/// Creates a new application with the current platform. -pub fn application() -> crate::Application { - crate::Application::with_platform(current_platform(false)) -} - -/// Creates a new headless application. -pub fn headless() -> crate::Application { - crate::Application::with_platform(current_platform(true)) -} - -/// Unlike `application`, this function returns a single-threaded web application. -#[cfg(target_family = "wasm")] -pub fn single_threaded_web() -> crate::Application { - crate::Application::with_platform(Rc::new(web::WebPlatform::new(false))) -} - -/// Initializes panic hooks and logging for the web platform. -/// Call this before running the application in a wasm_bindgen entrypoint. -#[cfg(target_family = "wasm")] -pub fn web_init() { - console_error_panic_hook::set_once(); - web::init_logging(); -} - -/// Returns a new headless renderer for the current platform, if available. -#[cfg(feature = "test-support")] -pub fn current_headless_renderer() -> Option> { - #[cfg(target_os = "macos")] - { - Some(Box::new(mac::metal_renderer::MetalHeadlessRenderer::new())) - } - - #[cfg(not(target_os = "macos"))] - { - None - } -} - // TODO(jk): return an enum instead of a string /// Return which compositor we're guessing we'll use. /// Does not attempt to connect to the given compositor. @@ -246,6 +158,11 @@ pub trait Platform: 'static { /// Returns the appearance of the application's windows. fn window_appearance(&self) -> WindowAppearance; + /// Returns the window button layout configuration when supported. + fn button_layout(&self) -> Option { + None + } + fn open_url(&self, url: &str); fn on_open_urls(&self, callback: Box)>); fn register_url_scheme(&self, url: &str) -> Task>; @@ -295,6 +212,14 @@ pub trait Platform: 'static { fn path_for_auxiliary_executable(&self, name: &str) -> Result; fn set_cursor_style(&self, style: CursorStyle); + + /// Hides the mouse cursor until the user moves the mouse over one of + /// this application's windows. + fn hide_cursor_until_mouse_moves(&self); + + /// Returns whether the mouse cursor is currently visible. + fn is_cursor_visible(&self) -> bool; + fn should_auto_hide_scrollbars(&self) -> bool; fn read_from_clipboard(&self) -> Option; @@ -401,22 +326,22 @@ pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame); /// An opaque identifier for a hardware display #[derive(PartialEq, Eq, Hash, Copy, Clone)] -pub struct DisplayId(pub(crate) u32); +pub struct DisplayId(pub(crate) u64); impl DisplayId { /// Create a new `DisplayId` from a raw platform display identifier. - pub fn new(id: u32) -> Self { + pub fn new(id: u64) -> Self { Self(id) } } -impl From for DisplayId { - fn from(id: u32) -> Self { +impl From for DisplayId { + fn from(id: u64) -> Self { Self(id) } } -impl From for u32 { +impl From for u64 { fn from(id: DisplayId) -> Self { id.0 } @@ -497,6 +422,145 @@ impl Default for WindowControls { } } +/// A window control button type used in [`WindowButtonLayout`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WindowButton { + /// The minimize button + Minimize, + /// The maximize button + Maximize, + /// The close button + Close, +} + +impl WindowButton { + /// Returns a stable element ID for rendering this button. + pub fn id(&self) -> &'static str { + match self { + WindowButton::Minimize => "minimize", + WindowButton::Maximize => "maximize", + WindowButton::Close => "close", + } + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + fn index(&self) -> usize { + match self { + WindowButton::Minimize => 0, + WindowButton::Maximize => 1, + WindowButton::Close => 2, + } + } +} + +/// Maximum number of [`WindowButton`]s per side in the titlebar. +pub const MAX_BUTTONS_PER_SIDE: usize = 3; + +/// Describes which [`WindowButton`]s appear on each side of the titlebar. +/// +/// On Linux, this is read from the desktop environment's configuration +/// (e.g. GNOME's `gtk-decoration-layout` gsetting) via [`WindowButtonLayout::parse`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WindowButtonLayout { + /// Buttons on the left side of the titlebar. + pub left: [Option; MAX_BUTTONS_PER_SIDE], + /// Buttons on the right side of the titlebar. + pub right: [Option; MAX_BUTTONS_PER_SIDE], +} + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +impl WindowButtonLayout { + /// Returns Zed's built-in fallback button layout for Linux titlebars. + pub fn linux_default() -> Self { + Self { + left: [None; MAX_BUTTONS_PER_SIDE], + right: [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close), + ], + } + } + + /// Parses a GNOME-style `button-layout` string (e.g. `"close,minimize:maximize"`). + pub fn parse(layout_string: &str) -> Result { + fn parse_side( + s: &str, + seen_buttons: &mut [bool; MAX_BUTTONS_PER_SIDE], + unrecognized: &mut Vec, + ) -> [Option; MAX_BUTTONS_PER_SIDE] { + let mut result = [None; MAX_BUTTONS_PER_SIDE]; + let mut i = 0; + for name in s.split(',') { + let trimmed = name.trim(); + if trimmed.is_empty() { + continue; + } + let button = match trimmed { + "minimize" => Some(WindowButton::Minimize), + "maximize" => Some(WindowButton::Maximize), + "close" => Some(WindowButton::Close), + other => { + unrecognized.push(other.to_string()); + None + } + }; + if let Some(button) = button { + if seen_buttons[button.index()] { + continue; + } + if let Some(slot) = result.get_mut(i) { + *slot = Some(button); + seen_buttons[button.index()] = true; + i += 1; + } + } + } + result + } + + let (left_str, right_str) = layout_string.split_once(':').unwrap_or(("", layout_string)); + let mut unrecognized = Vec::new(); + let mut seen_buttons = [false; MAX_BUTTONS_PER_SIDE]; + let layout = Self { + left: parse_side(left_str, &mut seen_buttons, &mut unrecognized), + right: parse_side(right_str, &mut seen_buttons, &mut unrecognized), + }; + + if !unrecognized.is_empty() + && layout.left.iter().all(Option::is_none) + && layout.right.iter().all(Option::is_none) + { + bail!( + "button layout string {:?} contains no valid buttons (unrecognized: {})", + layout_string, + unrecognized.join(", ") + ); + } + + Ok(layout) + } + + /// Formats the layout back into a GNOME-style `button-layout` string. + #[cfg(test)] + pub fn format(&self) -> String { + fn format_side(buttons: &[Option; MAX_BUTTONS_PER_SIDE]) -> String { + buttons + .iter() + .flatten() + .map(|button| match button { + WindowButton::Minimize => "minimize", + WindowButton::Maximize => "maximize", + WindowButton::Close => "close", + }) + .collect::>() + .join(",") + } + + format!("{}:{}", format_side(&self.left), format_side(&self.right)) + } +} + /// A type to describe which sides of the window are currently tiled in some way #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash, Default)] pub struct Tiling { @@ -527,6 +591,16 @@ impl Tiling { } } +/// Callbacks for the accessibility adapter. +pub struct A11yCallbacks { + /// Called when the adapter is activated (a screen reader connects). + pub activation: Box Option + Send + 'static>, + /// Called when an action is requested by the screen reader. + pub action: Box, + /// Called when the adapter is deactivated (screen reader disconnects). + pub deactivation: Box, +} + #[derive(Debug, Copy, Clone, Eq, PartialEq, Default)] #[expect(missing_docs)] pub struct RequestFrameOptions { @@ -578,6 +652,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn on_hit_test_window_control(&self, callback: Box Option>); fn on_close(&self, callback: Box); fn on_appearance_changed(&self, callback: Box); + fn on_button_layout_changed(&self, _callback: Box) {} fn draw(&self, scene: &Scene); fn completed_frame(&self) {} fn sprite_atlas(&self) -> Arc; @@ -594,6 +669,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { false } fn set_edited(&mut self, _edited: bool) {} + fn set_document_path(&self, _path: Option<&std::path::Path>) {} fn show_character_palette(&self) {} fn titlebar_double_click(&self) {} fn on_move_tab_to_new_window(&self, _callback: Box) {} @@ -632,6 +708,17 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn update_ime_position(&self, _bounds: Bounds); + fn play_system_bell(&self) {} + + /// Initialize the accessibility adapter with callbacks. + fn a11y_init(&self, _callbacks: A11yCallbacks) {} + + /// Provide a TreeUpdate to the accessibility adapter. + fn a11y_tree_update(&self, _tree_update: accesskit::TreeUpdate) {} + + /// Inform the adapter of updated window bounds. + fn a11y_update_window_bounds(&self) {} + #[cfg(any(test, feature = "test-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { None @@ -666,7 +753,7 @@ pub trait PlatformHeadlessRenderer { pub type RunnableVariant = Runnable; #[doc(hidden)] -pub type TimerResolutionGuard = util::Deferred>; +pub type TimerResolutionGuard = gpui_util::Deferred>; /// 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. @@ -686,7 +773,7 @@ pub trait PlatformDispatcher: Send + Sync { } fn increase_timer_resolution(&self) -> TimerResolutionGuard { - util::defer(Box::new(|| {})) + gpui_util::defer(Box::new(|| {})) } #[cfg(any(test, feature = "test-support"))] @@ -723,6 +810,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)] @@ -989,7 +1080,7 @@ impl AtlasTextureList { } } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq)] #[repr(C)] #[expect(missing_docs)] pub struct AtlasTile { @@ -1185,6 +1276,13 @@ impl PlatformInputHandler { .update(|window, cx| self.handler.accepts_text_input(window, cx)) .unwrap_or(true) } + + #[allow(dead_code)] + pub fn query_prefers_ime_for_printable_keys(&mut self) -> bool { + self.cx + .update(|window, cx| self.handler.prefers_ime_for_printable_keys(window, cx)) + .unwrap_or(false) + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1298,6 +1396,18 @@ pub trait InputHandler: 'static { fn accepts_text_input(&mut self, _window: &mut Window, _cx: &mut App) -> bool { true } + + /// Returns whether printable keys should be routed to the IME before keybinding + /// matching when a non-ASCII input source (e.g. Japanese, Korean, Chinese IME) + /// is active. This prevents multi-stroke keybindings like `jj` from intercepting + /// keys that the IME should compose. + /// + /// Defaults to `false`. The editor overrides this based on whether it expects + /// character input (e.g. Vim insert mode returns `true`, normal mode returns `false`). + /// The terminal keeps the default `false` so that raw keys reach the terminal process. + fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool { + false + } } /// The variables that can be configured when creating a new window @@ -1346,6 +1456,9 @@ pub struct WindowOptions { /// Note that this may be ignored. pub window_decorations: Option, + /// Icon image (X11 only) + pub icon: Option>, + /// Tab group name, allows opening the window as a native tab on macOS 10.12+. Windows with the same tabbing identifier will be grouped together. pub tabbing_identifier: Option, } @@ -1392,6 +1505,10 @@ pub struct WindowParams { #[cfg_attr(any(target_os = "linux", target_os = "freebsd"), allow(dead_code))] pub show: bool, + /// An image to set as the window icon (x11 only) + #[cfg_attr(feature = "wayland", allow(dead_code))] + pub icon: Option>, + #[cfg_attr(feature = "wayland", allow(dead_code))] pub display_id: Option, @@ -1452,6 +1569,7 @@ impl Default for WindowOptions { is_minimizable: true, display_id: None, window_background: WindowBackgroundAppearance::default(), + icon: None, app_id: None, window_min_size: None, window_decorations: None, @@ -1727,9 +1845,6 @@ pub enum CursorStyle { /// A cursor indicating that the operation will result in a context menu /// corresponds to the CSS cursor value `context-menu` ContextualMenu, - - /// Hide the cursor - None, } /// A clipboard item that should be copied to the clipboard @@ -1895,6 +2010,8 @@ pub enum ImageFormat { Tiff, /// .ico Ico, + /// Netpbm image formats (.pbm, .ppm, .pgm). + Pnm, } impl ImageFormat { @@ -1909,20 +2026,25 @@ impl ImageFormat { ImageFormat::Bmp => "image/bmp", ImageFormat::Tiff => "image/tiff", ImageFormat::Ico => "image/ico", + ImageFormat::Pnm => "image/x-portable-anymap", } } - /// Returns the ImageFormat for the given mime type + /// Returns the ImageFormat for the given mime type, including known aliases. pub fn from_mime_type(mime_type: &str) -> Option { + use strum::IntoEnumIterator; + Self::iter() + .find(|format| format.mime_type() == mime_type) + .or_else(|| Self::from_mime_type_alias(mime_type)) + } + + /// Non-canonical mime types that some producers use in the wild. + /// Unlike `mime_type()` which returns the single canonical form, + /// these are legacy or shortened variants we still need to recognize. + fn from_mime_type_alias(mime_type: &str) -> Option { match mime_type { - "image/png" => Some(Self::Png), - "image/jpeg" | "image/jpg" => Some(Self::Jpeg), - "image/webp" => Some(Self::Webp), - "image/gif" => Some(Self::Gif), - "image/svg+xml" => Some(Self::Svg), - "image/bmp" => Some(Self::Bmp), - "image/tiff" | "image/tif" => Some(Self::Tiff), - "image/ico" => Some(Self::Ico), + "image/jpg" => Some(Self::Jpeg), + "image/tif" => Some(Self::Tiff), _ => None, } } @@ -2014,12 +2136,22 @@ impl Image { let mut frames = SmallVec::new(); for frame in decoder.into_frames() { - let mut frame = frame?; - // Convert from RGBA to BGRA. - for pixel in frame.buffer_mut().chunks_exact_mut(4) { - pixel.swap(0, 2); + match frame { + Ok(mut frame) => { + // Convert from RGBA to BGRA. + for pixel in frame.buffer_mut().chunks_exact_mut(4) { + pixel.swap(0, 2); + } + frames.push(frame); + } + Err(err) => { + log::debug!("Skipping GIF frame due to decode error: {err}"); + } } - frames.push(frame); + } + + if frames.is_empty() { + anyhow::bail!("GIF could not be decoded: all frames failed"); } frames @@ -2032,9 +2164,10 @@ impl Image { ImageFormat::Ico => frames_for_image(&self.bytes, image::ImageFormat::Ico)?, ImageFormat::Svg => { return svg_renderer - .render_single_frame(&self.bytes, 1.0, false) + .render_single_frame(&self.bytes, 1.0) .map_err(Into::into); } + ImageFormat::Pnm => frames_for_image(&self.bytes, image::ImageFormat::Pnm)?, }; Ok(Arc::new(RenderImage::new(frames))) @@ -2113,3 +2246,209 @@ impl From for ClipboardString { } } } + +#[cfg(test)] +mod image_tests { + use super::*; + use std::sync::Arc; + + #[test] + fn test_svg_image_to_image_data_converts_to_bgra() { + let image = Image::from_bytes( + ImageFormat::Svg, + br##" + +"## + .to_vec(), + ); + + let render_image = image.to_image_data(SvgRenderer::new(Arc::new(()))).unwrap(); + let bytes = render_image.as_bytes(0).unwrap(); + + for pixel in bytes.chunks_exact(4) { + assert_eq!(pixel, &[0xF8, 0xBD, 0x38, 0xFF]); + } + } +} + +#[cfg(all(test, any(target_os = "linux", target_os = "freebsd")))] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn test_window_button_layout_parse_standard() { + let layout = WindowButtonLayout::parse("close,minimize:maximize").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_right_only() { + let layout = WindowButtonLayout::parse("minimize,maximize,close").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close) + ] + ); + } + + #[test] + fn test_window_button_layout_parse_left_only() { + let layout = WindowButtonLayout::parse("close,minimize,maximize:").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + Some(WindowButton::Maximize) + ] + ); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_with_whitespace() { + let layout = WindowButtonLayout::parse(" close , minimize : maximize ").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_empty() { + let layout = WindowButtonLayout::parse("").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_intentionally_empty() { + let layout = WindowButtonLayout::parse(":").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [None, None, None]); + } + + #[test] + fn test_window_button_layout_parse_invalid_buttons() { + let layout = WindowButtonLayout::parse("close,invalid,minimize:maximize,foo").unwrap(); + assert_eq!( + layout.left, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_parse_deduplicates_same_side_buttons() { + let layout = WindowButtonLayout::parse("close,close,minimize").unwrap(); + assert_eq!( + layout.right, + [ + Some(WindowButton::Close), + Some(WindowButton::Minimize), + None + ] + ); + assert_eq!(layout.format(), ":close,minimize"); + } + + #[test] + fn test_window_button_layout_parse_deduplicates_buttons_across_sides() { + let layout = WindowButtonLayout::parse("close:maximize,close,minimize").unwrap(); + assert_eq!(layout.left, [Some(WindowButton::Close), None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Maximize), + Some(WindowButton::Minimize), + None + ] + ); + + let button_ids: Vec<_> = layout + .left + .iter() + .chain(layout.right.iter()) + .flatten() + .map(WindowButton::id) + .collect(); + let unique_button_ids = button_ids.iter().copied().collect::>(); + assert_eq!(unique_button_ids.len(), button_ids.len()); + assert_eq!(layout.format(), "close:maximize,minimize"); + } + + #[test] + fn test_window_button_layout_parse_gnome_style() { + let layout = WindowButtonLayout::parse("close").unwrap(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!(layout.right, [Some(WindowButton::Close), None, None]); + } + + #[test] + fn test_window_button_layout_parse_elementary_style() { + let layout = WindowButtonLayout::parse("close:maximize").unwrap(); + assert_eq!(layout.left, [Some(WindowButton::Close), None, None]); + assert_eq!(layout.right, [Some(WindowButton::Maximize), None, None]); + } + + #[test] + fn test_window_button_layout_round_trip() { + let cases = [ + "close:minimize,maximize", + "minimize,maximize,close:", + ":close", + "close:", + "close:maximize", + ":", + ]; + + for case in cases { + let layout = WindowButtonLayout::parse(case).unwrap(); + assert_eq!(layout.format(), case, "Round-trip failed for: {}", case); + } + } + + #[test] + fn test_window_button_layout_linux_default() { + let layout = WindowButtonLayout::linux_default(); + assert_eq!(layout.left, [None, None, None]); + assert_eq!( + layout.right, + [ + Some(WindowButton::Minimize), + Some(WindowButton::Maximize), + Some(WindowButton::Close) + ] + ); + + let round_tripped = WindowButtonLayout::parse(&layout.format()).unwrap(); + assert_eq!(round_tripped, layout); + } + + #[test] + fn test_window_button_layout_parse_all_invalid() { + assert!(WindowButtonLayout::parse("asdfghjkl").is_err()); + } +} diff --git a/src/platform/app_menu.rs b/crates/gpui/src/platform/app_menu.rs similarity index 100% rename from src/platform/app_menu.rs rename to crates/gpui/src/platform/app_menu.rs diff --git a/src/platform/keyboard.rs b/crates/gpui/src/platform/keyboard.rs similarity index 100% rename from src/platform/keyboard.rs rename to crates/gpui/src/platform/keyboard.rs diff --git a/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs similarity index 100% rename from src/platform/keystroke.rs rename to crates/gpui/src/platform/keystroke.rs diff --git a/src/platform/layer_shell.rs b/crates/gpui/src/platform/layer_shell.rs similarity index 100% rename from src/platform/layer_shell.rs rename to crates/gpui/src/platform/layer_shell.rs diff --git a/src/platform/scap_screen_capture.rs b/crates/gpui/src/platform/scap_screen_capture.rs similarity index 99% rename from src/platform/scap_screen_capture.rs rename to crates/gpui/src/platform/scap_screen_capture.rs index 2c827bb0d8..797e19ba23 100644 --- a/src/platform/scap_screen_capture.rs +++ b/crates/gpui/src/platform/scap_screen_capture.rs @@ -126,7 +126,7 @@ fn start_default_target_screen_capture( ) { // Due to use of blocking APIs, a dedicated thread is used. std::thread::spawn(|| { - let start_result = util::maybe!({ + let start_result = gpui_util::maybe!({ let mut capturer = new_scap_capturer(None)?; capturer.start_capture(); let first_frame = capturer diff --git a/src/platform/test.rs b/crates/gpui/src/platform/test.rs similarity index 100% rename from src/platform/test.rs rename to crates/gpui/src/platform/test.rs diff --git a/src/platform/test/dispatcher.rs b/crates/gpui/src/platform/test/dispatcher.rs similarity index 95% rename from src/platform/test/dispatcher.rs rename to crates/gpui/src/platform/test/dispatcher.rs index e001eddb25..ef662c6c48 100644 --- a/src/platform/test/dispatcher.rs +++ b/crates/gpui/src/platform/test/dispatcher.rs @@ -1,6 +1,6 @@ -use crate::scheduler::Instant; -use crate::scheduler::{Clock, Scheduler, SessionId, TestScheduler, TestSchedulerConfig, Yield}; use crate::{PlatformDispatcher, Priority, RunnableVariant}; +use scheduler::Instant; +use scheduler::{Clock, Scheduler, SessionId, TestScheduler, TestSchedulerConfig, Yield}; use std::{ sync::{ Arc, @@ -139,8 +139,7 @@ impl PlatformDispatcher for TestDispatcher { } fn dispatch_on_main_thread(&self, runnable: RunnableVariant, _priority: Priority) { - self.scheduler - .schedule_foreground(self.session_id, runnable); + self.scheduler.schedule_local(self.session_id, runnable); } fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) { diff --git a/src/platform/test/display.rs b/crates/gpui/src/platform/test/display.rs similarity index 100% rename from src/platform/test/display.rs rename to crates/gpui/src/platform/test/display.rs diff --git a/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs similarity index 90% rename from src/platform/test/platform.rs rename to crates/gpui/src/platform/test/platform.rs index a59b21f038..b3bee3769e 100644 --- a/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; @@ -85,6 +86,10 @@ struct TestPrompt { pub(crate) struct TestPrompts { multiple_choice: VecDeque, new_path: VecDeque<(PathBuf, oneshot::Sender>>)>, + paths: VecDeque<( + PathPromptOptions, + oneshot::Sender>>>, + )>, } impl TestPlatform { @@ -147,6 +152,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 @@ -348,9 +380,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( @@ -403,6 +437,12 @@ impl Platform for TestPlatform { *self.active_cursor.lock() = style; } + fn hide_cursor_until_mouse_moves(&self) {} + + fn is_cursor_visible(&self) -> bool { + true + } + fn should_auto_hide_scrollbars(&self) -> bool { false } diff --git a/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs similarity index 96% rename from src/platform/test/window.rs rename to crates/gpui/src/platform/test/window.rs index 583450c9e9..2b5399ca98 100644 --- a/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -20,6 +20,7 @@ pub(crate) struct TestWindowState { display: Rc, pub(crate) title: Option, pub(crate) edited: bool, + pub(crate) document_path: Option, platform: Weak, // TODO: Replace with `Rc` sprite_atlas: Arc, @@ -75,6 +76,7 @@ impl TestWindow { renderer, title: Default::default(), edited: false, + document_path: None, should_close_handler: None, hit_test_window_control_callback: None, input_callback: None, @@ -230,6 +232,10 @@ impl PlatformWindow for TestWindow { self.0.lock().edited = edited; } + fn set_document_path(&self, path: Option<&std::path::Path>) { + self.0.lock().document_path = path.map(|p| p.to_path_buf()); + } + fn show_character_palette(&self) { unimplemented!() } @@ -353,8 +359,8 @@ impl PlatformAtlas for TestAtlas { >, ) -> anyhow::Result> { let mut state = self.0.lock(); - if let Some(tile) = state.tiles.get(key) { - return Ok(Some(tile.clone())); + if let Some(&tile) = state.tiles.get(key) { + return Ok(Some(tile)); } drop(state); @@ -384,7 +390,7 @@ impl PlatformAtlas for TestAtlas { }, ); - Ok(Some(state.tiles[key].clone())) + Ok(Some(state.tiles[key])) } fn remove(&self, key: &AtlasKey) { diff --git a/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs similarity index 97% rename from src/platform/visual_test.rs rename to crates/gpui/src/platform/visual_test.rs index 8b9bec7edd..3719a3ee24 100644 --- a/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -202,6 +202,14 @@ impl Platform for VisualTestPlatform { self.platform.set_cursor_style(style) } + fn hide_cursor_until_mouse_moves(&self) { + self.platform.hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + self.platform.is_cursor_visible() + } + fn should_auto_hide_scrollbars(&self) -> bool { self.platform.should_auto_hide_scrollbars() } diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs new file mode 100644 index 0000000000..6a3929e91b --- /dev/null +++ b/crates/gpui/src/platform_scheduler.rs @@ -0,0 +1,441 @@ +use crate::{PlatformDispatcher, RunnableMeta}; +use async_task::Runnable; +use chrono::{DateTime, Utc}; +use futures::channel::oneshot; +use scheduler::Instant; +use scheduler::{ + Clock, LocalExecutor, Priority, Scheduler, SessionId, Task, TestScheduler, Timer, + spawn_dedicated_thread, +}; +#[cfg(not(target_family = "wasm"))] +use std::task::{Context, Poll}; +use std::{ + any::Any, + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicU16, Ordering}, + }, + time::Duration, +}; + +/// A production implementation of [`Scheduler`] that wraps a [`PlatformDispatcher`]. +/// +/// This allows GPUI to use the scheduler crate's executor types with the platform's +/// native dispatch mechanisms (e.g., Grand Central Dispatch on macOS). +pub struct PlatformScheduler { + dispatcher: Arc, + clock: Arc, + next_session_id: AtomicU16, +} + +impl PlatformScheduler { + pub fn new(dispatcher: Arc) -> Self { + Self { + dispatcher: dispatcher.clone(), + clock: Arc::new(PlatformClock { dispatcher }), + next_session_id: AtomicU16::new(0), + } + } + + pub fn foreground_executor(self: &Arc) -> LocalExecutor { + let session_id = self.next_session_id(); + 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); + } + }) + } + + fn next_session_id(&self) -> SessionId { + SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst)) + } +} + +impl Scheduler for PlatformScheduler { + fn block( + &self, + _session_id: Option, + #[cfg_attr(target_family = "wasm", allow(unused_mut))] mut future: Pin< + &mut dyn Future, + >, + #[cfg_attr(target_family = "wasm", allow(unused_variables))] timeout: Option, + ) -> bool { + #[cfg(target_family = "wasm")] + { + let _ = (&future, &timeout); + panic!("Cannot block on wasm") + } + #[cfg(not(target_family = "wasm"))] + { + use waker_fn::waker_fn; + let deadline = timeout.map(|t| Instant::now() + t); + let parker = parking::Parker::new(); + let unparker = parker.unparker(); + let waker = waker_fn(move || { + unparker.unpark(); + }); + let mut cx = Context::from_waker(&waker); + if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { + return true; + } + + let park_deadline = |deadline: Instant| { + // Timer expirations are only delivered every ~15.6 milliseconds by default on Windows. + // We increase the resolution during this wait so that short timeouts stay reasonably short. + let _timer_guard = self.dispatcher.increase_timer_resolution(); + parker.park_deadline(deadline) + }; + + loop { + match deadline { + Some(deadline) if !park_deadline(deadline) && deadline <= Instant::now() => { + return false; + } + Some(_) => (), + None => parker.park(), + } + if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { + break true; + } + } + } + } + + fn schedule_local(&self, _session_id: SessionId, runnable: Runnable) { + self.dispatcher + .dispatch_on_main_thread(runnable, Priority::default()); + } + + fn schedule_background_with_priority( + &self, + runnable: Runnable, + priority: Priority, + ) { + self.dispatcher.dispatch(runnable, priority); + } + + fn spawn_realtime(&self, f: Box) { + self.dispatcher.spawn_realtime(f); + } + + #[track_caller] + fn timer(&self, duration: Duration) -> Timer { + let (tx, rx) = oneshot::channel(); + let dispatcher = self.dispatcher.clone(); + + // 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 }) + .spawn( + move |_| async move { + let _ = tx.send(()); + }, + move |runnable| { + dispatcher.dispatch_after(duration, runnable); + }, + ); + runnable.schedule(); + + Timer::new(rx) + } + + fn clock(&self) -> Arc { + self.clock.clone() + } + + fn spawn_dedicated( + self: Arc, + f: Box< + dyn FnOnce( + LocalExecutor, + ) + -> Pin> + 'static>> + + Send + + 'static, + >, + ) -> Task> { + let session_id = self.next_session_id(); + spawn_dedicated_thread(session_id, self, move |executor| f(executor)) + } + + fn as_test(&self) -> Option<&TestScheduler> { + None + } +} + +/// A production clock that uses the platform dispatcher's time. +struct PlatformClock { + dispatcher: Arc, +} + +impl Clock for PlatformClock { + fn utc_now(&self) -> DateTime { + Utc::now() + } + + fn now(&self) -> Instant { + self.dispatcher.now() + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use crate::{RunnableVariant, ThreadTaskTimings}; + use scheduler::BackgroundExecutor; + use std::time::Instant as StdInstant; + + // `spawn_dedicated` shouldn't touch the platform dispatcher at all; + // panicking on every method ensures the test catches it if it does. + struct SmokeDispatcher; + + impl PlatformDispatcher for SmokeDispatcher { + fn get_all_timings(&self) -> Vec { + 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 { + false + } + fn dispatch(&self, _runnable: RunnableVariant, _priority: Priority) { + panic!("SmokeDispatcher should not be asked to dispatch in this test"); + } + fn dispatch_on_main_thread(&self, _runnable: RunnableVariant, _priority: Priority) { + panic!("SmokeDispatcher does not implement a main thread"); + } + fn dispatch_after(&self, _duration: Duration, _runnable: RunnableVariant) { + panic!("SmokeDispatcher does not implement timers"); + } + fn spawn_realtime(&self, _f: Box) { + panic!("SmokeDispatcher does not implement realtime"); + } + } + + #[test] + fn spawn_dedicated_runs_on_a_real_separate_thread() { + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + let started = StdInstant::now(); + let task = background.spawn_dedicated(|_executor| async move { + // A genuine blocking syscall on the dedicated thread. If + // `spawn_dedicated` were running the future on any shared + // executor, this would stall that executor. + let thread_id_before = std::thread::current().id(); + std::thread::sleep(Duration::from_millis(50)); + let thread_id_after = std::thread::current().id(); + assert_eq!(thread_id_before, thread_id_after); + (thread_id_before, "slept") + }); + let (dedicated_thread_id, message) = futures::executor::block_on(task); + let elapsed = started.elapsed(); + assert_eq!(message, "slept"); + assert_ne!( + dedicated_thread_id, + std::thread::current().id(), + "dedicated future ran on the test thread" + ); + assert!( + elapsed >= Duration::from_millis(40), + "expected the dedicated thread to genuinely sleep, elapsed = {:?}", + elapsed + ); + } + + #[test] + fn spawn_dedicated_returns_not_send_future_output() { + // The whole point of `spawn_dedicated` is that the future can be + // `!Send`. Constructing one with `Rc>` ensures the + // signature actually permits it. + use std::cell::RefCell; + use std::rc::Rc; + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + let task = background.spawn_dedicated(|_executor| async move { + let state = Rc::new(RefCell::new(0_i32)); + for _ in 0..3 { + *state.borrow_mut() += 1; + } + *state.borrow() + }); + let output = futures::executor::block_on(task); + assert_eq!(output, 3); + } + + #[test] + fn spawn_dedicated_dropping_task_cancels_future() { + use parking_lot::Mutex; + use std::sync::mpsc; + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + + let (started_tx, started_rx) = mpsc::channel::<()>(); + let (after_park_tx, after_park_rx) = mpsc::channel::<()>(); + let observed_post_await_write = Arc::new(Mutex::new(false)); + + let task = { + let observed_post_await_write = observed_post_await_write.clone(); + background.spawn_dedicated(move |_executor| async move { + // Announce that the future is live on the dedicated thread. + started_tx + .send(()) + .expect("started signal must be received"); + // Park forever. Dropping the `Task` must cancel us here so + // the code below this `await` never runs. + futures::future::pending::<()>().await; + *observed_post_await_write.lock() = true; + after_park_tx + .send(()) + .expect("after-park signal must be received"); + }) + }; + + // Wait until the dedicated future is actually parked at the await. + started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("dedicated future failed to start"); + + // Drop the root Task: this must cancel the future. + drop(task); + + // If cancellation works, the future never advances past `pending`, + // so this recv must time out. + assert!( + after_park_rx + .recv_timeout(Duration::from_millis(100)) + .is_err(), + "dedicated future advanced past the await after its Task was dropped" + ); + assert!( + !*observed_post_await_write.lock(), + "dedicated future ran code past the cancellation point" + ); + } + + #[test] + fn spawn_dedicated_thread_tears_down_after_work_completes() { + use std::sync::mpsc; + + // Fires from `Drop` so we observe teardown of the dedicated future's + // captured state on whichever thread runs its destructor. + struct DropSignal { + tx: Option>, + } + impl Drop for DropSignal { + fn drop(&mut self) { + if let Some(tx) = self.tx.take() { + let _ = tx.send(std::thread::current().id()); + } + } + } + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + let (started_tx, started_rx) = mpsc::channel::(); + let (drop_tx, drop_rx) = mpsc::channel::(); + + let task = background.spawn_dedicated(move |_executor| async move { + // Captured by the future's state. When the future completes and + // its state is dropped on the dedicated thread, this guard's + // `Drop` fires and reports the thread id it ran on. + let _guard = DropSignal { tx: Some(drop_tx) }; + started_tx + .send(std::thread::current().id()) + .expect("started signal must be received"); + // Future returns immediately. The dedicated thread should then + // drop the future (firing _guard), exit the recv loop, and exit. + }); + + let dedicated_thread_id = started_rx + .recv_timeout(Duration::from_secs(2)) + .expect("dedicated future failed to start"); + assert_ne!( + dedicated_thread_id, + std::thread::current().id(), + "dedicated future ran on the test thread" + ); + + // Drive the root task to completion so its body finishes. + futures::executor::block_on(task); + + // The guard's drop runs from the dedicated thread as it tears down + // the future's captured state. If the executor/recv-loop were + // keeping the future alive past task completion, this would hang. + let drop_thread_id = drop_rx + .recv_timeout(Duration::from_secs(2)) + .expect("dedicated future's captured state was not dropped after task completion"); + assert_eq!( + drop_thread_id, dedicated_thread_id, + "dedicated future's captured state must be dropped on the dedicated thread, not elsewhere" + ); + } + + #[test] + fn spawn_dedicated_detached_child_outlives_root() { + use std::sync::mpsc; + + let background = + BackgroundExecutor::new(Arc::new(PlatformScheduler::new(Arc::new(SmokeDispatcher)))); + + // `gate_rx` lets the detached child park until the test explicitly + // releases it — after we've already observed the root completing. + let (gate_tx, gate_rx) = mpsc::channel::<()>(); + let (child_done_tx, child_done_rx) = mpsc::channel::(); + + let task = background.spawn_dedicated(move |executor| async move { + executor + .spawn(async move { + // Blocking on `recv` is normally wrong inside an + // executor, but the dedicated thread is exclusive to + // this session, so blocking the only future on it is + // fine — this is the property `spawn_dedicated` is + // designed to provide. + gate_rx + .recv() + .expect("gate sender dropped before child resumed"); + child_done_tx + .send(std::thread::current().id()) + .expect("child_done receiver dropped"); + }) + .detach(); + // Root finishes here. The detached child must keep the + // dedicated thread alive until it completes. + }); + + futures::executor::block_on(task); + + // Negative assertion: the child has not finished, because the gate + // hasn't been released yet. + assert!( + child_done_rx + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "detached child finished before being released" + ); + + // Release the gate. The detached child should now complete on the + // dedicated thread. + gate_tx.send(()).expect("gate receiver dropped"); + + let child_thread_id = child_done_rx + .recv_timeout(Duration::from_secs(2)) + .expect("detached child failed to complete after gate was released"); + assert_ne!( + child_thread_id, + std::thread::current().id(), + "detached child ran on the test thread instead of the dedicated thread" + ); + } +} diff --git a/src/prelude.rs b/crates/gpui/src/prelude.rs similarity index 88% rename from src/prelude.rs rename to crates/gpui/src/prelude.rs index 17b6d462d7..b5185a25e8 100644 --- a/src/prelude.rs +++ b/crates/gpui/src/prelude.rs @@ -5,5 +5,5 @@ pub use crate::{ AppContext as _, BorrowAppContext, Context, Element, InteractiveElement, IntoElement, ParentElement, Refineable, Render, RenderOnce, StatefulInteractiveElement, Styled, StyledImage, - VisualContext, local_util::FluentBuilder, + TaskExt as _, VisualContext, util::FluentBuilder, }; diff --git a/src/profiler.rs b/crates/gpui/src/profiler.rs similarity index 77% rename from src/profiler.rs rename to crates/gpui/src/profiler.rs index 610b5a7985..009afb9099 100644 --- a/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -1,10 +1,12 @@ -use crate::scheduler::Instant; +use scheduler::Instant; use std::{ cell::LazyCell, - collections::HashMap, - hash::Hasher, - hash::{DefaultHasher, Hash}, - sync::Arc, + collections::{HashMap, VecDeque}, + hash::{DefaultHasher, Hash, Hasher}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, thread::ThreadId, }; @@ -45,7 +47,6 @@ impl ThreadTaskTimings { 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); @@ -243,11 +244,14 @@ impl ProfilingCollector { } } -// Allow 20mb of task timing entries -const MAX_TASK_TIMINGS: usize = (20 * 1024 * 1024) / core::mem::size_of::(); +// 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. +const MAX_TASK_TIMINGS: usize = (16 * 1024 * 1024) / core::mem::size_of::(); #[doc(hidden)] -pub type TaskTimings = circular_buffer::CircularBuffer; +pub(crate) type TaskTimings = VecDeque; + #[doc(hidden)] pub type GuardedTaskTimings = spin::Mutex; @@ -287,7 +291,7 @@ thread_local! { pub struct ThreadTimings { pub thread_name: Option, pub thread_id: ThreadId, - pub timings: Box, + pub timings: TaskTimings, pub total_pushed: u64, } @@ -296,10 +300,38 @@ impl ThreadTimings { ThreadTimings { thread_name, thread_id, - timings: TaskTimings::boxed(), + timings: TaskTimings::new(), total_pushed: 0, } } + + /// If this task is the same as the last task, update the end time of the last task. + /// + /// Otherwise, add the new task timing to the list. + pub fn add_task_timing(&mut self, timing: TaskTiming) { + if let Some(last_timing) = self.timings.back_mut() + && last_timing.location == timing.location + && last_timing.start == timing.start + { + last_timing.end = timing.end; + } else { + while self.timings.len() + 1 > MAX_TASK_TIMINGS { + // This should only ever pop one element because it matches the insertion below. + self.timings.pop_front(); + } + self.timings.push_back(timing); + self.total_pushed += 1; + } + } + + pub fn get_thread_task_timings(&self) -> ThreadTaskTimings { + ThreadTaskTimings { + thread_name: self.thread_name.clone(), + thread_id: self.thread_id, + timings: self.timings.iter().cloned().collect(), + total_pushed: self.total_pushed, + } + } } impl Drop for ThreadTimings { @@ -318,19 +350,41 @@ impl Drop for ThreadTimings { } #[doc(hidden)] -#[allow(dead_code)] // Used by Linux and Windows dispatchers, not macOS pub fn add_task_timing(timing: TaskTiming) { + if !PROFILER_ENABLED.load(Ordering::Acquire) { + return; + } THREAD_TIMINGS.with(|timings| { - let mut timings = timings.lock(); - - if let Some(last_timing) = timings.timings.back_mut() { - if last_timing.location == timing.location && last_timing.start == timing.start { - last_timing.end = timing.end; - return; - } - } - - timings.timings.push_back(timing); - timings.total_pushed += 1; + timings.lock().add_task_timing(timing); }); } + +#[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 collection at runtime. +/// +/// When transitioning from enabled to disabled, `add_task_timing` becomes a +/// no-op and the existing per-thread buffers 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_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 +} diff --git a/src/queue.rs b/crates/gpui/src/queue.rs similarity index 100% rename from src/queue.rs rename to crates/gpui/src/queue.rs diff --git a/src/scene.rs b/crates/gpui/src/scene.rs similarity index 97% rename from src/scene.rs rename to crates/gpui/src/scene.rs index 22b1bb468d..bc7f5d79ea 100644 --- a/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -88,11 +88,11 @@ impl Scene { match &mut primitive { Primitive::Shadow(shadow) => { shadow.order = order; - self.shadows.push(shadow.clone()); + self.shadows.push(*shadow); } Primitive::Quad(quad) => { quad.order = order; - self.quads.push(quad.clone()); + self.quads.push(*quad); } Primitive::Path(path) => { path.order = order; @@ -101,19 +101,19 @@ impl Scene { } Primitive::Underline(underline) => { underline.order = order; - self.underlines.push(underline.clone()); + self.underlines.push(*underline); } Primitive::MonochromeSprite(sprite) => { sprite.order = order; - self.monochrome_sprites.push(sprite.clone()); + self.monochrome_sprites.push(*sprite); } Primitive::SubpixelSprite(sprite) => { sprite.order = order; - self.subpixel_sprites.push(sprite.clone()); + self.subpixel_sprites.push(*sprite); } Primitive::PolychromeSprite(sprite) => { sprite.order = order; - self.polychrome_sprites.push(sprite.clone()); + self.polychrome_sprites.push(*sprite); } Primitive::Surface(surface) => { surface.order = order; @@ -481,7 +481,7 @@ pub enum PrimitiveBatch { Surfaces(Range), } -#[derive(Default, Debug, Clone)] +#[derive(Default, Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] pub struct Quad { @@ -501,7 +501,7 @@ impl From for Primitive { } } -#[derive(Debug, Clone)] +#[derive(Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] pub struct Underline { @@ -520,7 +520,7 @@ impl From for Primitive { } } -#[derive(Debug, Clone)] +#[derive(Debug, Copy, Clone)] #[repr(C)] #[expect(missing_docs)] pub struct Shadow { @@ -530,6 +530,11 @@ pub struct Shadow { pub corner_radii: Corners, pub content_mask: ContentMask, pub color: Hsla, + pub element_bounds: Bounds, + pub element_corner_radii: Corners, + /// 0 = drop shadow (rendered outside the element), 1 = inset shadow (rendered inside). + pub inset: u32, + pub pad: u32, // align to 8 bytes } impl From for Primitive { @@ -652,7 +657,7 @@ impl Default for TransformationMatrix { } } -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] #[repr(C)] #[expect(missing_docs)] pub struct MonochromeSprite { @@ -671,7 +676,7 @@ impl From for Primitive { } } -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] #[repr(C)] #[expect(missing_docs)] pub struct SubpixelSprite { @@ -690,7 +695,7 @@ impl From for Primitive { } } -#[derive(Clone, Debug)] +#[derive(Copy, Clone, Debug)] #[repr(C)] #[expect(missing_docs)] pub struct PolychromeSprite { diff --git a/src/shared_uri.rs b/crates/gpui/src/shared_uri.rs similarity index 100% rename from src/shared_uri.rs rename to crates/gpui/src/shared_uri.rs diff --git a/src/style.rs b/crates/gpui/src/style.rs similarity index 95% rename from src/style.rs rename to crates/gpui/src/style.rs index f7058216ee..54e09bc37d 100644 --- a/src/style.rs +++ b/crates/gpui/src/style.rs @@ -351,6 +351,8 @@ pub struct BoxShadow { pub blur_radius: Pixels, /// How much should the shadow spread? pub spread_radius: Pixels, + /// Whether this is an inset shadow (drawn inside the element's bounds). + pub inset: bool, } /// How to handle whitespace in text @@ -439,9 +441,6 @@ pub struct TextStyle { pub line_clamp: Option, } -/// A workaround for Refineable macro expecting a Refinement of a Refinement -pub type TextStyleRefinementRefinement = TextStyleRefinement; - impl Default for TextStyle { fn default() -> Self { TextStyle { @@ -668,7 +667,7 @@ impl Style { .to_pixels(rem_size) .clamp_radii_for_quad_size(bounds.size); - window.paint_shadows(bounds, corner_radii, &self.box_shadow); + window.paint_drop_shadows(bounds, corner_radii, &self.box_shadow); let background_color = self.background.as_ref().and_then(Fill::color); if background_color.is_some_and(|color| !color.is_transparent()) { @@ -697,76 +696,22 @@ impl Style { )); } + window.paint_inset_shadows(bounds, corner_radii, &self.box_shadow); + continuation(window, cx); if self.is_border_visible() { let border_widths = self.border_widths.to_pixels(rem_size); - let max_border_width = border_widths.max(); - let max_corner_radius = corner_radii.max(); - let zero_size = Size { - width: Pixels::ZERO, - height: Pixels::ZERO, - }; - - let mut top_bounds = Bounds::from_corners( - bounds.origin, - bounds.top_right() + point(Pixels::ZERO, max_border_width.max(max_corner_radius)), - ); - top_bounds.size = top_bounds.size.max(&zero_size); - let mut bottom_bounds = Bounds::from_corners( - bounds.bottom_left() - point(Pixels::ZERO, max_border_width.max(max_corner_radius)), - bounds.bottom_right(), - ); - bottom_bounds.size = bottom_bounds.size.max(&zero_size); - let mut left_bounds = Bounds::from_corners( - top_bounds.bottom_left(), - bottom_bounds.origin + point(max_border_width, Pixels::ZERO), - ); - left_bounds.size = left_bounds.size.max(&zero_size); - let mut right_bounds = Bounds::from_corners( - top_bounds.bottom_right() - point(max_border_width, Pixels::ZERO), - bottom_bounds.top_right(), - ); - right_bounds.size = right_bounds.size.max(&zero_size); - let mut background = self.border_color.unwrap_or_default(); background.a = 0.; - let quad = quad( + window.paint_quad(quad( bounds, corner_radii, background, border_widths, self.border_color.unwrap_or_default(), self.border_style, - ); - - window.with_content_mask(Some(ContentMask { bounds: top_bounds }), |window| { - window.paint_quad(quad.clone()); - }); - window.with_content_mask( - Some(ContentMask { - bounds: right_bounds, - }), - |window| { - window.paint_quad(quad.clone()); - }, - ); - window.with_content_mask( - Some(ContentMask { - bounds: bottom_bounds, - }), - |window| { - window.paint_quad(quad.clone()); - }, - ); - window.with_content_mask( - Some(ContentMask { - bounds: left_bounds, - }), - |window| { - window.paint_quad(quad); - }, - ); + )); } #[cfg(debug_assertions)] diff --git a/src/styled.rs b/crates/gpui/src/styled.rs similarity index 98% rename from src/styled.rs rename to crates/gpui/src/styled.rs index 687e71a94c..e090ba973f 100644 --- a/src/styled.rs +++ b/crates/gpui/src/styled.rs @@ -61,6 +61,15 @@ pub trait Styled: Sized { self } + /// Set the space to be reserved for rendering the scrollbar. + /// + /// This will only affect the layout of the element when overflow for this element is set to + /// `Overflow::Scroll`. + fn scrollbar_width(mut self, width: impl Into) -> Self { + self.style().scrollbar_width = Some(width.into()); + self + } + /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { @@ -208,6 +217,13 @@ pub trait Styled: Sized { self } + /// Sets the element to prevent a flex item from growing. + /// [Docs](https://tailwindcss.com/docs/flex-grow#dont-grow) + fn flex_grow_0(mut self) -> Self { + self.style().flex_grow = Some(0.); + self + } + /// Sets the element to allow a flex item to shrink if needed. /// [Docs](https://tailwindcss.com/docs/flex-shrink) fn flex_shrink(mut self) -> Self { diff --git a/src/subscription.rs b/crates/gpui/src/subscription.rs similarity index 52% rename from src/subscription.rs rename to crates/gpui/src/subscription.rs index bd869f8d32..b0c55a3966 100644 --- a/src/subscription.rs +++ b/crates/gpui/src/subscription.rs @@ -1,11 +1,10 @@ -use collections::{BTreeMap, BTreeSet}; +use collections::BTreeMap; +use gpui_util::post_inc; use std::{ cell::{Cell, RefCell}, fmt::Debug, - mem, rc::Rc, }; -use util::post_inc; pub(crate) struct SubscriberSet( Rc>>, @@ -19,12 +18,12 @@ impl Clone for SubscriberSet { struct SubscriberSetState { subscribers: BTreeMap>>>, - dropped_subscribers: BTreeSet<(EmitterKey, usize)>, next_subscriber_id: usize, } struct Subscriber { active: Rc>, + dropped: Rc>, callback: Callback, } @@ -36,7 +35,6 @@ where pub fn new() -> Self { Self(Rc::new(RefCell::new(SubscriberSetState { subscribers: Default::default(), - dropped_subscribers: Default::default(), next_subscriber_id: 0, }))) } @@ -51,6 +49,7 @@ where callback: Callback, ) -> (Subscription, impl FnOnce() + use) { let active = Rc::new(Cell::new(false)); + let dropped = Rc::new(Cell::new(false)); let mut lock = self.0.borrow_mut(); let subscriber_id = post_inc(&mut lock.next_subscriber_id); lock.subscribers @@ -61,6 +60,7 @@ where subscriber_id, Subscriber { active: active.clone(), + dropped: dropped.clone(), callback, }, ); @@ -68,9 +68,10 @@ where let subscription = Subscription { unsubscribe: Some(Box::new(move || { + dropped.set(true); + let mut lock = this.borrow_mut(); let Some(subscribers) = lock.subscribers.get_mut(&emitter_key) else { - // remove was called with this emitter_key return; }; @@ -79,14 +80,7 @@ where if subscribers.is_empty() { lock.subscribers.remove(&emitter_key); } - return; } - - // We didn't manage to remove the subscription, which means it was dropped - // while invoking the callback. Mark it as dropped so that we can remove it - // later. - lock.dropped_subscribers - .insert((emitter_key, subscriber_id)); })), }; (subscription, move || active.set(true)) @@ -128,11 +122,14 @@ where }; subscribers.retain(|_, subscriber| { - if subscriber.active.get() { - f(&mut subscriber.callback) - } else { - true + if !subscriber.active.get() { + return true; } + if subscriber.dropped.get() { + return false; + } + let keep = f(&mut subscriber.callback); + keep && !subscriber.dropped.get() }); let mut lock = self.0.borrow_mut(); @@ -141,12 +138,6 @@ where subscribers.extend(new_subscribers); } - // Remove any dropped subscriptions that were dropped while invoking the callback. - for (dropped_emitter, dropped_subscription_id) in mem::take(&mut lock.dropped_subscribers) { - debug_assert_eq!(*emitter, dropped_emitter); - subscribers.remove(&dropped_subscription_id); - } - if !subscribers.is_empty() { lock.subscribers.insert(emitter.clone(), Some(subscribers)); } @@ -207,3 +198,154 @@ impl std::fmt::Debug for Subscription { f.debug_struct("Subscription").finish() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Global, TestApp}; + + #[test] + fn test_unsubscribe_during_callback_with_insert() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let observer_a_count = Rc::new(Cell::new(0usize)); + let observer_b_count = Rc::new(Cell::new(0usize)); + + let sub_a: Rc>> = Default::default(); + let sub_b: Rc>> = Default::default(); + + // Observer A fires first (lower subscriber_id). It drops itself and + // inserts a new observer for the same global. + *sub_a.borrow_mut() = Some(app.update({ + let count = observer_a_count.clone(); + let sub_a = sub_a.clone(); + move |cx| { + cx.observe_global::(move |cx| { + count.set(count.get() + 1); + sub_a.borrow_mut().take(); + cx.observe_global::(|_| {}).detach(); + }) + } + })); + + // Observer B fires second. It just drops itself. + *sub_b.borrow_mut() = Some(app.update({ + let count = observer_b_count.clone(); + let sub_b = sub_b.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + sub_b.borrow_mut().take(); + }) + } + })); + + // Both fire once. + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(observer_a_count.get(), 1); + assert_eq!(observer_b_count.get(), 1); + + // Neither should fire again — both dropped their subscriptions. + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(observer_a_count.get(), 1); + assert_eq!(observer_b_count.get(), 1, "orphaned subscriber fired again"); + } + + #[test] + fn test_callback_dropped_by_earlier_callback_does_not_fire() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let observer_b_count = Rc::new(Cell::new(0usize)); + let sub_b: Rc>> = Default::default(); + + // Observer A fires first and drops B's subscription. + app.update({ + let sub_b = sub_b.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + sub_b.borrow_mut().take(); + }) + .detach(); + } + }); + + // Observer B fires second — but A already dropped it. + *sub_b.borrow_mut() = Some(app.update({ + let count = observer_b_count.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + }) + } + })); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!( + observer_b_count.get(), + 0, + "B should not fire — A dropped its subscription" + ); + } + + #[test] + fn test_self_drop_during_callback() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let count = Rc::new(Cell::new(0usize)); + let sub: Rc>> = Default::default(); + + *sub.borrow_mut() = Some(app.update({ + let count = count.clone(); + let sub = sub.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + sub.borrow_mut().take(); + }) + } + })); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(count.get(), 1); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(count.get(), 1, "should not fire after self-drop"); + } + + #[test] + fn test_subscription_drop() { + struct TestGlobal; + impl Global for TestGlobal {} + + let mut app = TestApp::new(); + app.set_global(TestGlobal); + + let count = Rc::new(Cell::new(0usize)); + + let subscription = app.update({ + let count = count.clone(); + move |cx| { + cx.observe_global::(move |_cx| { + count.set(count.get() + 1); + }) + } + }); + + drop(subscription); + + app.update(|cx| cx.set_global(TestGlobal)); + assert_eq!(count.get(), 0, "should not fire after drop"); + } +} diff --git a/crates/gpui/src/svg_renderer.rs b/crates/gpui/src/svg_renderer.rs new file mode 100644 index 0000000000..3b5062f55d --- /dev/null +++ b/crates/gpui/src/svg_renderer.rs @@ -0,0 +1,416 @@ +use crate::{ + AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size, + swap_rgba_pa_to_bgra, +}; +use image::Frame; +use resvg::tiny_skia::Pixmap; +use smallvec::SmallVec; +use std::{ + hash::Hash, + sync::{Arc, LazyLock, OnceLock}, +}; + +#[cfg(target_os = "macos")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Apple Color Emoji", ".AppleColorEmojiUI"]; + +#[cfg(target_os = "windows")] +const EMOJI_FONT_FAMILIES: &[&str] = &["Segoe UI Emoji", "Segoe UI Symbol"]; + +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +const EMOJI_FONT_FAMILIES: &[&str] = &[ + "Noto Color Emoji", + "Emoji One", + "Twitter Color Emoji", + "JoyPixels", +]; + +#[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "linux", + target_os = "freebsd", +)))] +const EMOJI_FONT_FAMILIES: &[&str] = &[]; + +fn is_emoji_presentation(c: char) -> bool { + static EMOJI_PRESENTATION_REGEX: LazyLock = + LazyLock::new(|| regex::Regex::new("\\p{Emoji_Presentation}").unwrap()); + let mut buf = [0u8; 4]; + EMOJI_PRESENTATION_REGEX.is_match(c.encode_utf8(&mut buf)) +} + +fn font_has_char(db: &usvg::fontdb::Database, id: usvg::fontdb::ID, ch: char) -> bool { + db.with_face_data(id, |font_data, face_index| { + ttf_parser::Face::parse(font_data, face_index) + .ok() + .and_then(|face| face.glyph_index(ch)) + .is_some() + }) + .unwrap_or(false) +} + +fn select_emoji_font( + ch: char, + fonts: &[usvg::fontdb::ID], + db: &usvg::fontdb::Database, + families: &[&str], +) -> Option { + for family_name in families { + let query = usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name(family_name)], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }; + + let Some(id) = db.query(&query) else { + continue; + }; + + if fonts.contains(&id) || !font_has_char(db, id, ch) { + continue; + } + + return Some(id); + } + + None +} + +/// When rendering SVGs, we render them at twice the size to get a higher-quality result. +pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.; + +#[derive(Clone, PartialEq, Hash, Eq)] +#[expect(missing_docs)] +pub struct RenderSvgParams { + pub path: SharedString, + pub size: Size, +} + +#[derive(Clone)] +/// A struct holding everything necessary to render SVGs. +pub struct SvgRenderer { + asset_source: Arc, + usvg_options: Arc>, +} + +/// The size in which to render the SVG. +pub enum SvgSize { + /// An absolute size in device pixels. + Size(Size), + /// A scaling factor to apply to the size provided by the SVG. + ScaleFactor(f32), +} + +impl SvgRenderer { + /// Creates a new SVG renderer with the provided asset source. + pub fn new(asset_source: Arc) -> Self { + static SYSTEM_FONT_DB: LazyLock> = LazyLock::new(|| { + let mut db = usvg::fontdb::Database::new(); + db.load_system_fonts(); + Arc::new(db) + }); + + // Build the enriched font DB lazily on first SVG render rather than + // eagerly at construction time. This avoids the expensive deep-clone + // of the system font database for code paths that never render SVGs + // (e.g. tests). + let enriched_fontdb: Arc>> = Arc::new(OnceLock::new()); + + let default_font_resolver = usvg::FontResolver::default_font_selector(); + let font_resolver = Box::new({ + let asset_source = asset_source.clone(); + move |font: &usvg::Font, db: &mut Arc| { + if db.is_empty() { + let fontdb = enriched_fontdb.get_or_init(|| { + let mut db = (**SYSTEM_FONT_DB).clone(); + load_bundled_fonts(&*asset_source, &mut db); + fix_generic_font_families(&mut db); + Arc::new(db) + }); + *db = fontdb.clone(); + } + if let Some(id) = default_font_resolver(font, db) { + return Some(id); + } + // fontdb doesn't recognize CSS system font keywords like "system-ui" + // or "ui-sans-serif", so fall back to sans-serif before any face. + let sans_query = usvg::fontdb::Query { + families: &[usvg::fontdb::Family::SansSerif], + ..Default::default() + }; + db.query(&sans_query) + .or_else(|| db.faces().next().map(|f| f.id)) + } + }); + let default_fallback_selection = usvg::FontResolver::default_fallback_selector(); + let fallback_selection = Box::new( + move |ch: char, fonts: &[usvg::fontdb::ID], db: &mut Arc| { + if is_emoji_presentation(ch) { + if let Some(id) = select_emoji_font(ch, fonts, db.as_ref(), EMOJI_FONT_FAMILIES) + { + return Some(id); + } + } + + default_fallback_selection(ch, fonts, db) + }, + ); + let options = usvg::Options { + font_resolver: usvg::FontResolver { + select_font: font_resolver, + select_fallback: fallback_selection, + }, + ..Default::default() + }; + Self { + asset_source, + usvg_options: Arc::new(options), + } + } + + /// Renders the given bytes into an image buffer. + pub fn render_single_frame( + &self, + bytes: &[u8], + scale_factor: f32, + ) -> Result, usvg::Error> { + self.render_pixmap( + bytes, + SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR), + ) + .map(|pixmap| { + let mut buffer = + image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()) + .unwrap(); + + for pixel in buffer.chunks_exact_mut(4) { + swap_rgba_pa_to_bgra(pixel); + } + + let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)])); + image.scale_factor = SMOOTH_SVG_SCALE_FACTOR; + Arc::new(image) + }) + } + + pub(crate) fn render_alpha_mask( + &self, + params: &RenderSvgParams, + bytes: Option<&[u8]>, + ) -> Result, Vec)>> { + anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size"); + + let render_pixmap = |bytes| { + let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?; + + // Convert the pixmap's pixels into an alpha mask. + let size = Size::new( + DevicePixels(pixmap.width() as i32), + DevicePixels(pixmap.height() as i32), + ); + let alpha_mask = pixmap + .pixels() + .iter() + .map(|p| p.alpha()) + .collect::>(); + + Ok(Some((size, alpha_mask))) + }; + + if let Some(bytes) = bytes { + render_pixmap(bytes) + } else if let Some(bytes) = self.asset_source.load(¶ms.path)? { + render_pixmap(&bytes) + } else { + Ok(None) + } + } + + 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 { + SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(), + SvgSize::ScaleFactor(scale) => 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 transform = resvg::tiny_skia::Transform::from_scale(scale, scale); + + resvg::render(&tree, transform, &mut pixmap.as_mut()); + + Ok(pixmap) + } +} + +fn load_bundled_fonts(asset_source: &dyn AssetSource, db: &mut usvg::fontdb::Database) { + let font_paths = [ + "fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf", + "fonts/lilex/Lilex-Regular.ttf", + ]; + for path in font_paths { + match asset_source.load(path) { + Ok(Some(data)) => db.load_font_data(data.into_owned()), + Ok(None) => log::warn!("Bundled font not found: {path}"), + Err(error) => log::warn!("Failed to load bundled font {path}: {error}"), + } + } +} + +// fontdb defaults generic families to Microsoft fonts ("Arial", "Times New Roman") +// which aren't installed on most Linux systems. fontconfig normally overrides these, +// but when it fails the defaults remain and all generic family queries return None. +fn fix_generic_font_families(db: &mut usvg::fontdb::Database) { + use usvg::fontdb::{Family, Query}; + + let families_and_fallbacks: &[(Family<'_>, &str)] = &[ + (Family::SansSerif, "IBM Plex Sans"), + // No serif font bundled; use sans-serif as best available fallback. + (Family::Serif, "IBM Plex Sans"), + (Family::Monospace, "Lilex"), + (Family::Cursive, "IBM Plex Sans"), + (Family::Fantasy, "IBM Plex Sans"), + ]; + + for (family, fallback_name) in families_and_fallbacks { + let query = Query { + families: &[*family], + ..Default::default() + }; + if db.query(&query).is_none() { + match family { + Family::SansSerif => db.set_sans_serif_family(*fallback_name), + Family::Serif => db.set_serif_family(*fallback_name), + Family::Monospace => db.set_monospace_family(*fallback_name), + Family::Cursive => db.set_cursive_family(*fallback_name), + Family::Fantasy => db.set_fantasy_family(*fallback_name), + _ => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use usvg::fontdb::{Database, Family, Query}; + + const IBM_PLEX_REGULAR: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const LILEX_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); + + fn db_with_bundled_fonts() -> Database { + let mut db = Database::new(); + db.load_font_data(IBM_PLEX_REGULAR.to_vec()); + db.load_font_data(LILEX_REGULAR.to_vec()); + db + } + + #[test] + fn test_is_emoji_presentation() { + let cases = [ + ("a", false), + ("Z", false), + ("1", false), + ("#", false), + ("*", false), + ("漢", false), + ("中", false), + ("カ", false), + ("©", false), + ("♥", false), + ("😀", true), + ("✅", true), + ("🇺🇸", true), + // SVG fallback is not cluster-aware yet + ("©️", false), + ("♥️", false), + ("1️⃣", false), + ]; + for (s, expected) in cases { + assert_eq!( + is_emoji_presentation(s.chars().next().unwrap()), + expected, + "for char {:?}", + s + ); + } + } + + #[test] + fn fix_generic_font_families_sets_all_families() { + let mut db = db_with_bundled_fonts(); + fix_generic_font_families(&mut db); + + let families = [ + Family::SansSerif, + Family::Serif, + Family::Monospace, + Family::Cursive, + Family::Fantasy, + ]; + + for family in families { + let query = Query { + families: &[family], + ..Default::default() + }; + assert!( + db.query(&query).is_some(), + "Expected generic family {family:?} to resolve after fix_generic_font_families" + ); + } + } + + #[test] + fn test_select_emoji_font_skips_family_without_glyph() { + let mut db = db_with_bundled_fonts(); + + let ibm_plex_sans = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("IBM Plex Sans")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let lilex = db + .query(&usvg::fontdb::Query { + families: &[usvg::fontdb::Family::Name("Lilex")], + weight: usvg::fontdb::Weight(400), + stretch: usvg::fontdb::Stretch::Normal, + style: usvg::fontdb::Style::Normal, + }) + .unwrap(); + let selected = select_emoji_font('│', &[], &db, &["IBM Plex Sans", "Lilex"]).unwrap(); + + assert_eq!(selected, lilex); + assert!(!font_has_char(&db, ibm_plex_sans, '│')); + assert!(font_has_char(&db, selected, '│')); + } + + #[test] + fn fix_generic_font_families_monospace_resolves_to_lilex() { + let mut db = db_with_bundled_fonts(); + fix_generic_font_families(&mut db); + + let query = Query { + families: &[Family::Monospace], + ..Default::default() + }; + let id = db.query(&query).expect("Monospace should resolve"); + let face = db.face(id).expect("Face should exist"); + assert!( + face.families.iter().any(|(name, _)| name.contains("Lilex")), + "Monospace should map to Lilex, got {:?}", + face.families + ); + } +} diff --git a/src/tab_stop.rs b/crates/gpui/src/tab_stop.rs similarity index 100% rename from src/tab_stop.rs rename to crates/gpui/src/tab_stop.rs diff --git a/src/taffy.rs b/crates/gpui/src/taffy.rs similarity index 67% rename from src/taffy.rs rename to crates/gpui/src/taffy.rs index 094b65553d..4844748d6c 100644 --- a/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -1,6 +1,10 @@ use crate::{ AbsoluteLength, App, Bounds, DefiniteLength, Edges, GridTemplate, Length, Pixels, Point, Size, - Style, Window, point, size, + Style, Window, size, + util::{ + ceil_to_device_pixel, round_half_toward_zero, round_stroke_to_device_pixel, + round_to_device_pixel, + }, }; use collections::{FxHashMap, FxHashSet}; use stacksafe::{StackSafe, stacksafe}; @@ -30,6 +34,8 @@ struct NodeContext { pub struct TaffyLayoutEngine { taffy: TaffyTree, absolute_layout_bounds: FxHashMap>, + /// Unrounded absolute border-box top-left per-node coordinate in device pixels. + absolute_outer_origins: FxHashMap>, computed_layouts: FxHashSet, layout_bounds_scratch_space: Vec, } @@ -39,10 +45,11 @@ const EXPECT_MESSAGE: &str = "we should avoid taffy layout errors by constructio impl TaffyLayoutEngine { pub fn new() -> Self { let mut taffy = TaffyTree::new(); - taffy.enable_rounding(); + taffy.disable_rounding(); TaffyLayoutEngine { taffy, absolute_layout_bounds: FxHashMap::default(), + absolute_outer_origins: FxHashMap::default(), computed_layouts: FxHashSet::default(), layout_bounds_scratch_space: Vec::new(), } @@ -51,6 +58,7 @@ impl TaffyLayoutEngine { pub fn clear(&mut self) { self.taffy.clear(); self.absolute_layout_bounds.clear(); + self.absolute_outer_origins.clear(); self.computed_layouts.clear(); } @@ -170,10 +178,11 @@ impl TaffyLayoutEngine { // if !self.computed_layouts.insert(id) { - let mut stack = &mut self.layout_bounds_scratch_space; + let stack = &mut self.layout_bounds_scratch_space; stack.push(id); while let Some(id) = stack.pop() { self.absolute_layout_bounds.remove(&id); + self.absolute_outer_origins.remove(&id); stack.extend( self.taffy .children(id.into()) @@ -225,37 +234,122 @@ impl TaffyLayoutEngine { untransform(available_space.height), ); - let a: Size = + let measured_size: Size = (node_context.measure)(known_dimensions, available_space, window, cx); - size(a.width.0 * scale_factor, a.height.0 * scale_factor).into() + snap_measured_size_to_device_pixels(measured_size, scale_factor).into() }, ) .expect(EXPECT_MESSAGE); } + // Pixel snapping + // + // Painting primitives at non-integer pixel coordinates produces blurry + // output. Pixel snapping converts layout coordinates into integer + // device-pixel coordinates so painted edges land exactly on physical + // pixel boundaries. + // + // Non-integer coordinates can arise for several reasons, including: + // - flex distribution, percentages, centering, and text measurement + // can produce fractional element sizes and positions; + // - at fractional scale factors (for example 125% or 150%), integer + // logical-pixel values can map to non-integer device-pixel values. + // + // We pixel-snap by rounding in device-pixel space, after multiplying + // by `scale_factor`, so that snapping targets physical pixels. Bounds + // are divided by `scale_factor` before being returned to GPUI. + // + // Midpoints are rounded toward zero. This is a stylistic choice: a + // 1-logical-pixel line at 150% scale should render as 1 dp rather than + // 2 dp. + // + // Pixel snapping is done in two phases: + // + // 1. Pre-layout metric snapping. Before Taffy computes layout, all + // authored absolute lengths are rounded in `to_taffy`. This + // includes borders, padding, gaps, and explicit sizes. + // Custom-measured leaf nodes have their measured sizes rounded up + // to integer device-pixel lengths. + // + // 2. Post-layout edge snapping. After Taffy resolves the tree, layout + // relationships such as flex shares, grid tracks, percentages, and + // centering can produce new fractional edge positions. Boxes now + // have edges in absolute coordinates, and snapping must decide + // where those edges land on the device-pixel grid. + // + // Ideally, post-layout snapping would satisfy: + // + // - Edge closure. Two raw layout edges at the same absolute position + // should snap to the same pixel column. + // - Translation stability. A component's internal geometry should not + // change when it moves to a new absolute position. + // + // These goals are in tension because rounding is not associative. + // The simple local schemes make different tradeoffs: + // + // - Absolute edge rounding gives each window coordinate one answer, + // so coincident edges always close globally. But a span's snapped + // length is `round(far) - round(near)`, which may change by 1 dp + // as its absolute origin moves. + // + // - Parent-relative edge rounding rounds each child inside its + // parent's coordinate space. This guarantees translation stability, + // but a shared edge reached through different parents can + // accumulate different rounding, causing non-closure between + // cousins. + // + // - Length rounding rounds each width, height, and thickness + // independently and then places boxes from those rounded lengths. + // Sizes stay stable under translation, but neighboring boxes derive + // their shared boundary from different sources, so closure is not + // guaranteed. + // + // We apply absolute edge rounding for each element's outer box in + // post-layout rounding to preserve closure. Border and padding widths + // are not touched by post-layout rounding; they keep their pre-layout + // rounded value so that they remain stable under translation. + // + // This gives both closure and translation stability in the case that + // all local metrics are integer device-pixel lengths. Pre-layout + // rounding covers that in most cases. The exception is metrics + // resolved by layout relationships, such as percentages. Outer box + // edges will still close globally, and painted border widths are still + // snapped independently, but the raw content-box origin can carry a + // 1dp residual into descendants. + pub fn layout_bounds(&mut self, id: LayoutId, scale_factor: f32) -> Bounds { if let Some(layout) = self.absolute_layout_bounds.get(&id).cloned() { return layout; } let layout = self.taffy.layout(id.into()).expect(EXPECT_MESSAGE); - let mut bounds = Bounds { - origin: point( - Pixels(layout.location.x / scale_factor), - Pixels(layout.location.y / scale_factor), - ), - size: size( - Pixels(layout.size.width / scale_factor), - Pixels(layout.size.height / scale_factor), - ), + let layout_location = layout.location; + let layout_size = layout.size; + let parent = self.taffy.parent(id.0); + + let absolute_outer_origin = match parent { + Some(parent_id) => { + let parent_id = LayoutId::from(parent_id); + self.layout_bounds(parent_id, scale_factor); + let parent_origin = *self + .absolute_outer_origins + .get(&parent_id) + .expect("parent absolute outer origin should be cached"); + parent_origin + Point::from(layout_location) + } + None => Point::from(layout_location), }; + self.absolute_outer_origins + .insert(id, absolute_outer_origin); - if let Some(parent_id) = self.taffy.parent(id.0) { - let parent_bounds = self.layout_bounds(parent_id.into(), scale_factor); - bounds.origin += parent_bounds.origin; - } + let absolute_far = absolute_outer_origin + Point::from(Size::from(layout_size)); + let snapped_bounds = Bounds::from_corners( + absolute_outer_origin.map(round_half_toward_zero), + absolute_far.map(round_half_toward_zero), + ); + + let bounds = (snapped_bounds / scale_factor).map(Pixels); self.absolute_layout_bounds.insert(id, bounds); - bounds } } @@ -290,6 +384,29 @@ impl From for NodeId { } } +fn snap_measured_size_to_device_pixels(size: Size, scale_factor: f32) -> Size { + size.map(|d| ceil_to_device_pixel(d.0.max(0.0), scale_factor)) +} + +fn border_widths_to_taffy( + widths: &Edges, + rem_size: Pixels, + scale_factor: f32, +) -> TaffyRect { + let snap = |w: &AbsoluteLength| { + taffy::style::LengthPercentage::length(round_stroke_to_device_pixel( + w.to_pixels(rem_size).0, + scale_factor, + )) + }; + TaffyRect { + top: snap(&widths.top), + right: snap(&widths.right), + bottom: snap(&widths.bottom), + left: snap(&widths.left), + } +} + trait ToTaffy { fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> Output; } @@ -314,20 +431,23 @@ impl ToTaffy for Style { match template.min_size { // grid-template-*: repeat(, minmax(0, 1fr)); crate::TemplateColumnMinSize::Zero => { - vec![repeat(template.repeat, vec![minmax(length(0.0), fr(1.0))])] + vec![repeat( + template.repeat, + vec![minmax(length(0.0_f32), fr(1.0_f32))], + )] } // grid-template-*: repeat(, minmax(min-content, 1fr)); crate::TemplateColumnMinSize::MinContent => { vec![repeat( template.repeat, - vec![minmax(min_content(), fr(1.0))], + vec![minmax(min_content(), fr(1.0_f32))], )] } // grid-template-*: repeat(, minmax(0, max-content)) crate::TemplateColumnMinSize::MaxContent => { vec![repeat( template.repeat, - vec![minmax(length(0.0), max_content())], + vec![minmax(length(0.0_f32), max_content())], )] } } @@ -347,7 +467,7 @@ impl ToTaffy for Style { aspect_ratio: self.aspect_ratio, margin: self.margin.to_taffy(rem_size, scale_factor), padding: self.padding.to_taffy(rem_size, scale_factor), - border: self.border_widths.to_taffy(rem_size, scale_factor), + border: border_widths_to_taffy(&self.border_widths, rem_size, scale_factor), align_items: self.align_items.map(|x| x.into()), align_self: self.align_self.map(|x| x.into()), align_content: self.align_content.map(|x| x.into()), @@ -377,16 +497,7 @@ impl ToTaffy for Style { impl ToTaffy for AbsoluteLength { fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> f32 { - match self { - AbsoluteLength::Pixels(pixels) => { - let pixels: f32 = pixels.into(); - pixels * scale_factor - } - AbsoluteLength::Rems(rems) => { - let pixels: f32 = (*rems * rem_size).into(); - pixels * scale_factor - } - } + round_to_device_pixel(self.to_pixels(rem_size).0, scale_factor) } } @@ -415,16 +526,7 @@ impl ToTaffy for Length { impl ToTaffy for DefiniteLength { fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentage { match self { - DefiniteLength::Absolute(length) => match length { - AbsoluteLength::Pixels(pixels) => { - let pixels: f32 = pixels.into(); - taffy::style::LengthPercentage::length(pixels * scale_factor) - } - AbsoluteLength::Rems(rems) => { - let pixels: f32 = (*rems * rem_size).into(); - taffy::style::LengthPercentage::length(pixels * scale_factor) - } - }, + DefiniteLength::Absolute(length) => length.to_taffy(rem_size, scale_factor), DefiniteLength::Fraction(fraction) => { taffy::style::LengthPercentage::percent(*fraction) } @@ -435,16 +537,7 @@ impl ToTaffy for DefiniteLength { impl ToTaffy for DefiniteLength { fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentageAuto { match self { - DefiniteLength::Absolute(length) => match length { - AbsoluteLength::Pixels(pixels) => { - let pixels: f32 = pixels.into(); - taffy::style::LengthPercentageAuto::length(pixels * scale_factor) - } - AbsoluteLength::Rems(rems) => { - let pixels: f32 = (*rems * rem_size).into(); - taffy::style::LengthPercentageAuto::length(pixels * scale_factor) - } - }, + DefiniteLength::Absolute(length) => length.to_taffy(rem_size, scale_factor), DefiniteLength::Fraction(fraction) => { taffy::style::LengthPercentageAuto::percent(*fraction) } @@ -455,15 +548,7 @@ impl ToTaffy for DefiniteLength { impl ToTaffy for DefiniteLength { fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Dimension { match self { - DefiniteLength::Absolute(length) => match length { - AbsoluteLength::Pixels(pixels) => { - let pixels: f32 = pixels.into(); - taffy::style::Dimension::length(pixels * scale_factor) - } - AbsoluteLength::Rems(rems) => { - taffy::style::Dimension::length((*rems * rem_size * scale_factor).into()) - } - }, + DefiniteLength::Absolute(length) => length.to_taffy(rem_size, scale_factor), DefiniteLength::Fraction(fraction) => taffy::style::Dimension::percent(*fraction), } } @@ -471,16 +556,19 @@ impl ToTaffy for DefiniteLength { impl ToTaffy for AbsoluteLength { fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentage { - match self { - AbsoluteLength::Pixels(pixels) => { - let pixels: f32 = pixels.into(); - taffy::style::LengthPercentage::length(pixels * scale_factor) - } - AbsoluteLength::Rems(rems) => { - let pixels: f32 = (*rems * rem_size).into(); - taffy::style::LengthPercentage::length(pixels * scale_factor) - } - } + taffy::style::LengthPercentage::length(self.to_taffy(rem_size, scale_factor)) + } +} + +impl ToTaffy for AbsoluteLength { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::LengthPercentageAuto { + taffy::style::LengthPercentageAuto::length(self.to_taffy(rem_size, scale_factor)) + } +} + +impl ToTaffy for AbsoluteLength { + fn to_taffy(&self, rem_size: Pixels, scale_factor: f32) -> taffy::style::Dimension { + taffy::style::Dimension::length(self.to_taffy(rem_size, scale_factor)) } } @@ -628,3 +716,36 @@ impl From> for Size { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn border_widths_to_taffy_use_stroke_snapping() { + let border_widths = Edges { + top: Pixels(0.0).into(), + right: Pixels(0.4).into(), + bottom: Pixels(0.5).into(), + left: Pixels(1.6).into(), + }; + let taffy_border = border_widths_to_taffy(&border_widths, Pixels(16.0), 1.0); + + assert_eq!( + taffy_border.top, + taffy::style::LengthPercentage::length(0.0) + ); + assert_eq!( + taffy_border.right, + taffy::style::LengthPercentage::length(1.0) + ); + assert_eq!( + taffy_border.bottom, + taffy::style::LengthPercentage::length(1.0) + ); + assert_eq!( + taffy_border.left, + taffy::style::LengthPercentage::length(2.0) + ); + } +} diff --git a/src/test.rs b/crates/gpui/src/test.rs similarity index 85% rename from src/test.rs rename to crates/gpui/src/test.rs index ddcc3d27bd..a93424edea 100644 --- a/src/test.rs +++ b/crates/gpui/src/test.rs @@ -37,6 +37,9 @@ use std::{ /// Strategy injected into `#[gpui::property_test]` tests to control the seed /// given to the scheduler. Doesn't shrink, since all scheduler seeds are /// equivalent in complexity. If `$SEED` is set, it always uses that value. +/// +/// Note: this function is not intended to be used directly. Rather, it is +/// public so that it can be used from the `property_test` macro. pub fn seed_strategy() -> impl Strategy { match std::env::var("SEED") { Ok(val) => Just(val.parse().unwrap()).boxed(), @@ -44,22 +47,44 @@ pub fn seed_strategy() -> impl Strategy { } } +/// Applies a fixed RNG seed to a proptest config so that case generation +/// is deterministic. Uses `$SEED` if set, otherwise defaults to `0`. +/// This bridges the GPUI `SEED` env var to proptest's RNG seed, so that +/// a single variable controls both the scheduler seed and case generation. +/// +/// Note: this function is not intended to be used directly. Rather, it is +/// public so that it can be used from the `property_test` macro. +pub fn apply_seed_to_proptest_config( + mut config: proptest::test_runner::Config, +) -> proptest::test_runner::Config { + let seed = env::var("SEED") + .ok() + .and_then(|val| val.parse::().ok()) + .unwrap_or(0); + config.rng_seed = proptest::test_runner::RngSeed::Fixed(seed); + config +} + /// Similar to [`run_test`], but only runs the callback once, allowing /// [`FnOnce`] callbacks. This is intended for use with the /// `gpui::property_test` macro and generally should not be used directly. /// /// Doesn't support many features of [`run_test`], since these are provided by /// proptest. -pub fn run_test_once(seed: u64, test_fn: Box) { +pub fn run_test_once( + seed: u64, + test_fn: Box R>, +) -> R { let result = panic::catch_unwind(|| { let dispatcher = TestDispatcher::new(seed); let scheduler = dispatcher.scheduler().clone(); - test_fn(dispatcher); + let res = test_fn(dispatcher); scheduler.end_test(); + res }); match result { - Ok(()) => {} + Ok(r) => r, Err(e) => panic::resume_unwind(e), } } @@ -186,7 +211,7 @@ pub fn observe(entity: &Entity, cx: &mut TestAppContext) -> Obser let (tx, rx) = async_channel::unbounded(); let _subscription = cx.update(|cx| { cx.observe(entity, move |_, _| { - let _ = pollster::block_on(tx.send(())); + let _ = gpui::block_on(tx.send(())); }) }); let rx = Box::pin(rx); diff --git a/src/text_system.rs b/crates/gpui/src/text_system.rs similarity index 97% rename from src/text_system.rs rename to crates/gpui/src/text_system.rs index b62a0ad6fd..043d37f679 100644 --- a/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -45,11 +45,7 @@ pub struct FontFamilyId(pub usize); pub const SUBPIXEL_VARIANTS_X: u8 = 4; /// Number of subpixel glyph variants along the Y axis. -pub const SUBPIXEL_VARIANTS_Y: u8 = if cfg!(target_os = "windows") || cfg!(target_os = "linux") { - 1 -} else { - SUBPIXEL_VARIANTS_X -}; +pub const SUBPIXEL_VARIANTS_Y: u8 = 1; /// The GPUI text rendering sub system. pub struct TextSystem { @@ -238,12 +234,6 @@ impl TextSystem { Ok(self.advance(font_id, font_size, 'm')?.width) } - // Consider removing this? - /// Returns the shaped layout width of an `em`. - pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { - self.layout_width(font_id, font_size, 'm') - } - /// Returns the width of an `ch`. /// /// Uses the width of the `0` character in the given font and size. @@ -352,6 +342,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( @@ -698,6 +693,28 @@ impl WindowTextSystem { layout } + /// Returns the shaped layout width of for the given character, in the given font and size. + pub fn layout_width(&self, font_id: FontId, font_size: Pixels, ch: char) -> Pixels { + let mut buffer = [0; 4]; + let buffer: &_ = ch.encode_utf8(&mut buffer); + self.line_layout_cache + .layout_line( + buffer, + font_size, + &[FontRun { + len: buffer.len(), + font_id, + }], + None, + ) + .width + } + + /// Returns the shaped layout width of an `em`. + pub fn em_layout_width(&self, font_id: FontId, font_size: Pixels) -> Pixels { + self.layout_width(font_id, font_size, 'm') + } + /// Probe the line layout cache using a caller-provided content hash, without allocating. /// /// Returns `Some(layout)` if the layout is already cached in either the current frame @@ -1011,6 +1028,7 @@ pub struct RenderGlyphParams { pub scale_factor: f32, pub is_emoji: bool, pub subpixel_rendering: bool, + pub dilation: u8, } impl Eq for RenderGlyphParams {} @@ -1024,6 +1042,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/src/text_system/font_fallbacks.rs b/crates/gpui/src/text_system/font_fallbacks.rs similarity index 100% rename from src/text_system/font_fallbacks.rs rename to crates/gpui/src/text_system/font_fallbacks.rs diff --git a/src/text_system/font_features.rs b/crates/gpui/src/text_system/font_features.rs similarity index 100% rename from src/text_system/font_features.rs rename to crates/gpui/src/text_system/font_features.rs diff --git a/src/text_system/line.rs b/crates/gpui/src/text_system/line.rs similarity index 96% rename from src/text_system/line.rs rename to crates/gpui/src/text_system/line.rs index 7b5714188f..cb66359206 100644 --- a/src/text_system/line.rs +++ b/crates/gpui/src/text_system/line.rs @@ -208,8 +208,16 @@ impl ShapedLine { } // Split text - let left_text = SharedString::new(self.text[..byte_index].to_string()); - let right_text = SharedString::new(self.text[byte_index..].to_string()); + let left_text = if byte_index == self.text.len() { + self.text.clone() + } else { + SharedString::new(&self.text[..byte_index]) + }; + let right_text = if byte_index == 0 { + self.text.clone() + } else { + SharedString::new(&self.text[byte_index..]) + }; let left_width = x_offset; let right_width = self.layout.width - left_width; @@ -385,8 +393,12 @@ fn paint_line( glyph_origin.x - underline_origin.x, underline_style, ); - underline_origin.x = origin.x; - underline_origin.y += line_height; + if glyph.index < run_end { + underline_origin.x = origin.x; + underline_origin.y += line_height; + } else { + current_underline = None; + } } if let Some((strikethrough_origin, strikethrough_style)) = current_strikethrough.as_mut() @@ -399,8 +411,12 @@ fn paint_line( glyph_origin.x - strikethrough_origin.x, strikethrough_style, ); - strikethrough_origin.x = origin.x; - strikethrough_origin.y += line_height; + if glyph.index < run_end { + strikethrough_origin.x = origin.x; + strikethrough_origin.y += line_height; + } else { + current_strikethrough = None; + } } glyph_origin.x = aligned_origin_x( @@ -618,8 +634,12 @@ fn paint_line_background( }, *background_color, )); - background_origin.x = origin.x; - background_origin.y += line_height; + if glyph.index < run_end { + background_origin.x = origin.x; + background_origin.y += line_height; + } else { + current_background = None; + } } glyph_origin.x = aligned_origin_x( @@ -764,7 +784,7 @@ mod tests { }], len: text.len(), }), - text: SharedString::new(text.to_string()), + text: SharedString::new(text), decoration_runs: SmallVec::from(decorations.to_vec()), } } @@ -882,7 +902,7 @@ mod tests { ], len: 6, }), - text: SharedString::new("abcdef".to_string()), + text: "abcdef".into(), decoration_runs: SmallVec::new(), }; diff --git a/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs similarity index 85% rename from src/text_system/line_layout.rs rename to crates/gpui/src/text_system/line_layout.rs index 8f3d7563d0..5e85cefb8c 100644 --- a/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -186,7 +186,7 @@ impl LineLayout { if width > wrap_width && boundary > last_boundary { // When used line_clamp, we should limit the number of lines. if let Some(max_lines) = max_lines - && boundaries.len() >= max_lines - 1 + && boundaries.len() >= max_lines.saturating_sub(1) { break; } @@ -610,15 +610,7 @@ impl LineLayoutCache { .layout_line(&text, font_size, runs); if let Some(force_width) = force_width { - let mut glyph_pos = 0; - for run in layout.runs.iter_mut() { - for glyph in run.glyphs.iter_mut() { - if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) { - glyph.position.x = glyph_pos * force_width; - } - glyph_pos += 1; - } - } + apply_force_width_to_layout(&mut layout, force_width); } let key = Arc::new(CacheKey { @@ -767,15 +759,7 @@ impl LineLayoutCache { .layout_line(&text, font_size, runs); if let Some(force_width) = force_width { - let mut glyph_pos = 0; - for run in layout.runs.iter_mut() { - for glyph in run.glyphs.iter_mut() { - if (glyph.position.x - glyph_pos * force_width).abs() > px(1.) { - glyph.position.x = glyph_pos * force_width; - } - glyph_pos += 1; - } - } + apply_force_width_to_layout(&mut layout, force_width); } let key = Arc::new(HashedCacheKey { @@ -795,6 +779,36 @@ impl LineLayoutCache { } } +// Combining marks (e.g. Thai vowel signs, Arabic diacritics) are shaped by +// HarfBuzz at the same x position as their base character. The force-width +// loop must not advance the cell counter for these zero-advance glyphs, +// otherwise they get displaced into the next cell. We detect them by checking +// whether shaped x has advanced by at least half a cell beyond the last base. +fn apply_force_width_to_layout(layout: &mut LineLayout, force_width: Pixels) { + let mut glyph_pos: usize = 0; + // NEG_INFINITY ensures the first glyph is always classified as a base. + let mut last_base_shaped_x = px(f32::NEG_INFINITY); + let mut last_base_actual_x = px(0.); + + for run in layout.runs.iter_mut() { + for glyph in run.glyphs.iter_mut() { + let shaped_x = glyph.position.x; + + if shaped_x > last_base_shaped_x + force_width * 0.5 { + let forced_x = glyph_pos * force_width; + if (shaped_x - forced_x).abs() > px(1.) { + glyph.position.x = forced_x; + } + last_base_shaped_x = shaped_x; + last_base_actual_x = glyph.position.x; + glyph_pos += 1; + } else { + glyph.position.x = last_base_actual_x + (shaped_x - last_base_shaped_x); + } + } + } +} + /// A run of text with a single font. #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] #[expect(missing_docs)] @@ -942,3 +956,123 @@ impl AsCacheKeyRef for CacheKeyRef<'_> { *self } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::GlyphId; + + fn glyph_at(x: f32, index: usize) -> ShapedGlyph { + ShapedGlyph { + id: GlyphId(0), + position: point(px(x), px(0.)), + index, + is_emoji: false, + } + } + + fn make_layout(glyphs: Vec) -> LineLayout { + LineLayout { + font_size: px(16.), + width: px(100.), + ascent: px(12.), + descent: px(4.), + runs: vec![ShapedRun { + font_id: FontId(0), + glyphs, + }], + len: 0, + } + } + + fn glyph_x_positions(layout: &LineLayout) -> Vec { + layout.runs[0] + .glyphs + .iter() + .map(|g| f32::from(g.position.x)) + .collect() + } + + #[test] + fn test_force_width_latin_unchanged() { + let cell_width = px(8.); + let mut layout = make_layout(vec![glyph_at(0., 0), glyph_at(8., 1), glyph_at(16., 2)]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 8., 16.]); + } + + #[test] + fn test_force_width_combining_marks_not_advanced() { + let cell_width = px(8.); + // Simulates Thai "กี" — base consonant at x=0, combining vowel also at x=0 + let mut layout = make_layout(vec![ + glyph_at(0., 0), // ก (base) + glyph_at(0., 3), // ี (combining mark, same x) + ]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 0.]); + } + + #[test] + fn test_force_width_base_after_combining_mark() { + let cell_width = px(8.); + let mut layout = make_layout(vec![glyph_at(0., 0), glyph_at(0., 3), glyph_at(8., 6)]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 0., 8.]); + } + + #[test] + fn test_force_width_multiple_combining_marks() { + let cell_width = px(8.); + // Simulates "ก้" — base + vowel + tone mark (two combining marks stacked) + let mut layout = make_layout(vec![ + glyph_at(0., 0), // ก (base) + glyph_at(0., 3), // vowel (combining) + glyph_at(0., 6), // tone mark (combining) + glyph_at(8., 9), // next base + ]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0., 0., 0., 8.]); + } + + #[test] + fn test_force_width_corrects_drifted_base_positions() { + let cell_width = px(8.); + // Font metrics don't perfectly match cell grid — glyphs drift >1px from cell boundary + let mut layout = make_layout(vec![ + glyph_at(0.5, 0), // within 1px tolerance, kept as-is + glyph_at(10.2, 1), // >1px off from 8.0, corrected + glyph_at(19.8, 2), // >1px off from 16.0, corrected + ]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0.5, 8., 16.]); + } + + #[test] + fn test_force_width_combining_mark_after_within_tolerance_base() { + let cell_width = px(8.); + // Base glyph is within 1px of grid so it keeps its shaped position. + // The combining mark must align to the base's actual position, not the grid slot. + let mut layout = make_layout(vec![glyph_at(0.5, 0), glyph_at(0.5, 3)]); + + apply_force_width_to_layout(&mut layout, cell_width); + + let positions = glyph_x_positions(&layout); + assert_eq!(positions, vec![0.5, 0.5]); + } +} diff --git a/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs similarity index 69% rename from src/text_system/line_wrapper.rs rename to crates/gpui/src/text_system/line_wrapper.rs index ffc433c671..3335e7b31d 100644 --- a/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -201,9 +201,11 @@ impl LineWrapper { "{truncation_affix}{}", &line[line.ceil_char_boundary(truncate_ix + 1)..] )), - TruncateFrom::End => { - SharedString::from(format!("{}{truncation_affix}", &line[..truncate_ix])) - } + TruncateFrom::End => SharedString::from(format!( + "{}{truncation_affix}", + line[..truncate_ix] + .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()) + )), }; let mut runs = runs.to_vec(); update_runs_after_truncation(&result, truncation_affix, &mut runs, truncate_from); @@ -213,6 +215,150 @@ impl LineWrapper { } } + /// Truncate text to fit within a given number of wrapped lines. + /// + /// Unlike `truncate_line` which treats the text as a flat width budget + /// (`width * max_lines`), this method accounts for word-boundary wrapping: + /// it walks through characters once, tracking wrap boundaries and the + /// truncation point simultaneously. When text overflows on the last + /// allowed line, it truncates there and appends the affix. + /// + /// For `max_lines == 1`, this delegates to `truncate_line`. + pub fn truncate_wrapped_line<'a>( + &mut self, + text: SharedString, + wrap_width: Pixels, + max_lines: usize, + truncation_affix: &str, + runs: &'a [TextRun], + truncate_from: TruncateFrom, + ) -> (SharedString, Cow<'a, [TextRun]>) { + if max_lines <= 1 || truncate_from == TruncateFrom::Start { + return self.truncate_line( + text, + wrap_width * max_lines, + truncation_affix, + runs, + truncate_from, + ); + } + + let affix_width: Pixels = truncation_affix + .chars() + .map(|c| self.width_for_char(c)) + .sum(); + + let mut width = px(0.); + let mut line = 0usize; + let mut first_non_whitespace_ix = None; + let mut last_candidate_ix = 0usize; + let mut last_candidate_width = px(0.); + let mut last_wrap_ix = 0usize; + let mut prev_c = '\0'; + let mut indent: Option = None; + let mut truncate_ix = 0usize; + + for (ix, c) in text.char_indices() { + if c == '\n' { + if line >= max_lines - 1 && !text[ix + 1..].trim().is_empty() { + // Newline on the last allowed line with real content + // below. Truncate here. + let truncated = text[..truncate_ix] + .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()); + let result = SharedString::from(format!("{truncated}{truncation_affix}")); + let mut runs = runs.to_vec(); + update_runs_after_truncation( + &result, + truncation_affix, + &mut runs, + TruncateFrom::End, + ); + return (result, Cow::Owned(runs)); + } + + // Newline before the last line: it consumes a line. + line += 1; + width = px(0.); + first_non_whitespace_ix = None; + last_candidate_ix = 0; + last_candidate_width = px(0.); + last_wrap_ix = ix + 1; + prev_c = '\0'; + indent = None; + truncate_ix = ix + 1; + continue; + } + + let char_width = self.width_for_char(c); + + if Self::is_word_char(c) { + if prev_c == ' ' && first_non_whitespace_ix.is_some() { + last_candidate_ix = ix; + last_candidate_width = width; + } + } else if c != ' ' && first_non_whitespace_ix.is_some() { + last_candidate_ix = ix; + last_candidate_width = width; + } + + if c != ' ' && first_non_whitespace_ix.is_none() { + first_non_whitespace_ix = Some(ix); + } + + width += char_width; + + if line < max_lines - 1 { + // Before the last line: replicate wrap_line's boundary logic. + if width > wrap_width && ix > last_wrap_ix { + if let (None, Some(first_nw)) = (indent, first_non_whitespace_ix) { + indent = Some(Self::MAX_INDENT.min((first_nw - last_wrap_ix) as u32)); + } + + if last_candidate_ix > last_wrap_ix { + last_wrap_ix = last_candidate_ix; + width -= last_candidate_width; + last_candidate_ix = 0; + } else { + last_wrap_ix = ix; + width = char_width; + } + + if let Some(ind) = indent { + width += self.width_for_char(' ') * ind as f32; + } + + line += 1; + truncate_ix = last_wrap_ix; + } + } else { + // On the last line: track the furthest point where the affix + // still fits, and stop as soon as the line overflows. + if width + affix_width <= wrap_width { + truncate_ix = ix + c.len_utf8(); + } + + if width > wrap_width { + let truncated = text[..truncate_ix] + .trim_end_matches(|c: char| c.is_whitespace() || c.is_ascii_punctuation()); + let result = SharedString::from(format!("{truncated}{truncation_affix}")); + let mut runs = runs.to_vec(); + update_runs_after_truncation( + &result, + truncation_affix, + &mut runs, + TruncateFrom::End, + ); + return (result, Cow::Owned(runs)); + } + } + + prev_c = c; + } + + // Text fits within max_lines without truncation. + (text, Cow::Borrowed(runs)) + } + /// Any character in this list should be treated as a word character, /// meaning it can be part of a word that should not be wrapped. pub(crate) fn is_word_char(c: char) -> bool { @@ -241,8 +387,9 @@ impl LineWrapper { // Some other known special characters that should be treated as word characters, // e.g. `a-b`, `var_name`, `I'm`/`won’t`, '@mention`, `#hashtag`, `100%`, `3.1415`, - // `2^3`, `a~b`, `a=1`, `Self::new`, etc. - matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':') || + // `2^3`, `a~b`, `a=1`, `Self::new`, etc. Trailing punctuation like `,`, `.`, `:`, `;` + // is included so it stays attached to the preceding word when wrapping. + matches!(c, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') || // `⋯` character is special used in Zed, to keep this at the end of the line. matches!(c, '⋯') } @@ -837,6 +984,7 @@ mod tests { assert_word("$variable"); assert_word("a=1"); assert_word("Self::is_word_char"); + assert_word("on;"); assert_word("more⋯"); assert_word("won’t"); assert_word("‘twas"); @@ -934,4 +1082,251 @@ mod tests { ); }); } + + #[test] + fn test_multiline_truncation_fits_within_wrapped_lines() { + let mut wrapper = build_wrapper(); + + // With .ZedMono at 16px, each char is 9.6px wide. + // wrap_width = 72px fits ~7 chars per line. + // + // "aa bbbbbb cccccc dddddd eeee ffff" with wrap_width=72px wraps as: + // Line 1: "aa " (28.8px, wraps because "bbbbbb" won't fit) + // Line 2: "bbbbbb " (67.2px) + // Line 3: "cccccc " (67.2px) + // ... + // + // truncate_wrapped_line should wrap first to find line 2 starts at + // "bbbbbb...", then truncate only that line to fit with ellipsis. + let text: &str = "aa bbbbbb cccccc dddddd eeee ffff"; + let wrap_width = px(72.); + let max_lines: usize = 2; + + let runs = generate_test_runs(&[text.len()]); + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "\u{2026}", + &runs, + TruncateFrom::End, + ); + + // The truncated text, when wrapped, must fit within max_lines lines. + let wrap_count = wrapper + .wrap_line(&[LineFragment::text(&truncated)], wrap_width) + .count(); + + assert!( + wrap_count < max_lines, + "Truncated text '{}' wraps into {} visual lines, expected at most {}", + truncated, + wrap_count + 1, + max_lines + ); + + // The truncated text should end with the ellipsis. + assert!( + truncated.ends_with('\u{2026}'), + "Truncated text '{}' should end with ellipsis", + truncated + ); + } + + #[test] + fn test_multiline_truncation_no_truncation_needed() { + let mut wrapper = build_wrapper(); + + // Text that fits in 2 lines shouldn't be truncated. + // Line 1: "aa bbb " (67.2px), Line 2: "cccccc" (57.6px) + let text: &str = "aa bbb cccccc"; + let wrap_width = px(72.); + let max_lines: usize = 2; + + let runs = generate_test_runs(&[text.len()]); + let (result, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "\u{2026}", + &runs, + TruncateFrom::End, + ); + + assert_eq!( + result.as_ref(), + text, + "Text that fits should not be modified" + ); + } + + #[test] + fn test_multiline_truncation_three_lines() { + let mut wrapper = build_wrapper(); + + let text: &str = "aa bbb cccc ddddd eeee ffff gggg hhhh iiii jjjj"; + let wrap_width = px(72.); + let max_lines: usize = 3; + + let runs = generate_test_runs(&[text.len()]); + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "\u{2026}", + &runs, + TruncateFrom::End, + ); + + let wrap_count = wrapper + .wrap_line(&[LineFragment::text(&truncated)], wrap_width) + .count(); + + assert!( + wrap_count < max_lines, + "Truncated text '{}' wraps into {} visual lines, expected at most {}", + truncated, + wrap_count + 1, + max_lines + ); + + assert!( + truncated.ends_with('\u{2026}'), + "Truncated text '{}' should end with ellipsis", + truncated + ); + } + + #[test] + fn test_multiline_truncation_with_newlines() { + let mut wrapper = build_wrapper(); + + // "hello\nworld foo bar baz" with line_clamp(2): + // shape_text splits on \n, giving physical lines "hello" and + // "world foo bar baz". The newline consumes line 1, so the + // second physical line should be truncated on line 2. + let text: &str = "hello\nworld foo bar baz"; + let wrap_width = px(72.); + let max_lines: usize = 2; + + let runs = generate_test_runs(&[text.len()]); + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "\u{2026}", + &runs, + TruncateFrom::End, + ); + + // The newline should be preserved. + let parts: Vec<&str> = truncated.splitn(2, '\n').collect(); + assert_eq!( + parts.len(), + 2, + "Newline should be preserved: '{}'", + truncated + ); + assert_eq!(parts[0], "hello"); + + // The second line should fit within wrap_width and end with ellipsis. + let second_line_width: Pixels = parts[1].chars().map(|c| wrapper.width_for_char(c)).sum(); + assert!( + second_line_width <= wrap_width, + "Second line '{}' ({}px) exceeds wrap_width ({}px)", + parts[1], + second_line_width, + wrap_width + ); + assert!( + truncated.ends_with('\u{2026}'), + "Should end with ellipsis: '{}'", + truncated + ); + } + + #[test] + fn test_multiline_truncation_newline_on_last_line() { + let mut wrapper = build_wrapper(); + + // "hello\nworld\nmore" with line_clamp(2): + // Line 1: "hello", Line 2: "world" — but there's a third line, + // so line 2 should be truncated with ellipsis. + let text: &str = "hello\nworld\nmore"; + let wrap_width = px(72.); + let max_lines: usize = 2; + + let runs = generate_test_runs(&[text.len()]); + let (truncated, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "\u{2026}", + &runs, + TruncateFrom::End, + ); + + let parts: Vec<&str> = truncated.splitn(2, '\n').collect(); + assert_eq!(parts[0], "hello"); + assert!( + truncated.ends_with('\u{2026}'), + "Should end with ellipsis since there's more content: '{}'", + truncated + ); + } + + #[test] + fn test_multiline_truncation_trailing_newline() { + let mut wrapper = build_wrapper(); + + // "hello\nworld\n" with line_clamp(2): + // The trailing newline has no content after it, so no ellipsis. + let text: &str = "hello\nworld\n"; + let wrap_width = px(72.); + let max_lines: usize = 2; + + let runs = generate_test_runs(&[text.len()]); + let (result, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "\u{2026}", + &runs, + TruncateFrom::End, + ); + + assert!( + !result.ends_with('\u{2026}'), + "Trailing newline with no content should not add ellipsis: '{}'", + result + ); + } + + #[test] + fn test_multiline_truncation_newline_fits_exactly() { + let mut wrapper = build_wrapper(); + + // "hello\nworld" with line_clamp(2): + // Exactly 2 lines, no truncation needed. + let text: &str = "hello\nworld"; + let wrap_width = px(72.); + let max_lines: usize = 2; + + let runs = generate_test_runs(&[text.len()]); + let (result, _) = wrapper.truncate_wrapped_line( + text.into(), + wrap_width, + max_lines, + "\u{2026}", + &runs, + TruncateFrom::End, + ); + + assert_eq!( + result.as_ref(), + text, + "Text that fits exactly should not be modified: '{}'", + result + ); + } } diff --git a/src/local_util.rs b/crates/gpui/src/util.rs similarity index 56% rename from src/local_util.rs rename to crates/gpui/src/util.rs index ac7227cdd9..13ee752dad 100644 --- a/src/local_util.rs +++ b/crates/gpui/src/util.rs @@ -110,39 +110,6 @@ impl Future for WithTimeout { } } -/// Utilities for creating and managing shell commands. -pub mod command { - use smol::process::Command as SmolCommand; - use std::ffi::OsStr; - use std::process::Command as StdCommand; - - /// Creates a new `smol::process::Command` with platform-specific defaults. - pub fn new_command(program: impl AsRef) -> SmolCommand { - let mut cmd = SmolCommand::new(program); - #[cfg(target_os = "windows")] - { - use smol::process::windows::CommandExt; - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - } - cmd - } - - /// Creates a new `std::process::Command` with platform-specific defaults. - #[cfg(not(target_os = "windows"))] - pub fn new_std_command(program: impl AsRef) -> StdCommand { - StdCommand::new(program) - } - - /// Creates a new `std::process::Command` with platform-specific defaults. - #[cfg(target_os = "windows")] - pub fn new_std_command(program: impl AsRef) -> StdCommand { - use std::os::windows::process::CommandExt; - let mut cmd = StdCommand::new(program); - cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW - cmd - } -} - /// Increment the given atomic counter if it is not zero. /// Return the new value of the counter. pub(crate) fn atomic_incr_if_not_zero(counter: &AtomicUsize) -> usize { @@ -158,12 +125,106 @@ pub(crate) fn atomic_incr_if_not_zero(counter: &AtomicUsize) -> usize { } } +/// Rounds to the nearest integer with 0.5 ties toward zero. +#[inline] +pub(crate) fn round_half_toward_zero(value: f32) -> f32 { + (value.abs() - 0.5).ceil().copysign(value) +} + +#[inline] +pub(crate) fn round_half_toward_zero_f64(value: f64) -> f64 { + (value.abs() - 0.5).ceil().copysign(value) +} + +#[inline] +pub(crate) fn round_to_device_pixel(logical: f32, scale_factor: f32) -> f32 { + round_half_toward_zero(logical * scale_factor) +} + +#[inline] +pub(crate) fn round_stroke_to_device_pixel(logical: f32, scale_factor: f32) -> f32 { + if logical == 0.0 { + 0.0 + } else { + round_to_device_pixel(logical.max(0.0), scale_factor).max(1.0) + } +} + +#[inline] +pub(crate) fn floor_to_device_pixel(logical: f32, scale_factor: f32) -> f32 { + (logical * scale_factor).floor() +} + +#[inline] +pub(crate) fn ceil_to_device_pixel(logical: f32, scale_factor: f32) -> f32 { + (logical * scale_factor).ceil() +} + #[cfg(test)] mod tests { use crate::TestAppContext; use super::*; + #[test] + fn test_round_half_toward_zero() { + // Midpoint ties go toward zero + assert_eq!(round_half_toward_zero(0.5), 0.0); + assert_eq!(round_half_toward_zero(1.5), 1.0); + assert_eq!(round_half_toward_zero(2.5), 2.0); + assert_eq!(round_half_toward_zero(-0.5), 0.0); + assert_eq!(round_half_toward_zero(-1.5), -1.0); + assert_eq!(round_half_toward_zero(-2.5), -2.0); + + // Non-midpoint values round to nearest + assert_eq!(round_half_toward_zero(1.5001), 2.0); + assert_eq!(round_half_toward_zero(1.4999), 1.0); + assert_eq!(round_half_toward_zero(-1.5001), -2.0); + assert_eq!(round_half_toward_zero(-1.4999), -1.0); + + // Integers are unchanged + assert_eq!(round_half_toward_zero(0.0), 0.0); + assert_eq!(round_half_toward_zero(3.0), 3.0); + assert_eq!(round_half_toward_zero(-3.0), -3.0); + } + + #[test] + fn test_device_pixel_helpers() { + // Snap uses half-toward-zero: 1.0 * 1.5 = 1.5 ties toward 1.0. + assert_eq!(round_to_device_pixel(1.0, 1.5), 1.0); + // Below the tie rounds down, above rounds up. + assert_eq!(round_to_device_pixel(0.3, 2.0), 1.0); + assert_eq!(round_to_device_pixel(1.4, 1.0), 1.0); + assert_eq!(round_to_device_pixel(1.6, 1.0), 2.0); + + // Stroke uses snap, but clamps non-zero input up to at least 1dp. + assert_eq!(round_stroke_to_device_pixel(0.0, 1.0), 0.0); + assert_eq!(round_stroke_to_device_pixel(0.4, 1.0), 1.0); + assert_eq!(round_stroke_to_device_pixel(0.5, 1.0), 1.0); + assert_eq!(round_stroke_to_device_pixel(1.0, 1.5), 1.0); + assert_eq!(round_stroke_to_device_pixel(1.6, 1.0), 2.0); + + // Cover's near edge floors, far edge ceils. Together they form a strict superset. + assert_eq!(floor_to_device_pixel(0.3, 2.0), 0.0); + assert_eq!(ceil_to_device_pixel(0.3, 2.0), 1.0); + assert_eq!(floor_to_device_pixel(2.1, 1.0), 2.0); + assert_eq!(ceil_to_device_pixel(2.1, 1.0), 3.0); + + // Integer device-pixel inputs are stable under all three. + assert_eq!(round_to_device_pixel(2.0, 2.0), 4.0); + assert_eq!(floor_to_device_pixel(2.0, 2.0), 4.0); + assert_eq!(ceil_to_device_pixel(2.0, 2.0), 4.0); + } + + #[test] + fn test_round_half_toward_zero_f64() { + assert_eq!(round_half_toward_zero_f64(0.5), 0.0); + assert_eq!(round_half_toward_zero_f64(-0.5), 0.0); + assert_eq!(round_half_toward_zero_f64(1.5), 1.0); + assert_eq!(round_half_toward_zero_f64(-1.5), -1.0); + assert_eq!(round_half_toward_zero_f64(2.5001), 3.0); + } + #[gpui::test] async fn test_with_timeout(cx: &mut TestAppContext) { Task::ready(()) diff --git a/src/view.rs b/crates/gpui/src/view.rs similarity index 100% rename from src/view.rs rename to crates/gpui/src/view.rs diff --git a/src/window.rs b/crates/gpui/src/window.rs similarity index 87% rename from src/window.rs rename to crates/gpui/src/window.rs index c720b1b856..f529284029 100644 --- a/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1,24 +1,24 @@ #[cfg(any(feature = "inspector", debug_assertions))] use crate::Inspector; -use crate::scheduler::Instant; use crate::{ Action, AnyDrag, AnyElement, AnyImageCache, AnyTooltip, AnyView, App, AppContext, Arena, Asset, AsyncWindowContext, AvailableSpace, Background, BorderStyle, Bounds, BoxShadow, Capslock, - Context, Corners, CursorStyle, Decorations, DevicePixels, DispatchActionListener, - DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, EntityId, EventEmitter, - FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, Hsla, InputHandler, IsZero, - KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, KeystrokeEvent, LayoutId, - LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, MouseButton, MouseEvent, - MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, - PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, Priority, PromptButton, - PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, RenderImageParams, RenderSvgParams, - Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, - ScaledPixels, Scene, Shadow, SharedString, Size, StrikethroughStyle, Style, SubpixelSprite, - SubscriberSet, Subscription, SystemWindowTab, SystemWindowTabController, TabStopMap, - TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, - TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, - WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, - point, prelude::*, px, rems, size, transparent_black, + Context, Corners, CursorHideMode, CursorStyle, Decorations, DevicePixels, + DispatchActionListener, DispatchNodeId, DispatchTree, DisplayId, Edges, Effect, Entity, + EntityId, EventEmitter, FileDropEvent, FontId, Global, GlobalElementId, GlyphId, GpuSpecs, + Hsla, InputHandler, IsZero, KeyBinding, KeyContext, KeyDownEvent, KeyEvent, Keystroke, + KeystrokeEvent, LayoutId, LineLayoutIndex, Modifiers, ModifiersChangedEvent, MonochromeSprite, + MouseButton, MouseEvent, MouseMoveEvent, MouseUpEvent, Path, Pixels, PlatformAtlas, + PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PolychromeSprite, + Priority, PromptButton, PromptLevel, Quad, Render, RenderGlyphParams, RenderImage, + RenderImageParams, RenderSvgParams, Replay, ResizeEdge, SMOOTH_SVG_SCALE_FACTOR, + SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ScaledPixels, Scene, Shadow, SharedString, Size, + StrikethroughStyle, Style, SubpixelSprite, SubscriberSet, Subscription, SystemWindowTab, + SystemWindowTabController, TabStopMap, TaffyLayoutEngine, Task, TextRenderingMode, TextStyle, + TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; @@ -27,11 +27,16 @@ use core_video::pixel_buffer::CVPixelBuffer; use derive_more::{Deref, DerefMut}; use futures::FutureExt; use futures::channel::oneshot; +use gpui_util::post_inc; +use gpui_util::{ResultExt, measure}; +#[cfg(feature = "input-latency-histogram")] +use hdrhistogram::Histogram; use itertools::FoldWhile::{Continue, Done}; use itertools::Itertools; use parking_lot::RwLock; use raw_window_handle::{HandleError, HasDisplayHandle, HasWindowHandle}; use refineable::Refineable; +use scheduler::Instant; use slotmap::SlotMap; use smallvec::SmallVec; use std::{ @@ -47,21 +52,26 @@ use std::{ rc::Rc, sync::{ Arc, Weak, - atomic::{AtomicUsize, Ordering::SeqCst}, + atomic::{AtomicBool, AtomicUsize, Ordering::SeqCst}, }, time::Duration, }; -use util::post_inc; -use util::{ResultExt, measure}; use uuid::Uuid; +pub(crate) mod a11y; mod prompts; -use crate::local_util::atomic_incr_if_not_zero; +use self::a11y::A11y; +#[cfg(not(target_family = "wasm"))] +use self::a11y::ROOT_NODE_ID; +use crate::util::{ + atomic_incr_if_not_zero, ceil_to_device_pixel, floor_to_device_pixel, round_half_toward_zero, + round_half_toward_zero_f64, round_stroke_to_device_pixel, round_to_device_pixel, +}; pub use prompts::*; /// Default window size used when no explicit size is provided. -pub const DEFAULT_WINDOW_SIZE: Size = size(px(1536.), px(864.)); +pub const DEFAULT_WINDOW_SIZE: Size = size(px(1536.), px(1095.)); /// A 6:5 aspect ratio minimum window size to be used for functional, /// additional-to-main-Zed windows, like the settings and rules library windows. @@ -105,6 +115,7 @@ struct WindowInvalidatorInner { pub dirty: bool, pub draw_phase: DrawPhase, pub dirty_views: FxHashSet, + pub update_count: usize, } #[derive(Clone)] @@ -119,12 +130,14 @@ impl WindowInvalidator { dirty: true, draw_phase: DrawPhase::None, dirty_views: FxHashSet::default(), + update_count: 0, })), } } pub fn invalidate_view(&self, entity: EntityId, cx: &mut App) -> bool { let mut inner = self.inner.borrow_mut(); + inner.update_count += 1; inner.dirty_views.insert(entity); if inner.draw_phase == DrawPhase::None { inner.dirty = true; @@ -140,13 +153,21 @@ 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 { + inner.update_count += 1; + } } pub fn set_phase(&self, phase: DrawPhase) { self.inner.borrow_mut().draw_phase = phase } + pub fn update_count(&self) -> usize { + self.inner.borrow().update_count + } + pub fn take_views(&self) -> FxHashSet { mem::take(&mut self.inner.borrow_mut().dirty_views) } @@ -559,6 +580,17 @@ pub enum WindowControlArea { #[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)] pub struct HitboxId(u64); +#[cfg(feature = "test-support")] +impl HitboxId { + /// A placeholder HitboxId exclusively for integration testing API's that + /// need a hitbox but where the value of the hitbox does not matter. The + /// alternative is to make the Hitbox optional but that complicates the + /// implementation. + pub const fn placeholder() -> Self { + Self(0) + } +} + impl HitboxId { /// Checks if the hitbox with this ID is currently hovered. Returns `false` during keyboard /// input modality so that keyboard navigation suppresses hover highlights. Except when handling @@ -574,6 +606,22 @@ impl HitboxId { if window.last_input_was_keyboard() { return false; } + self.hit_test(window) + } + + /// Checks if the hitbox with this ID is currently hovered, regardless of the last + /// input modality used. + /// + /// See [`HitboxId::is_hovered`] for more details. + pub(crate) fn is_hovered_ignoring_last_input(self, window: &Window) -> bool { + // If this hitbox has captured the pointer, it's always considered hovered + if window.captured_hitbox == Some(self) { + return true; + } + self.hit_test(window) + } + + fn hit_test(self, window: &Window) -> bool { let hit_test = &window.mouse_hit_test; for id in hit_test.ids.iter().take(hit_test.hover_hitbox_count) { if self == *id { @@ -850,9 +898,11 @@ impl Frame { .rev() .fold_while(None, |style, request| match request.hitbox_id { None => Done(Some(request.style)), - Some(hitbox_id) => Continue( - style.or_else(|| hitbox_id.is_hovered(window).then_some(request.style)), - ), + Some(hitbox_id) => Continue(style.or_else(|| { + hitbox_id + .is_hovered_ignoring_last_input(window) + .then_some(request.style) + })), }) .into_inner() } @@ -951,12 +1001,15 @@ pub struct Window { pub(crate) bounds_observers: SubscriberSet<(), AnyObserver>, appearance: WindowAppearance, pub(crate) appearance_observers: SubscriberSet<(), AnyObserver>, + pub(crate) button_layout_observers: SubscriberSet<(), AnyObserver>, active: Rc>, hovered: Rc>, pub(crate) needs_present: Rc>, /// Tracks recent input event timestamps to determine if input is arriving at a high rate. /// Used to selectively enable VRR optimization only when input rate exceeds 60fps. pub(crate) input_rate_tracker: Rc>, + #[cfg(feature = "input-latency-histogram")] + input_latency_tracker: InputLatencyTracker, last_input_modality: InputModality, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, @@ -972,6 +1025,7 @@ pub struct Window { captured_hitbox: Option, #[cfg(any(feature = "inspector", debug_assertions))] inspector: Option>, + pub(crate) a11y: A11y, } #[derive(Clone, Debug, Default)] @@ -1025,6 +1079,88 @@ impl InputRateTracker { } } +/// A point-in-time snapshot of the input-latency histograms for a window, +/// suitable for external formatting. +#[cfg(feature = "input-latency-histogram")] +pub struct InputLatencySnapshot { + /// Histogram of input-to-frame latency samples, in nanoseconds. + pub latency_histogram: Histogram, + /// Histogram of input events coalesced per rendered frame. + pub events_per_frame_histogram: Histogram, + /// Count of input events that arrived mid-draw and were excluded from + /// latency recording. + pub mid_draw_events_dropped: u64, +} + +/// Records the time between when the first input event in a frame is dispatched +/// and when the resulting frame is presented, capturing worst-case latency when +/// multiple events are coalesced into a single frame. +#[cfg(feature = "input-latency-histogram")] +struct InputLatencyTracker { + /// Timestamp of the first unrendered input event in the current frame; + /// cleared when a frame is presented. + first_input_at: Option, + /// Count of input events received since the last frame was presented. + pending_input_count: u64, + /// Histogram of input-to-frame latency samples, in nanoseconds. + latency_histogram: Histogram, + /// Histogram of input events coalesced per rendered frame. + events_per_frame_histogram: Histogram, + /// Count of input events that arrived mid-draw and were excluded from + /// latency recording because their effects won't appear until the next frame. + mid_draw_events_dropped: u64, +} + +#[cfg(feature = "input-latency-histogram")] +impl InputLatencyTracker { + fn new() -> Result { + Ok(Self { + first_input_at: None, + pending_input_count: 0, + latency_histogram: Histogram::new(3) + .map_err(|e| anyhow!("Failed to create input latency histogram: {e}"))?, + events_per_frame_histogram: Histogram::new(3) + .map_err(|e| anyhow!("Failed to create events per frame histogram: {e}"))?, + mid_draw_events_dropped: 0, + }) + } + + /// Record that an input event was dispatched at the given time. + /// Only the first event's timestamp per frame is retained (worst-case latency). + fn record_input(&mut self, dispatch_time: Instant) { + self.first_input_at.get_or_insert(dispatch_time); + self.pending_input_count += 1; + } + + /// Record that an input event arrived during a draw phase and was excluded + /// from latency tracking. + fn record_mid_draw_input(&mut self) { + self.mid_draw_events_dropped += 1; + } + + /// Record that a frame was presented, flushing pending latency and coalescing samples. + fn record_frame_presented(&mut self) { + if let Some(first_input_at) = self.first_input_at.take() { + let latency_nanos = first_input_at.elapsed().as_nanos() as u64; + self.latency_histogram.record(latency_nanos).ok(); + } + if self.pending_input_count > 0 { + self.events_per_frame_histogram + .record(self.pending_input_count) + .ok(); + self.pending_input_count = 0; + } + } + + fn snapshot(&self) -> InputLatencySnapshot { + InputLatencySnapshot { + latency_histogram: self.latency_histogram.clone(), + events_per_frame_histogram: self.events_per_frame_histogram.clone(), + mid_draw_events_dropped: self.mid_draw_events_dropped, + } + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum DrawPhase { None, @@ -1132,6 +1268,11 @@ impl Window { app_id, window_min_size, window_decorations, + #[cfg_attr( + not(any(target_os = "linux", target_os = "freebsd")), + allow(unused_variables) + )] + icon, #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] tabbing_identifier, } = options; @@ -1150,6 +1291,7 @@ impl Window { show, display_id, window_min_size, + icon, #[cfg(target_os = "macos")] tabbing_identifier, }, @@ -1188,6 +1330,86 @@ impl Window { WindowBounds::Windowed(_) => {} } + let accessibility_force_disabled = cx.accessibility_force_disabled; + let a11y_active_flag = Arc::new(AtomicBool::new(false)); + + #[cfg(not(target_family = "wasm"))] + if !accessibility_force_disabled { + let initial_tree = accesskit::TreeUpdate { + nodes: vec![(ROOT_NODE_ID, accesskit::Node::new(accesskit::Role::Window))], + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: ROOT_NODE_ID, + }; + let (activation_sender, activation_receiver) = async_channel::unbounded::<()>(); + let (deactivation_sender, deactivation_receiver) = async_channel::unbounded::<()>(); + let (action_sender, action_receiver) = + async_channel::unbounded::(); + + platform_window.a11y_init(crate::A11yCallbacks { + activation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility activated"); + active_flag.store(true, SeqCst); + activation_sender.send_blocking(()).log_err(); + Some(initial_tree.clone()) + }) + }, + action: Box::new(move |request| { + action_sender.send_blocking(request).log_err(); + }), + deactivation: { + let active_flag = a11y_active_flag.clone(); + Box::new(move || { + log::info!("Accessibility deactivated"); + active_flag.store(false, SeqCst); + deactivation_sender.send_blocking(()).log_err(); + }) + }, + }); + + // A11y can be activated at any time, and so we cannot compute a + // correct `TreeUpdate` on-demand. When this happens, we return a + // default empty `TreeUpdate`. + // + // So we force a new frame, which will then send a correct `TreeUpdate`. + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while activation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while deactivation_receiver.recv().await.is_ok() { + handle + .update(&mut async_cx, |_, window, _| window.refresh()) + .log_err(); + } + }) + .detach(); + + let mut async_cx = cx.to_async(); + cx.foreground_executor() + .spawn(async move { + while let Ok(request) = action_receiver.recv().await { + handle + .update(&mut async_cx, |_, window, cx| { + window.handle_a11y_action(request, cx); + }) + .log_err(); + } + }) + .detach(); + } + platform_window.on_close(Box::new({ let window_id = handle.window_id(); let mut cx = cx.to_async(); @@ -1210,18 +1432,38 @@ impl Window { .update(&mut cx, |_, _, cx| cx.thermal_state()) .log_err(); - if thermal_state == Some(ThermalState::Serious) - || thermal_state == Some(ThermalState::Critical) + // Throttle frame rate based on conditions: + // - Thermal pressure (Serious/Critical): cap to ~60fps + // - Inactive window (not focused): cap to ~30fps to save energy + let min_frame_interval = if !request_frame_options.force_render + && !request_frame_options.require_presentation + && next_frame_callbacks.borrow().is_empty() { - let now = Instant::now(); - let last_frame_time = last_frame_time.replace(Some(now)); + None + } else if !active.get() { + Some(Duration::from_micros(33333)) + } else if let Some(ThermalState::Critical | ThermalState::Serious) = thermal_state { + Some(Duration::from_micros(16667)) + } else { + None + }; - if let Some(last_frame) = last_frame_time - && now.duration_since(last_frame) < Duration::from_micros(16667) + let now = Instant::now(); + if let Some(min_interval) = min_frame_interval { + if let Some(last_frame) = last_frame_time.get() + && now.duration_since(last_frame) < min_interval { + // Must still complete the frame on platforms that require it. + // On Wayland, `surface.frame()` was already called to request the + // next frame callback, so we must call `surface.commit()` (via + // `complete_frame`) or the compositor won't send another callback. + handle + .update(&mut cx, |_, window, _| window.complete_frame()) + .log_err(); return; } } + last_frame_time.set(Some(now)); let next_frame_callbacks = next_frame_callbacks.take(); if !next_frame_callbacks.is_empty() { @@ -1245,6 +1487,11 @@ impl Window { measure("frame duration", || { handle .update(&mut cx, |_, window, cx| { + if request_frame_options.force_render { + // Bypass cached view reuse so we don't replay stale + // atlas tile references after a GPU device recovery. + window.refresh(); + } let arena_clear_needed = window.draw(cx); window.present(); arena_clear_needed.clear(); @@ -1288,6 +1535,14 @@ impl Window { .log_err(); } })); + platform_window.on_button_layout_changed(Box::new({ + let mut cx = cx.to_async(); + move || { + handle + .update(&mut cx, |_, window, cx| window.button_layout_changed(cx)) + .log_err(); + } + })); platform_window.on_active_status_change(Box::new({ let mut cx = cx.to_async(); move |active| { @@ -1442,10 +1697,13 @@ impl Window { bounds_observers: SubscriberSet::new(), appearance, appearance_observers: SubscriberSet::new(), + button_layout_observers: SubscriberSet::new(), active, hovered, needs_present, input_rate_tracker, + #[cfg(feature = "input-latency-histogram")] + input_latency_tracker: InputLatencyTracker::new()?, last_input_modality: InputModality::Mouse, refreshing: false, activation_observers: SubscriberSet::new(), @@ -1460,6 +1718,7 @@ impl Window { captured_hitbox: None, #[cfg(any(feature = "inspector", debug_assertions))] inspector: None, + a11y: A11y::new(a11y_active_flag, accessibility_force_disabled), }) } @@ -1481,7 +1740,7 @@ pub struct DispatchEventResult { /// Indicates which region of the window is visible. Content falling outside of this mask will not be /// rendered. Currently, only rectangular content masks are supported, but we give the mask its own type /// to leave room to support more complex shapes in the future. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)] #[repr(C)] pub struct ContentMask { /// The bounds @@ -1534,6 +1793,22 @@ impl Window { subscription } + /// Registers a callback to be invoked when the window button layout changes. + pub fn observe_button_layout_changed( + &self, + mut callback: impl FnMut(&mut Window, &mut App) + 'static, + ) -> Subscription { + let (subscription, activate) = self.button_layout_observers.insert( + (), + Box::new(move |window, cx| { + callback(window, cx); + true + }), + ); + activate(); + subscription + } + /// Replaces the root entity of the window with a new one. pub fn replace_root( &mut self, @@ -1956,6 +2231,12 @@ impl Window { .retain(&(), |callback| callback(self, cx)); } + pub(crate) fn button_layout_changed(&mut self, cx: &mut App) { + self.button_layout_observers + .clone() + .retain(&(), |callback| callback(self, cx)); + } + /// Returns the appearance of the current window. pub fn appearance(&self) -> WindowAppearance { self.appearance @@ -2046,6 +2327,12 @@ impl Window { self.platform_window.set_edited(edited); } + /// Set the path of the file this window represents. + /// On macOS, this sets the window's accessibility document property (AXDocument). + pub fn set_document_path(&self, path: Option<&std::path::Path>) { + self.platform_window.set_document_path(path); + } + /// Determine the display on which the window is visible. pub fn display(&self, cx: &App) -> Option> { cx.platform @@ -2135,6 +2422,76 @@ impl Window { self.text_style().line_height_in_pixels(self.rem_size()) } + /// Rounds a logical value to the nearest device pixel. + #[inline] + pub fn pixel_snap(&self, value: Pixels) -> Pixels { + px(round_to_device_pixel(value.0, self.scale_factor()) / self.scale_factor()) + } + + /// f64 variant of [`Self::pixel_snap`]. + #[inline] + pub fn pixel_snap_f64(&self, value: f64) -> f64 { + let scale_factor = f64::from(self.scale_factor()); + round_half_toward_zero_f64(value * scale_factor) / scale_factor + } + + /// Snaps a bounds' origin and size to the nearest device pixel. + #[inline] + pub fn pixel_snap_bounds(&self, bounds: Bounds) -> Bounds { + bounds.map(|c| self.pixel_snap(c)) + } + + /// Snaps a point's coordinates to the nearest device pixel. + #[inline] + pub fn pixel_snap_point(&self, position: Point) -> Point { + position.map(|c| self.pixel_snap(c)) + } + + #[inline] + fn snap_bounds(&self, bounds: Bounds) -> Bounds { + let scale_factor = self.scale_factor(); + let left = round_to_device_pixel(bounds.left().0, scale_factor); + let top = round_to_device_pixel(bounds.top().0, scale_factor); + let right = round_to_device_pixel(bounds.right().0, scale_factor).max(left); + let bottom = round_to_device_pixel(bounds.bottom().0, scale_factor).max(top); + Bounds::from_corners( + point(ScaledPixels(left), ScaledPixels(top)), + point(ScaledPixels(right), ScaledPixels(bottom)), + ) + } + + /// Rounds half-to-zero but clamps any non-zero input up to 1 dp so thin strokes do not disappear. + #[inline] + fn snap_stroke(&self, value: Pixels) -> ScaledPixels { + ScaledPixels(round_stroke_to_device_pixel(value.0, self.scale_factor())) + } + + #[inline] + fn snap_border_widths(&self, edges: Edges) -> Edges { + edges.map(|e| self.snap_stroke(*e)) + } + + /// Floors the near edge and ceils the far edge, producing a strict superset of the raw region. + #[inline] + fn cover_bounds(&self, bounds: Bounds) -> Bounds { + let scale_factor = self.scale_factor(); + let left = floor_to_device_pixel(bounds.left().0, scale_factor); + let top = floor_to_device_pixel(bounds.top().0, scale_factor); + let right = ceil_to_device_pixel(bounds.right().0, scale_factor).max(left); + let bottom = ceil_to_device_pixel(bounds.bottom().0, scale_factor).max(top); + Bounds::from_corners( + point(ScaledPixels(left), ScaledPixels(top)), + point(ScaledPixels(right), ScaledPixels(bottom)), + ) + } + + #[inline] + fn snapped_content_mask(&self) -> ContentMask { + ContentMask { + bounds: self.cover_bounds(self.content_mask().bounds), + } + } + /// Call to prevent the default action of an event. Currently only used to prevent /// parent elements from becoming focused on mouse down. pub fn prevent_default(&mut self) { @@ -2223,8 +2580,21 @@ impl Window { self.requested_autoscroll = None; // Restore the previously-used input handler. + // Place it back into a None slot (left by a previous .take()) so that + // cached paint_range indices in reuse_paint find the handler at the + // expected position. if let Some(input_handler) = self.platform_window.take_input_handler() { - self.rendered_frame.input_handlers.push(Some(input_handler)); + if let Some(slot) = self + .rendered_frame + .input_handlers + .iter_mut() + .rev() + .find(|h| h.is_none()) + { + *slot = Some(input_handler); + } else { + self.rendered_frame.input_handlers.push(Some(input_handler)); + } } if !cx.mode.skip_drawing() { self.draw_roots(cx); @@ -2233,9 +2603,18 @@ impl Window { self.next_frame.window_active = self.active.get(); // Register requested input handler with the platform window. - if let Some(input_handler) = self.next_frame.input_handlers.pop() { - self.platform_window - .set_input_handler(input_handler.unwrap()); + // Use .take() instead of .pop() to preserve Vec length, so that cached + // paint_range indices remain valid for reuse_paint on the next frame. + // Search backwards to find the last Some entry, since reuse_paint may + // have copied None slots from the previous frame. (Fixes #50456) + if let Some(input_handler) = self + .next_frame + .input_handlers + .iter_mut() + .rev() + .find_map(|h| h.take()) + { + self.platform_window.set_input_handler(input_handler); } self.layout_engine.as_mut().unwrap().clear(); @@ -2309,16 +2688,29 @@ impl Window { } #[profiling::function] - fn present(&self) { + fn present(&mut self) { self.platform_window.draw(&self.rendered_frame.scene); + #[cfg(feature = "input-latency-histogram")] + self.input_latency_tracker.record_frame_presented(); self.needs_present.set(false); profiling::finish_frame!(); } + /// Returns a snapshot of the current input-latency histograms. + #[cfg(feature = "input-latency-histogram")] + pub fn input_latency_snapshot(&self) -> InputLatencySnapshot { + self.input_latency_tracker.snapshot() + } + fn draw_roots(&mut self, cx: &mut App) { self.invalidator.set_phase(DrawPhase::Prepaint); self.tooltip_bounds.take(); + self.a11y.sync_active_flag(); + if self.a11y.is_active() { + self.a11y.begin_frame(); + } + let _inspector_width: Pixels = rems(30.0).to_pixels(self.rem_size()); let root_size = { #[cfg(any(feature = "inspector", debug_assertions))] @@ -2385,6 +2777,26 @@ impl Window { #[cfg(any(feature = "inspector", debug_assertions))] self.paint_inspector_hitbox(cx); + + // a11y may have been activated/deactivated halfway through the frame + let a11y_active_start_of_frame = self.a11y.is_active(); + self.a11y.sync_active_flag(); + let a11y_active_end_of_frame = self.a11y.is_active(); + + let should_send_a11y_update = a11y_active_start_of_frame && a11y_active_end_of_frame; + + if a11y_active_start_of_frame { + // clear the builder state regardless + let tree_update = self.a11y.end_frame(); + + if should_send_a11y_update { + log::debug!( + "Sending a11y tree update: {} nodes", + tree_update.nodes.len() + ); + self.platform_window.a11y_tree_update(tree_update); + } + } } fn prepaint_tooltip(&mut self, cx: &mut App) -> Option { @@ -2537,7 +2949,7 @@ impl Window { .set_active_node(deferred_draw.parent_node); let paint_start = self.paint_index(); - let content_mask = deferred_draw.content_mask.clone(); + let content_mask = deferred_draw.content_mask; if let Some(element) = deferred_draw.element.as_mut() { self.with_rendered_view(deferred_draw.current_view, |window| { window.with_content_mask(content_mask, |window| { @@ -2614,7 +3026,7 @@ impl Window { parent_node: reused_subtree.refresh_node_id(deferred_draw.parent_node), element_id_stack: deferred_draw.element_id_stack.clone(), text_style_stack: deferred_draw.text_style_stack.clone(), - content_mask: deferred_draw.content_mask.clone(), + content_mask: deferred_draw.content_mask, rem_size: deferred_draw.rem_size, priority: deferred_draw.priority, element: None, @@ -2678,9 +3090,6 @@ impl Window { /// Push a text style onto the stack, and call a function with that style active. /// Use [`Window::text_style`] to get the current, combined text style. This method /// should only be called as part of element drawing. - // This function is called in a highly recursive manner in editor - // prepainting, make sure its inlined to reduce the stack burden - #[inline] pub fn with_text_style(&mut self, style: Option, f: F) -> R where F: FnOnce(&mut Self) -> R, @@ -3134,13 +3543,12 @@ impl Window { pub fn paint_layer(&mut self, bounds: Bounds, f: impl FnOnce(&mut Self) -> R) -> R { self.invalidator.debug_assert_paint(); - let scale_factor = self.scale_factor(); let content_mask = self.content_mask(); let clipped_bounds = bounds.intersect(&content_mask.bounds); if !clipped_bounds.is_empty() { self.next_frame .scene - .push_layer(clipped_bounds.scale(scale_factor)); + .push_layer(self.cover_bounds(clipped_bounds)); } let result = f(self); @@ -3152,10 +3560,12 @@ impl Window { result } - /// Paint one or more drop shadows into the scene for the next frame at the current z-index. + /// Paint the drop (non-inset) shadows from `shadows` into the scene at the current + /// z-index. Inset shadows are skipped; paint those with [`Self::paint_inset_shadows`] + /// after the element's background so they layer on top of the fill. /// /// This method should only be called as part of the paint phase of element drawing. - pub fn paint_shadows( + pub fn paint_drop_shadows( &mut self, bounds: Bounds, corner_radii: Corners, @@ -3164,17 +3574,71 @@ impl Window { self.invalidator.debug_assert_paint(); let scale_factor = self.scale_factor(); - let content_mask = self.content_mask(); + let content_mask = self.snapped_content_mask(); let opacity = self.element_opacity(); + let element_bounds = self.cover_bounds(bounds); + let element_corner_radii = corner_radii.scale(scale_factor); for shadow in shadows { + if shadow.inset { + continue; + } let shadow_bounds = (bounds + shadow.offset).dilate(shadow.spread_radius); self.next_frame.scene.insert_primitive(Shadow { order: 0, blur_radius: shadow.blur_radius.scale(scale_factor), - bounds: shadow_bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), + bounds: self.cover_bounds(shadow_bounds), + content_mask, corner_radii: corner_radii.scale(scale_factor), color: shadow.color.opacity(opacity), + element_bounds, + element_corner_radii, + inset: 0, + pad: 0, + }); + } + } + + /// Paint the inset shadows from `shadows` into the scene at the current z-index. Should + /// be called after the element's background so the shadow layers on top of the fill. + /// Drop shadows are skipped; paint those with [`Self::paint_drop_shadows`] before the background. + pub fn paint_inset_shadows( + &mut self, + bounds: Bounds, + corner_radii: Corners, + shadows: &[BoxShadow], + ) { + self.invalidator.debug_assert_paint(); + + let scale_factor = self.scale_factor(); + let content_mask = self.snapped_content_mask(); + let opacity = self.element_opacity(); + let element_bounds = self.cover_bounds(bounds); + let element_corner_radii = corner_radii.scale(scale_factor); + for shadow in shadows { + if !shadow.inset { + continue; + } + let hole = (bounds + shadow.offset).dilate(-shadow.spread_radius); + // Clamp at zero so a large spread can't produce negative radii, which would + // break the SDF in the shader. + let zero = Pixels::ZERO; + let hole_corner_radii = Corners { + top_left: (corner_radii.top_left - shadow.spread_radius).max(zero), + top_right: (corner_radii.top_right - shadow.spread_radius).max(zero), + bottom_right: (corner_radii.bottom_right - shadow.spread_radius).max(zero), + bottom_left: (corner_radii.bottom_left - shadow.spread_radius).max(zero), + }; + self.next_frame.scene.insert_primitive(Shadow { + order: 0, + blur_radius: shadow.blur_radius.scale(scale_factor), + bounds: self.cover_bounds(hole), + content_mask, + corner_radii: hole_corner_radii.scale(scale_factor), + color: shadow.color.opacity(opacity), + element_bounds, + element_corner_radii, + inset: 1, + pad: 0, }); } } @@ -3191,17 +3655,17 @@ impl Window { pub fn paint_quad(&mut self, quad: PaintQuad) { self.invalidator.debug_assert_paint(); - let scale_factor = self.scale_factor(); - let content_mask = self.content_mask(); let opacity = self.element_opacity(); + let snapped_bounds = self.snap_bounds(quad.bounds); + let snapped_border_widths = self.snap_border_widths(quad.border_widths); self.next_frame.scene.insert_primitive(Quad { order: 0, - bounds: quad.bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), + bounds: snapped_bounds, + content_mask: self.snapped_content_mask(), background: quad.background.opacity(opacity), border_color: quad.border_color.opacity(opacity), - corner_radii: quad.corner_radii.scale(scale_factor), - border_widths: quad.border_widths.scale(scale_factor), + corner_radii: quad.corner_radii.scale(self.scale_factor()), + border_widths: snapped_border_widths, border_style: quad.border_style, }); } @@ -3235,25 +3699,25 @@ impl Window { self.invalidator.debug_assert_paint(); let scale_factor = self.scale_factor(); + let thickness = self.snap_stroke(style.thickness); let height = if style.wavy { - style.thickness * 3. + ScaledPixels(thickness.0 * 3.) } else { - style.thickness + thickness }; let bounds = Bounds { - origin, - size: size(width, height), + origin: origin.map(|c| ScaledPixels(round_to_device_pixel(c.0, scale_factor))), + size: size(self.snap_stroke(width), height), }; - let content_mask = self.content_mask(); let element_opacity = self.element_opacity(); self.next_frame.scene.insert_primitive(Underline { order: 0, pad: 0, - bounds: bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), + bounds, + content_mask: self.snapped_content_mask(), color: style.color.unwrap_or_default().opacity(element_opacity), - thickness: style.thickness.scale(scale_factor), + thickness, wavy: if style.wavy { 1 } else { 0 }, }); } @@ -3272,18 +3736,17 @@ impl Window { let scale_factor = self.scale_factor(); let height = style.thickness; let bounds = Bounds { - origin, - size: size(width, height), + origin: origin.map(|c| ScaledPixels(round_to_device_pixel(c.0, scale_factor))), + size: size(self.snap_stroke(width), self.snap_stroke(height)), }; - let content_mask = self.content_mask(); let opacity = self.element_opacity(); self.next_frame.scene.insert_primitive(Underline { order: 0, pad: 0, - bounds: bounds.scale(scale_factor), - content_mask: content_mask.scale(scale_factor), - thickness: style.thickness.scale(scale_factor), + bounds, + content_mask: self.snapped_content_mask(), + thickness: self.snap_stroke(style.thickness), color: style.color.unwrap_or_default().opacity(opacity), wavy: 0, }); @@ -3311,11 +3774,19 @@ impl Window { let scale_factor = self.scale_factor(); let glyph_origin = origin.scale(scale_factor); - let subpixel_variant = Point { - x: (glyph_origin.x.0.fract() * SUBPIXEL_VARIANTS_X as f32).floor() as u8, - y: (glyph_origin.y.0.fract() * SUBPIXEL_VARIANTS_Y as f32).floor() as u8, - }; + let quantized_origin = Point::new( + round_half_toward_zero(glyph_origin.x.0 * SUBPIXEL_VARIANTS_X as f32) + / SUBPIXEL_VARIANTS_X as f32, + round_half_toward_zero(glyph_origin.y.0 * SUBPIXEL_VARIANTS_Y as f32) + / SUBPIXEL_VARIANTS_Y as f32, + ); + let subpixel_variant = Point::new( + (quantized_origin.x.fract() * SUBPIXEL_VARIANTS_X as f32) as u8, + (quantized_origin.y.fract() * SUBPIXEL_VARIANTS_Y as f32) as u8, + ); + let integer_origin = quantized_origin.map(|c| ScaledPixels(c.trunc())); 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, @@ -3324,6 +3795,7 @@ impl Window { scale_factor, is_emoji: false, subpixel_rendering, + dilation, }; let raster_bounds = self.text_system().raster_bounds(¶ms)?; @@ -3336,10 +3808,10 @@ impl Window { })? .expect("Callback above only errors or returns Some"); let bounds = Bounds { - origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + origin: integer_origin + raster_bounds.origin.map(Into::into), size: tile.bounds.size.map(Into::into), }; - let content_mask = self.content_mask().scale(scale_factor); + let content_mask = self.snapped_content_mask(); if subpixel_rendering { self.next_frame.scene.insert_primitive(SubpixelSprite { @@ -3366,100 +3838,6 @@ impl Window { Ok(()) } - /// Paints a monochrome glyph with pre-computed raster bounds. - /// - /// This is faster than `paint_glyph` because it skips the per-glyph cache lookup. - /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. - pub fn paint_glyph_with_raster_bounds( - &mut self, - origin: Point, - _font_id: FontId, - _glyph_id: GlyphId, - _font_size: Pixels, - color: Hsla, - raster_bounds: Bounds, - params: &RenderGlyphParams, - ) -> Result<()> { - self.invalidator.debug_assert_paint(); - - let element_opacity = self.element_opacity(); - let scale_factor = self.scale_factor(); - let glyph_origin = origin.scale(scale_factor); - - if !raster_bounds.is_zero() { - let tile = self - .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { - let (size, bytes) = self.text_system().rasterize_glyph(params)?; - Ok(Some((size, Cow::Owned(bytes)))) - })? - .expect("Callback above only errors or returns Some"); - let bounds = Bounds { - origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), - size: tile.bounds.size.map(Into::into), - }; - let content_mask = self.content_mask().scale(scale_factor); - self.next_frame.scene.insert_primitive(MonochromeSprite { - order: 0, - pad: 0, - bounds, - content_mask, - color: color.opacity(element_opacity), - tile, - transformation: TransformationMatrix::unit(), - }); - } - Ok(()) - } - - /// Paints an emoji glyph with pre-computed raster bounds. - /// - /// This is faster than `paint_emoji` because it skips the per-glyph cache lookup. - /// Use `ShapedLine::compute_glyph_raster_data` to batch-compute raster bounds during prepaint. - pub fn paint_emoji_with_raster_bounds( - &mut self, - origin: Point, - _font_id: FontId, - _glyph_id: GlyphId, - _font_size: Pixels, - raster_bounds: Bounds, - params: &RenderGlyphParams, - ) -> Result<()> { - self.invalidator.debug_assert_paint(); - - let scale_factor = self.scale_factor(); - let glyph_origin = origin.scale(scale_factor); - - if !raster_bounds.is_zero() { - let tile = self - .sprite_atlas - .get_or_insert_with(¶ms.clone().into(), &mut || { - let (size, bytes) = self.text_system().rasterize_glyph(params)?; - Ok(Some((size, Cow::Owned(bytes)))) - })? - .expect("Callback above only errors or returns Some"); - - let bounds = Bounds { - origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), - size: tile.bounds.size.map(Into::into), - }; - let content_mask = self.content_mask().scale(scale_factor); - let opacity = self.element_opacity(); - - self.next_frame.scene.insert_primitive(PolychromeSprite { - order: 0, - pad: 0, - grayscale: false, - bounds, - corner_radii: Default::default(), - content_mask, - tile, - opacity, - }); - } - Ok(()) - } - fn should_use_subpixel_rendering(&self, font_id: FontId, font_size: Pixels) -> bool { if self.platform_window.background_appearance() != WindowBackgroundAppearance::Opaque { return false; @@ -3498,15 +3876,16 @@ impl Window { let scale_factor = self.scale_factor(); let glyph_origin = origin.scale(scale_factor); + let integer_origin = glyph_origin.map(|c| ScaledPixels(round_half_toward_zero(c.0))); let params = RenderGlyphParams { font_id, glyph_id, font_size, - // We don't render emojis with subpixel variants. subpixel_variant: Default::default(), scale_factor, is_emoji: true, subpixel_rendering: false, + dilation: 0, }; let raster_bounds = self.text_system().raster_bounds(¶ms)?; @@ -3520,10 +3899,10 @@ impl Window { .expect("Callback above only errors or returns Some"); let bounds = Bounds { - origin: glyph_origin.map(|px| px.floor()) + raster_bounds.origin.map(Into::into), + origin: integer_origin + raster_bounds.origin.map(Into::into), size: tile.bounds.size.map(Into::into), }; - let content_mask = self.content_mask().scale(scale_factor); + let content_mask = self.snapped_content_mask(); let opacity = self.element_opacity(); self.next_frame.scene.insert_primitive(PolychromeSprite { @@ -3555,9 +3934,8 @@ impl Window { self.invalidator.debug_assert_paint(); let element_opacity = self.element_opacity(); - let scale_factor = self.scale_factor(); + let bounds = self.snap_bounds(bounds); - let bounds = bounds.scale(scale_factor); let params = RenderSvgParams { path, size: bounds.size.map(|pixels| { @@ -3577,7 +3955,7 @@ impl Window { else { return Ok(()); }; - let content_mask = self.content_mask().scale(scale_factor); + let content_mask = self.snapped_content_mask(); let svg_bounds = Bounds { origin: bounds.center() - Point::new( @@ -3589,13 +3967,14 @@ impl Window { .size .map(|value| ScaledPixels(value.0 as f32 / SMOOTH_SVG_SCALE_FACTOR)), }; + let final_bounds = svg_bounds + .map_origin(|value| ScaledPixels(round_half_toward_zero(value.0))) + .map_size(|size| size.ceil()); self.next_frame.scene.insert_primitive(MonochromeSprite { order: 0, pad: 0, - bounds: svg_bounds - .map_origin(|origin| origin.round()) - .map_size(|size| size.ceil()), + bounds: final_bounds, content_mask, color: color.opacity(element_opacity), tile, @@ -3619,8 +3998,7 @@ impl Window { ) -> Result<()> { self.invalidator.debug_assert_paint(); - let scale_factor = self.scale_factor(); - let bounds = bounds.scale(scale_factor); + let bounds = self.snap_bounds(bounds); let params = RenderImageParams { image_id: data.id, frame_index, @@ -3638,17 +4016,15 @@ impl Window { ))) })? .expect("Callback above only returns Some"); - let content_mask = self.content_mask().scale(scale_factor); - let corner_radii = corner_radii.scale(scale_factor); + let content_mask = self.snapped_content_mask(); + let corner_radii = corner_radii.scale(self.scale_factor()); let opacity = self.element_opacity(); self.next_frame.scene.insert_primitive(PolychromeSprite { order: 0, pad: 0, grayscale, - bounds: bounds - .map_origin(|origin| origin.floor()) - .map_size(|size| size.ceil()), + bounds, content_mask, corner_radii, tile, @@ -3666,9 +4042,8 @@ impl Window { self.invalidator.debug_assert_paint(); - let scale_factor = self.scale_factor(); - let bounds = bounds.scale(scale_factor); - let content_mask = self.content_mask().scale(scale_factor); + let bounds = self.snap_bounds(bounds); + let content_mask = self.snapped_content_mask(); self.next_frame.scene.insert_primitive(PaintSurface { order: 0, bounds, @@ -3773,7 +4148,8 @@ impl Window { .unwrap() .layout_bounds(layout_id, scale_factor) .map(Into::into); - bounds.origin += self.element_offset(); + let snapped_offset = self.pixel_snap_point(self.element_offset()); + bounds.origin += snapped_offset; bounds } @@ -4061,6 +4437,9 @@ impl Window { /// Dispatch a mouse or keyboard event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { + #[cfg(feature = "input-latency-histogram")] + let dispatch_time = Instant::now(); + let update_count_before = self.invalidator.update_count(); // Track input modality for focus-visible styling and hover suppression. // Hover is suppressed during keyboard modality so that keyboard navigation // doesn't show hover highlights on the item under the mouse cursor. @@ -4114,7 +4493,6 @@ impl Window { self.modifiers = scroll_wheel.modifiers; PlatformInput::ScrollWheel(scroll_wheel) } - #[cfg(any(target_os = "linux", target_os = "macos"))] PlatformInput::Pinch(pinch) => { self.mouse_position = pinch.position; self.modifiers = pinch.modifiers; @@ -4171,8 +4549,14 @@ impl Window { self.dispatch_key_event(any_key_event, cx); } - if self.invalidator.is_dirty() { + if self.invalidator.update_count() > update_count_before { self.input_rate_tracker.borrow_mut().record_input(); + #[cfg(feature = "input-latency-histogram")] + if self.invalidator.not_drawing() { + self.input_latency_tracker.record_input(dispatch_time); + } else { + self.input_latency_tracker.record_mid_draw_input(); + } } DispatchEventResult { @@ -4280,6 +4664,14 @@ impl Window { } else if let Some(key_down_event) = event.downcast_ref::() { self.pending_modifier.saw_keystroke = true; keystroke = Some(key_down_event.keystroke.clone()); + if key_down_event.keystroke.key_char.is_some() + && matches!( + cx.cursor_hide_mode, + CursorHideMode::OnTyping | CursorHideMode::OnTypingAndAction + ) + { + cx.platform.hide_cursor_until_mouse_moves(); + } } let Some(keystroke) = keystroke else { @@ -4545,6 +4937,22 @@ impl Window { node_id: DispatchNodeId, action: &dyn Action, cx: &mut App, + ) { + self.dispatch_action_on_node_inner(node_id, action, cx); + + if !cx.propagate_event + && cx.cursor_hide_mode == CursorHideMode::OnTypingAndAction + && self.last_input_was_keyboard() + { + cx.platform.hide_cursor_until_mouse_moves(); + } + } + + fn dispatch_action_on_node_inner( + &mut self, + node_id: DispatchNodeId, + action: &dyn Action, + cx: &mut App, ) { let dispatch_path = self.rendered_frame.dispatch_tree.dispatch_path(node_id); @@ -4993,6 +5401,93 @@ impl Window { .set_tabbing_identifier(tabbing_identifier) } + /// Request the OS to play an alert sound. On some platforms this is associated + /// with the window, for others it's just a simple global function call. + pub fn play_system_bell(&self) { + self.platform_window.play_system_bell() + } + + /// Register a listener for an accessibility action on a specific node. + /// The listener will be called when a screen reader requests the given + /// action on the node identified by `node_id`. + /// + /// See the [accessibility guide](crate::_accessibility) for an overview. + pub fn on_a11y_action( + &mut self, + node_id: accesskit::NodeId, + action: accesskit::Action, + listener: impl FnMut(Option<&accesskit::ActionData>, &mut Window, &mut App) + 'static, + ) { + self.a11y + .action_listeners + .entry(node_id) + .or_default() + .push((action, Box::new(listener))); + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) fn handle_a11y_action(&mut self, request: accesskit::ActionRequest, cx: &mut App) { + // Take listeners out temporarily so the closures can borrow Window + // mutably, then restore them afterward. + if let Some(mut listeners) = self.a11y.action_listeners.remove(&request.target_node) { + let extra_data = request.data.as_ref(); + let mut matched = false; + for (action, listener) in &mut listeners { + if *action == request.action { + listener(extra_data, self, cx); + matched = true; + } + } + self.a11y + .action_listeners + .insert(request.target_node, listeners); + if matched { + return; + } + } + + // Fall back to built-in action handling. + match request.action { + accesskit::Action::Click => { + if let Some(bounds) = self.a11y.node_bounds.get(&request.target_node).copied() { + let center = bounds.center(); + let mouse_down = PlatformInput::MouseDown(crate::MouseDownEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + first_mouse: false, + }); + let mouse_up = PlatformInput::MouseUp(MouseUpEvent { + button: MouseButton::Left, + position: center, + modifiers: Modifiers::default(), + click_count: 1, + }); + self.dispatch_event(mouse_down, cx); + self.dispatch_event(mouse_up, cx); + } + } + accesskit::Action::Focus => { + if let Some(focus_id) = self.a11y.focus_ids.get(&request.target_node).copied() + && let Some(handle) = FocusHandle::for_id(focus_id, &cx.focus_handles) + { + self.focus(&handle, cx); + } + } + accesskit::Action::Blur => { + self.blur(); + } + _ => { + log::debug!( + "Unhandled a11y action: {:?} on {:?}", + request.action, + request.target_node + ); + } + } + } + /// Toggles the inspector mode on this window. #[cfg(any(feature = "inspector", debug_assertions))] pub fn toggle_inspector(&mut self, cx: &mut App) { @@ -5459,6 +5954,8 @@ pub enum ElementId { CodeLocation(core::panic::Location<'static>), /// A labeled child of an element. NamedChild(Arc, SharedString), + /// A byte array ID (used for text-anchors) + OpaqueId([u8; 20]), } impl ElementId { @@ -5480,6 +5977,7 @@ impl Display for ElementId { ElementId::Path(path) => write!(f, "{}", path.display())?, ElementId::CodeLocation(location) => write!(f, "{}", location)?, ElementId::NamedChild(id, name) => write!(f, "{}-{}", id, name)?, + ElementId::OpaqueId(opaque_id) => write!(f, "{:x?}", opaque_id)?, } Ok(()) @@ -5536,7 +6034,7 @@ impl From> for ElementId { impl From<&'static str> for ElementId { fn from(name: &'static str) -> Self { - ElementId::Name(name.into()) + ElementId::Name(SharedString::new_static(name)) } } @@ -5548,13 +6046,13 @@ impl<'a> From<&'a FocusHandle> for ElementId { impl From<(&'static str, EntityId)> for ElementId { fn from((name, id): (&'static str, EntityId)) -> Self { - ElementId::NamedInteger(name.into(), id.as_u64()) + ElementId::NamedInteger(SharedString::new_static(name), id.as_u64()) } } impl From<(&'static str, usize)> for ElementId { fn from((name, id): (&'static str, usize)) -> Self { - ElementId::NamedInteger(name.into(), id as u64) + ElementId::NamedInteger(SharedString::new_static(name), id as u64) } } @@ -5566,7 +6064,7 @@ impl From<(SharedString, usize)> for ElementId { impl From<(&'static str, u64)> for ElementId { fn from((name, id): (&'static str, u64)) -> Self { - ElementId::NamedInteger(name.into(), id) + ElementId::NamedInteger(SharedString::new_static(name), id) } } @@ -5578,7 +6076,7 @@ impl From for ElementId { impl From<(&'static str, u32)> for ElementId { fn from((name, id): (&'static str, u32)) -> Self { - ElementId::NamedInteger(name.into(), id.into()) + ElementId::NamedInteger(SharedString::new_static(name), u64::from(id)) } } @@ -5594,6 +6092,12 @@ impl From<&'static core::panic::Location<'static>> for ElementId { } } +impl From<[u8; 20]> for ElementId { + fn from(opaque_id: [u8; 20]) -> Self { + ElementId::OpaqueId(opaque_id) + } +} + /// 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 new file mode 100644 index 0000000000..dfb818ead9 --- /dev/null +++ b/crates/gpui/src/window/a11y.rs @@ -0,0 +1,361 @@ +//! Accessibility support, provided by [AccessKit][accesskit]. +//! +//! There are user-facing guide-level docs [here](crate::_accessibility). +//! +//! ## Architecture +//! +//! ```text +//! ┌────────────────────────────────┐ ┌─────────────────────┐ +//! ┌─▶│ AccessKit Adapter (MacOS) │◀─▶│ MacOS System APIs │ +//! │ └────────────────────────────────┘ └─────────────────────┘ +//! │ +//! ┌──────┐ ┌───────────┐ │ ┌────────────────────────────────┐ ┌─────────────────────┐ +//! │ GPUI │◀─▶│ AccessKit │◀─┼─▶│ AccessKit Adapter (Windows) │◀─▶│ Windows System APIs │ +//! └──────┘ └───────────┘ │ └────────────────────────────────┘ └─────────────────────┘ +//! │ +//! │ ┌────────────────────────────────┐ ┌─────────────────────┐ +//! └─▶│ AccessKit Adapter (Linux) │◀─▶│ dbus │ +//! └────────────────────────────────┘ └─────────────────────┘ +//! ``` +//! +//! In order for GPUI apps to be usable for people using assistive technology, +//! we must do a few things: +//! - Inform the system when the UI changes meaningfully. This includes: +//! - Reporting new/removed/changed UI elements +//! - *Not* reporting irrelevant UI changes, e.g. an invisible `div()` being +//! added. +//! - Reporting the appearance and capabilities of each UI element. For example: +//! - What does this piece of text say? +//! - How far along is this progress bar? +//! - Can this node be focused? +//! - Can this node have a value directly assigned? (e.g. a slider) +//! - Allowing the system to interact with the UI by dispatching actions to +//! nodes. Note that AccessKit has its own [`Action`] type, which is not the +//! [`crate::Action`] trait. +//! - Activate and deactivate accessibility features when requested by the +//! system. +//! +//! Activating and deactivating at the right time is trivial, so I won't go into +//! detail here. The other two are almost orthogonal in implementation. +//! +//! The state for both lives in the [`A11y`] struct in this module. +//! +//! ### Reporting UI changes +//! +//! Every frame, we build a [`TreeUpdate`] and send it to the platform-specific +//! adapter. A [`TreeUpdate`] is a representation of a subset of the UI tree. +//! When the adapter receives the update, it diffs it against the previous +//! update, and calls platform-specific APIs to inform screen readers about the +//! changes. Nodes may have been created, destroyed, or updated. +//! +//! Each node has an ID, and this ID *should* be stable across frames. If a +//! node's ID changes, then, from AccessKit's point of view, it is a different +//! node. +//! +//! We derive the node ID from the [`GlobalElementId`] in +//! [`GlobalElementId::accesskit_node_id`]. Nodes without [`GlobalElementId`]s +//! cannot produce an AccessKit [`NodeId`], and so are not included in the +//! accessibility tree. We try to warn when using accessibility APIs on +//! [`div()`] without setting an ID. +//! +//! This all happens in [`Drawable::prepaint`]. The [`A11y`] struct maintains a +//! stack of nodes during prepainting, which we can use to calculate the +//! [`NodeId`]s, and record parent-child relationships. Once all [`Element`]s in +//! a frame have been prepainted, we send the resulting [`TreeUpdate`] object to +//! the adapter and the screen reader can announce the changes. +//! +//! ### Responding to actions +//! +//! On adapter creation, we provide a callback to the adapter, which can be used +//! to dispatch actions. This callback forwards to [`A11y::action_listeners`], a +//! mapping from [`NodeId`]s to action handlers (basically just `Box`). +//! +//! This is populated in: +//! - [`Window::on_a11y_action`], which is called by: +//! - [`Interactivity::paint`], which is called by: +//! - [`InteractiveElement::on_a11y_action`], which is a public-facing API +//! +//! These are cleared at the start of a frame, and re-populated during painting. +//! +//! [`Element`]: crate::Element +//! [`GlobalElementId`]: crate::GlobalElementId +//! [`div()`]: crate::div +//! [`Interactivity::paint`]: crate::Interactivity::paint +//! [`InteractiveElement::on_a11y_action`]: crate::InteractiveElement::on_a11y_action +//! [`NodeId`]: accesskit::NodeId +//! [`Drawable::prepaint`]: crate::Drawable::prepaint + +use crate::{App, Bounds, FocusId, Pixels, Window}; +use accesskit::{Action, NodeId, TreeUpdate}; +use collections::{FxHashMap, FxHashSet}; +use smallvec::SmallVec; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +/// The fixed AccessKit node ID used for the root of every window's a11y tree. +pub(crate) const ROOT_NODE_ID: NodeId = NodeId(0); + +/// A listener for an accessibility action on a specific node. +pub(crate) type A11yActionListener = + Box, &mut Window, &mut App) + 'static>; + +/// Per-window accessibility state. +/// +/// Manages the AccessKit tree that is built each frame and the mappings +/// needed to dispatch incoming action requests back to the right elements. +pub(crate) struct A11y { + /// Whether accessibility has been [forcibly disabled] for this window. + /// + /// [forcibly disabled]: crate::Application::new_inaccessible + force_disabled: bool, + /// Whether a11y features have been requested by the system. + /// + /// Updated by AccessKit using callbacks provided to the adapter. Can change + /// halfway through a frame. + active_flag: Arc, + /// Whether a11y features are active for *this specific frame*. + /// + /// At the start of each frame, we load [`Self::active_flag`] (using + /// [`Self::sync_active_flag`]) and use this to determine whether we + /// should construct a [`TreeUpdate`] for this frame. It's important that + /// this value is stable within a frame, because the builder API exposed by + /// this type maintains a stack of nodes and each must be pushed and popped + /// exactly once. + /// + /// At the end of the frame, we re-call [`Self::sync_active_flag`] to + /// determine whether we should actually send the finished [`TreeUpdate`]. + active_this_frame: bool, + pub(crate) nodes: A11yNodeBuilder, + pub(crate) focus_ids: FxHashMap, + pub(crate) node_bounds: FxHashMap>, + pub(crate) action_listeners: FxHashMap>, +} + +impl A11y { + pub(crate) fn new(active_flag: Arc, force_disabled: bool) -> Self { + Self { + force_disabled, + active_flag, + active_this_frame: false, + nodes: A11yNodeBuilder::new(), + focus_ids: FxHashMap::default(), + node_bounds: FxHashMap::default(), + action_listeners: FxHashMap::default(), + } + } + + /// Ensures that [`Self::is_active`] returns up to date information. + /// + /// See the docs for [`Self::active_flag`] and [`Self::active_this_frame`] + /// for more commentary. + pub(crate) fn sync_active_flag(&mut self) { + self.active_this_frame = !self.force_disabled && self.active_flag.load(Ordering::SeqCst); + } + + pub(crate) fn is_active(&self) -> bool { + self.active_this_frame + } + + /// Clear per-frame state and push the root node to start a new frame. + pub(crate) fn begin_frame(&mut self) { + self.focus_ids.clear(); + self.node_bounds.clear(); + self.action_listeners.clear(); + self.nodes.begin_frame(); + } + + /// Finalize the tree and produce a [`TreeUpdate`] for the platform adapter. + pub(crate) fn end_frame(&mut self) -> TreeUpdate { + let tree_update = self.nodes.finalize(); + + // Zed currently doesn't set any a11y APIs on *any* UI elements, so a + // tree with nodes other than the root indicates a bug in the + // `TreeUpdate`-producing logic. + // + // Remove this when adding aria attributes. + if tree_update.nodes.len() > 1 { + log::warn!( + "expected an empty a11y tree update (only the root node), but got {} nodes; Zed has no accessible UI elements yet", + tree_update.nodes.len() + ); + } + + tree_update + } +} + +pub(crate) struct A11yNodeBuilder { + ids_stack: SmallVec<[NodeId; 16]>, + nodes_stack: SmallVec<[accesskit::Node; 16]>, + /// This is the exact type required by accesskit, so we can't just make it a + /// `HashMap` to remove the need for `seen_ids` + all_nodes: Vec<(NodeId, accesskit::Node)>, + seen_ids: FxHashSet, + focus: NodeId, + #[cfg(debug_assertions)] + has_set_focus: bool, +} + +impl A11yNodeBuilder { + fn new() -> Self { + Self { + ids_stack: SmallVec::new(), + nodes_stack: SmallVec::new(), + all_nodes: Vec::new(), + seen_ids: FxHashSet::default(), + focus: ROOT_NODE_ID, + #[cfg(debug_assertions)] + has_set_focus: false, + } + } + + /// Push a new node onto the stack. It becomes a child of the current + /// top-of-stack node. + /// + /// Returns `true` if the node was successfully pushed. + pub(crate) fn push(&mut self, id: NodeId, node: accesskit::Node) -> bool { + debug_assert!(!self.ids_stack.is_empty(), "push called before push_root"); + + if !self.seen_ids.insert(id) { + debug_assert!( + false, + "Duplicate a11y node id: {id:?}. In a release build, this node would be silently discarded from the a11y tree." + ); + // We need to return `false` here because inserting a duplicate + // node will cause a panic in accesskit + return false; + } + + if let Some(parent) = self.nodes_stack.last_mut() { + parent.push_child(id); + } + self.ids_stack.push(id); + self.nodes_stack.push(node); + true + } + + /// Pop the current node off the stack and finalize it into the all_nodes + /// list. + pub(crate) fn pop(&mut self) { + debug_assert!(self.ids_stack.len() > 1, "pop would remove the root node"); + + if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) { + self.all_nodes.push((id, node)); + } + } + + /// Push the root node to start a new frame. + fn begin_frame(&mut self) { + self.all_nodes.clear(); + self.ids_stack.clear(); + self.nodes_stack.clear(); + self.seen_ids.clear(); + #[cfg(debug_assertions)] + { + self.has_set_focus = false; + } + let root_node = accesskit::Node::new(accesskit::Role::Window); + + self.ids_stack.push(ROOT_NODE_ID); + self.nodes_stack.push(root_node); + self.focus = ROOT_NODE_ID; + } + + /// Returns whether a node with the given ID has been pushed in this frame. + pub(crate) fn has_node(&self, id: NodeId) -> bool { + id == ROOT_NODE_ID || self.seen_ids.contains(&id) + } + + /// Set the focused node for this frame. + pub(crate) fn set_focus(&mut self, id: NodeId) { + #[cfg(debug_assertions)] + { + debug_assert!( + !self.has_set_focus, + "set_focus called more than once in a single frame" + ); + self.has_set_focus = true; + } + self.focus = id; + } + + fn finalize(&mut self) -> TreeUpdate { + // Stack should contain only the root node + debug_assert_eq!(self.ids_stack.len(), 1); + debug_assert_eq!(self.ids_stack[0], ROOT_NODE_ID); + + if self.ids_stack.len() != 1 { + log::error!( + "a11y: Stack imbalance at end of frame: expected 1 (root), got {}. \ + Some elements may have pushed without popping.", + self.ids_stack.len() + ); + } + + // Pop remaining nodes (should just be the root). + while !self.ids_stack.is_empty() { + if let (Some(id), Some(node)) = (self.ids_stack.pop(), self.nodes_stack.pop()) { + self.all_nodes.push((id, node)); + } + } + + let nodes = std::mem::take(&mut self.all_nodes); + let update = TreeUpdate { + nodes, + tree: Some(accesskit::Tree::new(ROOT_NODE_ID)), + tree_id: accesskit::TreeId::ROOT, + focus: self.focus, + }; + + Self::repair_tree_update(update) + } + + /// Accesskit panics on invalid [`TreeUpdate`]s. This function defensively + /// checks invariants that accesskit panics on, and tries to fix them. + fn repair_tree_update(mut update: TreeUpdate) -> TreeUpdate { + let node_ids: FxHashSet = update.nodes.iter().map(|(id, _)| *id).collect(); + + // Focus must point to a node in the tree. + if !node_ids.contains(&update.focus) { + log::error!( + "a11y: Focused node {:?} is not in the tree ({} nodes). \ + Falling back to root. This is a bug in the a11y tree builder.", + update.focus, + update.nodes.len() + ); + update.focus = ROOT_NODE_ID; + } + + // Every child reference must point to a node in the update. + for (id, node) in &mut update.nodes { + let has_invalid_child = node + .children() + .iter() + .any(|child_id| !node_ids.contains(child_id)); + if has_invalid_child { + let children = node.children(); + let invalid_count = children + .iter() + .filter(|child_id| !node_ids.contains(child_id)) + .count(); + log::error!( + "a11y: Node {:?} references {} children not present in the tree. \ + Stripping invalid child references.", + id, + invalid_count + ); + let valid: Vec = children + .iter() + .copied() + .filter(|child_id| node_ids.contains(child_id)) + .collect(); + node.set_children(valid); + } + } + + update + } +} diff --git a/src/window/prompts.rs b/crates/gpui/src/window/prompts.rs similarity index 100% rename from src/window/prompts.rs rename to crates/gpui/src/window/prompts.rs diff --git a/crates/gpui/tests/action_macros.rs b/crates/gpui/tests/action_macros.rs new file mode 100644 index 0000000000..66ef6fba2c --- /dev/null +++ b/crates/gpui/tests/action_macros.rs @@ -0,0 +1,55 @@ +use gpui::{Action, actions}; +use gpui_macros::register_action; +use schemars::JsonSchema; +use serde::Deserialize; + +#[test] +fn test_action_macros() { + actions!( + test_only, + [ + SomeAction, + /// Documented action + SomeActionWithDocs, + ] + ); + + #[derive(PartialEq, Clone, Deserialize, JsonSchema, Action)] + #[action(namespace = test_only)] + #[serde(deny_unknown_fields)] + struct AnotherAction; + + #[derive(PartialEq, Clone, gpui::private::serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct RegisterableAction {} + + register_action!(RegisterableAction); + + impl gpui::Action for RegisterableAction { + fn boxed_clone(&self) -> Box { + unimplemented!() + } + + fn partial_eq(&self, _action: &dyn gpui::Action) -> bool { + unimplemented!() + } + + fn name(&self) -> &'static str { + unimplemented!() + } + + fn name_for_type() -> &'static str + where + Self: Sized, + { + unimplemented!() + } + + fn build(_value: serde_json::Value) -> anyhow::Result> + where + Self: Sized, + { + unimplemented!() + } + } +} diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml new file mode 100644 index 0000000000..0e9de0ebc0 --- /dev/null +++ b/crates/gpui_linux/Cargo.toml @@ -0,0 +1,134 @@ +[package] +name = "gpui_linux" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/gpui_linux.rs" + +[features] +default = ["wayland", "x11"] +test-support = ["gpui/test-support"] +wayland = [ + "bitflags", + "gpui_wgpu", + "ashpd/wayland", + + "calloop-wayland-source", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", + "filedescriptor", + "xkbcommon", + "open", + "gpui/wayland", +] +x11 = [ + "gpui_wgpu", + "ashpd", + + "as-raw-xcb-connection", + "x11rb", + "xkbcommon", + "xim", + "x11-clipboard", + "filedescriptor", + "open", + "scap?/x11", +] +screen-capture = [ + "gpui/screen-capture", + "scap", +] + + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +accesskit.workspace = true +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, 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 +url.workspace = true +util.workspace = true +uuid.workspace = true + +# Always used +oo7 = { version = "0.6", default-features = false, features = [ + "async-std", + "native_crypto", +] } +calloop = "0.14.3" +raw-window-handle = "0.6" + +# Used in both windowing options +ashpd = { workspace = true, optional = true } +swash = { version = "0.2.6" } +bitflags = { workspace = true, optional = true } +filedescriptor = { version = "0.8.2", optional = true } +open = { version = "5.2.0", optional = true } +xkbcommon = { version = "0.8.0", features = ["wayland", "x11"], optional = true } + +# Screen capture +scap = { workspace = true, optional = true } + +# Wayland +calloop-wayland-source = { version = "0.4.1", optional = true } +wayland-backend = { version = "0.3.3", features = [ + "client_system", + "dlopen", +], optional = true } +wayland-client = { version = "0.31.11", optional = true } +wayland-cursor = { version = "0.31.11", optional = true } +wayland-protocols = { version = "0.32.9", features = [ + "client", + "staging", + "unstable", +], optional = true } +wayland-protocols-plasma = { version = "0.3.9", features = [ + "client", +], optional = true } +wayland-protocols-wlr = { version = "0.3.9", features = [ + "client", +], optional = true } + +# X11 +as-raw-xcb-connection = { version = "1", optional = true } +x11rb = { version = "0.13.1", features = [ + "allow-unsafe-code", + "xkb", + "randr", + "xinput", + "cursor", + "resource_manager", + "sync", + "dri3", +], optional = true } +# WARNING: If you change this, you must also publish a new version of zed-xim to crates.io +xim = { git = "https://github.com/zed-industries/xim-rs.git", rev = "16f35a2c881b815a2b6cdfd6687988e84f8447d8", features = [ + "x11rb-xcb", + "x11rb-client", +], package = "zed-xim", version = "0.4.0-zed", optional = true } +x11-clipboard = { version = "0.9.3", optional = true } diff --git a/crates/gpui_linux/LICENSE-APACHE b/crates/gpui_linux/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_linux/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_linux/src/gpui_linux.rs b/crates/gpui_linux/src/gpui_linux.rs new file mode 100644 index 0000000000..62d972dc4e --- /dev/null +++ b/crates/gpui_linux/src/gpui_linux.rs @@ -0,0 +1,4 @@ +#![cfg(any(target_os = "linux", target_os = "freebsd"))] +mod linux; + +pub use linux::current_platform; diff --git a/src/platform/linux.rs b/crates/gpui_linux/src/linux.rs similarity index 100% rename from src/platform/linux.rs rename to crates/gpui_linux/src/linux.rs diff --git a/src/platform/linux/dispatcher.rs b/crates/gpui_linux/src/linux/dispatcher.rs similarity index 94% rename from src/platform/linux/dispatcher.rs rename to crates/gpui_linux/src/linux/dispatcher.rs index a72276cc76..22df5799dd 100644 --- a/src/platform/linux/dispatcher.rs +++ b/crates/gpui_linux/src/linux/dispatcher.rs @@ -13,7 +13,7 @@ use std::{ use gpui::{ GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, PriorityQueueReceiver, - PriorityQueueSender, RunnableVariant, THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, profiler, + PriorityQueueSender, RunnableVariant, TaskTiming, ThreadTaskTimings, profiler, }; struct TimerAfter { @@ -135,25 +135,7 @@ impl PlatformDispatcher for LinuxDispatcher { } 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, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { diff --git a/src/platform/linux/headless.rs b/crates/gpui_linux/src/linux/headless.rs similarity index 100% rename from src/platform/linux/headless.rs rename to crates/gpui_linux/src/linux/headless.rs diff --git a/src/platform/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs similarity index 97% rename from src/platform/linux/headless/client.rs rename to crates/gpui_linux/src/linux/headless/client.rs index 4593f42efc..56cc9e8df0 100644 --- a/src/platform/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -4,7 +4,7 @@ use std::rc::Rc; use calloop::{EventLoop, LoopHandle}; use util::ResultExt; -use crate::platform::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout}; +use crate::linux::{LinuxClient, LinuxCommon, LinuxKeyboardLayout}; use gpui::{ AnyWindowHandle, CursorStyle, DisplayId, PlatformDisplay, PlatformKeyboardLayout, PlatformWindow, WindowParams, diff --git a/src/platform/linux/keyboard.rs b/crates/gpui_linux/src/linux/keyboard.rs similarity index 100% rename from src/platform/linux/keyboard.rs rename to crates/gpui_linux/src/linux/keyboard.rs diff --git a/src/platform/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs similarity index 97% rename from src/platform/linux/platform.rs rename to crates/gpui_linux/src/linux/platform.rs index 5800957232..343600cd86 100644 --- a/src/platform/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -13,20 +13,21 @@ use std::{ time::Duration, }; -use crate::command::{new_command, new_std_command}; use anyhow::{Context as _, anyhow}; use calloop::LoopSignal; use futures::channel::oneshot; use util::ResultExt as _; +use util::command::{new_command, new_std_command}; #[cfg(any(feature = "wayland", feature = "x11"))] use xkbcommon::xkb::{self, Keycode, Keysym, State}; -use crate::platform::linux::{LinuxDispatcher, PriorityQueueCalloopReceiver}; +use crate::linux::{LinuxDispatcher, PriorityQueueCalloopReceiver}; use gpui::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, Menu, MenuItem, OwnedMenu, PathPromptOptions, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, - PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, WindowParams, + PlatformWindow, Result, RunnableVariant, Task, ThermalState, WindowAppearance, + WindowButtonLayout, WindowParams, }; #[cfg(any(feature = "wayland", feature = "x11"))] use gpui::{Pixels, Point, px}; @@ -79,6 +80,10 @@ pub(crate) trait LinuxClient { options: WindowParams, ) -> anyhow::Result>; fn set_cursor_style(&self, style: CursorStyle); + fn hide_cursor_until_mouse_moves(&self) {} + fn is_cursor_visible(&self) -> bool { + true + } fn open_uri(&self, uri: &str); fn reveal_path(&self, path: PathBuf); fn write_to_primary(&self, item: ClipboardItem); @@ -114,6 +119,7 @@ pub(crate) struct LinuxCommon { pub(crate) text_system: Arc, pub(crate) appearance: WindowAppearance, pub(crate) auto_hide_scrollbars: bool, + pub(crate) button_layout: WindowButtonLayout, pub(crate) callbacks: PlatformHandlers, pub(crate) signal: LoopSignal, pub(crate) menus: Vec, @@ -124,9 +130,7 @@ impl LinuxCommon { let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); #[cfg(any(feature = "wayland", feature = "x11"))] - let text_system = Arc::new(crate::platform::linux::CosmicTextSystem::new( - "IBM Plex Sans", - )); + let text_system = Arc::new(crate::linux::CosmicTextSystem::new("IBM Plex Sans")); #[cfg(not(any(feature = "wayland", feature = "x11")))] let text_system = Arc::new(gpui::NoopTextSystem::new()); @@ -142,6 +146,7 @@ impl LinuxCommon { text_system, appearance: WindowAppearance::Light, auto_hide_scrollbars: false, + button_layout: WindowButtonLayout::linux_default(), callbacks, signal, menus: Vec::new(), @@ -529,6 +534,14 @@ impl Platform for LinuxPlatform

{ self.inner.set_cursor_style(style) } + fn hide_cursor_until_mouse_moves(&self) { + self.inner.hide_cursor_until_mouse_moves() + } + + fn is_cursor_visible(&self) -> bool { + self.inner.is_cursor_visible() + } + fn should_auto_hide_scrollbars(&self) -> bool { self.inner.with_common(|common| common.auto_hide_scrollbars) } @@ -603,6 +616,10 @@ impl Platform for LinuxPlatform

{ self.inner.with_common(|common| common.appearance) } + fn button_layout(&self) -> Option { + Some(self.inner.with_common(|common| common.button_layout)) + } + fn register_url_scheme(&self, _: &str) -> Task> { Task::ready(Err(anyhow!("register_url_scheme unimplemented"))) } @@ -771,12 +788,6 @@ pub(super) fn cursor_style_to_icon_names(style: CursorStyle) -> &'static [&'stat CursorStyle::DragLink => &["alias"], CursorStyle::DragCopy => &["copy"], CursorStyle::ContextualMenu => &["context-menu"], - CursorStyle::None => { - #[cfg(debug_assertions)] - panic!("CursorStyle::None should be handled separately in the client"); - #[cfg(not(debug_assertions))] - &[DEFAULT_CURSOR_ICON_NAME] - } } } @@ -1057,9 +1068,7 @@ pub(super) fn capslock_from_xkb(keymap_state: &State) -> gpui::Capslock { /// [`CompositorGpuHint`] that the GPU adapter selection code can use to /// prioritize the compositor's rendering device. #[cfg(any(feature = "wayland", feature = "x11"))] -pub(super) fn compositor_gpu_hint_from_dev_t( - dev: u64, -) -> Option { +pub(super) fn compositor_gpu_hint_from_dev_t(dev: u64) -> Option { fn dev_major(dev: u64) -> u32 { ((dev >> 8) & 0xfff) as u32 | (((dev >> 32) & !0xfff) as u32) } @@ -1089,7 +1098,7 @@ pub(super) fn compositor_gpu_hint_from_dev_t( device_id, ); - Some(crate::platform::wgpu::CompositorGpuHint { + Some(gpui_wgpu::CompositorGpuHint { vendor_id, device_id, }) diff --git a/crates/gpui_linux/src/linux/text_system.rs b/crates/gpui_linux/src/linux/text_system.rs new file mode 100644 index 0000000000..d6571021e0 --- /dev/null +++ b/crates/gpui_linux/src/linux/text_system.rs @@ -0,0 +1 @@ +pub(crate) use gpui_wgpu::CosmicTextSystem; diff --git a/src/platform/linux/wayland.rs b/crates/gpui_linux/src/linux/wayland.rs similarity index 86% rename from src/platform/linux/wayland.rs rename to crates/gpui_linux/src/linux/wayland.rs index aa1e797404..3e90688d1b 100644 --- a/src/platform/linux/wayland.rs +++ b/crates/gpui_linux/src/linux/wayland.rs @@ -37,11 +37,5 @@ pub(super) fn to_shape(style: CursorStyle) -> Shape { CursorStyle::DragLink => Shape::Alias, CursorStyle::DragCopy => Shape::Copy, CursorStyle::ContextualMenu => Shape::ContextMenu, - CursorStyle::None => { - #[cfg(debug_assertions)] - panic!("CursorStyle::None should be handled separately in the client"); - #[cfg(not(debug_assertions))] - Shape::Default - } } } diff --git a/src/platform/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs similarity index 93% rename from src/platform/linux/wayland/client.rs rename to crates/gpui_linux/src/linux/wayland/client.rs index 2d5dd1b089..6a7c4c76e4 100644 --- a/src/platform/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -58,6 +58,7 @@ use wayland_protocols::xdg::decoration::zv1::client::{ zxdg_decoration_manager_v1, zxdg_toplevel_decoration_v1, }; use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base}; +use wayland_protocols::xdg::system_bell::v1::client::xdg_system_bell_v1; use wayland_protocols::{ wp::cursor_shape::v1::client::{wp_cursor_shape_device_v1, wp_cursor_shape_manager_v1}, xdg::dialog::v1::client::xdg_wm_dialog_v1::{self, XdgWmDialogV1}, @@ -76,7 +77,7 @@ use super::{ window::{ImeInput, WaylandWindowStatePtr}, }; -use crate::platform::linux::{ +use crate::linux::{ DOUBLE_CLICK_INTERVAL, LinuxClient, LinuxCommon, LinuxKeyboardLayout, SCROLL_LINES, capslock_from_xkb, cursor_style_to_icon_names, get_xkb_compose_state, is_within_click_distance, keystroke_from_xkb, keystroke_underlying_dead_key, modifiers_from_xkb, open_uri_internal, @@ -90,15 +91,15 @@ use crate::platform::linux::{ }, xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, }; -use crate::platform::wgpu::{CompositorGpuHint, GpuContext}; use gpui::{ AnyWindowHandle, Bounds, Capslock, CursorStyle, DevicePixels, DisplayId, FileDropEvent, ForegroundExecutor, KeyDownEvent, KeyUpEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseExitEvent, MouseMoveEvent, MouseUpEvent, NavigationDirection, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, - ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowParams, point, - profiler, px, size, + ScrollDelta, ScrollWheelEvent, SharedString, Size, TaskTiming, TouchPhase, WindowButtonLayout, + WindowParams, point, profiler, px, size, }; +use gpui_wgpu::{CompositorGpuHint, GpuContext}; use wayland_protocols::wp::linux_dmabuf::zv1::client::{ zwp_linux_dmabuf_feedback_v1, zwp_linux_dmabuf_v1, }; @@ -129,6 +130,7 @@ pub struct Globals { pub text_input_manager: Option, pub gesture_manager: Option, pub dialog: Option, + pub system_bell: Option, pub executor: ForegroundExecutor, } @@ -170,6 +172,7 @@ impl Globals { text_input_manager: globals.bind(&qh, 1..=1, ()).ok(), gesture_manager: globals.bind(&qh, 1..=3, ()).ok(), dialog: globals.bind(&qh, dialog_v..=dialog_v, ()).ok(), + system_bell: globals.bind(&qh, 1..=1, ()).ok(), executor, qh, } @@ -182,6 +185,7 @@ pub struct InProgressOutput { scale: Option, position: Option>, size: Option>, + subpixel: Option, } impl InProgressOutput { @@ -192,6 +196,7 @@ impl InProgressOutput { name: self.name.clone(), scale, bounds: Bounds::new(position, size), + subpixel: self.subpixel, }) } else { None @@ -204,6 +209,7 @@ pub struct Output { pub name: Option, pub scale: i32, pub bounds: Bounds, + pub subpixel: Option, } pub(crate) struct WaylandClientState { @@ -250,6 +256,7 @@ pub(crate) struct WaylandClientState { keyboard_focused_window: Option, loop_handle: LoopHandle<'static, WaylandClientStatePtr>, cursor_style: Option, + cursor_hidden_window: Option, clipboard: Clipboard, data_offers: Vec>, primary_data_offer: Option>, @@ -404,6 +411,65 @@ impl WaylandClientStatePtr { { state.keyboard_focused_window = Some(window); } + if let Some(window) = state.cursor_hidden_window.take() + && !window.ptr_eq(&closed_window) + { + state.cursor_hidden_window = Some(window); + } + } +} + +impl WaylandClientState { + fn hide_cursor_until_mouse_moves(&mut self) { + if self.cursor_hidden_window.is_some() { + return; + } + let Some(focused_window) = self.mouse_focused_window.clone() else { + // No surface to apply the hidden cursor to. + return; + }; + let Some(wl_pointer) = self.wl_pointer.clone() else { + // Seat lost its pointer capability; nothing to hide. + return; + }; + let serial = self.serial_tracker.get(SerialKind::MouseEnter); + wl_pointer.set_cursor(serial, None, 0, 0); + self.cursor_hidden_window = Some(focused_window); + } + + fn restore_cursor_after_hide(&mut self) { + if self.cursor_hidden_window.take().is_none() { + return; + } + let Some(style) = self.cursor_style else { + return; + }; + let serial = self.serial_tracker.get(SerialKind::MouseEnter); + if let Some(cursor_shape_device) = &self.cursor_shape_device { + cursor_shape_device.set_shape(serial, to_shape(style)); + return; + } + let Some(focused_window) = self.mouse_focused_window.clone() else { + log::warn!( + "wayland: no focused surface to restore cursor style {:?} after hide; cursor may stay invisible", + style + ); + return; + }; + let Some(wl_pointer) = self.wl_pointer.clone() else { + log::warn!( + "wayland: no wl_pointer to restore cursor style {:?} after hide; cursor may stay invisible", + style + ); + return; + }; + let scale = focused_window.primary_output_scale(); + self.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); } } @@ -567,6 +633,19 @@ impl WaylandClient { } } } + XDPEvent::ButtonLayout(layout_str) => { + if let Some(client) = client.0.upgrade() { + let layout = WindowButtonLayout::parse(&layout_str) + .log_err() + .unwrap_or_else(WindowButtonLayout::linux_default); + let mut client = client.borrow_mut(); + client.common.button_layout = layout; + + for window in client.windows.values_mut() { + window.set_button_layout(); + } + } + } XDPEvent::CursorTheme(theme) => { if let Some(client) = client.0.upgrade() { let mut client = client.borrow_mut(); @@ -646,6 +725,7 @@ impl WaylandClient { loop_handle: handle.clone(), enter_token: None, cursor_style: None, + cursor_hidden_window: None, clipboard: Clipboard::new(conn.clone(), handle.clone()), data_offers: Vec::new(), primary_data_offer: None, @@ -688,7 +768,7 @@ impl LinuxClient for WaylandClient { .outputs .iter() .find_map(|(object_id, output)| { - (object_id.protocol_id() == u32::from(id)).then(|| { + (object_id.protocol_id() as u64 == u64::from(id)).then(|| { Rc::new(WaylandDisplay { id: object_id.clone(), name: output.name.clone(), @@ -730,11 +810,11 @@ impl LinuxClient for WaylandClient { let parent = state.keyboard_focused_window.clone(); let target_output = params.display_id.and_then(|display_id| { - let target_protocol_id: u32 = display_id.into(); + let target_protocol_id: u64 = display_id.into(); state .wl_outputs .iter() - .find(|(id, _)| id.protocol_id() == target_protocol_id) + .find(|(id, _)| id.protocol_id() as u64 == target_protocol_id) .map(|(_, output)| output.clone()) }); @@ -766,33 +846,42 @@ impl LinuxClient for WaylandClient { .as_ref() .is_some_and(|w| !w.is_blocked())); - if need_update { - let serial = state.serial_tracker.get(SerialKind::MouseEnter); - state.cursor_style = Some(style); - - if let CursorStyle::None = style { - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - wl_pointer.set_cursor(serial, None, 0, 0); - } else if let Some(cursor_shape_device) = &state.cursor_shape_device { - cursor_shape_device.set_shape(serial, to_shape(style)); - } else if let Some(focused_window) = &state.mouse_focused_window { - // cursor-shape-v1 isn't supported, set the cursor using a surface. - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - let scale = focused_window.primary_output_scale(); - state.cursor.set_icon( - &wl_pointer, - serial, - cursor_style_to_icon_names(style), - scale, - ); - } + if !need_update { + return; } + + state.cursor_style = Some(style); + + // Don't clobber the invisible cursor; restore reads back from `cursor_style`. + if state.cursor_hidden_window.is_some() { + return; + } + + let serial = state.serial_tracker.get(SerialKind::MouseEnter); + if let Some(cursor_shape_device) = &state.cursor_shape_device { + cursor_shape_device.set_shape(serial, to_shape(style)); + } else if let Some(focused_window) = &state.mouse_focused_window { + // cursor-shape-v1 isn't supported, set the cursor using a surface. + let wl_pointer = state + .wl_pointer + .clone() + .expect("window is focused by pointer"); + let scale = focused_window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + cursor_style_to_icon_names(style), + scale, + ); + } + } + + fn hide_cursor_until_mouse_moves(&self) { + self.0.borrow_mut().hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + self.0.borrow().cursor_hidden_window.is_none() } fn open_uri(&self, uri: &str) { @@ -862,7 +951,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set_primary(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state.serial_tracker.get_latest(); let data_source = primary_selection_manager.create_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); @@ -882,7 +971,7 @@ impl LinuxClient for WaylandClient { }; if state.mouse_focused_window.is_some() || state.keyboard_focused_window.is_some() { state.clipboard.set(item); - let serial = state.serial_tracker.get(SerialKind::KeyPress); + let serial = state.serial_tracker.get_latest(); let data_source = data_device_manager.create_data_source(&state.globals.qh, ()); for mime_type in TEXT_MIME_TYPES { data_source.offer(mime_type.to_string()); @@ -992,7 +1081,7 @@ fn detect_compositor_gpu() -> Option { feedback.destroy(); dmabuf.destroy(); - crate::platform::linux::compositor_gpu_hint_from_dev_t(state.device?) + crate::linux::compositor_gpu_hint_from_dev_t(state.device?) } impl Dispatch for WaylandClientStatePtr { @@ -1052,6 +1141,7 @@ impl Dispatch for WaylandClientStat } delegate_noop!(WaylandClientStatePtr: ignore xdg_activation_v1::XdgActivationV1); +delegate_noop!(WaylandClientStatePtr: ignore xdg_system_bell_v1::XdgSystemBellV1); delegate_noop!(WaylandClientStatePtr: ignore wl_compositor::WlCompositor); delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_device_v1::WpCursorShapeDeviceV1); delegate_noop!(WaylandClientStatePtr: ignore wp_cursor_shape_manager_v1::WpCursorShapeManagerV1); @@ -1145,8 +1235,11 @@ impl Dispatch for WaylandClientStatePtr { wl_output::Event::Scale { factor } => { in_progress_output.scale = Some(factor); } - wl_output::Event::Geometry { x, y, .. } => { - in_progress_output.position = Some(point(DevicePixels(x), DevicePixels(y))) + wl_output::Event::Geometry { x, y, subpixel, .. } => { + in_progress_output.position = Some(point(DevicePixels(x), DevicePixels(y))); + if let WEnum::Value(subpixel) = subpixel { + in_progress_output.subpixel = Some(subpixel); + } } wl_output::Event::Mode { width, height, .. } => { in_progress_output.size = Some(size(DevicePixels(width), DevicePixels(height))) @@ -1405,6 +1498,7 @@ impl Dispatch for WaylandClientStatePtr { state.enter_token.take(); // Prevent keyboard events from repeating after opening e.g. a file chooser and closing it quickly state.repeat.current_id += 1; + state.restore_cursor_after_hide(); if let Some(window) = keyboard_focused_window { if let Some(ref mut compose) = state.compose_state { @@ -1705,14 +1799,9 @@ impl Dispatch for WaylandClientStatePtr { if state.enter_token.is_some() { state.enter_token = None; } + state.restore_cursor_after_hide(); if let Some(style) = state.cursor_style { - if let CursorStyle::None = style { - let wl_pointer = state - .wl_pointer - .clone() - .expect("window is focused by pointer"); - wl_pointer.set_cursor(serial, None, 0, 0); - } else if let Some(cursor_shape_device) = &state.cursor_shape_device { + if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, to_shape(style)); } else { let scale = window.primary_output_scale(); @@ -1738,6 +1827,7 @@ impl Dispatch for WaylandClientStatePtr { state.mouse_focused_window = None; state.mouse_location = None; state.button_pressed = None; + state.cursor_hidden_window = None; drop(state); focused_window.handle_input(input); @@ -1753,6 +1843,7 @@ impl Dispatch for WaylandClientStatePtr { return; } state.mouse_location = Some(point(px(surface_x as f32), px(surface_y as f32))); + state.restore_cursor_after_hide(); if let Some(window) = state.mouse_focused_window.clone() { if window.is_blocked() { @@ -2216,7 +2307,13 @@ impl Dispatch for WaylandClientStatePtr { let paths: SmallVec<[_; 2]> = file_list .lines() .filter_map(|path| Url::parse(path).log_err()) - .filter_map(|url| url.to_file_path().log_err()) + .filter_map(|url| match url.to_file_path() { + Ok(url) => Some(url), + Err(()) => { + log::error!("Failed turn {url:?} into a file path"); + None + } + }) .collect(); let position = Point::new(x.into(), y.into()); diff --git a/src/platform/linux/wayland/clipboard.rs b/crates/gpui_linux/src/linux/wayland/clipboard.rs similarity index 99% rename from src/platform/linux/wayland/clipboard.rs rename to crates/gpui_linux/src/linux/wayland/clipboard.rs index a04940a25c..49c58724da 100644 --- a/src/platform/linux/wayland/clipboard.rs +++ b/crates/gpui_linux/src/linux/wayland/clipboard.rs @@ -10,7 +10,7 @@ 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::platform::linux::{WaylandClientStatePtr, platform::read_fd}; +use crate::linux::{WaylandClientStatePtr, platform::read_fd}; use gpui::{ClipboardEntry, ClipboardItem, Image, ImageFormat, hash}; /// Text mime types that we'll offer to other programs. diff --git a/src/platform/linux/wayland/cursor.rs b/crates/gpui_linux/src/linux/wayland/cursor.rs similarity index 97% rename from src/platform/linux/wayland/cursor.rs rename to crates/gpui_linux/src/linux/wayland/cursor.rs index 2efa17ab01..957fcf39be 100644 --- a/src/platform/linux/wayland/cursor.rs +++ b/crates/gpui_linux/src/linux/wayland/cursor.rs @@ -1,5 +1,5 @@ -use crate::platform::linux::Globals; -use crate::platform::linux::{DEFAULT_CURSOR_ICON_NAME, log_cursor_icon_warning}; +use crate::linux::Globals; +use crate::linux::{DEFAULT_CURSOR_ICON_NAME, log_cursor_icon_warning}; use anyhow::{Context as _, anyhow}; use util::ResultExt; diff --git a/src/platform/linux/wayland/display.rs b/crates/gpui_linux/src/linux/wayland/display.rs similarity index 94% rename from src/platform/linux/wayland/display.rs rename to crates/gpui_linux/src/linux/wayland/display.rs index 874cae8783..8fa9122d62 100644 --- a/src/platform/linux/wayland/display.rs +++ b/crates/gpui_linux/src/linux/wayland/display.rs @@ -25,7 +25,7 @@ impl Hash for WaylandDisplay { impl PlatformDisplay for WaylandDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.id.protocol_id()) + DisplayId::new(self.id.protocol_id() as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/src/platform/linux/wayland/layer_shell.rs b/crates/gpui_linux/src/linux/wayland/layer_shell.rs similarity index 100% rename from src/platform/linux/wayland/layer_shell.rs rename to crates/gpui_linux/src/linux/wayland/layer_shell.rs diff --git a/src/platform/linux/wayland/serial.rs b/crates/gpui_linux/src/linux/wayland/serial.rs similarity index 57% rename from src/platform/linux/wayland/serial.rs rename to crates/gpui_linux/src/linux/wayland/serial.rs index eadc7a9ca9..ffb405727b 100644 --- a/src/platform/linux/wayland/serial.rs +++ b/crates/gpui_linux/src/linux/wayland/serial.rs @@ -46,4 +46,22 @@ impl SerialTracker { .map(|serial_data| serial_data.serial) .unwrap_or(0) } + + /// Returns the most recent serial across all tracked kinds. + /// + /// Wayland compositor serial numbers are monotonically increasing, so the + /// highest value is always the most recently received one. This is the + /// correct serial to use for [`set_selection`] when the triggering event + /// may have been a mouse press rather than a key press: using 0 (the + /// default when a kind has never been seen) causes compositors to silently + /// reject the request. + /// + /// Returns 0 only if no serial of any kind has been received yet. + pub fn get_latest(&self) -> u32 { + self.serials + .values() + .map(|serial_data| serial_data.serial) + .max() + .unwrap_or(0) + } } diff --git a/src/platform/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs similarity index 91% rename from src/platform/linux/wayland/window.rs rename to crates/gpui_linux/src/linux/wayland/window.rs index ee122a8bb9..d8cf35686e 100644 --- a/src/platform/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -27,9 +27,8 @@ use wayland_protocols::{ use wayland_protocols_plasma::blur::client::org_kde_kwin_blur; use wayland_protocols_wlr::layer_shell::v1::client::zwlr_layer_surface_v1; -use crate::platform::linux::wayland::{display::WaylandDisplay, serial::SerialKind}; -use crate::platform::linux::{Globals, Output, WaylandClientStatePtr, get_window}; -use crate::platform::wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; +use crate::linux::wayland::{display::WaylandDisplay, serial::SerialKind}; +use crate::linux::{Globals, Output, WaylandClientStatePtr, get_window}; use gpui::{ AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, @@ -38,6 +37,7 @@ use gpui::{ WindowDecorations, WindowKind, WindowParams, layer_shell::LayerShellNotSupportedError, px, size, }; +use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig, wgpu}; #[derive(Default)] pub(crate) struct Callbacks { @@ -50,6 +50,7 @@ pub(crate) struct Callbacks { should_close: Option bool>>, close: Option>, appearance_changed: Option>, + button_layout_changed: Option>, } #[derive(Debug, Clone, Copy)] @@ -115,11 +116,14 @@ pub struct WaylandWindowState { handle: AnyWindowHandle, 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, window_controls: WindowControls, client_inset: Option, + accesskit_adapter: Option, } pub enum WaylandSurfaceState { @@ -322,7 +326,7 @@ impl WaylandWindowState { viewport: Option, client: WaylandClientStatePtr, globals: Globals, - gpu_context: crate::platform::wgpu::GpuContext, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, options: WindowParams, parent: Option, @@ -343,6 +347,8 @@ impl WaylandWindowState { height: DevicePixels(f32::from(options.bounds.size.height) as i32), }, transparent: true, + // Prefer Mailbox to avoid blocking. Falls back to FIFO if Mailbox is unsupported. + preferred_present_mode: Some(wgpu::PresentMode::Mailbox), }; WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; @@ -388,9 +394,12 @@ impl WaylandWindowState { handle, active: false, hovered: false, + force_render_after_recovery: false, + renderer_presented: false, in_progress_window_controls: None, window_controls: WindowControls::default(), client_inset: None, + accesskit_adapter: None, }) } @@ -399,6 +408,16 @@ impl WaylandWindowState { || self.background_appearance != WindowBackgroundAppearance::Opaque } + fn update_subpixel_layout(&mut self) { + use wayland_client::protocol::wl_output::Subpixel; + let is_bgr = self + .display + .as_ref() + .and_then(|(_, output)| output.subpixel) + .is_some_and(|s| s == Subpixel::HorizontalBgr); + self.renderer.set_subpixel_layout(is_bgr); + } + pub fn primary_output_scale(&mut self) -> i32 { let mut scale = 1; let mut current_output = self.display.take(); @@ -493,7 +512,7 @@ impl WaylandWindow { pub fn new( handle: AnyWindowHandle, globals: Globals, - gpu_context: crate::platform::wgpu::GpuContext, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, client: WaylandClientStatePtr, params: WindowParams, @@ -569,11 +588,16 @@ impl WaylandWindowStatePtr { let mut state = self.state.borrow_mut(); state.surface.frame(&state.globals.qh, state.surface.id()); state.resize_throttle = false; + let force_render = state.force_render_after_recovery; + state.force_render_after_recovery = false; drop(state); let mut cb = self.callbacks.borrow_mut(); if let Some(fun) = cb.request_frame.as_mut() { - fun(Default::default()); + fun(RequestFrameOptions { + force_render, + ..Default::default() + }); } } @@ -765,7 +789,12 @@ impl WaylandWindowStatePtr { } } xdg_toplevel::Event::WmCapabilities { capabilities } => { - let mut window_controls = WindowControls::default(); + let mut window_controls = WindowControls { + maximize: false, + minimize: false, + fullscreen: false, + window_menu: false, + }; let states = extract_states::(&capabilities); @@ -850,6 +879,7 @@ impl WaylandWindowStatePtr { state.outputs.insert(id, output.clone()); let scale = state.primary_output_scale(); + state.update_subpixel_layout(); // We use `PreferredBufferScale` instead to set the scale if it's available if state.surface.version() < wl_surface::EVT_PREFERRED_BUFFER_SCALE_SINCE { @@ -862,6 +892,7 @@ impl WaylandWindowStatePtr { state.outputs.remove(&output.id()); let scale = state.primary_output_scale(); + state.update_subpixel_layout(); // We use `PreferredBufferScale` instead to set the scale if it's available if state.surface.version() < wl_surface::EVT_PREFERRED_BUFFER_SCALE_SINCE { @@ -1018,6 +1049,9 @@ impl WaylandWindowStatePtr { fun(focus); self.callbacks.borrow_mut().active_status_change = Some(fun); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1038,6 +1072,14 @@ impl WaylandWindowStatePtr { } } + pub fn set_button_layout(&self) { + let callback = self.callbacks.borrow_mut().button_layout_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().button_layout_changed = Some(fun); + } + } + pub fn primary_output_scale(&self) -> i32 { self.state.borrow_mut().primary_output_scale() } @@ -1335,6 +1377,10 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } + fn on_button_layout_changed(&self, callback: Box) { + self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); + } + fn draw(&self, scene: &Scene) { let mut state = self.borrow_mut(); @@ -1349,25 +1395,34 @@ impl PlatformWindow for WaylandWindow { .display_ptr() .cast::(), }; - state.renderer.recover(&raw_window).unwrap_or_else(|err| { - panic!( - "GPU device lost and recovery failed. \ - This may happen after system suspend/resume. \ - Please restart the application.\n\nError: {err}" - ) - }); + match state.renderer.recover(&raw_window) { + Ok(()) => {} + Err(err) => { + log::warn!("GPU recovery failed, will retry on next frame: {err}"); + } + } - // The current scene references atlas textures that were cleared during recovery. - // Skip this frame and let the next frame rebuild the scene with fresh textures. + state.force_render_after_recovery = true; return; } - state.renderer.draw(scene); + 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(); + + // Work around a bug in old versions of wlroots where committing without a buffer attached + // can cause invalid synchronization that leads to graphical corruption. + if !state.renderer_presented { + state.surface.commit(); + } + + state.renderer_presented = false; } fn sprite_atlas(&self) -> Arc { @@ -1457,6 +1512,72 @@ impl PlatformWindow for WaylandWindow { fn gpu_specs(&self) -> Option { self.borrow().renderer.gpu_specs().into() } + + fn play_system_bell(&self) { + let state = self.borrow(); + let surface = if state.surface_state.toplevel().is_some() { + Some(&state.surface) + } else { + None + }; + if let Some(bell) = state.globals.system_bell.as_ref() { + bell.ring(surface); + } + } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler(callbacks.action); + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + + self.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + fn a11y_update_window_bounds(&self) { + // Wayland doesn't expose window position, so this is a no-op + } +} + +struct TrivialActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } fn update_window(mut state: RefMut) { diff --git a/src/platform/linux/x11.rs b/crates/gpui_linux/src/linux/x11.rs similarity index 100% rename from src/platform/linux/x11.rs rename to crates/gpui_linux/src/linux/x11.rs diff --git a/src/platform/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs similarity index 79% rename from src/platform/linux/x11/client.rs rename to crates/gpui_linux/src/linux/x11/client.rs index 824ba161e9..1e573a54bf 100644 --- a/src/platform/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -29,7 +29,7 @@ use x11rb::{ protocol::xkb::ConnectionExt as _, protocol::xproto::{ AtomEnum, ChangeWindowAttributesAux, ClientMessageData, ClientMessageEvent, - ConnectionExt as _, EventMask, ModMask, Visibility, + ConnectionExt as _, EventMask, Visibility, }, protocol::{Event, dri3, randr, render, xinput, xkb, xproto}, resource_manager::Database, @@ -48,7 +48,7 @@ use super::{ pressed_button_from_mask, xcb_flush, }; -use crate::platform::linux::{ +use crate::linux::{ 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, @@ -56,17 +56,15 @@ use crate::platform::linux::{ reveal_path_internal, xdg_desktop_portal::{Event as XDPEvent, XDPEventSource}, }; -use crate::platform::linux::{ - LinuxCommon, LinuxKeyboardLayout, X11Window, modifiers_from_xinput_info, -}; +use crate::linux::{LinuxCommon, LinuxKeyboardLayout, X11Window, modifiers_from_xinput_info}; -use crate::platform::wgpu::{CompositorGpuHint, GpuContext}; use gpui::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, - TouchPhase, WindowParams, point, px, + TouchPhase, WindowButtonLayout, WindowParams, point, px, }; +use gpui_wgpu::{CompositorGpuHint, GpuContext}; /// Value for DeviceId parameters which selects all devices. pub(crate) const XINPUT_ALL_DEVICES: xinput::DeviceId = 0; @@ -178,6 +176,7 @@ pub struct X11ClientState { pub(crate) last_mouse_button: Option, pub(crate) last_location: Point, pub(crate) current_count: usize, + pub(crate) pinch_scale: f32, pub(crate) gpu_context: GpuContext, pub(crate) compositor_gpu: Option, @@ -189,7 +188,7 @@ pub struct X11ClientState { xkb_device_id: i32, client_side_decorations_supported: bool, pub(crate) x_root_index: usize, - pub(crate) _resource_database: Database, + pub(crate) resource_database: Database, pub(crate) atoms: XcbAtoms, pub(crate) windows: HashMap, pub(crate) mouse_focused_window: Option, @@ -212,9 +211,13 @@ pub struct X11ClientState { pub(crate) cursor_handle: cursor::Handle, pub(crate) cursor_styles: HashMap, pub(crate) cursor_cache: HashMap>, + pub(crate) invisible_cursor_cache: Option, + pub(crate) cursor_hidden_window: Option, pointer_device_states: BTreeMap, + pub(crate) supports_xinput_gestures: bool, + pub(crate) common: LinuxCommon, pub(crate) clipboard: Clipboard, pub(crate) clipboard_item: Option, @@ -248,6 +251,9 @@ impl X11ClientStatePtr { if state.keyboard_focused_window == Some(x_window) { state.keyboard_focused_window = None; } + if state.cursor_hidden_window == Some(x_window) { + state.cursor_hidden_window = None; + } state.cursor_styles.remove(&x_window); } @@ -344,16 +350,26 @@ impl X11Client { xcb_connection.prefetch_extension_information(render::X11_EXTENSION_NAME)?; xcb_connection.prefetch_extension_information(xinput::X11_EXTENSION_NAME)?; - // Announce to X server that XInput up to 2.1 is supported. To increase this to 2.2 and - // beyond, support for touch events would need to be added. + // Announce to X server that XInput up to 2.4 is supported. + // Version 2.4 is needed for gesture events (GesturePinchBegin/Update/End). + // The server responds with the highest version it supports; if < 2.4, + // we must not request gesture event masks in XISelectEvents. let xinput_version = get_reply( || "XInput XiQueryVersion failed", - xcb_connection.xinput_xi_query_version(2, 1), + xcb_connection.xinput_xi_query_version(2, 4), )?; assert!( xinput_version.major_version >= 2, "XInput version >= 2 required." ); + let supports_xinput_gestures = xinput_version.major_version > 2 + || (xinput_version.major_version == 2 && xinput_version.minor_version >= 4); + log::info!( + "XInput version: {}.{}, gesture support: {}", + xinput_version.major_version, + xinput_version.minor_version, + supports_xinput_gestures, + ); let pointer_device_states = current_pointer_device_states(&xcb_connection, &BTreeMap::new()).unwrap_or_default(); @@ -474,6 +490,15 @@ impl X11Client { window.window.set_appearance(appearance); } } + XDPEvent::ButtonLayout(layout_str) => { + let layout = WindowButtonLayout::parse(&layout_str) + .log_err() + .unwrap_or_else(WindowButtonLayout::linux_default); + client.with_common(|common| common.button_layout = layout); + for window in client.0.borrow_mut().windows.values_mut() { + window.window.set_button_layout(); + } + } XDPEvent::CursorTheme(_) | XDPEvent::CursorSize(_) => { // noop, X11 manages this for us. } @@ -495,6 +520,7 @@ impl X11Client { last_mouse_button: None, last_location: Point::new(px(0.0), px(0.0)), current_count: 0, + pinch_scale: 1.0, gpu_context: Rc::new(RefCell::new(None)), compositor_gpu, scale_factor, @@ -504,7 +530,7 @@ impl X11Client { xkb_device_id, client_side_decorations_supported, x_root_index, - _resource_database: resource_database, + resource_database, atoms, windows: HashMap::default(), mouse_focused_window: None, @@ -522,9 +548,13 @@ impl X11Client { cursor_handle, cursor_styles: HashMap::default(), cursor_cache: HashMap::default(), + cursor_hidden_window: None, + invisible_cursor_cache: None, pointer_device_states, + supports_xinput_gestures, + clipboard, clipboard_item: None, xdnd_state: Xdnd::default(), @@ -885,7 +915,13 @@ impl X11Client { let paths: SmallVec<[_; 2]> = file_list .lines() .filter_map(|path| Url::parse(path).log_err()) - .filter_map(|url| url.to_file_path().log_err()) + .filter_map(|url| match url.to_file_path() { + Ok(url) => Some(url), + Err(()) => { + log::error!("Failed turn {url:?} into a file path"); + None + } + }) .collect(); let input = PlatformInput::FileDrop(FileDropEvent::Entered { position: state.xdnd_state.position, @@ -942,6 +978,7 @@ impl X11Client { compose_state.reset(); } state.pre_edit_text.take(); + state.restore_cursor_after_hide(); drop(state); self.reset_ime(); window.handle_ime_delete(); @@ -1011,24 +1048,17 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; state.pre_key_char_down.take(); - - // Macros containing modifiers might result in - // the modifiers missing from the event. - // We therefore update the mask from the global state. - update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let key_event_state = xkb_state_for_key_event(&state.xkb, event.state); let keystroke = { let code = event.detail.into(); - let mut keystroke = keystroke_from_xkb(&state.xkb, modifiers, code); - let keysym = state.xkb.key_get_one_sym(code); + let mut keystroke = keystroke_from_xkb(&key_event_state, modifiers, code); + let keysym = key_event_state.key_get_one_sym(code); if keysym.is_modifier_key() { return Some(()); } - // should be called after key_get_one_sym - state.xkb.update_key(code, xkbc::KeyDirection::Down); - if let Some(mut compose_state) = state.compose_state.take() { compose_state.feed(keysym); match compose_state.status() { @@ -1081,24 +1111,17 @@ impl X11Client { let modifiers = modifiers_from_state(event.state); state.modifiers = modifiers; - - // Macros containing modifiers might result in - // the modifiers missing from the event. - // We therefore update the mask from the global state. - update_xkb_mask_from_event_state(&mut state.xkb, event.state); + let key_event_state = xkb_state_for_key_event(&state.xkb, event.state); let keystroke = { let code = event.detail.into(); - let keystroke = keystroke_from_xkb(&state.xkb, modifiers, code); - let keysym = state.xkb.key_get_one_sym(code); + let keystroke = keystroke_from_xkb(&key_event_state, modifiers, code); + let keysym = key_event_state.key_get_one_sym(code); if keysym.is_modifier_key() { return Some(()); } - // should be called after key_get_one_sym - state.xkb.update_key(code, xkbc::KeyDirection::Up); - keystroke }; drop(state); @@ -1209,6 +1232,7 @@ impl X11Client { Event::XinputMotion(event) => { let window = self.get_window(event.event)?; let mut state = self.0.borrow_mut(); + state.restore_cursor_after_hide(); if window.is_blocked() { // We want to set the cursor to the default arrow // when the window is blocked @@ -1271,6 +1295,7 @@ impl X11Client { window.set_hovered(true); let mut state = self.0.borrow_mut(); state.mouse_focused_window = Some(event.event); + state.restore_cursor_after_hide(); } Event::XinputLeave(event) if event.mode == xinput::NotifyMode::NORMAL => { let mut state = self.0.borrow_mut(); @@ -1317,6 +1342,64 @@ impl X11Client { reset_pointer_device_scroll_positions(pointer); } } + Event::XinputGesturePinchBegin(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + state.pinch_scale = 1.0; + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: 0.0, + modifiers, + phase: gpui::TouchPhase::Started, + })); + } + Event::XinputGesturePinchUpdate(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + // scale is in FP16.16 format: divide by 65536 to get the float value + let new_absolute_scale = event.scale as f32 / 65536.0; + let previous_scale = state.pinch_scale; + let zoom_delta = new_absolute_scale - previous_scale; + state.pinch_scale = new_absolute_scale; + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: zoom_delta, + modifiers, + phase: gpui::TouchPhase::Moved, + })); + } + Event::XinputGesturePinchEnd(event) => { + let window = self.get_window(event.event)?; + let mut state = self.0.borrow_mut(); + state.pinch_scale = 1.0; + let modifiers = modifiers_from_xinput_info(event.mods); + state.modifiers = modifiers; + let position = point( + px(event.event_x as f32 / u16::MAX as f32 / state.scale_factor), + px(event.event_y as f32 / u16::MAX as f32 / state.scale_factor), + ); + drop(state); + window.handle_input(PlatformInput::Pinch(gpui::PinchEvent { + position, + delta: 0.0, + modifiers, + phase: gpui::TouchPhase::Ended, + })); + } _ => {} }; @@ -1484,7 +1567,7 @@ impl LinuxClient for X11Client { X11Display::new( &state.xcb_connection, state.scale_factor, - u32::from(id) as usize, + u64::from(id) as usize, ) .ok()?, )) @@ -1525,6 +1608,11 @@ impl LinuxClient for X11Client { let scale_factor = state.scale_factor; let appearance = state.common.appearance; let compositor_gpu = state.compositor_gpu.take(); + let supports_xinput_gestures = state.supports_xinput_gestures; + let is_bgr = state + .resource_database + .get_string("Xft.rgba", "Xft.Rgba") + .is_some_and(|v| v.eq_ignore_ascii_case("bgr")); let window = X11Window::new( handle, X11ClientStatePtr(Rc::downgrade(&self.0)), @@ -1540,6 +1628,8 @@ impl LinuxClient for X11Client { scale_factor, appearance, parent_window, + supports_xinput_gestures, + is_bgr, )?; check_reply( || "Failed to set XdndAware property", @@ -1587,11 +1677,17 @@ impl LinuxClient for X11Client { return; } + state.cursor_styles.insert(focused_window, style); + + // Don't clobber the invisible cursor; restore reads back from `cursor_styles`. + if state.cursor_hidden_window == Some(focused_window) { + return; + } + let Some(cursor) = state.get_cursor_icon(style) else { return; }; - state.cursor_styles.insert(focused_window, style); check_reply( || "Failed to set cursor style", state.xcb_connection.change_window_attributes( @@ -1606,6 +1702,14 @@ impl LinuxClient for X11Client { state.xcb_connection.flush().log_err(); } + fn hide_cursor_until_mouse_moves(&self) { + self.0.borrow_mut().hide_cursor_until_mouse_moves(); + } + + fn is_cursor_visible(&self) -> bool { + self.0.borrow().cursor_hidden_window.is_none() + } + fn open_uri(&self, uri: &str) { #[cfg(any(feature = "wayland", feature = "x11"))] open_uri_internal( @@ -1876,11 +1980,14 @@ impl X11ClientState { if let Some(window) = state.windows.get_mut(&x_window) { let expose_event_received = window.expose_event_received; window.expose_event_received = false; + let force_render = std::mem::take( + &mut window.window.state.borrow_mut().force_render_after_recovery, + ); let window = window.window.clone(); drop(state); window.refresh(RequestFrameOptions { require_presentation: expose_event_received, - force_render: false, + force_render, }); } xcb_connection @@ -1903,41 +2010,33 @@ impl X11ClientState { return *cursor; } - let result; - match style { - CursorStyle::None => match create_invisible_cursor(&self.xcb_connection) { - Ok(loaded_cursor) => result = Ok(loaded_cursor), - Err(err) => result = Err(err.context("X11: error while creating invisible cursor")), - }, - _ => 'outer: { - let mut errors = String::new(); - let cursor_icon_names = cursor_style_to_icon_names(style); - for cursor_icon_name in cursor_icon_names { - match self - .cursor_handle - .load_cursor(&self.xcb_connection, cursor_icon_name) - { - Ok(loaded_cursor) => { - if loaded_cursor != x11rb::NONE { - result = Ok(loaded_cursor); - break 'outer; - } - } - Err(err) => { - errors.push_str(&err.to_string()); - errors.push('\n'); + let result = 'outer: { + let mut errors = String::new(); + let cursor_icon_names = cursor_style_to_icon_names(style); + for cursor_icon_name in cursor_icon_names { + match self + .cursor_handle + .load_cursor(&self.xcb_connection, cursor_icon_name) + { + Ok(loaded_cursor) => { + if loaded_cursor != x11rb::NONE { + break 'outer Ok(loaded_cursor); } } + Err(err) => { + errors.push_str(&err.to_string()); + errors.push('\n'); + } } - if errors.is_empty() { - result = Err(anyhow!( - "errors while loading cursor icons {:?}:\n{}", - cursor_icon_names, - errors - )); - } else { - result = Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)); - } + } + if errors.is_empty() { + Err(anyhow!( + "errors while loading cursor icons {:?}:\n{}", + cursor_icon_names, + errors + )) + } else { + Err(anyhow!("did not find cursor icons {:?}", cursor_icon_names)) } }; @@ -1969,6 +2068,73 @@ impl X11ClientState { self.cursor_cache.insert(style, cursor); cursor } + + fn get_or_create_invisible_cursor(&mut self) -> Option { + if let Some(cursor) = self.invisible_cursor_cache { + return Some(cursor); + } + let cursor = create_invisible_cursor(&self.xcb_connection) + .context("X11: error while creating invisible cursor") + .log_err()?; + self.invisible_cursor_cache = Some(cursor); + Some(cursor) + } + + fn hide_cursor_until_mouse_moves(&mut self) { + if self.cursor_hidden_window.is_some() { + return; + } + let Some(focused_window) = self.mouse_focused_window else { + // No window to apply the per-window invisible cursor to. + return; + }; + let Some(invisible_cursor) = self.get_or_create_invisible_cursor() else { + return; + }; + check_reply( + || "Failed to hide cursor", + self.xcb_connection.change_window_attributes( + focused_window, + &ChangeWindowAttributesAux { + cursor: Some(invisible_cursor), + ..Default::default() + }, + ), + ) + .log_err(); + self.xcb_connection.flush().log_err(); + self.cursor_hidden_window = Some(focused_window); + } + + fn restore_cursor_after_hide(&mut self) { + let Some(hidden_window) = self.cursor_hidden_window.take() else { + return; + }; + let style = self + .cursor_styles + .get(&hidden_window) + .copied() + .unwrap_or(CursorStyle::Arrow); + let Some(cursor) = self.get_cursor_icon(style) else { + log::warn!( + "X11: no cursor icon available to restore {:?} after hide; cursor may stay invisible", + style + ); + return; + }; + check_reply( + || "Failed to restore cursor style after hide", + self.xcb_connection.change_window_attributes( + hidden_window, + &ChangeWindowAttributesAux { + cursor: Some(cursor), + ..Default::default() + }, + ), + ) + .log_err(); + self.xcb_connection.flush().log_err(); + } } // Adapted from: @@ -2008,7 +2174,7 @@ fn detect_compositor_gpu( let path = format!("/proc/self/fd/{}", fd.as_raw_fd()); let metadata = std::fs::metadata(&path).ok()?; - crate::platform::linux::compositor_gpu_hint_from_dev_t(metadata.rdev()) + crate::linux::compositor_gpu_hint_from_dev_t(metadata.rdev()) } fn check_compositor_present(xcb_connection: &XCBConnection, root: xproto::Window) -> bool { @@ -2332,7 +2498,7 @@ fn make_scroll_wheel_event( fn create_invisible_cursor( connection: &XCBConnection, -) -> anyhow::Result { +) -> anyhow::Result { let empty_pixmap = connection.generate_id()?; let root = connection.setup().roots[0].root; connection.create_pixmap(1, empty_pixmap, root, 1, 1)?; @@ -2599,17 +2765,352 @@ fn valid_scale_factor(scale_factor: f32) -> bool { } #[inline] -fn update_xkb_mask_from_event_state(xkb: &mut xkbc::State, event_state: xproto::KeyButMask) { - let depressed_mods = event_state.remove((ModMask::LOCK | ModMask::M2).bits()); - let latched_mods = xkb.serialize_mods(xkbc::STATE_MODS_LATCHED); - let locked_mods = xkb.serialize_mods(xkbc::STATE_MODS_LOCKED); - let locked_layout = xkb.serialize_layout(xkbc::STATE_LAYOUT_LOCKED); - xkb.update_mask( - depressed_mods.into(), - latched_mods, - locked_mods, - 0, - 0, - locked_layout, +fn xkb_state_for_key_event(xkb: &xkbc::State, event_state: xproto::KeyButMask) -> xkbc::State { + let keymap = xkb.get_keymap(); + let mut key_event_state = xkbc::State::new(&keymap); + + let latched_modifiers = xkb.serialize_mods(xkbc::STATE_MODS_LATCHED); + let locked_modifiers = xkb.serialize_mods(xkbc::STATE_MODS_LOCKED); + let active_modifier_mask: xkbc::ModMask = u16::from( + event_state + & (xproto::KeyButMask::SHIFT + | xproto::KeyButMask::LOCK + | xproto::KeyButMask::CONTROL + | xproto::KeyButMask::MOD1 + | xproto::KeyButMask::MOD2 + | xproto::KeyButMask::MOD3 + | xproto::KeyButMask::MOD4 + | xproto::KeyButMask::MOD5), + ) + .into(); + let depressed_modifiers = active_modifier_mask & !(latched_modifiers | locked_modifiers); + + key_event_state.update_mask( + depressed_modifiers, + latched_modifiers, + locked_modifiers, + xkb.serialize_layout(xkbc::STATE_LAYOUT_DEPRESSED), + xkb.serialize_layout(xkbc::STATE_LAYOUT_LATCHED), + xkb.serialize_layout(xkbc::STATE_LAYOUT_LOCKED), ); + + key_event_state +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_keymap(layouts: &str) -> xkbc::Keymap { + test_keymap_with_variant(layouts, "") + } + + fn test_keymap_with_variant(layouts: &str, variant: &str) -> xkbc::Keymap { + let context = xkbc::Context::new(xkbc::CONTEXT_NO_FLAGS); + xkbc::Keymap::new_from_names( + &context, + "", + "pc105", + layouts, + variant, + None, + xkbc::COMPILE_NO_FLAGS, + ) + .expect("test keymap should compile") + } + + // Returns a state where the second layout is active via a temporary + // mechanism (holding a key down or one-shot), not a permanent toggle. + fn state_with_non_locked_layout(keymap: &xkbc::Keymap) -> xkbc::State { + let mut depressed_layout_state = xkbc::State::new(keymap); + depressed_layout_state.update_mask(0, 0, 0, 1, 0, 0); + if depressed_layout_state.serialize_layout(STATE_LAYOUT_EFFECTIVE) == 1 { + return depressed_layout_state; + } + + let mut latched_layout_state = xkbc::State::new(keymap); + latched_layout_state.update_mask(0, 0, 0, 0, 1, 0); + if latched_layout_state.serialize_layout(STATE_LAYOUT_EFFECTIVE) == 1 { + return latched_layout_state; + } + + panic!("test keymap should support a non-locked secondary layout"); + } + + #[test] + fn key_event_state_uses_event_modifiers_without_mutating_server_state() { + let keymap = test_keymap("us"); + let server_state = xkbc::State::new(&keymap); + // The "9" key on a US keyboard. + let keycode = keymap + .key_by_name("AE09") + .expect("test key should exist in the keymap"); + + // Simulate pressing Shift+9 (which should produce "("). + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::SHIFT); + let keystroke = keystroke_from_xkb( + &key_event_state, + modifiers_from_state(xproto::KeyButMask::SHIFT), + keycode, + ); + + // Assert Shift+9 produces "(" on US layout. + assert_eq!(keystroke.key, "("); + assert_eq!(keystroke.key_char.as_deref(), Some("(")); + // Assert the long-lived server state was not mutated by the key event. + assert_eq!(server_state.key_get_utf8(keycode), "9"); + } + + #[test] + fn key_event_state_ignores_pointer_button_bits() { + let keymap = test_keymap("us"); + let server_state = xkbc::State::new(&keymap); + // The "9" key on a US keyboard. + let keycode = keymap + .key_by_name("AE09") + .expect("test key should exist in the keymap"); + + // Simulate Shift held down. + let shifted_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::SHIFT); + // Simulate Shift held down while also clicking the left mouse button. + let shifted_with_button_state = xkb_state_for_key_event( + &server_state, + xproto::KeyButMask::SHIFT | xproto::KeyButMask::BUTTON1, + ); + + // Assert the mouse button has no effect on modifier state. + assert_eq!( + shifted_with_button_state.serialize_mods(xkbc::STATE_MODS_EFFECTIVE), + shifted_state.serialize_mods(xkbc::STATE_MODS_EFFECTIVE) + ); + // Assert both cases produce the same character. + assert_eq!( + shifted_with_button_state.key_get_utf8(keycode), + shifted_state.key_get_utf8(keycode) + ); + } + + #[test] + fn key_event_state_preserves_non_locked_layout_components() { + // US + Russian dual-layout keyboard. + let keymap = test_keymap("us,ru"); + // Simulate the Russian layout being active via a temporary layout + // switch (holding a key), not a permanent toggle. + let server_state = state_with_non_locked_layout(&keymap); + // The "Q" key position, which produces a Cyrillic character in Russian layout. + let keycode = keymap + .key_by_name("AD01") + .expect("test key should exist in the keymap"); + + let expected_text = server_state.key_get_utf8(keycode); + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::default()); + + // Assert the temporary layout switch is preserved. + assert_eq!( + key_event_state.serialize_layout(STATE_LAYOUT_EFFECTIVE), + server_state.serialize_layout(STATE_LAYOUT_EFFECTIVE) + ); + // Assert the key produces the same character as expected from the + // Russian layout. + assert_eq!(key_event_state.key_get_utf8(keycode), expected_text); + } + + // https://github.com/zed-industries/zed/issues/14282 + #[test] + fn capslock_toggle_produces_uppercase() { + let keymap = test_keymap("us"); + let mut server_state = xkbc::State::new(&keymap); + // The "A" key position on a US keyboard. + let keycode = keymap + .key_by_name("AC01") + .expect("'a' key should exist in the keymap"); + + // Simulate the user having toggled CapsLock on (it's now permanently + // active until pressed again). + let lock_mod = u16::from(xproto::KeyButMask::LOCK) as xkbc::ModMask; + server_state.update_mask(0, 0, lock_mod, 0, 0, 0); + + // Simulate pressing the "a" key while CapsLock is on. + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::LOCK); + + // Assert CapsLock is treated as a toggle (locked), not as a held key + // (depressed). This distinction matters because XKB only applies + // capitalization when CapsLock is in the "locked" state. + assert_eq!( + key_event_state.serialize_mods(xkbc::STATE_MODS_LOCKED) & lock_mod, + lock_mod, + ); + // Assert typing "a" with CapsLock on produces "A". + assert_eq!(key_event_state.key_get_utf8(keycode), "A"); + } + + // https://github.com/zed-industries/zed/issues/14282 + #[test] + fn neo2_level3_via_capslock_produces_ellipsis() { + // Neo 2 is a German keyboard layout that repurposes CapsLock as a + // "level 3" modifier key for accessing additional characters. + let keymap = test_keymap_with_variant("de", "neo"); + let server_state = xkbc::State::new(&keymap); + // The key in the "Q" position, which produces "x" on Neo 2 base layer. + let keycode = keymap + .key_by_name("AD01") + .expect("test key should exist in the keymap"); + + // Simulate holding CapsLock, which in Neo 2 activates the "level 3" + // layer (mapped to the Mod5 modifier internally). + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::MOD5); + + // Assert holding CapsLock + pressing the "x" key produces "..." + // (ellipsis), which is the level 3 character on that key in Neo 2. + assert_eq!(key_event_state.key_get_utf8(keycode), "\u{2026}"); + } + + // https://github.com/zed-industries/zed/issues/14282 + #[test] + fn neo2_latched_mod5_preserved() { + // Neo 2 also supports "latching" the level 3 modifier (via Caps+Tab), + // which activates it for only the next keypress and then deactivates. + let keymap = test_keymap_with_variant("de", "neo"); + let mut server_state = xkbc::State::new(&keymap); + let keycode = keymap + .key_by_name("AD01") + .expect("test key should exist in the keymap"); + + // Simulate the level 3 modifier being latched (one-shot active). + let mod5 = u16::from(xproto::KeyButMask::MOD5) as xkbc::ModMask; + server_state.update_mask(0, mod5, 0, 0, 0, 0); + + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::MOD5); + + // Assert the modifier stays classified as "latched" (one-shot) rather + // than being reclassified as "depressed" (held down). This matters + // because latched modifiers auto-deactivate after one keypress. + assert_eq!( + key_event_state.serialize_mods(xkbc::STATE_MODS_LATCHED) & mod5, + mod5, + ); + // Assert the latched level 3 still produces the ellipsis character. + assert_eq!(key_event_state.key_get_utf8(keycode), "\u{2026}"); + } + + // https://github.com/zed-industries/zed/pull/31193 + #[test] + fn german_layout_correct_key_resolution() { + // Standard German keyboard layout. + let keymap = test_keymap("de"); + let server_state = xkbc::State::new(&keymap); + // The "7" key on the number row. + let keycode = keymap + .key_by_name("AE07") + .expect("'7' key should exist in the keymap"); + + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::default()); + + // Assert pressing the "7" key on a German layout produces "7". + assert_eq!(key_event_state.key_get_utf8(keycode), "7"); + } + + // https://github.com/zed-industries/zed/issues/26468 + // https://github.com/zed-industries/zed/issues/16667 + #[test] + fn space_works_with_cyrillic_layout_active() { + // US + Russian dual-layout keyboard. + let keymap = test_keymap("us,ru"); + let mut server_state = xkbc::State::new(&keymap); + let space = keymap + .key_by_name("SPCE") + .expect("space key should exist in the keymap"); + + // Simulate the user having switched to the Russian layout + // (e.g. via a keyboard shortcut like Super+Space). + server_state.update_mask(0, 0, 0, 0, 0, 1); + + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::default()); + + // Assert the Russian layout is still active after constructing the + // key event state (not accidentally reset to US). + assert_eq!(key_event_state.serialize_layout(STATE_LAYOUT_EFFECTIVE), 1); + // Assert pressing space while on the Russian layout still types a space. + assert_eq!(key_event_state.key_get_utf8(space), " "); + } + + // https://github.com/zed-industries/zed/issues/40678 + #[test] + fn macro_shift_bracket_produces_brace() { + let keymap = test_keymap("us"); + let server_state = xkbc::State::new(&keymap); + // The "]" key on a US keyboard. + let bracket = keymap + .key_by_name("AD12") + .expect("']' key should exist in the keymap"); + + // Simulate a keyboard macro (e.g. from a ZMK/QMK firmware keyboard) + // that sends Shift + "]" very rapidly. The modifier state notification + // for Shift hasn't reached us yet, so the server state has no + // modifiers. But the key event itself carries the correct Shift state. + assert_eq!(server_state.serialize_mods(xkbc::STATE_MODS_EFFECTIVE), 0); + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::SHIFT); + + // Assert Shift+"]" produces "}" even when the Shift notification + // arrived late. + assert_eq!(key_event_state.key_get_utf8(bracket), "}"); + } + + // https://github.com/zed-industries/zed/issues/49329 + #[test] + fn sequential_key_events_do_not_corrupt_state() { + let keymap = test_keymap("us"); + let server_state = xkbc::State::new(&keymap); + + // Simulate typing "a s d" with spaces in between, all without any + // modifier keys held. + let keys: &[(&str, &str)] = &[ + ("AC01", "a"), + ("SPCE", " "), + ("AC02", "s"), + ("SPCE", " "), + ("AC03", "d"), + ]; + + for &(key_name, expected_utf8) in keys { + let keycode = keymap + .key_by_name(key_name) + .expect("test key should exist in the keymap"); + + let key_event_state = + xkb_state_for_key_event(&server_state, xproto::KeyButMask::default()); + + // Assert each key in the sequence produces the expected character + // (no dropped or garbled input from state corruption). + assert_eq!( + key_event_state.key_get_utf8(keycode), + expected_utf8, + "key {key_name} should produce {expected_utf8:?}", + ); + } + + // Assert the server state is completely untouched after processing + // all key events. + assert_eq!(server_state.serialize_mods(xkbc::STATE_MODS_EFFECTIVE), 0); + assert_eq!(server_state.serialize_layout(STATE_LAYOUT_EFFECTIVE), 0); + } + + // https://github.com/zed-industries/zed/issues/26468 + #[test] + fn space_works_with_czech_layout_active() { + // US + Czech dual-layout keyboard. + let keymap = test_keymap("us,cz"); + let mut server_state = xkbc::State::new(&keymap); + let space = keymap + .key_by_name("SPCE") + .expect("space key should exist in the keymap"); + + // Simulate the user having switched to the Czech layout. + server_state.update_mask(0, 0, 0, 0, 0, 1); + + let key_event_state = xkb_state_for_key_event(&server_state, xproto::KeyButMask::default()); + + // Assert pressing space while on the Czech layout still types a space. + assert_eq!(key_event_state.key_get_utf8(space), " "); + } } diff --git a/src/platform/linux/x11/clipboard.rs b/crates/gpui_linux/src/linux/x11/clipboard.rs similarity index 97% rename from src/platform/linux/x11/clipboard.rs rename to crates/gpui_linux/src/linux/x11/clipboard.rs index d2ea58b3f8..fb6cecd904 100644 --- a/src/platform/linux/x11/clipboard.rs +++ b/crates/gpui_linux/src/linux/x11/clipboard.rs @@ -48,6 +48,7 @@ use x11rb::{ }; use gpui::{ClipboardItem, Image, ImageFormat, hash}; +use strum::IntoEnumIterator; type Result = std::result::Result; @@ -87,7 +88,7 @@ x11rb::atom_manager! { BMP__MIME: ImageFormat::mime_type(ImageFormat::Bmp ).as_bytes(), TIFF_MIME: ImageFormat::mime_type(ImageFormat::Tiff).as_bytes(), ICO__MIME: ImageFormat::mime_type(ImageFormat::Ico ).as_bytes(), - + PNM__MIME: ImageFormat::mime_type(ImageFormat::Pnm ).as_bytes(), // This is just some random name for the property on our window, into which // the clipboard owner writes the data we requested. ARBOARD_CLIPBOARD, @@ -989,14 +990,8 @@ impl Clipboard { self.inner.write(data, selection, wait) } - #[allow(unused)] - pub(crate) fn set_image( - &self, - image: Image, - selection: ClipboardKind, - wait: WaitConfig, - ) -> Result<()> { - let format = match image.format { + fn image_format_atom(&self, format: ImageFormat) -> Atom { + match format { ImageFormat::Png => self.inner.atoms.PNG__MIME, ImageFormat::Jpeg => self.inner.atoms.JPEG_MIME, ImageFormat::Webp => self.inner.atoms.WEBP_MIME, @@ -1005,7 +1000,18 @@ impl Clipboard { ImageFormat::Bmp => self.inner.atoms.BMP__MIME, ImageFormat::Tiff => self.inner.atoms.TIFF_MIME, ImageFormat::Ico => self.inner.atoms.ICO__MIME, - }; + ImageFormat::Pnm => self.inner.atoms.PNM__MIME, + } + } + + #[allow(unused)] + pub(crate) fn set_image( + &self, + image: Image, + selection: ClipboardKind, + wait: WaitConfig, + ) -> Result<()> { + let format = self.image_format_atom(image.format); let data = vec![ClipboardData { bytes: image.bytes, format: self.inner.atoms.PNG__MIME, @@ -1014,28 +1020,11 @@ impl Clipboard { } pub(crate) fn get_any(&self, selection: ClipboardKind) -> Result { - const IMAGE_FORMAT_COUNT: usize = 7; - let image_format_atoms: [Atom; IMAGE_FORMAT_COUNT] = [ - self.inner.atoms.PNG__MIME, - self.inner.atoms.JPEG_MIME, - self.inner.atoms.WEBP_MIME, - self.inner.atoms.GIF__MIME, - self.inner.atoms.SVG__MIME, - self.inner.atoms.BMP__MIME, - self.inner.atoms.TIFF_MIME, - ]; - let image_formats: [ImageFormat; IMAGE_FORMAT_COUNT] = [ - ImageFormat::Png, - ImageFormat::Jpeg, - ImageFormat::Webp, - ImageFormat::Gif, - ImageFormat::Svg, - ImageFormat::Bmp, - ImageFormat::Tiff, - ]; + let image_entries = ImageFormat::iter() + .map(|format| (self.image_format_atom(format), format)) + .collect::>(); - const TEXT_FORMAT_COUNT: usize = 6; - let text_format_atoms: [Atom; TEXT_FORMAT_COUNT] = [ + let text_format_atoms: &[Atom] = &[ self.inner.atoms.UTF8_STRING, self.inner.atoms.UTF8_MIME_0, self.inner.atoms.UTF8_MIME_1, @@ -1044,17 +1033,11 @@ impl Clipboard { self.inner.atoms.TEXT_MIME_UNKNOWN, ]; - let atom_none: Atom = AtomEnum::NONE.into(); - - const FORMAT_ATOM_COUNT: usize = TEXT_FORMAT_COUNT + IMAGE_FORMAT_COUNT; - - let mut format_atoms: [Atom; FORMAT_ATOM_COUNT] = [atom_none; FORMAT_ATOM_COUNT]; - // image formats first, as they are more specific, and read will return the first // format that the contents can be converted to - format_atoms[0..IMAGE_FORMAT_COUNT].copy_from_slice(&image_format_atoms); - format_atoms[IMAGE_FORMAT_COUNT..].copy_from_slice(&text_format_atoms); - debug_assert!(!format_atoms.contains(&atom_none)); + let mut format_atoms = Vec::with_capacity(image_entries.len() + text_format_atoms.len()); + format_atoms.extend(image_entries.iter().map(|(atom, _)| *atom)); + format_atoms.extend_from_slice(text_format_atoms); let result = self.inner.read(&format_atoms, selection)?; @@ -1063,7 +1046,7 @@ impl Clipboard { self.inner.atom_name(result.format) ); - for (format_atom, image_format) in image_format_atoms.into_iter().zip(image_formats) { + for (format_atom, image_format) in image_entries { if result.format == format_atom { let bytes = result.bytes; let id = hash(&bytes); diff --git a/src/platform/linux/x11/display.rs b/crates/gpui_linux/src/linux/x11/display.rs similarity index 96% rename from src/platform/linux/x11/display.rs rename to crates/gpui_linux/src/linux/x11/display.rs index 900c55e759..582d76f7f6 100644 --- a/src/platform/linux/x11/display.rs +++ b/crates/gpui_linux/src/linux/x11/display.rs @@ -38,7 +38,7 @@ impl X11Display { impl PlatformDisplay for X11Display { fn id(&self) -> DisplayId { - DisplayId::new(self.x_screen_index as u32) + DisplayId::new(self.x_screen_index as u64) } fn uuid(&self) -> anyhow::Result { diff --git a/src/platform/linux/x11/event.rs b/crates/gpui_linux/src/linux/x11/event.rs similarity index 100% rename from src/platform/linux/x11/event.rs rename to crates/gpui_linux/src/linux/x11/event.rs diff --git a/src/platform/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs similarity index 90% rename from src/platform/linux/x11/window.rs rename to crates/gpui_linux/src/linux/x11/window.rs index af976dd532..1f3ca51155 100644 --- a/src/platform/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1,8 +1,7 @@ use anyhow::{Context as _, anyhow}; use x11rb::connection::RequestConnection; -use crate::platform::linux::X11ClientStatePtr; -use crate::platform::wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; +use crate::linux::X11ClientStatePtr; use gpui::{ AnyWindowHandle, Bounds, Decorations, DevicePixels, ForegroundExecutor, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, @@ -10,6 +9,7 @@ use gpui::{ Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowDecorations, WindowKind, WindowParams, px, }; +use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; use collections::FxHashSet; use raw_window_handle as rwh; @@ -60,6 +60,7 @@ x11rb::atom_manager! { WM_TRANSIENT_FOR, _NET_WM_PID, _NET_WM_NAME, + _NET_WM_ICON, _NET_WM_STATE, _NET_WM_STATE_MAXIMIZED_VERT, _NET_WM_STATE_MAXIMIZED_HORZ, @@ -250,6 +251,7 @@ pub struct Callbacks { should_close: Option bool>>, close: Option>, appearance_changed: Option>, + button_layout_changed: Option>, } pub struct X11WindowState { @@ -276,12 +278,14 @@ pub struct X11WindowState { hidden: bool, active: bool, hovered: bool, + pub(crate) force_render_after_recovery: bool, fullscreen: bool, client_side_decorations_supported: bool, decorations: WindowDecorations, edge_constraints: Option, pub handle: AnyWindowHandle, last_insets: [u32; 4], + accesskit_adapter: Option, } impl X11WindowState { @@ -340,7 +344,7 @@ impl rwh::HasDisplayHandle for X11Window { }; let screen_id = { let state = self.0.state.borrow(); - u32::from(state.display.id()) as i32 + u64::from(state.display.id()) as i32 }; let handle = rwh::XcbDisplayHandle::new(Some(non_zero), screen_id); Ok(unsafe { rwh::DisplayHandle::borrow_raw(handle.into()) }) @@ -410,7 +414,7 @@ impl X11WindowState { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: crate::platform::wgpu::GpuContext, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, params: WindowParams, xcb: &Rc, @@ -421,10 +425,12 @@ impl X11WindowState { scale_factor: f32, appearance: WindowAppearance, parent_window: Option, + supports_xinput_gestures: bool, + is_bgr: bool, ) -> anyhow::Result { let x_screen_index = params .display_id - .map_or(x_main_screen_index, |did| u32::from(did) as usize); + .map_or(x_main_screen_index, |did| u64::from(did) as usize); let visual_set = find_visuals(xcb, x_screen_index); @@ -658,19 +664,27 @@ impl X11WindowState { ), )?; + let mut xi_event_mask = xinput::XIEventMask::MOTION + | xinput::XIEventMask::BUTTON_PRESS + | xinput::XIEventMask::BUTTON_RELEASE + | xinput::XIEventMask::ENTER + | xinput::XIEventMask::LEAVE; + if supports_xinput_gestures { + // x11rb 0.13 doesn't define XIEventMask constants for gesture + // events, so we construct them from the event opcodes (each + // XInput event type N maps to mask bit N). + xi_event_mask |= + xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_BEGIN_EVENT) + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_UPDATE_EVENT) + | xinput::XIEventMask::from(1u32 << xinput::GESTURE_PINCH_END_EVENT); + } check_reply( || "X11 XiSelectEvents failed.", xcb.xinput_xi_select_events( x_window, &[xinput::EventMask { deviceid: XINPUT_ALL_DEVICE_GROUPS, - mask: vec![ - xinput::XIEventMask::MOTION - | xinput::XIEventMask::BUTTON_PRESS - | xinput::XIEventMask::BUTTON_RELEASE - | xinput::XIEventMask::ENTER - | xinput::XIEventMask::LEAVE, - ], + mask: vec![xi_event_mask], }], ), )?; @@ -690,7 +704,7 @@ impl X11WindowState { xcb_flush(xcb); - let renderer = { + let mut renderer = { let raw_window = RawWindow { connection: as_raw_xcb_connection::AsRawXcbConnection::as_raw_xcb_connection( xcb, @@ -708,10 +722,13 @@ impl X11WindowState { // If the window appearance changes, then the renderer will get updated // too transparent: false, + preferred_present_mode: None, }; WgpuRenderer::new(gpu_context, &raw_window, config, compositor_gpu)? }; + renderer.set_subpixel_layout(is_bgr); + // Set max window size hints 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(); @@ -731,6 +748,29 @@ impl X11WindowState { size_hints.set_normal_hints(xcb, x_window), )?; + if let Some(image) = params.icon { + // https://specifications.freedesktop.org/wm-spec/1.4/ar01s05.html#id-1.6.13 + let property_size = 2 + (image.width() * image.height()) as usize; + let mut property_data: Vec = Vec::with_capacity(property_size); + property_data.push(image.width()); + property_data.push(image.height()); + property_data.extend(image.pixels().map(|px| { + let [r, g, b, a]: [u8; 4] = px.0; + u32::from_le_bytes([b, g, r, a]) + })); + + check_reply( + || "X11 ChangeProperty32 for _NET_ICON_NAME failed.", + xcb.change_property32( + xproto::PropMode::REPLACE, + x_window, + atoms._NET_WM_ICON, + xproto::AtomEnum::CARDINAL, + &property_data, + ), + )?; + } + let display = Rc::new(X11Display::new(xcb, scale_factor, x_screen_index)?); Ok(Self { @@ -749,6 +789,7 @@ impl X11WindowState { input_handler: None, active: false, hovered: false, + force_render_after_recovery: false, fullscreen: false, maximized_vertical: false, maximized_horizontal: false, @@ -761,6 +802,7 @@ impl X11WindowState { decorations: WindowDecorations::Server, last_insets: [0, 0, 0, 0], edge_constraints: None, + accesskit_adapter: None, counter_id: sync_request_counter, last_sync_counter: None, }) @@ -834,7 +876,7 @@ impl X11Window { handle: AnyWindowHandle, client: X11ClientStatePtr, executor: ForegroundExecutor, - gpu_context: crate::platform::wgpu::GpuContext, + gpu_context: gpui_wgpu::GpuContext, compositor_gpu: Option, params: WindowParams, xcb: &Rc, @@ -845,6 +887,8 @@ impl X11Window { scale_factor: f32, appearance: WindowAppearance, parent_window: Option, + supports_xinput_gestures: bool, + is_bgr: bool, ) -> anyhow::Result { let ptr = X11WindowStatePtr { state: Rc::new(RefCell::new(X11WindowState::new( @@ -862,6 +906,8 @@ impl X11Window { scale_factor, appearance, parent_window, + supports_xinput_gestures, + is_bgr, )?)), callbacks: Rc::new(RefCell::new(Callbacks::default())), xcb: xcb.clone(), @@ -1233,6 +1279,9 @@ impl X11WindowStatePtr { fun(focus); self.callbacks.borrow_mut().active_status_change = Some(fun); } + if let Some(adapter) = self.state.borrow_mut().accesskit_adapter.as_mut() { + adapter.update_window_focus_state(focus); + } } pub fn set_hovered(&self, focus: bool) { @@ -1256,6 +1305,14 @@ impl X11WindowStatePtr { self.callbacks.borrow_mut().appearance_changed = Some(fun); } } + + pub fn set_button_layout(&self) { + let callback = self.callbacks.borrow_mut().button_layout_changed.take(); + if let Some(mut fun) = callback { + fun(); + self.callbacks.borrow_mut().button_layout_changed = Some(fun); + } + } } impl PlatformWindow for X11Window { @@ -1602,6 +1659,10 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().appearance_changed = Some(callback); } + fn on_button_layout_changed(&self, callback: Box) { + self.0.callbacks.borrow_mut().button_layout_changed = Some(callback); + } + fn draw(&self, scene: &Scene) { let mut inner = self.0.state.borrow_mut(); @@ -1614,20 +1675,22 @@ impl PlatformWindow for X11Window { window_id: self.0.x_window, visual_id: inner.visual_id, }; - inner.renderer.recover(&raw_window).unwrap_or_else(|err| { - panic!( - "GPU device lost and recovery failed. \ - This may happen after system suspend/resume. \ - Please restart the application.\n\nError: {err}" - ) - }); + match inner.renderer.recover(&raw_window) { + Ok(()) => {} + Err(err) => { + log::warn!("GPU recovery failed, will retry on next frame: {err}"); + } + } - // The current scene references atlas textures that were cleared during recovery. - // Skip this frame and let the next frame rebuild the scene with fresh textures. + 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 { @@ -1823,4 +1886,89 @@ impl PlatformWindow for X11Window { fn gpu_specs(&self) -> Option { self.0.state.borrow().renderer.gpu_specs().into() } + + fn play_system_bell(&self) { + // Volume 0% means don't increase or decrease from system volume + let _ = self.0.xcb.bell(0); + } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let activation_handler = TrivialActivationHandler { + callback: callbacks.activation, + }; + let action_handler = TrivialActionHandler(callbacks.action); + let deactivation_handler = TrivialDeactivationHandler { + callback: callbacks.deactivation, + }; + + let adapter = + accesskit_unix::Adapter::new(activation_handler, action_handler, deactivation_handler); + + self.0.state.borrow_mut().accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let mut state = self.0.state.borrow_mut(); + if let Some(adapter) = state.accesskit_adapter.as_mut() { + adapter.update_if_active(|| tree_update); + } + } + + 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); + } + } +} + +struct TrivialActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for TrivialActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct TrivialActionHandler(Box); + +impl accesskit::ActionHandler for TrivialActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } +} + +struct TrivialDeactivationHandler { + callback: Box, +} + +impl accesskit::DeactivationHandler for TrivialDeactivationHandler { + fn deactivate_accessibility(&mut self) { + (self.callback)(); + } } diff --git a/src/platform/linux/x11/xim_handler.rs b/crates/gpui_linux/src/linux/x11/xim_handler.rs similarity index 100% rename from src/platform/linux/x11/xim_handler.rs rename to crates/gpui_linux/src/linux/x11/xim_handler.rs diff --git a/src/platform/linux/xdg_desktop_portal.rs b/crates/gpui_linux/src/linux/xdg_desktop_portal.rs similarity index 82% rename from src/platform/linux/xdg_desktop_portal.rs rename to crates/gpui_linux/src/linux/xdg_desktop_portal.rs index 911ac319db..9b5d72476b 100644 --- a/src/platform/linux/xdg_desktop_portal.rs +++ b/crates/gpui_linux/src/linux/xdg_desktop_portal.rs @@ -15,6 +15,7 @@ pub enum Event { CursorTheme(String), #[cfg_attr(feature = "x11", allow(dead_code))] CursorSize(u32), + ButtonLayout(String), } pub struct XDPEventSource { @@ -51,6 +52,13 @@ impl XDPEventSource { sender.send(Event::CursorSize(initial_size as u32))?; } + if let Ok(initial_layout) = settings + .read::("org.gnome.desktop.wm.preferences", "button-layout") + .await + { + sender.send(Event::ButtonLayout(initial_layout))?; + } + if let Ok(mut cursor_theme_changed) = settings .receive_setting_changed_with_args( "org.gnome.desktop.interface", @@ -89,6 +97,25 @@ impl XDPEventSource { .detach(); } + if let Ok(mut button_layout_changed) = settings + .receive_setting_changed_with_args( + "org.gnome.desktop.wm.preferences", + "button-layout", + ) + .await + { + let sender = sender.clone(); + background + .spawn(async move { + while let Some(layout) = button_layout_changed.next().await { + let layout = layout?; + sender.send(Event::ButtonLayout(layout))?; + } + anyhow::Ok(()) + }) + .detach(); + } + let mut appearance_changed = settings.receive_color_scheme_changed().await?; while let Some(scheme) = appearance_changed.next().await { sender.send(Event::WindowAppearance( diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml new file mode 100644 index 0000000000..2c9446401b --- /dev/null +++ b/crates/gpui_macos/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "gpui_macos" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/gpui_macos.rs" + +[features] +default = ["gpui/default"] +test-support = ["gpui/test-support"] +runtime_shaders = [] +font-kit = ["dep:font-kit"] +screen-capture = ["gpui/screen-capture"] + +[dependencies] +gpui.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +accesskit.workspace = true +accesskit_macos.workspace = true +anyhow.workspace = true +async-task = "4.7" +block = "0.1" +cocoa.workspace = true +collections.workspace = true +core-foundation.workspace = true +core-foundation-sys.workspace = true +core-graphics = "0.24" +core-text = "21" +core-video.workspace = true +ctor.workspace = true +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 = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } +foreign-types = "0.5" +futures.workspace = true +image.workspace = true +itertools.workspace = true +libc.workspace = true +log.workspace = true +mach2.workspace = true +media.workspace = true +metal.workspace = true +objc.workspace = true +objc2-app-kit.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/LICENSE-APACHE b/crates/gpui_macos/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_macos/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_macos/build.rs b/crates/gpui_macos/build.rs new file mode 100644 index 0000000000..d5c1893f4c --- /dev/null +++ b/crates/gpui_macos/build.rs @@ -0,0 +1,174 @@ +#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] + +fn main() { + #[cfg(target_os = "macos")] + macos_build::run(); +} + +#[cfg(target_os = "macos")] +mod macos_build { + use std::{ + env, + path::{Path, PathBuf}, + }; + + use cbindgen::Config; + + pub fn run() { + let header_path = generate_shader_bindings(); + + #[cfg(feature = "runtime_shaders")] + emit_stitched_shaders(&header_path); + #[cfg(not(feature = "runtime_shaders"))] + compile_metal_shaders(&header_path); + } + + fn generate_shader_bindings() -> PathBuf { + let output_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("scene.h"); + + let gpui_dir = find_gpui_crate_dir(); + + let mut config = Config { + include_guard: Some("SCENE_H".into()), + language: cbindgen::Language::C, + no_includes: true, + ..Default::default() + }; + config.export.include.extend([ + "Bounds".into(), + "Corners".into(), + "Edges".into(), + "Size".into(), + "Pixels".into(), + "PointF".into(), + "Hsla".into(), + "ContentMask".into(), + "Uniforms".into(), + "AtlasTile".into(), + "PathRasterizationInputIndex".into(), + "PathVertex_ScaledPixels".into(), + "PathRasterizationVertex".into(), + "ShadowInputIndex".into(), + "Shadow".into(), + "QuadInputIndex".into(), + "Underline".into(), + "UnderlineInputIndex".into(), + "Quad".into(), + "BorderStyle".into(), + "SpriteInputIndex".into(), + "MonochromeSprite".into(), + "PolychromeSprite".into(), + "PathSprite".into(), + "SurfaceInputIndex".into(), + "SurfaceBounds".into(), + "TransformationMatrix".into(), + ]); + config.no_includes = true; + config.enumeration.prefix_with_name = true; + + let mut builder = cbindgen::Builder::new(); + + let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + + // Source files from gpui that define types used in shaders + let gpui_src_paths = [ + gpui_dir.join("src/scene.rs"), + gpui_dir.join("src/geometry.rs"), + gpui_dir.join("src/color.rs"), + gpui_dir.join("src/window.rs"), + gpui_dir.join("src/platform.rs"), + ]; + + // Source files from this crate + let local_src_paths = [crate_dir.join("src/metal_renderer.rs")]; + + for src_path in gpui_src_paths.iter().chain(local_src_paths.iter()) { + println!("cargo:rerun-if-changed={}", src_path.display()); + builder = builder.with_src(src_path); + } + + builder + .with_config(config) + .generate() + .expect("Unable to generate bindings") + .write_to_file(&output_path); + + output_path + } + + /// Locate the gpui crate directory relative to this crate. + fn find_gpui_crate_dir() -> PathBuf { + gpui::GPUI_MANIFEST_DIR.into() + } + + /// To enable runtime compilation, we need to "stitch" the shaders file with the generated header + /// so that it is self-contained. + #[cfg(feature = "runtime_shaders")] + fn emit_stitched_shaders(header_path: &Path) { + fn stitch_header(header: &Path, shader_path: &Path) -> std::io::Result { + let header_contents = std::fs::read_to_string(header)?; + let shader_contents = std::fs::read_to_string(shader_path)?; + let stitched_contents = format!("{header_contents}\n{shader_contents}"); + let out_path = + PathBuf::from(env::var("OUT_DIR").unwrap()).join("stitched_shaders.metal"); + std::fs::write(&out_path, stitched_contents)?; + Ok(out_path) + } + let shader_source_path = "./src/shaders.metal"; + let shader_path = PathBuf::from(shader_source_path); + stitch_header(header_path, &shader_path).unwrap(); + println!("cargo:rerun-if-changed={}", &shader_source_path); + } + + #[cfg(not(feature = "runtime_shaders"))] + fn compile_metal_shaders(header_path: &Path) { + use std::process::{self, Command}; + let shader_path = "./src/shaders.metal"; + let air_output_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.air"); + let metallib_output_path = + PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metallib"); + println!("cargo:rerun-if-changed={}", shader_path); + + let output = Command::new("xcrun") + .args([ + "-sdk", + "macosx", + "metal", + "-gline-tables-only", + "-mmacosx-version-min=10.15.7", + "-MO", + "-c", + shader_path, + "-include", + (header_path.to_str().unwrap()), + "-o", + ]) + .arg(&air_output_path) + .output() + .unwrap(); + + if !output.status.success() { + println!( + "cargo::error=metal shader compilation failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + process::exit(1); + } + + let output = Command::new("xcrun") + .args(["-sdk", "macosx", "metallib"]) + .arg(air_output_path) + .arg("-o") + .arg(metallib_output_path) + .output() + .unwrap(); + + if !output.status.success() { + println!( + "cargo::error=metallib compilation failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + process::exit(1); + } + } +} diff --git a/src/platform/mac/dispatcher.rs b/crates/gpui_macos/src/dispatcher.rs similarity index 82% rename from src/platform/mac/dispatcher.rs rename to crates/gpui_macos/src/dispatcher.rs index dd6f546f68..f4b80ec7cb 100644 --- a/src/platform/mac/dispatcher.rs +++ b/crates/gpui_macos/src/dispatcher.rs @@ -1,7 +1,7 @@ use dispatch2::{DispatchQueue, DispatchQueueGlobalPriority, DispatchTime, GlobalQueueIdentifier}; use gpui::{ - GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, - THREAD_TIMINGS, TaskTiming, ThreadTaskTimings, + GLOBAL_THREAD_TIMINGS, PlatformDispatcher, Priority, RunnableMeta, RunnableVariant, TaskTiming, + ThreadTaskTimings, add_task_timing, }; use mach2::{ kern_return::KERN_SUCCESS, @@ -42,25 +42,7 @@ impl PlatformDispatcher for MacDispatcher { } 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, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { @@ -204,33 +186,16 @@ extern "C" fn trampoline(context: *mut c_void) { let location = runnable.metadata().location; let start = Instant::now(); - let timing = TaskTiming { + let mut 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); - }); + add_task_timing(timing); 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); - }); + timing.end = Some(Instant::now()); + add_task_timing(timing); } diff --git a/src/platform/mac/display.rs b/crates/gpui_macos/src/display.rs similarity index 97% rename from src/platform/mac/display.rs rename to crates/gpui_macos/src/display.rs index 4a48a1aae4..8e5db58935 100644 --- a/src/platform/mac/display.rs +++ b/crates/gpui_macos/src/display.rs @@ -1,10 +1,11 @@ -use super::ns_string; +use crate::ns_string; use anyhow::Result; use cocoa::{ appkit::NSScreen, base::{id, nil}, foundation::{NSArray, NSDictionary}, }; +use core_foundation::base::CFRelease; use core_foundation::uuid::{CFUUIDGetUUIDBytes, CFUUIDRef}; use core_graphics::display::{CGDirectDisplayID, CGDisplayBounds, CGGetActiveDisplayList}; use gpui::{Bounds, DisplayId, Pixels, PlatformDisplay, point, px, size}; @@ -72,7 +73,7 @@ unsafe extern "C" { impl PlatformDisplay for MacDisplay { fn id(&self) -> DisplayId { - DisplayId::new(self.0) + DisplayId::new(self.0 as u64) } fn uuid(&self) -> Result { @@ -83,6 +84,7 @@ impl PlatformDisplay for MacDisplay { ); let bytes = unsafe { CFUUIDGetUUIDBytes(cfuuid) }; + unsafe { CFRelease(cfuuid as _) }; Ok(Uuid::from_bytes([ bytes.byte0, bytes.byte1, diff --git a/src/platform/mac/display_link.rs b/crates/gpui_macos/src/display_link.rs similarity index 100% rename from src/platform/mac/display_link.rs rename to crates/gpui_macos/src/display_link.rs diff --git a/src/platform/mac/events.rs b/crates/gpui_macos/src/events.rs similarity index 99% rename from src/platform/mac/events.rs rename to crates/gpui_macos/src/events.rs index 8d4d9ea3ee..71bcb105e8 100644 --- a/src/platform/mac/events.rs +++ b/crates/gpui_macos/src/events.rs @@ -5,7 +5,7 @@ use gpui::{ ScrollWheelEvent, TouchPhase, point, px, }; -use super::{ +use crate::{ LMGetKbdType, NSStringExt, TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, UCKeyTranslate, kTISPropertyUnicodeKeyLayoutData, }; diff --git a/src/platform/mac.rs b/crates/gpui_macos/src/gpui_macos.rs similarity index 98% rename from src/platform/mac.rs rename to crates/gpui_macos/src/gpui_macos.rs index fcc0aada50..b9b55430b8 100644 --- a/src/platform/mac.rs +++ b/crates/gpui_macos/src/gpui_macos.rs @@ -1,3 +1,4 @@ +#![cfg(target_os = "macos")] //! macOS platform implementation for GPUI. //! //! macOS screens have a y axis that goes up from the bottom of the screen and diff --git a/src/platform/mac/keyboard.rs b/crates/gpui_macos/src/keyboard.rs similarity index 99% rename from src/platform/mac/keyboard.rs rename to crates/gpui_macos/src/keyboard.rs index b94509f38a..63e56a260a 100644 --- a/src/platform/mac/keyboard.rs +++ b/crates/gpui_macos/src/keyboard.rs @@ -5,7 +5,7 @@ use objc::{msg_send, runtime::Object, sel, sel_impl}; use gpui::{KeybindingKeystroke, Keystroke, PlatformKeyboardLayout, PlatformKeyboardMapper}; -use super::{ +use crate::{ TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, kTISPropertyInputSourceID, kTISPropertyLocalizedName, }; @@ -69,6 +69,8 @@ impl MacKeyboardLayout { let name: *const std::os::raw::c_char = msg_send![name, UTF8String]; let name = CStr::from_ptr(name).to_str().unwrap().to_string(); + let _: () = msg_send![current_keyboard, release]; + Self { id, name } } } diff --git a/src/platform/mac/metal_atlas.rs b/crates/gpui_macos/src/metal_atlas.rs similarity index 76% rename from src/platform/mac/metal_atlas.rs rename to crates/gpui_macos/src/metal_atlas.rs index eacd9407fe..5bedf9df8c 100644 --- a/src/platform/mac/metal_atlas.rs +++ b/crates/gpui_macos/src/metal_atlas.rs @@ -44,7 +44,7 @@ impl PlatformAtlas for MetalAtlas { ) -> Result> { let mut lock = self.0.lock(); if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) + Ok(Some(*tile)) } else { let Some((size, bytes)) = build()? else { return Ok(None); @@ -54,14 +54,14 @@ impl PlatformAtlas for MetalAtlas { .context("failed to allocate")?; let texture = lock.texture(tile.texture_id); texture.upload(tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); + lock.tiles_by_key.insert(key.clone(), tile); Ok(Some(tile)) } } fn remove(&self, key: &AtlasKey) { let mut lock = self.0.lock(); - let Some(id) = lock.tiles_by_key.get(key).map(|v| v.texture_id) else { + let Some(id) = lock.tiles_by_key.remove(key).map(|v| v.texture_id) else { return; }; @@ -81,10 +81,8 @@ impl PlatformAtlas for MetalAtlas { if let Some(mut texture) = texture_slot.take() { texture.decrement_ref_count(); - if texture.is_unreferenced() { textures.free_list.push(id.index as usize); - lock.tiles_by_key.remove(key); } else { *texture_slot = Some(texture); } @@ -271,3 +269,81 @@ fn point_from_etagere(value: etagere::Point) -> Point { struct AssertSend(T); unsafe impl Send for AssertSend {} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::PlatformAtlas; + use std::borrow::Cow; + + fn create_atlas() -> Option { + let device = metal::Device::system_default()?; + Some(MetalAtlas::new(device, true)) + } + + fn make_image_key(image_id: usize, frame_index: usize) -> AtlasKey { + AtlasKey::Image(gpui::RenderImageParams { + image_id: gpui::ImageId(image_id), + frame_index, + }) + } + + fn insert_tile(atlas: &MetalAtlas, key: &AtlasKey, size: Size) -> AtlasTile { + 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![0u8; byte_count])))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + } + + #[test] + fn test_remove_clears_stale_keys_from_tiles_by_key() { + let Some(atlas) = create_atlas() else { + return; + }; + + let small = Size { + width: DevicePixels(64), + height: DevicePixels(64), + }; + + let key_a = make_image_key(1, 0); + let key_b = make_image_key(2, 0); + let key_c = make_image_key(3, 0); + + let tile_a = insert_tile(&atlas, &key_a, small); + let tile_b = insert_tile(&atlas, &key_b, small); + let tile_c = insert_tile(&atlas, &key_c, small); + + assert_eq!(tile_a.texture_id, tile_b.texture_id); + assert_eq!(tile_b.texture_id, tile_c.texture_id); + + // Remove A: texture still has B and C, so it stays. + // The key for A must be removed from tiles_by_key. + atlas.remove(&key_a); + + // Remove B: texture still has C. + atlas.remove(&key_b); + + // Remove C: texture becomes unreferenced and is deleted. + atlas.remove(&key_c); + + // Re-inserting A must allocate a fresh tile on a new texture, + // NOT return a stale tile referencing the deleted texture. + let tile_a2 = insert_tile(&atlas, &key_a, small); + + // The texture must actually exist — this would panic before the fix. + let _texture = atlas.metal_texture(tile_a2.texture_id); + } + + #[test] + fn test_remove_nonexistent_key_is_noop() { + let Some(atlas) = create_atlas() else { + return; + }; + let key = make_image_key(999, 0); + atlas.remove(&key); + } +} diff --git a/src/platform/mac/metal_renderer.rs b/crates/gpui_macos/src/metal_renderer.rs similarity index 99% rename from src/platform/mac/metal_renderer.rs rename to crates/gpui_macos/src/metal_renderer.rs index 2e8c3f59b5..73b53ce6ea 100644 --- a/src/platform/mac/metal_renderer.rs +++ b/crates/gpui_macos/src/metal_renderer.rs @@ -1,4 +1,4 @@ -use super::metal_atlas::MetalAtlas; +use crate::metal_atlas::MetalAtlas; use anyhow::Result; use block::ConcreteBlock; use cocoa::{ @@ -1469,7 +1469,7 @@ impl MetalRenderer { buffer_contents, SurfaceBounds { bounds: surface.bounds, - content_mask: surface.content_mask.clone(), + content_mask: surface.content_mask, }, ); } diff --git a/src/platform/mac/open_type.rs b/crates/gpui_macos/src/open_type.rs similarity index 100% rename from src/platform/mac/open_type.rs rename to crates/gpui_macos/src/open_type.rs diff --git a/src/platform/mac/pasteboard.rs b/crates/gpui_macos/src/pasteboard.rs similarity index 98% rename from src/platform/mac/pasteboard.rs rename to crates/gpui_macos/src/pasteboard.rs index d2c07a0cac..8362ab8f3b 100644 --- a/src/platform/mac/pasteboard.rs +++ b/crates/gpui_macos/src/pasteboard.rs @@ -14,7 +14,7 @@ use objc::{msg_send, runtime::Object, sel, sel_impl}; use smallvec::SmallVec; use strum::IntoEnumIterator as _; -use super::ns_string; +use crate::ns_string; use gpui::{ ClipboardEntry, ClipboardItem, ClipboardString, ExternalPaths, Image, ImageFormat, hash, }; @@ -272,6 +272,7 @@ impl From for UTType { ImageFormat::Bmp => Self::bmp(), ImageFormat::Svg => Self::svg(), ImageFormat::Ico => Self::ico(), + ImageFormat::Pnm => Self::pnm(), } } } @@ -320,6 +321,11 @@ impl UTType { Self(unsafe { NSPasteboardTypeTIFF }) // This is a rare case where there's a built-in NSPasteboardType } + pub fn pnm() -> Self { + //https://en.wikipedia.org/w/index.php?title=Netpbm&oldid=1336679433 under Uniform Type Identifier + Self(unsafe { ns_string("public.pbm") }) + } + fn inner(&self) -> *const Object { self.0 } diff --git a/src/platform/mac/platform.rs b/crates/gpui_macos/src/platform.rs similarity index 93% rename from src/platform/mac/platform.rs rename to crates/gpui_macos/src/platform.rs index 69f8e91e6f..87346991d6 100644 --- a/src/platform/mac/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -1,8 +1,8 @@ -use super::{ +use crate::{ BoolExt, MacDispatcher, MacDisplay, MacKeyboardLayout, MacKeyboardMapper, MacWindow, events::key_to_native, ns_string, pasteboard::Pasteboard, renderer, + set_active_window_cursor_style, }; -use crate::command::{new_command, new_std_command}; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; use cocoa::{ @@ -52,9 +52,15 @@ use std::{ ptr, rc::Rc, slice, str, - sync::{Arc, OnceLock}, + sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, + }, +}; +use util::{ + ResultExt, + command::{new_command, new_std_command}, }; -use util::ResultExt; #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; @@ -63,7 +69,7 @@ const MAC_PLATFORM_IVAR: &str = "platform"; static mut APP_CLASS: *const Class = ptr::null(); static mut APP_DELEGATE_CLASS: *const Class = ptr::null(); -#[ctor] +#[ctor(unsafe)] unsafe fn build_classes() { unsafe { APP_CLASS = { @@ -73,7 +79,7 @@ unsafe fn build_classes() { } }; unsafe { - APP_DELEGATE_CLASS = unsafe { + APP_DELEGATE_CLASS = { let mut decl = ClassDecl::new("GPUIApplicationDelegate", class!(NSResponder)).unwrap(); decl.add_ivar::<*mut c_void>(MAC_PLATFORM_IVAR); decl.add_method( @@ -153,7 +159,6 @@ unsafe fn build_classes() { } } -/// The macOS implementation of the GPUI platform. pub struct MacPlatform(Mutex); pub(crate) struct MacPlatformState { @@ -177,15 +182,16 @@ pub(crate) struct MacPlatformState { dock_menu: Option, menus: Option>, keyboard_mapper: Rc, + /// Mirrors `[NSCursor setHiddenUntilMouseMoves:]` state, which AppKit doesn't expose. + cursor_visible: Arc, } impl MacPlatform { - /// Creates a new MacPlatform. pub fn new(headless: bool) -> Self { let dispatcher = Arc::new(MacDispatcher::new()); #[cfg(feature = "font-kit")] - let text_system = Arc::new(super::MacTextSystem::new()); + let text_system = Arc::new(crate::MacTextSystem::new()); #[cfg(not(feature = "font-kit"))] let text_system = Arc::new(gpui::NoopTextSystem::new()); @@ -214,6 +220,7 @@ impl MacPlatform { on_thermal_state_change: None, menus: None, keyboard_mapper, + cursor_visible: Arc::new(AtomicBool::new(true)), })) } @@ -513,17 +520,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. @@ -618,12 +627,22 @@ impl Platform for MacPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> Result> { - let renderer_context = self.0.lock().renderer_context.clone(); + 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, - self.foreground_executor(), - self.background_executor(), + cursor_visible, + foreground_executor, + background_executor, renderer_context, ))) } @@ -632,7 +651,7 @@ impl Platform for MacPlatform { unsafe { let app = NSApplication::sharedApplication(nil); let appearance: id = msg_send![app, effectiveAppearance]; - super::window_appearance::window_appearance_from_native(appearance) + crate::window_appearance::window_appearance_from_native(appearance) } } @@ -854,12 +873,13 @@ impl Platform for MacPlatform { .lock() .background_executor .spawn(async move { - let mut child: Option = new_command("open") + if let Some(mut child) = new_command("open") + .arg("--") .arg(path) .spawn() .context("invoking open command") - .log_err(); - if let Some(mut child) = child { + .log_err() + { child.status().await.log_err(); } }) @@ -978,55 +998,24 @@ impl Platform for MacPlatform { /// in macOS's [NSCursor](https://developer.apple.com/documentation/appkit/nscursor). fn set_cursor_style(&self, style: CursorStyle) { unsafe { - if style == CursorStyle::None { - let _: () = msg_send![class!(NSCursor), setHiddenUntilMouseMoves:YES]; - return; - } - - 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]; - } + set_active_window_cursor_style(style); } } + fn hide_cursor_until_mouse_moves(&self) { + let cursor_visible = self.0.lock().cursor_visible.clone(); + if !cursor_visible.swap(false, Ordering::Relaxed) { + return; + } + unsafe { + let _: () = msg_send![class!(NSCursor), setHiddenUntilMouseMoves: YES]; + } + } + + fn is_cursor_visible(&self) -> bool { + self.0.lock().cursor_visible.load(Ordering::Relaxed) + } + fn should_auto_hide_scrollbars(&self) -> bool { #[allow(non_upper_case_globals)] const NSScrollerStyleOverlay: NSInteger = 1; @@ -1389,6 +1378,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, @@ -1410,6 +1400,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/src/platform/mac/screen_capture.rs b/crates/gpui_macos/src/screen_capture.rs similarity index 99% rename from src/platform/mac/screen_capture.rs rename to crates/gpui_macos/src/screen_capture.rs index f358262e72..5ece21a34c 100644 --- a/src/platform/mac/screen_capture.rs +++ b/crates/gpui_macos/src/screen_capture.rs @@ -1,4 +1,4 @@ -use super::ns_string; +use crate::ns_string; use anyhow::{Result, anyhow}; use block::ConcreteBlock; use cocoa::{ @@ -28,7 +28,7 @@ use objc::{ }; use std::{cell::RefCell, ffi::c_void, mem, ptr, rc::Rc}; -use super::NSStringExt; +use crate::NSStringExt; #[derive(Clone)] pub struct MacScreenCaptureSource { @@ -284,7 +284,7 @@ pub(crate) fn get_sources() -> oneshot::Receiver 1, d >= 0.5 -> 0. + alpha = 1. - alpha; + float element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); + } + return input.color * float4(1., 1., 1., alpha); } @@ -1215,6 +1228,20 @@ float4 fill_color(Background background, break; } } + + // Dither to reduce banding in gradients (especially dark/alpha). + // Triangular-distributed noise breaks up 8-bit quantization steps. + // ±2/255 for RGB (enough for dark-on-dark compositing), + // ±3/255 for alpha (needs more because alpha × dark color = tiny steps). + { + float2 seed = position * 0.6180339887; // golden ratio spread + float r1 = fract(sin(dot(seed, float2(12.9898, 78.233))) * 43758.5453); + float r2 = fract(sin(dot(seed, float2(39.3460, 11.135))) * 24634.6345); + float tri = r1 + r2 - 1.0; // triangular PDF, range [-1, +1] + color.rgb += tri * 2.0 / 255.0; + color.a += tri * 3.0 / 255.0; + } + break; } case 2: { @@ -1237,14 +1264,14 @@ float4 fill_color(Background background, // checkerboard float size = background.gradient_angle_or_pattern_height; float2 relative_position = position - float2(bounds.origin.x, bounds.origin.y); - + float x_index = floor(relative_position.x / size); float y_index = floor(relative_position.y / size); float should_be_colored = fmod(x_index + y_index, 2.0); - + color = solid_color; color.a *= saturate(should_be_colored); - break; + break; } } diff --git a/src/platform/mac/text_system.rs b/crates/gpui_macos/src/text_system.rs similarity index 92% rename from src/platform/mac/text_system.rs rename to crates/gpui_macos/src/text_system.rs index adc3e0f9bd..2808e535ad 100644 --- a/src/platform/mac/text_system.rs +++ b/crates/gpui_macos/src/text_system.rs @@ -4,7 +4,7 @@ use collections::HashMap; 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,9 +46,9 @@ 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 super::open_type::apply_features_and_fallbacks; +use crate::open_type::apply_features_and_fallbacks; #[allow(non_upper_case_globals)] const kCGImageAlphaOnly: u32 = 7; @@ -214,6 +214,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 { @@ -361,7 +397,7 @@ 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); - let mut bounds: Bounds = 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, @@ -369,14 +405,8 @@ impl MacTextSystemState { font_kit::canvas::RasterizationOptions::GrayscaleAa, )?); - // Add 3% of font size as padding, clamped between 1 and 5 pixels - // to avoid clipping of anti-aliased edges. - let pad = - ((params.font_size.as_f32() * 0.03 * params.scale_factor).ceil() as i32).clamp(1, 5); - bounds.origin.x -= DevicePixels(pad); - bounds.size.width += DevicePixels(pad); - - Ok(bounds) + // Expand the bounds by 1 pixel on each side to give CG room for anti-aliasing. + Ok(bounds.dilate(DevicePixels(1))) } fn rasterize_glyph( @@ -438,13 +468,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) @@ -705,7 +742,7 @@ mod lenient_font_attributes { #[cfg(test)] mod tests { - use super::MacTextSystem; + use crate::MacTextSystem; use gpui::{FontRun, GlyphId, PlatformTextSystem, font, px}; #[test] diff --git a/src/platform/mac/window.rs b/crates/gpui_macos/src/window.rs similarity index 80% rename from src/platform/mac/window.rs rename to crates/gpui_macos/src/window.rs index fca15ac669..68cb31d9ea 100644 --- a/src/platform/mac/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -1,5 +1,7 @@ -use super::{ - BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, events::platform_input_from_native, +use crate::{ + BoolExt, DisplayLink, MacDisplay, NSRange, NSStringExt, TISCopyCurrentKeyboardInputSource, + TISGetInputSourceProperty, events::platform_input_from_native, + kTISPropertyInputSourceIsASCIICapable, kTISPropertyInputSourceType, kTISTypeKeyboardInputMode, ns_string, renderer, }; #[cfg(any(test, feature = "test-support"))] @@ -23,17 +25,20 @@ use cocoa::{ }; use dispatch2::DispatchQueue; use gpui::{ - AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, ExternalPaths, FileDropEvent, - ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, - MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, PlatformAtlas, PlatformDisplay, - PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, - RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance, + AnyWindowHandle, BackgroundExecutor, Bounds, Capslock, CursorStyle, ExternalPaths, + FileDropEvent, ForegroundExecutor, KeyDownEvent, Keystroke, Modifiers, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, 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_foundation::base::{CFRelease, CFTypeRef}; +use core_foundation_sys::base::CFEqual; +use core_foundation_sys::number::{CFBooleanGetValue, CFBooleanRef}; use core_graphics::display::{CGDirectDisplayID, CGPoint, CGRect}; use ctor::ctor; use futures::channel::oneshot; @@ -44,6 +49,7 @@ use objc::{ runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES}, sel, sel_impl, }; +use objc2_app_kit::NSBeep; use parking_lot::Mutex; use raw_window_handle as rwh; use smallvec::SmallVec; @@ -55,7 +61,10 @@ use std::{ path::PathBuf, ptr::{self, NonNull}, rc::Rc, - sync::{Arc, Weak}, + sync::{ + Arc, Weak, + atomic::{AtomicBool, Ordering}, + }, time::Duration, }; use util::ResultExt; @@ -113,7 +122,7 @@ unsafe extern "C" { ) -> i32; } -#[ctor] +#[ctor(unsafe)] unsafe fn build_classes() { unsafe { WINDOW_CLASS = build_window_class("GPUIWindow", class!(NSWindow)); @@ -121,169 +130,177 @@ unsafe fn build_classes() { VIEW_CLASS = { let mut decl = ClassDecl::new("GPUIView", class!(NSView)).unwrap(); decl.add_ivar::<*mut c_void>(WINDOW_STATE_IVAR); - unsafe { - decl.add_method(sel!(dealloc), dealloc_view as extern "C" fn(&Object, Sel)); + decl.add_method(sel!(dealloc), dealloc_view as extern "C" fn(&Object, Sel)); - decl.add_method( - sel!(performKeyEquivalent:), - handle_key_equivalent as extern "C" fn(&Object, Sel, id) -> BOOL, - ); - decl.add_method( - sel!(keyDown:), - handle_key_down as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(keyUp:), - handle_key_up as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(rightMouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(rightMouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(otherMouseDown:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(otherMouseUp:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseMoved:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(pressureChangeWithEvent:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseExited:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(magnifyWithEvent:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(mouseDragged:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(scrollWheel:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(swipeWithEvent:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); - decl.add_method( - sel!(flagsChanged:), - handle_view_event as extern "C" fn(&Object, Sel, id), - ); + decl.add_method( + sel!(performKeyEquivalent:), + handle_key_equivalent as extern "C" fn(&Object, Sel, id) -> BOOL, + ); + decl.add_method( + sel!(keyDown:), + handle_key_down as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(keyUp:), + handle_key_up as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(rightMouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(rightMouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseDown:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseUp:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + 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), + ); + decl.add_method( + sel!(mouseExited:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(magnifyWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(mouseDragged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(rightMouseDragged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(otherMouseDragged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(scrollWheel:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(swipeWithEvent:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(flagsChanged:), + handle_view_event as extern "C" fn(&Object, Sel, id), + ); - decl.add_method( - sel!(makeBackingLayer), - make_backing_layer as extern "C" fn(&Object, Sel) -> id, - ); + decl.add_method( + sel!(makeBackingLayer), + make_backing_layer as extern "C" fn(&Object, Sel) -> id, + ); - decl.add_protocol(Protocol::get("CALayerDelegate").unwrap()); - decl.add_method( - sel!(viewDidChangeBackingProperties), - view_did_change_backing_properties as extern "C" fn(&Object, Sel), - ); - decl.add_method( - sel!(setFrameSize:), - set_frame_size as extern "C" fn(&Object, Sel, NSSize), - ); - decl.add_method( - sel!(displayLayer:), - display_layer as extern "C" fn(&Object, Sel, id), - ); + decl.add_protocol(Protocol::get("CALayerDelegate").unwrap()); + decl.add_method( + sel!(viewDidChangeBackingProperties), + view_did_change_backing_properties as extern "C" fn(&Object, Sel), + ); + decl.add_method( + sel!(setFrameSize:), + set_frame_size as extern "C" fn(&Object, Sel, NSSize), + ); + decl.add_method( + sel!(displayLayer:), + display_layer as extern "C" fn(&Object, Sel, id), + ); - decl.add_protocol(Protocol::get("NSTextInputClient").unwrap()); - decl.add_method( - sel!(validAttributesForMarkedText), - valid_attributes_for_marked_text as extern "C" fn(&Object, Sel) -> id, - ); - decl.add_method( - sel!(hasMarkedText), - has_marked_text as extern "C" fn(&Object, Sel) -> BOOL, - ); - decl.add_method( - sel!(markedRange), - marked_range as extern "C" fn(&Object, Sel) -> NSRange, - ); - decl.add_method( - sel!(selectedRange), - selected_range as extern "C" fn(&Object, Sel) -> NSRange, - ); - decl.add_method( - sel!(firstRectForCharacterRange:actualRange:), - first_rect_for_character_range - as extern "C" fn(&Object, Sel, NSRange, id) -> NSRect, - ); - decl.add_method( - sel!(insertText:replacementRange:), - insert_text as extern "C" fn(&Object, Sel, id, NSRange), - ); - decl.add_method( - sel!(setMarkedText:selectedRange:replacementRange:), - set_marked_text as extern "C" fn(&Object, Sel, id, NSRange, NSRange), - ); - decl.add_method(sel!(unmarkText), unmark_text as extern "C" fn(&Object, Sel)); - decl.add_method( - sel!(attributedSubstringForProposedRange:actualRange:), - attributed_substring_for_proposed_range - as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> id, - ); - decl.add_method( - sel!(viewDidChangeEffectiveAppearance), - view_did_change_effective_appearance as extern "C" fn(&Object, Sel), - ); + decl.add_protocol(Protocol::get("NSTextInputClient").unwrap()); + decl.add_method( + sel!(validAttributesForMarkedText), + valid_attributes_for_marked_text as extern "C" fn(&Object, Sel) -> id, + ); + decl.add_method( + sel!(hasMarkedText), + has_marked_text as extern "C" fn(&Object, Sel) -> BOOL, + ); + decl.add_method( + sel!(markedRange), + marked_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + decl.add_method( + sel!(selectedRange), + selected_range as extern "C" fn(&Object, Sel) -> NSRange, + ); + decl.add_method( + sel!(firstRectForCharacterRange:actualRange:), + first_rect_for_character_range + as extern "C" fn(&Object, Sel, NSRange, id) -> NSRect, + ); + decl.add_method( + sel!(insertText:replacementRange:), + insert_text as extern "C" fn(&Object, Sel, id, NSRange), + ); + decl.add_method( + sel!(setMarkedText:selectedRange:replacementRange:), + set_marked_text as extern "C" fn(&Object, Sel, id, NSRange, NSRange), + ); + decl.add_method(sel!(unmarkText), unmark_text as extern "C" fn(&Object, Sel)); + decl.add_method( + sel!(attributedSubstringForProposedRange:actualRange:), + attributed_substring_for_proposed_range + as extern "C" fn(&Object, Sel, NSRange, *mut c_void) -> id, + ); + decl.add_method( + sel!(viewDidChangeEffectiveAppearance), + view_did_change_effective_appearance as extern "C" fn(&Object, Sel), + ); - // Suppress beep on keystrokes with modifier keys. - decl.add_method( - sel!(doCommandBySelector:), - do_command_by_selector as extern "C" fn(&Object, Sel, Sel), - ); + // Suppress beep on keystrokes with modifier keys. + decl.add_method( + sel!(doCommandBySelector:), + do_command_by_selector as extern "C" fn(&Object, Sel, Sel), + ); - decl.add_method( - sel!(acceptsFirstMouse:), - accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, - ); + decl.add_method( + sel!(acceptsFirstMouse:), + accepts_first_mouse as extern "C" fn(&Object, Sel, id) -> BOOL, + ); - decl.add_method( - sel!(characterIndexForPoint:), - character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, - ); - } + decl.add_method( + sel!(characterIndexForPoint:), + character_index_for_point as extern "C" fn(&Object, Sel, NSPoint) -> u64, + ); decl.register() }; BLURRED_VIEW_CLASS = { let mut decl = ClassDecl::new("BlurredView", class!(NSVisualEffectView)).unwrap(); - unsafe { - decl.add_method( - sel!(initWithFrame:), - blurred_view_init_with_frame as extern "C" fn(&Object, Sel, NSRect) -> id, - ); - decl.add_method( - sel!(updateLayer), - blurred_view_update_layer as extern "C" fn(&Object, Sel), - ); - decl.register() - } + decl.add_method( + sel!(initWithFrame:), + blurred_view_init_with_frame as extern "C" fn(&Object, Sel, NSRect) -> id, + ); + decl.add_method( + sel!(updateLayer), + blurred_view_update_layer as extern "C" fn(&Object, Sel), + ); + decl.register() }; } } @@ -296,6 +313,46 @@ 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. The class check 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() + && msg_send![key_window, isKindOfClass: WINDOW_CLASS] + { + Some(key_window) + } else if !main_window.is_null() && msg_send![main_window, isKindOfClass: WINDOW_CLASS] { + 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(); @@ -412,6 +469,8 @@ struct MacWindowState { native_view: NonNull, blurred_view: Option, background_appearance: WindowBackgroundAppearance, + cursor_style: CursorStyle, + cursor_visible: Arc, display_link: Option, renderer: renderer::Renderer, request_frame_callback: Option>, @@ -440,6 +499,8 @@ struct MacWindowState { select_previous_tab_callback: Option>, toggle_tab_bar_callback: Option>, activated_least_once: bool, + closed: Arc, + accesskit_adapter: Option, // The parent window if this window is a sheet (Dialog kind) sheet_parent: Option, } @@ -610,7 +671,9 @@ impl MacWindow { display_id, window_min_size, tabbing_identifier, + .. }: WindowParams, + cursor_visible: Arc, foreground_executor: ForegroundExecutor, background_executor: BackgroundExecutor, renderer_context: renderer::Context, @@ -727,6 +790,8 @@ impl MacWindow { native_view: NonNull::new_unchecked(native_view), blurred_view: None, background_appearance: WindowBackgroundAppearance::Opaque, + cursor_style: CursorStyle::Arrow, + cursor_visible, display_link: None, renderer: renderer::new_renderer( renderer_context, @@ -764,6 +829,8 @@ impl MacWindow { select_previous_tab_callback: None, toggle_tab_bar_callback: None, activated_least_once: false, + closed: Arc::new(AtomicBool::new(false)), + accesskit_adapter: None, sheet_parent: None, }))); @@ -1020,6 +1087,17 @@ impl Drop for MacWindow { } } +/// Calls `f` if the window is not closed. +/// +/// This should be used when spawning foreground tasks interacting with the +/// window, as some messages will end hard faulting if dispatched to no longer +/// valid window handles. +fn if_window_not_closed(closed: Arc, f: impl FnOnce()) { + if !closed.load(Ordering::Acquire) { + f(); + } +} + impl PlatformWindow for MacWindow { fn bounds(&self) -> Bounds { self.0.as_ref().lock().bounds() @@ -1040,14 +1118,15 @@ impl PlatformWindow for MacWindow { fn resize(&mut self, size: Size) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { + if_window_not_closed(closed, || unsafe { window.setContentSize_(NSSize { width: size.width.as_f32() as f64, height: size.height.as_f32() as f64, }); - } + }) }) .detach(); } @@ -1116,7 +1195,7 @@ impl PlatformWindow for MacWindow { fn appearance(&self) -> WindowAppearance { unsafe { let appearance: id = msg_send![self.0.lock().native_window, effectiveAppearance]; - super::window_appearance::window_appearance_from_native(appearance) + crate::window_appearance::window_appearance_from_native(appearance) } } @@ -1191,30 +1270,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]; @@ -1230,25 +1296,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(super::events::ESCAPE_KEY as u32) { + 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(); @@ -1260,15 +1325,21 @@ impl PlatformWindow for MacWindow { } }); let block = block.copy(); - let native_window = self.0.lock().native_window; - let executor = self.0.lock().foreground_executor.clone(); + let lock = self.0.lock(); + let native_window = lock.native_window; + let closed = lock.closed.clone(); + let executor = lock.foreground_executor.clone(); executor .spawn(async move { - let _: () = msg_send![ - alert, - beginSheetModalForWindow: native_window - completionHandler: block - ]; + if !closed.load(Ordering::Acquire) { + let _: () = msg_send![ + alert, + beginSheetModalForWindow: native_window + completionHandler: block + ]; + } else { + let _: () = msg_send![alert, release]; + } }) .detach(); @@ -1277,12 +1348,16 @@ impl PlatformWindow for MacWindow { } fn activate(&self) { - let window = self.0.lock().native_window; - let executor = self.0.lock().foreground_executor.clone(); + let lock = self.0.lock(); + let window = lock.native_window; + let closed = lock.closed.clone(); + let executor = lock.foreground_executor.clone(); executor .spawn(async move { - unsafe { - let _: () = msg_send![window, makeKeyAndOrderFront: nil]; + if !closed.load(Ordering::Acquire) { + unsafe { + let _: () = msg_send![window, makeKeyAndOrderFront: nil]; + } } }) .detach(); @@ -1397,6 +1472,18 @@ impl PlatformWindow for MacWindow { self.0.lock().move_traffic_light(); } + fn set_document_path(&self, path: Option<&std::path::Path>) { + unsafe { + let window = self.0.lock().native_window; + 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) { let this = self.0.lock(); let window = this.native_window; @@ -1420,11 +1507,12 @@ impl PlatformWindow for MacWindow { fn zoom(&self) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { + if_window_not_closed(closed, || unsafe { window.zoom_(nil); - } + }) }) .detach(); } @@ -1432,11 +1520,12 @@ impl PlatformWindow for MacWindow { fn toggle_fullscreen(&self) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { + if_window_not_closed(closed, || unsafe { window.toggleFullScreen_(nil); - } + }) }) .detach(); } @@ -1577,45 +1666,48 @@ impl PlatformWindow for MacWindow { fn titlebar_double_click(&self) { let this = self.0.lock(); let window = this.native_window; + let closed = this.closed.clone(); this.foreground_executor .spawn(async move { - unsafe { - let defaults: id = NSUserDefaults::standardUserDefaults(); - let domain = ns_string("NSGlobalDomain"); - let key = ns_string("AppleActionOnDoubleClick"); + if_window_not_closed(closed, || { + unsafe { + let defaults: id = NSUserDefaults::standardUserDefaults(); + let domain = ns_string("NSGlobalDomain"); + let key = ns_string("AppleActionOnDoubleClick"); - let dict: id = msg_send![defaults, persistentDomainForName: domain]; - let action: id = if !dict.is_null() { - msg_send![dict, objectForKey: key] - } else { - nil - }; + let dict: id = msg_send![defaults, persistentDomainForName: domain]; + let action: id = if !dict.is_null() { + msg_send![dict, objectForKey: key] + } else { + nil + }; - let action_str = if !action.is_null() { - CStr::from_ptr(NSString::UTF8String(action)).to_string_lossy() - } else { - "".into() - }; + let action_str = if !action.is_null() { + CStr::from_ptr(NSString::UTF8String(action)).to_string_lossy() + } else { + "".into() + }; - match action_str.as_ref() { - "None" => { - // "Do Nothing" selected, so do no action - } - "Minimize" => { - window.miniaturize_(nil); - } - "Maximize" => { - window.zoom_(nil); - } - "Fill" => { - // There is no documented API for "Fill" action, so we'll just zoom the window - window.zoom_(nil); - } - _ => { - window.zoom_(nil); + match action_str.as_ref() { + "None" => { + // "Do Nothing" selected, so do no action + } + "Minimize" => { + window.miniaturize_(nil); + } + "Maximize" => { + window.zoom_(nil); + } + "Fill" => { + // There is no documented API for "Fill" action, so we'll just zoom the window + window.zoom_(nil); + } + _ => { + window.zoom_(nil); + } } } - } + }) }) .detach(); } @@ -1631,11 +1723,68 @@ impl PlatformWindow for MacWindow { } } + fn play_system_bell(&self) { + unsafe { NSBeep() } + } + #[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) } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let mut lock = self.0.lock(); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + let action_handler = A11yActionHandler(callbacks.action); + + let adapter = unsafe { + accesskit_macos::SubclassingAdapter::for_window( + lock.native_window as *mut c_void, + activation_handler, + action_handler, + ) + }; + + lock.accesskit_adapter = Some(adapter); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut lock = self.0.lock(); + lock.accesskit_adapter + .as_mut() + .and_then(|adapter| adapter.update_if_active(|| tree_update)) + }; + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // macOS handles window bounds tracking automatically via NSAccessibility. + } +} + +struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } impl rwh::HasWindowHandle for MacWindow { @@ -1651,12 +1800,7 @@ impl rwh::HasWindowHandle for MacWindow { impl rwh::HasDisplayHandle for MacWindow { fn display_handle(&self) -> Result, rwh::HandleError> { - // SAFETY: This is a no-op on macOS - unsafe { - Ok(rwh::DisplayHandle::borrow_raw( - rwh::AppKitDisplayHandle::new().into(), - )) - } + Ok(rwh::DisplayHandle::appkit()) } } @@ -1713,6 +1857,57 @@ 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. The 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], + }; + + 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) } @@ -1750,6 +1945,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(); @@ -1801,7 +2035,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 @@ -1878,6 +2133,20 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { let event = unsafe { platform_input_from_native(native_event, Some(window_height)) }; if let Some(mut event) = event { + // AppKit unhides the cursor on the next mouse movement; mirror that here. + if matches!( + event, + PlatformInput::MouseMove(_) + | PlatformInput::MouseDown(_) + | PlatformInput::MouseUp(_) + | PlatformInput::MousePressure(_) + | PlatformInput::MouseExited(_) + | PlatformInput::ScrollWheel(_) + | PlatformInput::Pinch(_) + ) { + lock.cursor_visible.store(true, Ordering::Relaxed); + } + match &mut event { PlatformInput::MouseDown( event @ MouseDownEvent { @@ -2103,6 +2372,9 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let lock = window_state.lock(); let is_active = unsafe { lock.native_window.isKeyWindow() == YES }; + // AppKit also unhides the cursor on activation changes, so mirror that here. + lock.cursor_visible.store(true, Ordering::Relaxed); + // When opening a pop-up while the application isn't active, Cocoa sends a spurious // `windowDidBecomeKey` message to the previous key window even though that window // isn't actually key. This causes a bug if the application is later activated while @@ -2124,6 +2396,16 @@ extern "C" fn window_did_change_key_status(this: &Object, selector: Sel, _: id) let executor = lock.foreground_executor.clone(); drop(lock); + let a11y_events = { + let mut lock = window_state.lock(); + lock.accesskit_adapter + .as_mut() + .and_then(|adapter| adapter.update_view_focus_state(is_active)) + }; + if let Some(events) = a11y_events { + events.raise(); + } + // When a window becomes active, trigger an immediate synchronous frame request to prevent // tab flicker when switching between windows in native tabs mode. // @@ -2185,6 +2467,7 @@ extern "C" fn close_window(this: &Object, _: Sel) { let close_callback = { let window_state = get_window_state(this); let mut lock = window_state.as_ref().lock(); + lock.closed.store(true, Ordering::Release); lock.close_callback.take() }; diff --git a/src/platform/mac/window_appearance.rs b/crates/gpui_macos/src/window_appearance.rs similarity index 100% rename from src/platform/mac/window_appearance.rs rename to crates/gpui_macos/src/window_appearance.rs diff --git a/crates/gpui_macros/Cargo.toml b/crates/gpui_macros/Cargo.toml new file mode 100644 index 0000000000..513dd61d7b --- /dev/null +++ b/crates/gpui_macros/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "gpui_macros" +version = "0.1.0" +edition.workspace = true +publish = false +license = "Apache-2.0" +description = "Macros used by gpui" + +[lints] +workspace = true + +[features] +inspector = [] + +[lib] +path = "src/gpui_macros.rs" +proc-macro = true +doctest = true + +[dependencies] +heck.workspace = true +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["inspector"] } \ No newline at end of file diff --git a/crates/gpui_macros/LICENSE-APACHE b/crates/gpui_macros/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_macros/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/tooling/macros/src/derive_action.rs b/crates/gpui_macros/src/derive_action.rs similarity index 100% rename from tooling/macros/src/derive_action.rs rename to crates/gpui_macros/src/derive_action.rs diff --git a/tooling/macros/src/derive_app_context.rs b/crates/gpui_macros/src/derive_app_context.rs similarity index 92% rename from tooling/macros/src/derive_app_context.rs rename to crates/gpui_macros/src/derive_app_context.rs index 46f9e58409..451731479c 100644 --- a/tooling/macros/src/derive_app_context.rs +++ b/crates/gpui_macros/src/derive_app_context.rs @@ -79,6 +79,15 @@ pub fn derive_app_context(input: TokenStream) -> TokenStream { self.#app_variable.update_window(window, f) } + fn with_window( + &mut self, + entity_id: gpui::EntityId, + f: impl FnOnce(&mut gpui::Window, &mut gpui::App) -> R, + ) -> Option + { + self.#app_variable.with_window(entity_id, f) + } + fn read_window( &self, window: &gpui::WindowHandle, diff --git a/tooling/macros/src/derive_inspector_reflection.rs b/crates/gpui_macros/src/derive_inspector_reflection.rs similarity index 100% rename from tooling/macros/src/derive_inspector_reflection.rs rename to crates/gpui_macros/src/derive_inspector_reflection.rs diff --git a/tooling/macros/src/derive_into_element.rs b/crates/gpui_macros/src/derive_into_element.rs similarity index 100% rename from tooling/macros/src/derive_into_element.rs rename to crates/gpui_macros/src/derive_into_element.rs diff --git a/tooling/macros/src/derive_render.rs b/crates/gpui_macros/src/derive_render.rs similarity index 100% rename from tooling/macros/src/derive_render.rs rename to crates/gpui_macros/src/derive_render.rs diff --git a/tooling/macros/src/derive_visual_context.rs b/crates/gpui_macros/src/derive_visual_context.rs similarity index 100% rename from tooling/macros/src/derive_visual_context.rs rename to crates/gpui_macros/src/derive_visual_context.rs diff --git a/tooling/macros/src/gpui_macros.rs b/crates/gpui_macros/src/gpui_macros.rs similarity index 100% rename from tooling/macros/src/gpui_macros.rs rename to crates/gpui_macros/src/gpui_macros.rs diff --git a/tooling/macros/src/property_test.rs b/crates/gpui_macros/src/property_test.rs similarity index 65% rename from tooling/macros/src/property_test.rs rename to crates/gpui_macros/src/property_test.rs index 6bf60eca1b..fe1585cc06 100644 --- a/tooling/macros/src/property_test.rs +++ b/crates/gpui_macros/src/property_test.rs @@ -1,7 +1,12 @@ use proc_macro2::TokenStream; -use quote::{format_ident, quote, quote_spanned}; +use quote::{ToTokens, format_ident, quote, quote_spanned}; use syn::{ - FnArg, Ident, ItemFn, Type, parse2, punctuated::Punctuated, spanned::Spanned, token::Comma, + Expr, FnArg, Ident, ItemFn, MetaNameValue, Token, Type, + parse::{Parse, ParseStream}, + parse2, + punctuated::Punctuated, + spanned::Spanned, + token::Comma, }; pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { @@ -12,8 +17,15 @@ pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { }; }; + let args = match parse2::(args) { + Ok(args) => args, + Err(e) => return e.to_compile_error(), + }; + let test_name = func.sig.ident.clone(); + let test_ret_ty = func.sig.output.clone(); let inner_fn_name = format_ident!("__{test_name}"); + let outer_fn_attributes = &func.attrs; let parsed_args = parse_args(func.sig.inputs, &test_name); @@ -39,27 +51,32 @@ pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { let run_test_body = match &asyncness { None => quote! { #cx_vars - #inner_fn_name(#inner_args); + let result = #inner_fn_name(#inner_args); #cx_teardowns + result }, Some(_) => quote! { let foreground_executor = gpui::ForegroundExecutor::new(std::sync::Arc::new(dispatcher.clone())); #cx_vars - foreground_executor.block_test(#inner_fn_name(#inner_args)); + let result = foreground_executor.block_test(#inner_fn_name(#inner_args)); #cx_teardowns + result }, }; + let fixed_macro_invocation = args.render(); + quote! { #arg_errors - #[::gpui::proptest::property_test(proptest_path = "::gpui::proptest", #args)] - fn #test_name(#proptest_args) { + #fixed_macro_invocation + #(#outer_fn_attributes)* + fn #test_name(#proptest_args) #test_ret_ty { #inner_fn ::gpui::run_test_once( __seed, - Box::new(move |dispatcher| { + Box::new(move |dispatcher| #test_ret_ty { #run_test_body }), ) @@ -67,6 +84,63 @@ pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { } } +struct Args { + config: Option, + remaining_args: Vec, + errors: TokenStream, +} + +impl Args { + /// By default, proptest uses random seeds unless `$PROPTEST_SEED` is set. + /// Rather than managing both `$SEED` and `$PROPTEST_SEED`, we intercept + /// `config = ...` tokens and add a call to `gpui::apply_seed_to_config`. + fn render(&self) -> TokenStream { + let user_provided_config = match &self.config { + None => quote! { ::gpui::proptest::prelude::ProptestConfig::default() }, + Some(config) => config.into_token_stream(), + }; + + let fixed_config = quote!(::gpui::apply_seed_to_proptest_config(#user_provided_config)); + let remaining_args = &self.remaining_args; + let errors = &self.errors; + + quote! { + #errors + #[::gpui::proptest::property_test( + proptest_path = "::gpui::proptest", + config = #fixed_config, + #(#remaining_args,)* + )] + } + } +} + +impl Parse for Args { + fn parse(input: ParseStream) -> syn::Result { + let pairs = Punctuated::::parse_terminated(input)?; + + let mut config = None; + let mut remaining_args = vec![]; + let mut errors = quote!(); + + for pair in pairs { + match pair.path.get_ident().map(Ident::to_string).as_deref() { + Some("config") => config = Some(pair.value), + Some("proptest_path") => errors.extend(quote_spanned! {pair.span() => + compile_error!("`gpui::property_test` overrides the `proptest_path` parameter") + }), + _ => remaining_args.push(pair), + } + } + + Ok(Self { + config, + remaining_args, + errors, + }) + } +} + #[derive(Default)] struct ParsedArgs { cx_vars: TokenStream, diff --git a/tooling/macros/src/register_action.rs b/crates/gpui_macros/src/register_action.rs similarity index 100% rename from tooling/macros/src/register_action.rs rename to crates/gpui_macros/src/register_action.rs diff --git a/tooling/macros/src/styles.rs b/crates/gpui_macros/src/styles.rs similarity index 99% rename from tooling/macros/src/styles.rs rename to crates/gpui_macros/src/styles.rs index 133c9fdebe..6a0095a6c7 100644 --- a/tooling/macros/src/styles.rs +++ b/crates/gpui_macros/src/styles.rs @@ -327,12 +327,6 @@ pub fn cursor_style_methods(input: TokenStream) -> TokenStream { self } - /// Sets cursor style when hovering over an element to `none`. - /// [Docs](https://tailwindcss.com/docs/cursor) - #visibility fn cursor_none(mut self, cursor: CursorStyle) -> Self { - self.style().mouse_cursor = Some(gpui::CursorStyle::None); - self - } }; output.into() @@ -416,6 +410,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(0.), spread_radius: px(0.), + inset: false, }]); self } @@ -431,6 +426,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(1.)), blur_radius: px(2.), spread_radius: px(0.), + inset: false, }]); self } @@ -447,12 +443,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { 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, } ]); self @@ -470,12 +468,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { 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, } ]); self @@ -493,12 +493,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { 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, } ]); self @@ -516,12 +518,14 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { 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, } ]); self @@ -538,6 +542,7 @@ pub fn box_shadow_style_methods(input: TokenStream) -> TokenStream { offset: point(px(0.), px(25.)), blur_radius: px(50.), spread_radius: px(-12.), + inset: false, }]); self } diff --git a/tooling/macros/src/test.rs b/crates/gpui_macros/src/test.rs similarity index 100% rename from tooling/macros/src/test.rs rename to crates/gpui_macros/src/test.rs diff --git a/tooling/macros/tests/derive_context.rs b/crates/gpui_macros/tests/derive_context.rs similarity index 100% rename from tooling/macros/tests/derive_context.rs rename to crates/gpui_macros/tests/derive_context.rs diff --git a/tooling/macros/tests/derive_inspector_reflection.rs b/crates/gpui_macros/tests/derive_inspector_reflection.rs similarity index 100% rename from tooling/macros/tests/derive_inspector_reflection.rs rename to crates/gpui_macros/tests/derive_inspector_reflection.rs diff --git a/tooling/macros/tests/render_test.rs b/crates/gpui_macros/tests/render_test.rs similarity index 100% rename from tooling/macros/tests/render_test.rs rename to crates/gpui_macros/tests/render_test.rs diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml new file mode 100644 index 0000000000..22d44a96b2 --- /dev/null +++ b/crates/gpui_platform/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "gpui_platform" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/gpui_platform.rs" + +[features] +default = [] +font-kit = ["gpui_macos/font-kit"] +test-support = ["gpui/test-support", "gpui_macos/test-support"] +screen-capture = ["gpui/screen-capture", "gpui_macos/screen-capture", "gpui_windows/screen-capture", "gpui_linux/screen-capture"] +runtime_shaders = ["gpui_macos/runtime_shaders"] +wayland = ["gpui_linux/wayland"] +x11 = ["gpui_linux/x11"] + +[dependencies] +gpui.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +gpui_macos.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +gpui_windows.workspace = true +gpui = { workspace = true, features = ["windows-manifest"] } + +[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies] +gpui_linux.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +gpui_web.workspace = true +console_error_panic_hook = "0.1.7" diff --git a/crates/gpui_platform/LICENSE-APACHE b/crates/gpui_platform/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_platform/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs new file mode 100644 index 0000000000..1d2fea90b4 --- /dev/null +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -0,0 +1,186 @@ +//! Convenience crate that re-exports GPUI's platform traits and the +//! `current_platform` constructor so consumers don't need `#[cfg]` gating. + +pub use gpui::Platform; + +use std::rc::Rc; + +/// Returns a background executor for the current platform. +pub fn background_executor() -> gpui::BackgroundExecutor { + current_platform(true).background_executor() +} + +pub fn application() -> gpui::Application { + gpui::Application::with_platform(current_platform(false)) +} + +pub fn headless() -> gpui::Application { + gpui::Application::with_platform(current_platform(true)) +} + +/// Unlike `application`, this function returns a single-threaded web application. +#[cfg(target_family = "wasm")] +pub fn single_threaded_web() -> gpui::Application { + gpui::Application::with_platform(Rc::new(gpui_web::WebPlatform::new(false))) +} + +/// Initializes panic hooks and logging for the web platform. +/// Call this before running the application in a wasm_bindgen entrypoint. +#[cfg(target_family = "wasm")] +pub fn web_init() { + console_error_panic_hook::set_once(); + gpui_web::init_logging(); +} + +/// Returns the default [`Platform`] for the current OS. +pub fn current_platform(headless: bool) -> Rc { + #[cfg(target_os = "macos")] + { + Rc::new(gpui_macos::MacPlatform::new(headless)) + } + + #[cfg(target_os = "windows")] + { + Rc::new( + gpui_windows::WindowsPlatform::new(headless) + .expect("failed to initialize Windows platform"), + ) + } + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + { + gpui_linux::current_platform(headless) + } + + #[cfg(target_family = "wasm")] + { + let _ = headless; + Rc::new(gpui_web::WebPlatform::new(true)) + } +} + +/// 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 + } +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + use gpui::{AppContext, Empty, VisualTestAppContext}; + use std::cell::RefCell; + use std::time::Duration; + + // Note: All VisualTestAppContext tests are ignored by default because they require + // the macOS main thread. Standard Rust tests run on worker threads, which causes + // SIGABRT when interacting with macOS AppKit/Cocoa APIs. + // + // To run these tests, use: + // cargo test -p gpui visual_test_context -- --ignored --test-threads=1 + + #[test] + #[ignore] // Requires macOS main thread + fn test_foreground_tasks_run_with_run_until_parked() { + let mut cx = VisualTestAppContext::new(current_platform(false)); + + let task_ran = Rc::new(RefCell::new(false)); + + // Spawn a foreground task via the App's spawn method + // This should use our TestDispatcher, not the MacDispatcher + { + let task_ran = task_ran.clone(); + cx.update(|cx| { + cx.spawn(async move |_| { + *task_ran.borrow_mut() = true; + }) + .detach(); + }); + } + + // The task should not have run yet + assert!(!*task_ran.borrow()); + + // Run until parked should execute the foreground task + cx.run_until_parked(); + + // Now the task should have run + assert!(*task_ran.borrow()); + } + + #[test] + #[ignore] // Requires macOS main thread + fn test_advance_clock_triggers_delayed_tasks() { + let mut cx = VisualTestAppContext::new(current_platform(false)); + + let task_ran = Rc::new(RefCell::new(false)); + + // Spawn a task that waits for a timer + { + let task_ran = task_ran.clone(); + let executor = cx.background_executor.clone(); + cx.update(|cx| { + cx.spawn(async move |_| { + executor.timer(Duration::from_millis(500)).await; + *task_ran.borrow_mut() = true; + }) + .detach(); + }); + } + + // Run until parked - the task should be waiting on the timer + cx.run_until_parked(); + assert!(!*task_ran.borrow()); + + // Advance clock past the timer duration + cx.advance_clock(Duration::from_millis(600)); + + // Now the task should have completed + assert!(*task_ran.borrow()); + } + + #[test] + #[ignore] // Requires macOS main thread - window creation fails on test threads + fn test_window_spawn_uses_test_dispatcher() { + let mut cx = VisualTestAppContext::new(current_platform(false)); + + let task_ran = Rc::new(RefCell::new(false)); + + let window = cx + .open_offscreen_window_default(|_, cx| cx.new(|_| Empty)) + .expect("Failed to open window"); + + // Spawn a task via window.spawn - this is the critical test case + // for tooltip behavior, as tooltips use window.spawn for delayed show + { + let task_ran = task_ran.clone(); + cx.update_window(window.into(), |_, window, cx| { + window + .spawn(cx, async move |_| { + *task_ran.borrow_mut() = true; + }) + .detach(); + }) + .ok(); + } + + // The task should not have run yet + assert!(!*task_ran.borrow()); + + // Run until parked should execute the foreground task spawned via window + cx.run_until_parked(); + + // Now the task should have run + assert!(*task_ran.borrow()); + } +} diff --git a/crates/gpui_shared_string/Cargo.toml b/crates/gpui_shared_string/Cargo.toml new file mode 100644 index 0000000000..e1e975a550 --- /dev/null +++ b/crates/gpui_shared_string/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "gpui_shared_string" +version = "0.1.0" +publish.workspace = true +edition.workspace = true + +[lib] +path = "gpui_shared_string.rs" + +[dependencies] +schemars.workspace = true +serde.workspace = true +smol_str = "0.3.6" + +[lints] +workspace = true diff --git a/crates/gpui_shared_string/LICENSE-APACHE b/crates/gpui_shared_string/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_shared_string/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/src/shared_string.rs b/crates/gpui_shared_string/gpui_shared_string.rs similarity index 55% rename from src/shared_string.rs rename to crates/gpui_shared_string/gpui_shared_string.rs index 350184d350..4395065b9f 100644 --- a/src/shared_string.rs +++ b/crates/gpui_shared_string/gpui_shared_string.rs @@ -1,27 +1,35 @@ -use derive_more::{Deref, DerefMut}; - -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; use std::{ borrow::{Borrow, Cow}, sync::Arc, }; -use util::arc_cow::ArcCow; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use smol_str::SmolStr; /// A shared string is an immutable string that can be cheaply cloned in GPUI /// tasks. Essentially an abstraction over an `Arc` and `&'static str`, -#[derive(Deref, DerefMut, Eq, PartialEq, PartialOrd, Ord, Hash, Clone)] -pub struct SharedString(ArcCow<'static, str>); +/// currently backed by a [`SmolStr`]. +#[derive(Eq, PartialEq, PartialOrd, Ord, Hash, Clone)] +pub struct SharedString(SmolStr); + +impl std::ops::Deref for SharedString { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.0.as_str() + } +} impl SharedString { /// Creates a static [`SharedString`] from a `&'static str`. pub const fn new_static(str: &'static str) -> Self { - Self(ArcCow::Borrowed(str)) + Self(SmolStr::new_static(str)) } - /// Creates a [`SharedString`] from anything that can become an `Arc` - pub fn new(str: impl Into>) -> Self { - SharedString(ArcCow::Owned(str.into())) + /// Creates a [`SharedString`]. + pub fn new(str: impl AsRef) -> Self { + SharedString(SmolStr::new(str)) } /// Get a &str from the underlying string. @@ -46,7 +54,7 @@ impl JsonSchema for SharedString { impl Default for SharedString { fn default() -> Self { - Self(ArcCow::Owned(Arc::default())) + Self::new_static("") } } @@ -70,7 +78,7 @@ impl std::fmt::Debug for SharedString { impl std::fmt::Display for SharedString { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0.as_ref()) + write!(f, "{}", self.0.as_str()) } } @@ -99,29 +107,79 @@ impl<'a> PartialEq<&'a str> for SharedString { } impl From<&SharedString> for SharedString { - fn from(value: &SharedString) -> Self { - value.clone() + #[inline] + fn from(s: &SharedString) -> SharedString { + s.clone() + } +} + +impl From<&str> for SharedString { + #[inline] + fn from(s: &str) -> SharedString { + SharedString(SmolStr::from(s)) + } +} + +impl From<&mut str> for SharedString { + #[inline] + fn from(s: &mut str) -> SharedString { + SharedString(SmolStr::from(s)) + } +} + +impl From<&String> for SharedString { + #[inline] + fn from(s: &String) -> SharedString { + SharedString(SmolStr::from(s)) + } +} + +impl From for SharedString { + #[inline(always)] + fn from(text: String) -> Self { + SharedString(SmolStr::from(text)) + } +} + +impl From> for SharedString { + #[inline] + fn from(s: Box) -> SharedString { + SharedString(SmolStr::from(s)) + } +} + +impl From> for SharedString { + #[inline] + fn from(s: Arc) -> SharedString { + SharedString(SmolStr::from(s)) + } +} + +impl From<&Arc> for SharedString { + #[inline] + fn from(s: &Arc) -> SharedString { + SharedString(SmolStr::from(s.clone())) + } +} + +impl<'a> From> for SharedString { + #[inline] + fn from(s: Cow<'a, str>) -> SharedString { + SharedString(SmolStr::from(s)) } } impl From for Arc { - fn from(val: SharedString) -> Self { - match val.0 { - ArcCow::Borrowed(borrowed) => Arc::from(borrowed), - ArcCow::Owned(owned) => owned, - } - } -} - -impl>> From for SharedString { - fn from(value: T) -> Self { - Self(value.into()) + #[inline(always)] + fn from(text: SharedString) -> Self { + text.0.into() } } impl From for String { - fn from(val: SharedString) -> Self { - val.0.to_string() + #[inline(always)] + fn from(text: SharedString) -> Self { + text.0.into() } } @@ -140,6 +198,6 @@ impl<'de> Deserialize<'de> for SharedString { D: serde::Deserializer<'de>, { let s = String::deserialize(deserializer)?; - Ok(SharedString::from(s)) + Ok(SharedString::new(&s)) } } diff --git a/crates/gpui_tokio/Cargo.toml b/crates/gpui_tokio/Cargo.toml new file mode 100644 index 0000000000..e9d72b8ec2 --- /dev/null +++ b/crates/gpui_tokio/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "gpui_tokio" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/gpui_tokio.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +util.workspace = true +gpui.workspace = true +tokio = { workspace = true, features = ["rt", "rt-multi-thread"] } diff --git a/crates/gpui_tokio/LICENSE-APACHE b/crates/gpui_tokio/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_tokio/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_tokio/src/gpui_tokio.rs b/crates/gpui_tokio/src/gpui_tokio.rs new file mode 100644 index 0000000000..d32843ae86 --- /dev/null +++ b/crates/gpui_tokio/src/gpui_tokio.rs @@ -0,0 +1,100 @@ +use std::future::Future; + +use gpui::{App, AppContext, Global, ReadGlobal, Task}; +use util::defer; + +pub use tokio::task::JoinError; + +/// Initializes the Tokio wrapper using a new Tokio runtime with 2 worker threads. +/// +/// If you need more threads (or access to the runtime outside of GPUI), you can create the runtime +/// yourself and pass a Handle to `init_from_handle`. +pub fn init(cx: &mut App) { + let runtime = tokio::runtime::Builder::new_multi_thread() + // Since we now have two executors, let's try to keep our footprint small + .worker_threads(2) + .enable_all() + .build() + .expect("Failed to initialize Tokio"); + + let handle = runtime.handle().clone(); + cx.set_global(GlobalTokio { + owned_runtime: Some(runtime), + handle, + }); +} + +/// Initializes the Tokio wrapper using a Tokio runtime handle. +pub fn init_from_handle(cx: &mut App, handle: tokio::runtime::Handle) { + cx.set_global(GlobalTokio { + owned_runtime: None, + handle, + }); +} + +struct GlobalTokio { + owned_runtime: Option, + handle: tokio::runtime::Handle, +} + +impl Global for GlobalTokio {} + +impl Drop for GlobalTokio { + fn drop(&mut self) { + if let Some(runtime) = self.owned_runtime.take() { + runtime.shutdown_background(); + } + } +} + +pub struct Tokio {} + +impl Tokio { + /// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task + /// Note that the Tokio task will be cancelled if the GPUI task is dropped + pub fn spawn(cx: &C, f: Fut) -> Task> + where + C: AppContext, + Fut: Future + Send + 'static, + R: Send + 'static, + { + cx.read_global(|tokio: &GlobalTokio, cx| { + let join_handle = tokio.handle.spawn(f); + let abort_handle = join_handle.abort_handle(); + let cancel = defer(move || { + abort_handle.abort(); + }); + cx.background_spawn(async move { + let result = join_handle.await; + drop(cancel); + result + }) + }) + } + + /// Spawns the given future on Tokio's thread pool, and returns it via a GPUI task + /// Note that the Tokio task will be cancelled if the GPUI task is dropped + pub fn spawn_result(cx: &C, f: Fut) -> Task> + where + C: AppContext, + Fut: Future> + Send + 'static, + R: Send + 'static, + { + cx.read_global(|tokio: &GlobalTokio, cx| { + let join_handle = tokio.handle.spawn(f); + let abort_handle = join_handle.abort_handle(); + let cancel = defer(move || { + abort_handle.abort(); + }); + cx.background_spawn(async move { + let result = join_handle.await?; + drop(cancel); + result + }) + }) + } + + pub fn handle(cx: &App) -> tokio::runtime::Handle { + GlobalTokio::global(cx).handle.clone() + } +} diff --git a/crates/gpui_web/Cargo.toml b/crates/gpui_web/Cargo.toml new file mode 100644 index 0000000000..5980fa5e85 --- /dev/null +++ b/crates/gpui_web/Cargo.toml @@ -0,0 +1,72 @@ +[package] +name = "gpui_web" +version = "0.1.0" +publish.workspace = true +edition.workspace = true +license = "Apache-2.0" +autoexamples = false + +[lints] +workspace = true + +[features] +default = ["multithreaded"] +multithreaded = ["dep:wasm_thread"] + +[lib] +path = "src/gpui_web.rs" + +[target.'cfg(target_family = "wasm")'.dependencies] +gpui.workspace = true +parking_lot = { workspace = true, features = ["nightly"] } +gpui_wgpu.workspace = true +http_client.workspace = true +anyhow.workspace = true +futures.workspace = true +log.workspace = true +smallvec.workspace = true +uuid.workspace = true +wasm-bindgen.workspace = true +wasm-bindgen-futures = "0.4" +web-time.workspace = true +console_error_panic_hook = "0.1.7" +js-sys = "0.3" +raw-window-handle = "0.6" +wasm_thread = { version = "0.3", features = ["es_modules"], optional = true } +web-sys = { version = "0.3", features = [ + "console", + "CompositionEvent", + "CssStyleDeclaration", + "DataTransfer", + "Document", + "DomRect", + "DragEvent", + "Element", + "EventTarget", + "File", + "FileList", + "HtmlCanvasElement", + "HtmlElement", + "HtmlInputElement", + "KeyboardEvent", + "MediaQueryList", + "MediaQueryListEvent", + "MouseEvent", + "Navigator", + "PointerEvent", + "ResizeObserver", + "ResizeObserverBoxOptions", + "ResizeObserverEntry", + "ResizeObserverSize", + "ResizeObserverOptions", + "Screen", + "Storage", + "VisualViewport", + "Headers", + "Request", + "RequestInit", + "RequestRedirect", + "Response", + "WheelEvent", + "Window", +] } diff --git a/crates/gpui_web/LICENSE-APACHE b/crates/gpui_web/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_web/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_web/examples/hello_web/.cargo/config.toml b/crates/gpui_web/examples/hello_web/.cargo/config.toml new file mode 100644 index 0000000000..de4cb1a897 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/.cargo/config.toml @@ -0,0 +1,14 @@ +[target.wasm32-unknown-unknown] +rustflags = [ + "-C", "target-feature=+atomics,+bulk-memory,+mutable-globals", + "-C", "link-arg=--shared-memory", + "-C", "link-arg=--max-memory=1073741824", + "-C", "link-arg=--import-memory", + "-C", "link-arg=--export=__wasm_init_tls", + "-C", "link-arg=--export=__tls_size", + "-C", "link-arg=--export=__tls_align", + "-C", "link-arg=--export=__tls_base", +] + +[unstable] +build-std = ["std,panic_abort"] diff --git a/crates/gpui_web/examples/hello_web/.gitignore b/crates/gpui_web/examples/hello_web/.gitignore new file mode 100644 index 0000000000..2b1bc8d911 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/.gitignore @@ -0,0 +1,3 @@ +/dist +/target +Cargo.lock diff --git a/crates/gpui_web/examples/hello_web/Cargo.toml b/crates/gpui_web/examples/hello_web/Cargo.toml new file mode 100644 index 0000000000..7f8c5cf137 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/Cargo.toml @@ -0,0 +1,16 @@ +[workspace] + +[package] +name = "hello_web" +version = "0.1.0" +edition = "2024" +publish = false + +[[bin]] +name = "hello_web" +path = "main.rs" + +[dependencies] +gpui = { path = "../../../gpui" } +gpui_platform = { path = "../../../gpui_platform" } +web-time = "1" diff --git a/crates/gpui_web/examples/hello_web/LICENSE-APACHE b/crates/gpui_web/examples/hello_web/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_web/examples/hello_web/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_web/examples/hello_web/index.html b/crates/gpui_web/examples/hello_web/index.html new file mode 100644 index 0000000000..692f3edf06 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/index.html @@ -0,0 +1,31 @@ + + + + + + GPUI Web: hello_web + + + + + diff --git a/crates/gpui_web/examples/hello_web/main.rs b/crates/gpui_web/examples/hello_web/main.rs new file mode 100644 index 0000000000..a6ff103e64 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/main.rs @@ -0,0 +1,422 @@ +use gpui::prelude::*; +use gpui::{ + App, Bounds, Context, ElementId, SharedString, Task, Window, WindowBounds, WindowOptions, div, + px, rgb, size, +}; + +// --------------------------------------------------------------------------- +// Prime counting (intentionally brute-force so it hammers the CPU) +// --------------------------------------------------------------------------- + +fn is_prime(n: u64) -> bool { + if n < 2 { + return false; + } + if n < 4 { + return true; + } + if n % 2 == 0 || n % 3 == 0 { + return false; + } + let mut i = 5; + while i * i <= n { + if n % i == 0 || n % (i + 2) == 0 { + return false; + } + i += 6; + } + true +} + +fn count_primes_in_range(start: u64, end: u64) -> u64 { + let mut count = 0; + for n in start..end { + if is_prime(n) { + count += 1; + } + } + count +} + +// --------------------------------------------------------------------------- +// App state +// --------------------------------------------------------------------------- + +const NUM_CHUNKS: u64 = 12; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Preset { + TenMillion, + FiftyMillion, + HundredMillion, +} + +impl Preset { + fn label(self) -> &'static str { + match self { + Preset::TenMillion => "10 M", + Preset::FiftyMillion => "50 M", + Preset::HundredMillion => "100 M", + } + } + + fn value(self) -> u64 { + match self { + Preset::TenMillion => 10_000_000, + Preset::FiftyMillion => 50_000_000, + Preset::HundredMillion => 100_000_000, + } + } + + const ALL: [Preset; 3] = [ + Preset::TenMillion, + Preset::FiftyMillion, + Preset::HundredMillion, + ]; +} + +struct ChunkResult { + count: u64, +} + +struct Run { + limit: u64, + chunks_done: u64, + chunk_results: Vec, + total: Option, + elapsed: Option, +} + +struct HelloWeb { + selected_preset: Preset, + current_run: Option, + history: Vec, + _tasks: Vec>, +} + +impl HelloWeb { + fn new(_cx: &mut Context) -> Self { + Self { + selected_preset: Preset::TenMillion, + current_run: None, + history: Vec::new(), + _tasks: Vec::new(), + } + } + + fn start_search(&mut self, cx: &mut Context) { + let limit = self.selected_preset.value(); + let chunk_size = limit / NUM_CHUNKS; + + self.current_run = Some(Run { + limit, + chunks_done: 0, + chunk_results: Vec::new(), + total: None, + elapsed: None, + }); + self._tasks.clear(); + cx.notify(); + + let start_time = web_time::Instant::now(); + + for i in 0..NUM_CHUNKS { + let range_start = i * chunk_size; + let range_end = if i == NUM_CHUNKS - 1 { + limit + } else { + range_start + chunk_size + }; + + let task = cx.spawn(async move |this, cx| { + let count = cx + .background_spawn(async move { count_primes_in_range(range_start, range_end) }) + .await; + + this.update(cx, |this, cx| { + if let Some(run) = &mut this.current_run { + run.chunk_results.push(ChunkResult { count }); + run.chunks_done += 1; + + if run.chunks_done == NUM_CHUNKS { + let total: u64 = run.chunk_results.iter().map(|r| r.count).sum(); + let elapsed_ms = start_time.elapsed().as_secs_f64() * 1000.0; + run.total = Some(total); + run.elapsed = Some(elapsed_ms); + this.history.push( + format!( + "π({}) = {} ({:.0} ms, {} chunks)", + format_number(run.limit), + format_number(total), + elapsed_ms, + NUM_CHUNKS, + ) + .into(), + ); + } + cx.notify(); + } + }) + .ok(); + }); + + self._tasks.push(task); + } + } +} + +fn format_number(n: u64) -> String { + let s = n.to_string(); + let mut result = String::new(); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + result.push(','); + } + result.push(ch); + } + result.chars().rev().collect() +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +const BG_BASE: u32 = 0x1e1e2e; +const BG_SURFACE: u32 = 0x313244; +const BG_OVERLAY: u32 = 0x45475a; +const TEXT_PRIMARY: u32 = 0xcdd6f4; +const TEXT_SECONDARY: u32 = 0xa6adc8; +const TEXT_DIM: u32 = 0x6c7086; +const ACCENT_YELLOW: u32 = 0xf9e2af; +const ACCENT_GREEN: u32 = 0xa6e3a1; +const ACCENT_BLUE: u32 = 0x89b4fa; +const ACCENT_MAUVE: u32 = 0xcba6f7; + +impl Render for HelloWeb { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let is_running = self.current_run.as_ref().is_some_and(|r| r.total.is_none()); + + // -- Preset buttons -- + let preset_row = Preset::ALL.iter().enumerate().fold( + div().flex().flex_row().gap_2(), + |row, (index, &preset)| { + let is_selected = preset == self.selected_preset; + let (bg, text_color) = if is_selected { + (ACCENT_BLUE, BG_BASE) + } else { + (BG_OVERLAY, TEXT_SECONDARY) + }; + row.child( + div() + .id(ElementId::NamedInteger("preset".into(), index as u64)) + .px_3() + .py_1() + .rounded_md() + .bg(rgb(bg)) + .text_color(rgb(text_color)) + .text_sm() + .cursor_pointer() + .when(!is_running, |this| { + this.on_click(cx.listener(move |this, _event, _window, _cx| { + this.selected_preset = preset; + })) + }) + .child(preset.label()), + ) + }, + ); + + // -- Go button -- + let (go_bg, go_text, go_label) = if is_running { + (BG_OVERLAY, TEXT_DIM, "Running…") + } else { + (ACCENT_GREEN, BG_BASE, "Count Primes") + }; + let go_button = div() + .id("go") + .px_4() + .py(px(6.)) + .rounded_md() + .bg(rgb(go_bg)) + .text_color(rgb(go_text)) + .cursor_pointer() + .when(!is_running, |this| { + this.on_click(cx.listener(|this, _event, _window, cx| { + this.start_search(cx); + })) + }) + .child(go_label); + + // -- Progress / result area -- + let status_area = if let Some(run) = &self.current_run { + let progress_fraction = run.chunks_done as f32 / NUM_CHUNKS as f32; + let progress_pct = (progress_fraction * 100.0) as u32; + + let status_text: SharedString = if let Some(total) = run.total { + format!( + "Found {} primes below {} in {:.0} ms", + format_number(total), + format_number(run.limit), + run.elapsed.unwrap_or(0.0), + ) + .into() + } else { + format!( + "Searching up to {} … {}/{} chunks ({}%)", + format_number(run.limit), + run.chunks_done, + NUM_CHUNKS, + progress_pct, + ) + .into() + }; + + let bar_color = if run.total.is_some() { + ACCENT_GREEN + } else { + ACCENT_BLUE + }; + + let chunk_dots = + (0..NUM_CHUNKS as usize).fold(div().flex().flex_row().gap_1().mt_2(), |row, i| { + let done = i < run.chunks_done as usize; + let color = if done { ACCENT_MAUVE } else { BG_OVERLAY }; + row.child(div().size(px(10.)).rounded_sm().bg(rgb(color))) + }); + + div() + .flex() + .flex_col() + .w_full() + .gap_2() + .child(div().text_color(rgb(TEXT_PRIMARY)).child(status_text)) + .child( + div() + .w_full() + .h(px(8.)) + .rounded_sm() + .bg(rgb(BG_OVERLAY)) + .child( + div() + .h_full() + .rounded_sm() + .bg(rgb(bar_color)) + .w(gpui::relative(progress_fraction)), + ), + ) + .child(chunk_dots) + } else { + div().flex().flex_col().w_full().child( + div() + .text_color(rgb(TEXT_DIM)) + .child("Select a range and press Count Primes to begin."), + ) + }; + + // -- History log -- + let history_section = if self.history.is_empty() { + div() + } else { + self.history + .iter() + .rev() + .fold(div().flex().flex_col().gap_1(), |col, entry| { + col.child( + div() + .text_sm() + .text_color(rgb(TEXT_SECONDARY)) + .child(entry.clone()), + ) + }) + }; + + // -- Layout -- + div() + .flex() + .flex_col() + .size_full() + .bg(rgb(BG_BASE)) + .justify_center() + .items_center() + .gap_4() + .p_4() + // Title + .child( + div() + .text_xl() + .text_color(rgb(TEXT_PRIMARY)) + .child("Prime Sieve — GPUI Web"), + ) + .child(div().text_sm().text_color(rgb(TEXT_DIM)).child(format!( + "Background threads: {} · Chunks per run: {}", + std::thread::available_parallelism().map_or(2, |n| n.get().max(2)), + NUM_CHUNKS, + ))) + // Controls + .child( + div() + .flex() + .flex_col() + .items_center() + .gap_3() + .p_4() + .w(px(500.)) + .rounded_lg() + .bg(rgb(BG_SURFACE)) + .child( + div() + .text_sm() + .text_color(rgb(ACCENT_YELLOW)) + .child("Count primes below:"), + ) + .child(preset_row) + .child(go_button), + ) + // Status + .child( + div() + .flex() + .flex_col() + .w(px(500.)) + .p_4() + .rounded_lg() + .bg(rgb(BG_SURFACE)) + .child(status_area), + ) + // History + .when(!self.history.is_empty(), |this| { + this.child( + div() + .flex() + .flex_col() + .w(px(500.)) + .p_4() + .rounded_lg() + .bg(rgb(BG_SURFACE)) + .gap_2() + .child(div().text_sm().text_color(rgb(TEXT_DIM)).child("History")) + .child(history_section), + ) + }) + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +fn main() { + gpui_platform::web_init(); + gpui_platform::application().run(|cx: &mut App| { + let bounds = Bounds::centered(None, size(px(640.), px(560.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |_, cx| cx.new(HelloWeb::new), + ) + .expect("failed to open window"); + cx.activate(true); + }); +} diff --git a/crates/gpui_web/examples/hello_web/rust-toolchain.toml b/crates/gpui_web/examples/hello_web/rust-toolchain.toml new file mode 100644 index 0000000000..8ea6df3d47 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "nightly" +targets = ["wasm32-unknown-unknown"] +components = ["rust-src", "rustfmt", "clippy"] diff --git a/crates/gpui_web/examples/hello_web/trunk.toml b/crates/gpui_web/examples/hello_web/trunk.toml new file mode 100644 index 0000000000..5ef787c270 --- /dev/null +++ b/crates/gpui_web/examples/hello_web/trunk.toml @@ -0,0 +1,7 @@ +[serve] +addresses = ["127.0.0.1"] +port = 8080 +open = true + +# Headers required for WebGPU / SharedArrayBuffer support. +headers = { "Cross-Origin-Embedder-Policy" = "require-corp", "Cross-Origin-Opener-Policy" = "same-origin" } diff --git a/src/platform/web/dispatcher.rs b/crates/gpui_web/src/dispatcher.rs similarity index 100% rename from src/platform/web/dispatcher.rs rename to crates/gpui_web/src/dispatcher.rs diff --git a/src/platform/web/display.rs b/crates/gpui_web/src/display.rs similarity index 100% rename from src/platform/web/display.rs rename to crates/gpui_web/src/display.rs diff --git a/src/platform/web/events.rs b/crates/gpui_web/src/events.rs similarity index 100% rename from src/platform/web/events.rs rename to crates/gpui_web/src/events.rs diff --git a/src/platform/web.rs b/crates/gpui_web/src/gpui_web.rs similarity index 91% rename from src/platform/web.rs rename to crates/gpui_web/src/gpui_web.rs index d96d14af06..9cd773823b 100644 --- a/src/platform/web.rs +++ b/crates/gpui_web/src/gpui_web.rs @@ -1,3 +1,5 @@ +#![cfg(target_family = "wasm")] + mod dispatcher; mod display; mod events; diff --git a/src/platform/web/http_client.rs b/crates/gpui_web/src/http_client.rs similarity index 100% rename from src/platform/web/http_client.rs rename to crates/gpui_web/src/http_client.rs diff --git a/src/platform/web/keyboard.rs b/crates/gpui_web/src/keyboard.rs similarity index 100% rename from src/platform/web/keyboard.rs rename to crates/gpui_web/src/keyboard.rs diff --git a/src/platform/web/logging.rs b/crates/gpui_web/src/logging.rs similarity index 100% rename from src/platform/web/logging.rs rename to crates/gpui_web/src/logging.rs diff --git a/src/platform/web/platform.rs b/crates/gpui_web/src/platform.rs similarity index 76% rename from src/platform/web/platform.rs rename to crates/gpui_web/src/platform.rs index 2af56afe64..ace27bef48 100644 --- a/src/platform/web/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -1,7 +1,6 @@ use crate::dispatcher::WebDispatcher; use crate::display::WebDisplay; use crate::keyboard::WebKeyboardLayout; -use crate::platform::wgpu::WgpuContext; use crate::window::WebWindow; use anyhow::Result; use futures::channel::oneshot; @@ -11,13 +10,15 @@ use gpui::{ PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PlatformWindow, Task, ThermalState, WindowAppearance, WindowParams, }; +use gpui_wgpu::WgpuContext; use std::{ borrow::Cow, - cell::RefCell, + cell::{Cell, RefCell}, path::{Path, PathBuf}, rc::Rc, sync::Arc, }; +use wasm_bindgen::prelude::*; static BUNDLED_FONTS: &[&[u8]] = &[ include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"), @@ -39,6 +40,9 @@ pub struct WebPlatform { active_display: Rc, callbacks: RefCell, wgpu_context: Rc>>, + cursor_visible: Rc>, + last_cursor_css: Rc>, + _cursor_restore_listeners: Vec, } #[derive(Default)] @@ -63,9 +67,9 @@ impl WebPlatform { )); let background_executor = BackgroundExecutor::new(dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(dispatcher); - let text_system = Arc::new( - crate::platform::wgpu::CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"), - ); + let text_system = Arc::new(gpui_wgpu::CosmicTextSystem::new_without_system_fonts( + "IBM Plex Sans", + )); let fonts = BUNDLED_FONTS .iter() .map(|bytes| Cow::Borrowed(*bytes)) @@ -77,6 +81,14 @@ impl WebPlatform { let active_display: Rc = Rc::new(WebDisplay::new(browser_window.clone())); + let cursor_visible = Rc::new(Cell::new(true)); + let last_cursor_css = Rc::new(Cell::new("default")); + let cursor_restore_listeners = cursor_restore_listeners( + &browser_window, + cursor_visible.clone(), + last_cursor_css.clone(), + ); + Self { browser_window, background_executor, @@ -86,6 +98,9 @@ impl WebPlatform { active_display, callbacks: RefCell::new(WebPlatformCallbacks::default()), wgpu_context: Rc::new(RefCell::new(None)), + cursor_visible, + last_cursor_css, + _cursor_restore_listeners: cursor_restore_listeners, } } } @@ -292,18 +307,25 @@ impl Platform for WebPlatform { CursorStyle::DragLink => "alias", CursorStyle::DragCopy => "copy", CursorStyle::ContextualMenu => "context-menu", - CursorStyle::None => "none", }; - if let Some(document) = self.browser_window.document() { - if let Some(body) = document.body() { - if let Err(error) = body.style().set_property("cursor", css_cursor) { - log::warn!("Failed to set cursor style: {error:?}"); - } - } + self.last_cursor_css.set(css_cursor); + if self.cursor_visible.get() { + set_body_cursor(&self.browser_window, css_cursor); } } + fn hide_cursor_until_mouse_moves(&self) { + if !self.cursor_visible.replace(false) { + return; + } + set_body_cursor(&self.browser_window, "none"); + } + + fn is_cursor_visible(&self) -> bool { + self.cursor_visible.get() + } + fn should_auto_hide_scrollbars(&self) -> bool { true } @@ -342,3 +364,72 @@ impl Platform for WebPlatform { self.callbacks.borrow_mut().keyboard_layout_change = Some(callback); } } + +struct EventListenerHandle { + target: web_sys::EventTarget, + event_name: &'static str, + closure: Closure, +} + +impl Drop for EventListenerHandle { + fn drop(&mut self) { + self.target + .remove_event_listener_with_callback( + self.event_name, + self.closure.as_ref().unchecked_ref(), + ) + .ok(); + } +} + +fn cursor_restore_listeners( + browser_window: &web_sys::Window, + cursor_visible: Rc>, + last_cursor_css: Rc>, +) -> Vec { + let mut handles = Vec::new(); + let Some(document) = browser_window.document() else { + return handles; + }; + + let make_restore_handler = |browser_window: web_sys::Window| { + let cursor_visible = cursor_visible.clone(); + let last_cursor_css = last_cursor_css.clone(); + Closure::::new(move |_event: JsValue| { + if !cursor_visible.replace(true) { + set_body_cursor(&browser_window, last_cursor_css.get()); + } + }) + }; + + let mut add_listener = |target: &web_sys::EventTarget, event_name: &'static str| { + let closure = make_restore_handler(browser_window.clone()); + target + .add_event_listener_with_callback(event_name, closure.as_ref().unchecked_ref()) + .ok(); + handles.push(EventListenerHandle { + target: target.clone(), + event_name, + closure, + }); + }; + + let document_target: &web_sys::EventTarget = document.as_ref(); + let window_target: &web_sys::EventTarget = browser_window.as_ref(); + + add_listener(document_target, "mousemove"); + add_listener(document_target, "mouseenter"); + add_listener(window_target, "blur"); + add_listener(document_target, "visibilitychange"); + + handles +} + +fn set_body_cursor(browser_window: &web_sys::Window, css_cursor: &str) { + if let Some(document) = browser_window.document() + && let Some(body) = document.body() + && let Err(error) = body.style().set_property("cursor", css_cursor) + { + log::warn!("Failed to set cursor style: {error:?}"); + } +} diff --git a/src/platform/web/window.rs b/crates/gpui_web/src/window.rs similarity index 99% rename from src/platform/web/window.rs rename to crates/gpui_web/src/window.rs index c33f8c0142..b9399d32e6 100644 --- a/src/platform/web/window.rs +++ b/crates/gpui_web/src/window.rs @@ -3,7 +3,6 @@ use crate::events::{ClickState, WebEventListeners, is_mac_platform}; use std::sync::Arc; use std::{cell::Cell, cell::RefCell, rc::Rc}; -use crate::platform::wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; use gpui::{ AnyWindowHandle, Bounds, Capslock, Decorations, DevicePixels, DispatchEventResult, GpuSpecs, Modifiers, MouseButton, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, @@ -11,6 +10,7 @@ use gpui::{ ResizeEdge, Scene, Size, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, WindowParams, px, }; +use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig}; use wasm_bindgen::prelude::*; #[derive(Default)] @@ -140,6 +140,7 @@ impl WebWindow { let renderer_config = WgpuSurfaceConfig { size: device_size, transparent: false, + preferred_present_mode: None, }; let renderer = WgpuRenderer::new_from_canvas(context, &canvas, renderer_config)?; diff --git a/crates/gpui_wgpu/Cargo.toml b/crates/gpui_wgpu/Cargo.toml new file mode 100644 index 0000000000..61a5746df3 --- /dev/null +++ b/crates/gpui_wgpu/Cargo.toml @@ -0,0 +1,54 @@ +[package] +name = "gpui_wgpu" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/gpui_wgpu.rs" + +[features] +default = [] +font-kit = ["dep:font-kit"] + +[dependencies] +gpui.workspace = true +anyhow.workspace = true +bytemuck = "1" +collections.workspace = true +cosmic-text = "0.19.0" +etagere = "0.2" +itertools.workspace = true +log.workspace = true +parking_lot.workspace = true +profiling.workspace = true +raw-window-handle = "0.6" +smallvec.workspace = true +swash = "0.2.6" +unicode-segmentation.workspace = true +gpui_util.workspace = true +wgpu.workspace = true + +# Optional: only needed on platforms with multiple font sources (e.g. Linux) +# 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 = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } + +[target.'cfg(not(target_family = "wasm"))'.dependencies] +pollster.workspace = true + +[target.'cfg(target_family = "wasm")'.dependencies] +wasm-bindgen.workspace = true +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } +js-sys = "0.3" + +[dev-dependencies] +criterion.workspace = true + +[[bench]] +name = "layout_line" +harness = false diff --git a/crates/gpui_wgpu/LICENSE-APACHE b/crates/gpui_wgpu/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_wgpu/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_wgpu/benches/layout_line.rs b/crates/gpui_wgpu/benches/layout_line.rs new file mode 100644 index 0000000000..f4a9289229 --- /dev/null +++ b/crates/gpui_wgpu/benches/layout_line.rs @@ -0,0 +1,82 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui::{FontFallbacks, FontRun, PlatformTextSystem, font, px}; +use gpui_wgpu::CosmicTextSystem; +use std::borrow::Cow; + +const LILEX: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); +const IBM_PLEX: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + +// ~4 000 chars of typical ASCII code text. +fn code_text() -> String { + concat!( + " fn compute_run_spans(\n", + " text: &str,\n", + " run_offset: usize,\n", + " run_len: usize,\n", + " primary: FontId,\n", + " fallback_chain: &[(FontId, SharedString)],\n", + " covers: &impl Fn(FontId, char) -> bool,\n", + " ) -> SmallVec<[RunSpan; 4]> {\n", + " let mut spans = SmallVec::new();\n", + " let run_end = run_offset + run_len;\n", + " if run_end <= run_offset { return spans; }\n", + " let run_text = &text[run_offset..run_end];\n", + " let mut span_start = run_offset;\n", + " let mut span_slot: Option = None;\n", + " for (ch_idx, ch) in run_text.char_indices() {\n", + " let abs = run_offset + ch_idx;\n", + " let next = pick_covering_slot(ch, span_slot, primary, fallback_chain, covers);\n", + " if next == span_slot { continue; }\n", + " if abs > span_start {\n", + " spans.push(RunSpan { start: span_start, end: abs, slot: span_slot });\n", + " }\n", + " span_start = abs;\n", + " span_slot = next;\n", + " }\n", + " spans\n", + " }\n", + ) + .repeat(8) // ~3 800 chars +} + +fn bench_layout_line(c: &mut Criterion) { + let system = CosmicTextSystem::new_without_system_fonts("Lilex"); + system + .add_fonts(vec![Cow::Borrowed(LILEX), Cow::Borrowed(IBM_PLEX)]) + .unwrap(); + + let font_id_no_fallback = system.font_id(&font("Lilex")).unwrap(); + + let font_id_with_fallback = { + let mut f = font("Lilex"); + f.fallbacks = Some(FontFallbacks::from_fonts(vec!["IBM Plex Sans".to_string()])); + system.font_id(&f).unwrap() + }; + + let text = code_text(); + + let runs_no_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_no_fallback, + }]; + let runs_with_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_with_fallback, + }]; + + 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)) + }); + + group.bench_function("with_fallback_ascii", |b| { + b.iter(|| system.layout_line(&text, px(14.0), &runs_with_fallback)) + }); + + group.finish(); +} + +criterion_group!(benches, bench_layout_line); +criterion_main!(benches); diff --git a/src/platform/wgpu/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs similarity index 57% rename from src/platform/wgpu/cosmic_text_system.rs rename to crates/gpui_wgpu/src/cosmic_text_system.rs index 81285598a9..456ba59c0a 100644 --- a/src/platform/wgpu/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -1,13 +1,14 @@ use anyhow::{Context as _, Ok, Result}; use collections::HashMap; use cosmic_text::{ - Attrs, AttrsList, Family, Font as CosmicTextFont, FontFeatures as CosmicFontFeatures, - FontSystem, ShapeBuffer, ShapeLine, + Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont, + FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, }; use gpui::{ - Bounds, DevicePixels, Font, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, LineLayout, - Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, - ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, size, + Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, + LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, + SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, + size, }; use itertools::Itertools; @@ -18,6 +19,7 @@ use swash::{ scale::{Render, ScaleContext, Source, StrikeWith}, zeno::{Format, Vector}, }; +use unicode_segmentation::UnicodeSegmentation; pub struct CosmicTextSystem(RwLock); @@ -25,11 +27,16 @@ pub struct CosmicTextSystem(RwLock); struct FontKey { family: SharedString, features: FontFeatures, + fallbacks: Option, } impl FontKey { - fn new(family: SharedString, features: FontFeatures) -> Self { - Self { family, features } + fn new(family: SharedString, features: FontFeatures, fallbacks: Option) -> Self { + Self { + family, + features, + fallbacks, + } } } @@ -49,6 +56,9 @@ struct LoadedFont { font: Arc, features: CosmicFontFeatures, is_known_emoji_font: bool, + /// resolved at load time so `layout_line` shares one chain across faces. + /// `Arc` keeps clone cheap on the per-run hot path. + user_fallback_chain: Arc<[(FontId, SharedString)]>, } impl CosmicTextSystem { @@ -103,11 +113,16 @@ impl PlatformTextSystem for CosmicTextSystem { fn font_id(&self, font: &Font) -> Result { let mut state = self.0.write(); - let key = FontKey::new(font.family.clone(), font.features.clone()); + let key = FontKey::new( + font.family.clone(), + font.features.clone(), + font.fallbacks.clone(), + ); let candidates = if let Some(font_ids) = state.font_ids_by_family_cache.get(&key) { font_ids.as_slice() } else { - let font_ids = state.load_family(&font.family, &font.features)?; + let font_ids = + state.load_family(&font.family, &font.features, font.fallbacks.as_ref())?; state.font_ids_by_family_cache.insert(key.clone(), font_ids); state.font_ids_by_family_cache[&key].as_ref() }; @@ -214,7 +229,43 @@ impl CosmicTextSystemState { &mut self, name: &str, features: &FontFeatures, + fallbacks: Option<&FontFallbacks>, ) -> Result> { + // recurse with `fallbacks = None` so a fallback family cannot pull in + // another chain. missing fallback families are dropped so a typo in + // settings still lets the primary family load. + let user_fallback_chain: Arc<[(FontId, SharedString)]> = match fallbacks { + Some(fallbacks) if !fallbacks.fallback_list().is_empty() => { + let mut chain: Vec<(FontId, SharedString)> = Vec::new(); + for fallback_name in fallbacks.fallback_list() { + let fb_key = FontKey::new( + SharedString::from(fallback_name.clone()), + features.clone(), + None, + ); + let fb_ids = if let Some(cached) = self.font_ids_by_family_cache.get(&fb_key) { + cached.clone() + } else { + let loaded = self.load_family(fallback_name, features, None)?; + self.font_ids_by_family_cache + .insert(fb_key.clone(), loaded.clone()); + loaded + }; + let Some(&fb_id) = fb_ids.first() else { + continue; + }; + let db_id = self.loaded_fonts[fb_id.0].font.id(); + if let Some(face) = self.font_system.db().face(db_id) + && let Some(family) = face.families.first() + { + chain.push((fb_id, SharedString::from(family.0.clone()))); + } + } + Arc::from(chain) + } + _ => Arc::from(Vec::new()), + }; + let name = gpui::font_name_with_fallbacks(name, &self.system_font_fallback); let families = self @@ -225,6 +276,8 @@ impl CosmicTextSystemState { .map(|face| (face.id, face.post_script_name.clone())) .collect::>(); + let cosmic_features = cosmic_font_features(features)?; + let mut loaded_font_ids = SmallVec::new(); for (font_id, postscript_name) in families { let font = self @@ -249,8 +302,9 @@ impl CosmicTextSystemState { loaded_font_ids.push(font_id); self.loaded_fonts.push(LoadedFont { font, - features: cosmic_font_features(features)?, + features: cosmic_features.clone(), is_known_emoji_font: check_is_known_emoji_font(&postscript_name), + user_fallback_chain: Arc::clone(&user_fallback_chain), }); } @@ -302,7 +356,15 @@ impl CosmicTextSystemState { } Ok((bitmap_size, image.data)) } - swash::scale::image::Content::Mask => Ok((bitmap_size, image.data)), + swash::scale::image::Content::Mask => { + if params.subpixel_rendering { + // We must always return RGBA data when subpixel rendering is requested. + let expanded = image.data.iter().flat_map(|&a| [a, a, a, a]).collect(); + Ok((bitmap_size, expanded)) + } else { + Ok((bitmap_size, image.data)) + } + } } } @@ -333,7 +395,7 @@ impl CosmicTextSystemState { Source::Outline, ] } else { - &[Source::Outline] + &[Source::Bitmap(StrikeWith::ExactSize), Source::Outline] }; let mut renderer = Render::new(sources); @@ -383,6 +445,7 @@ impl CosmicTextSystemState { font, features: CosmicFontFeatures::new(), is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name), + user_fallback_chain: Arc::from(Vec::new()), }); Ok(font_id) @@ -394,13 +457,15 @@ impl CosmicTextSystemState { let mut attrs_list = AttrsList::new(&Attrs::new()); let mut offs = 0; for run in font_runs { + let run_end = offs + run.len; + let loaded_font = self.loaded_font(run.font_id); let Some(face) = self.font_system.db().face(loaded_font.font.id()) else { log::warn!( "font face not found in database for font_id {:?}", run.font_id ); - offs += run.len; + offs = run_end; continue; }; let Some(first_family) = face.families.first() else { @@ -408,21 +473,62 @@ impl CosmicTextSystemState { "font face has no family names for font_id {:?}", run.font_id ); - offs += run.len; + offs = run_end; continue; }; - attrs_list.add_span( - offs..(offs + run.len), - &Attrs::new() - .metadata(run.font_id.0) - .family(Family::Name(&first_family.0)) - .stretch(face.stretch) - .style(face.style) - .weight(face.weight) - .font_features(loaded_font.features.clone()), - ); - offs += run.len; + let primary_family_name: SharedString = first_family.0.clone().into(); + let primary_stretch = face.stretch; + let primary_style = face.style; + let primary_weight = face.weight; + let primary_features = loaded_font.features.clone(); + let fallback_chain = Arc::clone(&loaded_font.user_fallback_chain); + + // build one `Attrs` per slot up front. each clone of span attrs + // would otherwise re-allocate the `font_features` Vec. + let primary_attrs = Attrs::new() + .metadata(run.font_id.0) + .family(Family::Name(&primary_family_name)) + .stretch(primary_stretch) + .style(primary_style) + .weight(primary_weight) + .font_features(primary_features.clone()); + let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = fallback_chain + .iter() + .map(|(fb_id, fb_name)| { + Attrs::new() + .metadata(fb_id.0) + .family(Family::Name(fb_name)) + .stretch(primary_stretch) + .style(primary_style) + .weight(primary_weight) + .font_features(primary_features.clone()) + }) + .collect(); + + let spans = if fallback_chain.is_empty() { + let mut spans = SmallVec::<[RunSpan; 4]>::new(); + spans.push(RunSpan { + start: offs, + end: run_end, + slot: None, + font_id: run.font_id, + }); + spans + } else { + let loaded_fonts = &self.loaded_fonts; + let covers = |id: FontId, ch: char| charmap_covers(loaded_fonts, id, ch); + compute_run_spans(text, offs, run.len, run.font_id, &fallback_chain, &covers) + }; + + for span in spans { + let attrs = match span.slot { + None => &primary_attrs, + Some(ix) => &fallback_attrs[ix], + }; + attrs_list.add_span(span.start..span.end, attrs); + } + offs = run_end; } let line = ShapeLine::new( @@ -438,6 +544,7 @@ impl CosmicTextSystemState { f32::from(font_size), None, // We do our own wrapping cosmic_text::Wrap::None, + Ellipsize::None, None, &mut layout_lines, None, @@ -585,6 +692,117 @@ fn find_best_match( Ok(best_index) } +/// one contiguous slice of a `FontRun` that maps to a single slot. `slot` is +/// `None` for the primary font and `Some(ix)` for `fallback_chain[ix]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RunSpan { + start: usize, + end: usize, + slot: Option, + font_id: FontId, +} + +/// walks `text[run_offset..run_offset + run_len]` and groups codepoints into +/// spans. inheriting codepoints stay in the current span so shaping clusters +/// like emoji zwj sequences and combining marks are not torn apart. +fn compute_run_spans( + text: &str, + run_offset: usize, + run_len: usize, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> SmallVec<[RunSpan; 4]> { + let mut spans = SmallVec::new(); + let run_end = run_offset + run_len; + if run_end <= run_offset { + return spans; + } + if fallback_chain.is_empty() { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + let run_text = &text[run_offset..run_end]; + let mut span_start = run_offset; + let mut span_slot: Option = None; + let mut span_font_id = primary; + for (grapheme_idx, grapheme) in run_text.grapheme_indices(true) { + let abs = run_offset + grapheme_idx; + let ch = grapheme.chars().next().unwrap_or('\0'); + let next_slot = pick_covering_slot(ch, span_slot, primary, fallback_chain, covers); + if next_slot == span_slot { + continue; + } + if abs > span_start { + spans.push(RunSpan { + start: span_start, + end: abs, + slot: span_slot, + font_id: span_font_id, + }); + } + span_start = abs; + span_slot = next_slot; + span_font_id = slot_font_id(next_slot, primary, fallback_chain); + } + if span_start < run_end { + spans.push(RunSpan { + start: span_start, + end: run_end, + slot: span_slot, + font_id: span_font_id, + }); + } + spans +} + +fn slot_font_id( + slot: Option, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], +) -> FontId { + match slot { + None => primary, + Some(ix) => fallback_chain[ix].0, + } +} + +fn pick_covering_slot( + ch: char, + current: Option, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> Option { + if (ch as u32) <= 0x7F { + return None; + } + if covers(primary, ch) { + return None; + } + let current_id = slot_font_id(current, primary, fallback_chain); + if covers(current_id, ch) { + return current; + } + for (ix, (fb_id, _)) in fallback_chain.iter().enumerate() { + if covers(*fb_id, ch) { + return Some(ix); + } + } + None +} + +fn charmap_covers(loaded_fonts: &[LoadedFont], id: FontId, ch: char) -> bool { + loaded_fonts + .get(id.0) + .is_some_and(|loaded| loaded.font.as_swash().charmap().map(ch) != 0) +} + fn cosmic_font_features(features: &FontFeatures) -> Result { let mut result = CosmicFontFeatures::new(); for feature in features.0.iter() { @@ -643,3 +861,202 @@ fn check_is_known_emoji_font(postscript_name: &str) -> bool { // TODO: Include other common emoji fonts postscript_name == "NotoColorEmoji" } + +#[cfg(test)] +mod tests { + use super::*; + + fn fid(i: usize) -> FontId { + FontId(i) + } + + fn chain(ids: &[usize]) -> SmallVec<[(FontId, SharedString); 4]> { + ids.iter() + .map(|&i| (fid(i), SharedString::from(format!("fb{i}")))) + .collect() + } + + fn span(start: usize, end: usize, slot: Option, font_id: FontId) -> RunSpan { + RunSpan { + start, + end, + slot, + font_id, + } + } + + #[test] + fn primary_wins_over_current_fallback_when_primary_covers() { + let primary = fid(0); + let fb = chain(&[1, 2]); + let covers = |id: FontId, _: char| id == fid(0) || id == fid(1); + assert_eq!( + pick_covering_slot('a', Some(0), primary, &fb, &covers), + None + ); + } + + #[test] + fn primary_preferred_over_fallback_when_both_cover() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |_: FontId, _: char| true; + assert_eq!(pick_covering_slot('a', None, primary, &fb, &covers), None); + } + + #[test] + fn falls_through_chain_in_order() { + let primary = fid(0); + let fb = chain(&[1, 2, 3]); + // only fallback 2 at index 1 covers. + let covers = |id: FontId, _: char| id == fid(2); + assert_eq!( + pick_covering_slot('字', None, primary, &fb, &covers), + Some(1) + ); + } + + #[test] + fn no_coverage_returns_primary() { + let primary = fid(0); + let fb = chain(&[1, 2]); + let covers = |_: FontId, _: char| false; + // nothing covers. return `None` so the `cosmic-text` built in script + // fallback can take over during shaping. + assert_eq!( + pick_covering_slot('\u{1F600}', Some(1), primary, &fb, &covers), + None + ); + } + + #[test] + fn empty_chain_always_returns_primary() { + let primary = fid(0); + let fb: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + assert_eq!(pick_covering_slot('a', None, primary, &fb, &covers), None); + } + + #[test] + fn slot_font_id_resolution() { + let primary = fid(7); + let fb = chain(&[10, 20]); + assert_eq!(slot_font_id(None, primary, &fb), fid(7)); + assert_eq!(slot_font_id(Some(0), primary, &fb), fid(10)); + assert_eq!(slot_font_id(Some(1), primary, &fb), fid(20)); + } + + #[test] + fn run_spans_with_no_chain_emit_one_primary_span() { + let primary = fid(0); + let fb: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + let text = "hello"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_byte_offsets_for_multibyte_chars() { + let primary = fid(0); + let fb = chain(&[1]); + // primary covers ascii. fallback covers cjk. + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "a字b"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + // '字' is 3 bytes so split is at 1 then 4. + assert_eq!( + spans.as_slice(), + &[ + span(0, 1, None, primary), + span(1, 4, Some(0), fid(1)), + span(4, 5, None, primary), + ] + ); + } + + #[test] + fn run_spans_respect_run_offset() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + // outer text has a prefix that is not part of this run. + let text = "xx字y"; + let run_offset = 2; + let run_len = text.len() - run_offset; + let spans = compute_run_spans(text, run_offset, run_len, primary, &fb, &covers); + assert_eq!( + spans.as_slice(), + &[span(2, 5, Some(0), fid(1)), span(5, 6, None, primary)] + ); + } + + #[test] + fn run_spans_keep_combining_marks_with_base_in_fallback() { + let primary = fid(0); + let fb = chain(&[1]); + // primary covers ascii only. fallback covers the base char. + // combining mark must stay in the fallback span even when fallback + // does not advertise coverage of it. + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + ch == '\u{0905}' + } + }; + // \u{0905} devanagari short a + \u{0902} candrabindu mark. + let text = "\u{0905}\u{0902}"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_keep_zwj_inside_emoji_cluster() { + let primary = fid(0); + let fb = chain(&[1]); + // only fallback covers the emoji codepoints. zwj must not split. + let covers = |id: FontId, ch: char| id == fid(1) && ch != '\u{200D}'; + // family zwj sequence woman zwj girl. + let text = "\u{1F469}\u{200D}\u{1F467}"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_collapse_adjacent_same_slot() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "字字字"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_empty_run_returns_no_spans() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |_: FontId, _: char| true; + let spans = compute_run_spans("anything", 3, 0, primary, &fb, &covers); + assert!(spans.is_empty()); + } +} diff --git a/src/platform/wgpu.rs b/crates/gpui_wgpu/src/gpui_wgpu.rs similarity index 100% rename from src/platform/wgpu.rs rename to crates/gpui_wgpu/src/gpui_wgpu.rs diff --git a/src/platform/wgpu/shaders.wgsl b/crates/gpui_wgpu/src/shaders.wgsl similarity index 95% rename from src/platform/wgpu/shaders.wgsl rename to crates/gpui_wgpu/src/shaders.wgsl index 12ce7d29b0..933b88e84d 100644 --- a/src/platform/wgpu/shaders.wgsl +++ b/crates/gpui_wgpu/src/shaders.wgsl @@ -87,7 +87,8 @@ struct GammaParams { gamma_ratios: vec4, grayscale_enhanced_contrast: f32, subpixel_enhanced_contrast: f32, - pad: vec2, + is_bgr: u32, + pad: u32, } @group(0) @binding(0) var globals: GlobalParams; @@ -950,10 +951,18 @@ fn fmod(a: f32, b: f32) -> f32 { struct Shadow { order: u32, blur_radius: f32, + // The shadow rect for drop shadows; the "hole" rect for inset shadows. bounds: Bounds, corner_radii: Corners, content_mask: Bounds, color: Hsla, + // Only consulted when `inset == 1u`: the element's own bounds, used as a rounded-rect + // clip so the shadow never escapes the element. + element_bounds: Bounds, + element_corner_radii: Corners, + // 0 = drop shadow, 1 = inset shadow. + inset: u32, + pad: u32, // align to 8 bytes } @group(1) @binding(0) var b_shadows: array; @@ -970,17 +979,22 @@ fn vs_shadow(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) ins let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); var shadow = b_shadows[instance_id]; - let margin = 3.0 * shadow.blur_radius; - // Set the bounds of the shadow and adjust its size based on the shadow's - // spread radius to achieve the spreading effect - shadow.bounds.origin -= vec2(margin); - shadow.bounds.size += 2.0 * vec2(margin); + var geometry: Bounds; + if (shadow.inset != 0u) { + geometry = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + let margin = 3.0 * shadow.blur_radius; + geometry = shadow.bounds; + geometry.origin -= vec2(margin); + geometry.size += 2.0 * vec2(margin); + } var out = ShadowVarying(); - out.position = to_device_position(unit_vertex, shadow.bounds); + out.position = to_device_position(unit_vertex, geometry); out.color = hsla_to_rgba(shadow.color); out.shadow_id = instance_id; - out.clip_distances = distance_from_clip_rect(unit_vertex, shadow.bounds, shadow.content_mask); + out.clip_distances = distance_from_clip_rect(unit_vertex, geometry, shadow.content_mask); return out; } @@ -998,21 +1012,36 @@ fn fs_shadow(input: ShadowVarying) -> @location(0) vec4 { let corner_radius = pick_corner_radius(center_to_point, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - let low = center_to_point.y - half_size.y; - let high = center_to_point.y + half_size.y; - let start = clamp(-3.0 * shadow.blur_radius, low, high); - let end = clamp(3.0 * shadow.blur_radius, low, high); + var alpha: f32; + if (shadow.blur_radius == 0.0) { + let distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + let low = center_to_point.y - half_size.y; + let high = center_to_point.y + half_size.y; + let start = clamp(-3.0 * shadow.blur_radius, low, high); + let end = clamp(3.0 * shadow.blur_radius, low, high); - // Accumulate samples (we can get away with surprisingly few samples) - let step = (end - start) / 4.0; - var y = start + step * 0.5; - var alpha = 0.0; - for (var i = 0; i < 4; i += 1) { - let blur = blur_along_x(center_to_point.x, center_to_point.y - y, - shadow.blur_radius, corner_radius, half_size); - alpha += blur * gaussian(y, shadow.blur_radius) * step; - y += step; + // Accumulate samples (we can get away with surprisingly few samples) + let step = (end - start) / 4.0; + var y = start + step * 0.5; + alpha = 0.0; + for (var i = 0; i < 4; i += 1) { + let blur = blur_along_x(center_to_point.x, center_to_point.y - y, + shadow.blur_radius, corner_radius, half_size); + alpha += blur * gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + let element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return blend_color(input.color, alpha); diff --git a/src/platform/wgpu/shaders_subpixel.wgsl b/crates/gpui_wgpu/src/shaders_subpixel.wgsl similarity index 93% rename from src/platform/wgpu/shaders_subpixel.wgsl rename to crates/gpui_wgpu/src/shaders_subpixel.wgsl index 9f1f73de4c..8fd936469d 100644 --- a/src/platform/wgpu/shaders_subpixel.wgsl +++ b/crates/gpui_wgpu/src/shaders_subpixel.wgsl @@ -38,7 +38,10 @@ fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_i @fragment fn fs_subpixel_sprite(input: SubpixelSpriteOutput) -> SubpixelSpriteFragmentOutput { - let sample = textureSample(t_sprite, s_sprite, input.tile_position).rgb; + var sample = textureSample(t_sprite, s_sprite, input.tile_position).rgb; + if (gamma_params.is_bgr != 0u) { + sample = sample.bgr; + } let alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, gamma_params.subpixel_enhanced_contrast, gamma_params.gamma_ratios); // Alpha clip after using the derivatives. diff --git a/src/platform/wgpu/wgpu_atlas.rs b/crates/gpui_wgpu/src/wgpu_atlas.rs similarity index 64% rename from src/platform/wgpu/wgpu_atlas.rs rename to crates/gpui_wgpu/src/wgpu_atlas.rs index 3eba5c533f..94b4b561c0 100644 --- a/src/platform/wgpu/wgpu_atlas.rs +++ b/crates/gpui_wgpu/src/wgpu_atlas.rs @@ -8,6 +8,8 @@ use gpui::{ use parking_lot::Mutex; use std::{borrow::Cow, ops, sync::Arc}; +use crate::WgpuContext; + fn device_size_to_etagere(size: Size) -> etagere::Size { size2(size.width.0, size.height.0) } @@ -31,6 +33,7 @@ struct WgpuAtlasState { device: Arc, queue: Arc, max_texture_size: u32, + color_texture_format: wgpu::TextureFormat, storage: WgpuAtlasStorage, tiles_by_key: FxHashMap, pending_uploads: Vec, @@ -41,18 +44,31 @@ pub struct WgpuTextureInfo { } impl WgpuAtlas { - pub fn new(device: Arc, queue: Arc) -> Self { + pub fn new( + device: Arc, + queue: Arc, + color_texture_format: wgpu::TextureFormat, + ) -> Self { let max_texture_size = device.limits().max_texture_dimension_2d; WgpuAtlas(Mutex::new(WgpuAtlasState { device, queue, max_texture_size, + color_texture_format, storage: WgpuAtlasStorage::default(), tiles_by_key: Default::default(), pending_uploads: Vec::new(), })) } + pub fn from_context(context: &WgpuContext) -> Self { + Self::new( + context.device.clone(), + context.queue.clone(), + context.color_texture_format(), + ) + } + pub fn before_frame(&self) { let mut lock = self.0.lock(); lock.flush_uploads(); @@ -66,12 +82,22 @@ impl WgpuAtlas { } } + /// Clears all cached textures and tiles, forcing them to be recreated. + /// Use this for incremental recovery when the device is still valid. + pub fn clear(&self) { + let mut lock = self.0.lock(); + lock.storage = WgpuAtlasStorage::default(); + lock.tiles_by_key.clear(); + lock.pending_uploads.clear(); + } + /// Handles device lost by clearing all textures and cached tiles. /// The atlas will lazily recreate textures as needed on subsequent frames. - pub fn handle_device_lost(&self, device: Arc, queue: Arc) { + pub fn handle_device_lost(&self, context: &WgpuContext) { let mut lock = self.0.lock(); - lock.device = device; - lock.queue = queue; + lock.device = context.device.clone(); + lock.queue = context.queue.clone(); + lock.color_texture_format = context.color_texture_format(); lock.storage = WgpuAtlasStorage::default(); lock.tiles_by_key.clear(); lock.pending_uploads.clear(); @@ -86,7 +112,7 @@ impl PlatformAtlas for WgpuAtlas { ) -> Result> { let mut lock = self.0.lock(); if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) + Ok(Some(*tile)) } else { profiling::scope!("new tile"); let Some((size, bytes)) = build()? else { @@ -96,7 +122,7 @@ impl PlatformAtlas for WgpuAtlas { .allocate(size, key.texture_kind()) .context("failed to allocate")?; lock.upload_texture(tile.texture_id, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); + lock.tiles_by_key.insert(key.clone(), tile); Ok(Some(tile)) } } @@ -115,6 +141,8 @@ impl PlatformAtlas for WgpuAtlas { if let Some(mut texture) = texture_slot.take() { texture.decrement_ref_count(); if texture.is_unreferenced() { + lock.pending_uploads + .retain(|upload| upload.id != texture.id); lock.storage[id.kind] .free_list .push(texture.id.index as usize); @@ -165,8 +193,7 @@ impl WgpuAtlasState { let size = min_size.min(&max_atlas_size).max(&DEFAULT_ATLAS_SIZE); let format = match kind { AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, - AtlasTextureKind::Subpixel => wgpu::TextureFormat::Bgra8Unorm, - AtlasTextureKind::Polychrome => wgpu::TextureFormat::Bgra8Unorm, + AtlasTextureKind::Subpixel | AtlasTextureKind::Polychrome => self.color_texture_format, }; let texture = self.device.create_texture(&wgpu::TextureDescriptor { @@ -219,16 +246,21 @@ impl WgpuAtlasState { } fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { - self.pending_uploads.push(PendingUpload { - id, - bounds, - data: bytes.to_vec(), - }); + let data = self + .storage + .get(id) + .map(|texture| swizzle_upload_data(bytes, texture.format)) + .unwrap_or_else(|| bytes.to_vec()); + + self.pending_uploads + .push(PendingUpload { id, bounds, data }); } fn flush_uploads(&mut self) { for upload in self.pending_uploads.drain(..) { - let texture = &self.storage[upload.id]; + let Some(texture) = self.storage.get(upload.id) else { + continue; + }; let bytes_per_pixel = texture.bytes_per_pixel(); self.queue.write_texture( @@ -286,6 +318,15 @@ impl ops::IndexMut for WgpuAtlasStorage { } } +impl WgpuAtlasStorage { + fn get(&self, id: AtlasTextureId) -> Option<&WgpuAtlasTexture> { + self[id.kind] + .textures + .get(id.index as usize) + .and_then(|t| t.as_ref()) + } +} + impl ops::Index for WgpuAtlasStorage { type Output = WgpuAtlasTexture; fn index(&self, id: AtlasTextureId) -> &Self::Output { @@ -328,7 +369,7 @@ impl WgpuAtlasTexture { fn bytes_per_pixel(&self) -> u8 { match self.format { wgpu::TextureFormat::R8Unorm => 1, - wgpu::TextureFormat::Bgra8Unorm => 4, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm => 4, _ => 4, } } @@ -341,3 +382,100 @@ impl WgpuAtlasTexture { self.live_atlas_keys == 0 } } + +fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { + match format { + wgpu::TextureFormat::Rgba8Unorm => { + let mut data = bytes.to_vec(); + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + data + } + _ => bytes.to_vec(), + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use gpui::block_on; + use gpui::{ImageId, RenderImageParams}; + use std::sync::Arc; + + 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 + .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; + 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 + .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?; + Ok((Arc::new(device), Arc::new(queue))) + }) + } + + #[test] + fn before_frame_skips_uploads_for_removed_texture() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let key = AtlasKey::Image(RenderImageParams { + image_id: ImageId(1), + frame_index: 0, + }); + let size = Size { + width: DevicePixels(1), + height: DevicePixels(1), + }; + let mut build = || Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255])))); + + // Regression test: before the fix, this panicked in flush_uploads + atlas + .get_or_insert_with(&key, &mut build)? + .expect("tile should be created"); + atlas.remove(&key); + atlas.before_frame(); + Ok(()) + } + + #[test] + fn swizzle_upload_data_preserves_bgra_uploads() { + let input = vec![0x10, 0x20, 0x30, 0x40]; + assert_eq!( + swizzle_upload_data(&input, wgpu::TextureFormat::Bgra8Unorm), + input + ); + } + + #[test] + fn swizzle_upload_data_converts_bgra_to_rgba() { + let input = vec![0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD]; + assert_eq!( + swizzle_upload_data(&input, wgpu::TextureFormat::Rgba8Unorm), + vec![0x30, 0x20, 0x10, 0x40, 0xCC, 0xBB, 0xAA, 0xDD] + ); + } +} diff --git a/src/platform/wgpu/wgpu_context.rs b/crates/gpui_wgpu/src/wgpu_context.rs similarity index 73% rename from src/platform/wgpu/wgpu_context.rs rename to crates/gpui_wgpu/src/wgpu_context.rs index d6bf04894b..9dd5599393 100644 --- a/src/platform/wgpu/wgpu_context.rs +++ b/crates/gpui_wgpu/src/wgpu_context.rs @@ -1,9 +1,10 @@ #[cfg(not(target_family = "wasm"))] use anyhow::Context as _; +#[cfg(not(target_family = "wasm"))] +use gpui_util::ResultExt; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -#[cfg(not(target_family = "wasm"))] -use util::ResultExt; +use wgpu::TextureFormat; pub struct WgpuContext { pub instance: wgpu::Instance, @@ -11,6 +12,7 @@ pub struct WgpuContext { pub device: Arc, pub queue: Arc, dual_source_blending: bool, + color_texture_format: wgpu::TextureFormat, device_lost: Arc, } @@ -26,6 +28,25 @@ impl WgpuContext { instance: wgpu::Instance, surface: &wgpu::Surface<'_>, compositor_gpu: Option, + ) -> anyhow::Result { + Self::new_with_options(instance, surface, compositor_gpu, false) + } + + #[cfg(not(target_family = "wasm"))] + pub fn new_rejecting_software( + instance: wgpu::Instance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + ) -> anyhow::Result { + Self::new_with_options(instance, surface, compositor_gpu, true) + } + + #[cfg(not(target_family = "wasm"))] + fn new_with_options( + instance: wgpu::Instance, + surface: &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) @@ -41,12 +62,13 @@ impl WgpuContext { // Select an adapter by actually testing surface configuration with the real device. // This is the only reliable way to determine compatibility on hybrid GPU systems. - let (adapter, device, queue, dual_source_blending) = - pollster::block_on(Self::select_adapter_and_device( + let (adapter, device, queue, dual_source_blending, color_texture_format) = + gpui::block_on(Self::select_adapter_and_device( &instance, device_id_filter, surface, compositor_gpu.as_ref(), + reject_software, ))?; let device_lost = Arc::new(AtomicBool::new(false)); @@ -72,6 +94,7 @@ impl WgpuContext { device: Arc::new(device), queue: Arc::new(queue), dual_source_blending, + color_texture_format, device_lost, }) } @@ -102,7 +125,8 @@ impl WgpuContext { ); let device_lost = Arc::new(AtomicBool::new(false)); - let (device, queue, dual_source_blending) = Self::create_device(&adapter).await?; + let (device, queue, dual_source_blending, color_texture_format) = + Self::create_device(&adapter).await?; Ok(Self { instance, @@ -110,13 +134,14 @@ impl WgpuContext { device: Arc::new(device), queue: Arc::new(queue), dual_source_blending, + color_texture_format, device_lost, }) } async fn create_device( adapter: &wgpu::Adapter, - ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool)> { + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool, TextureFormat)> { let dual_source_blending = adapter .features() .contains(wgpu::Features::DUAL_SOURCE_BLENDING); @@ -131,6 +156,8 @@ impl WgpuContext { ); } + let color_atlas_texture_format = Self::select_color_texture_format(adapter)?; + let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor { label: Some("gpui_device"), @@ -145,7 +172,12 @@ impl WgpuContext { .await .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; - Ok((device, queue, dual_source_blending)) + Ok(( + device, + queue, + dual_source_blending, + color_atlas_texture_format, + )) } #[cfg(not(target_family = "wasm"))] @@ -185,7 +217,14 @@ impl WgpuContext { device_id_filter: Option, surface: &wgpu::Surface<'_>, compositor_gpu: Option<&CompositorGpuHint>, - ) -> anyhow::Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue, bool)> { + reject_software: bool, + ) -> anyhow::Result<( + wgpu::Adapter, + wgpu::Device, + wgpu::Queue, + bool, + TextureFormat, + )> { let mut adapters: Vec<_> = instance.enumerate_adapters(wgpu::Backends::all()).await; if adapters.is_empty() { @@ -226,18 +265,20 @@ impl WgpuContext { _ => 1, }; - let type_priority: u8 = match info.device_type { - wgpu::DeviceType::DiscreteGpu => 0, - wgpu::DeviceType::IntegratedGpu => 1, - wgpu::DeviceType::Other => 2, - wgpu::DeviceType::VirtualGpu => 3, - wgpu::DeviceType::Cpu => 4, + let type_priority: u8 = if info.device_type == wgpu::DeviceType::Cpu { + 4 + } else { + match info.device_type { + wgpu::DeviceType::DiscreteGpu => 0, + wgpu::DeviceType::IntegratedGpu => 1, + wgpu::DeviceType::Other => 2, + wgpu::DeviceType::VirtualGpu => 3, + wgpu::DeviceType::Cpu => 4, + } }; let backend_priority: u8 = match info.backend { - wgpu::Backend::Vulkan => 0, - wgpu::Backend::Metal => 0, - wgpu::Backend::Dx12 => 0, + wgpu::Backend::Vulkan | wgpu::Backend::Metal | wgpu::Backend::Dx12 => 0, _ => 1, }; @@ -266,16 +307,32 @@ impl WgpuContext { // Test each adapter by creating a device and configuring the surface 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; + } + log::info!("Testing adapter: {} ({:?})...", info.name, info.backend); match Self::try_adapter_with_surface(&adapter, surface).await { - Ok((device, queue, dual_source_blending)) => { + Ok((device, queue, dual_source_blending, color_atlas_texture_format)) => { log::info!( "Selected GPU (passed configuration test): {} ({:?})", info.name, info.backend ); - return Ok((adapter, device, queue, dual_source_blending)); + return Ok(( + adapter, + device, + queue, + dual_source_blending, + color_atlas_texture_format, + )); } Err(e) => { log::info!( @@ -297,7 +354,7 @@ impl WgpuContext { async fn try_adapter_with_surface( adapter: &wgpu::Adapter, surface: &wgpu::Surface<'_>, - ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool)> { + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool, TextureFormat)> { let caps = surface.get_capabilities(adapter); if caps.formats.is_empty() { anyhow::bail!("no compatible surface formats"); @@ -306,7 +363,8 @@ impl WgpuContext { anyhow::bail!("no compatible alpha modes"); } - let (device, queue, dual_source_blending) = Self::create_device(adapter).await?; + let (device, queue, dual_source_blending, color_atlas_texture_format) = + Self::create_device(adapter).await?; let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); let test_config = wgpu::SurfaceConfiguration { @@ -327,13 +385,55 @@ impl WgpuContext { anyhow::bail!("surface configuration failed: {e}"); } - Ok((device, queue, dual_source_blending)) + Ok(( + device, + queue, + dual_source_blending, + color_atlas_texture_format, + )) } + fn select_color_texture_format(adapter: &wgpu::Adapter) -> anyhow::Result { + let required_usages = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + let bgra_features = adapter.get_texture_format_features(wgpu::TextureFormat::Bgra8Unorm); + if bgra_features.allowed_usages.contains(required_usages) { + return Ok(wgpu::TextureFormat::Bgra8Unorm); + } + + let rgba_features = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba8Unorm); + if rgba_features.allowed_usages.contains(required_usages) { + let info = adapter.get_info(); + log::warn!( + "Adapter {} ({:?}) does not support Bgra8Unorm atlas textures with usages {:?}; \ + falling back to Rgba8Unorm atlas textures.", + info.name, + info.backend, + required_usages, + ); + return Ok(wgpu::TextureFormat::Rgba8Unorm); + } + + let info = adapter.get_info(); + Err(anyhow::anyhow!( + "Adapter {} ({:?}, device={:#06x}) does not support a usable color atlas texture \ + format with usages {:?}. Bgra8Unorm allowed usages: {:?}; \ + Rgba8Unorm allowed usages: {:?}.", + info.name, + info.backend, + info.device, + required_usages, + bgra_features.allowed_usages, + rgba_features.allowed_usages, + )) + } pub fn supports_dual_source_blending(&self) -> bool { self.dual_source_blending } + pub fn color_texture_format(&self) -> wgpu::TextureFormat { + self.color_texture_format + } + /// 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/src/platform/wgpu/wgpu_renderer.rs b/crates/gpui_wgpu/src/wgpu_renderer.rs similarity index 89% rename from src/platform/wgpu/wgpu_renderer.rs rename to crates/gpui_wgpu/src/wgpu_renderer.rs index 4da255a02d..08f30dc009 100644 --- a/src/platform/wgpu/wgpu_renderer.rs +++ b/crates/gpui_wgpu/src/wgpu_renderer.rs @@ -50,7 +50,8 @@ struct GammaParams { gamma_ratios: [f32; 4], grayscale_enhanced_contrast: f32, subpixel_enhanced_contrast: f32, - _pad: [f32; 2], + is_bgr: u32, + _pad: u32, } #[derive(Clone, Debug)] @@ -71,6 +72,13 @@ struct PathRasterizationVertex { pub struct WgpuSurfaceConfig { pub size: Size, pub transparent: bool, + /// Preferred presentation mode. When `Some`, the renderer will use this + /// mode if supported by the surface, falling back to `Fifo`. + /// When `None`, defaults to `Fifo` (VSync). + /// + /// Mobile platforms may prefer `Mailbox` (triple-buffering) to avoid + /// blocking in `get_current_texture()` during lifecycle transitions. + pub preferred_present_mode: Option, } struct WgpuPipelines { @@ -114,6 +122,15 @@ struct WgpuResources { path_msaa_view: Option, } +impl WgpuResources { + fn invalidate_intermediate_textures(&mut self) { + self.path_intermediate_texture = None; + self.path_intermediate_view = None; + self.path_msaa_texture = None; + self.path_msaa_view = None; + } +} + pub struct WgpuRenderer { /// Shared GPU context for device recovery coordination (unused on WASM). #[allow(dead_code)] @@ -130,6 +147,7 @@ pub struct WgpuRenderer { max_buffer_size: u64, storage_buffer_alignment: u64, rendering_params: RenderingParameters, + is_bgr: bool, dual_source_blending: bool, adapter_info: wgpu::AdapterInfo, transparent_alpha_mode: wgpu::CompositeAlphaMode, @@ -138,6 +156,8 @@ pub struct WgpuRenderer { last_error: Arc>>, failed_frame_count: u32, device_lost: std::sync::Arc, + surface_configured: bool, + needs_redraw: bool, } impl WgpuRenderer { @@ -209,10 +229,7 @@ impl WgpuRenderer { None => ctx_ref.insert(WgpuContext::new(instance, &surface, compositor_gpu)?), }; - let atlas = Arc::new(WgpuAtlas::new( - Arc::clone(&context.device), - Arc::clone(&context.queue), - )); + let atlas = Arc::new(WgpuAtlas::from_context(context)); Self::new_internal( Some(Rc::clone(&gpu_context)), @@ -235,10 +252,7 @@ impl WgpuRenderer { .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))?; - let atlas = Arc::new(WgpuAtlas::new( - Arc::clone(&context.device), - Arc::clone(&context.queue), - )); + let atlas = Arc::new(WgpuAtlas::from_context(context)); Self::new_internal(None, context, surface, config, None, atlas) } @@ -321,7 +335,10 @@ impl WgpuRenderer { format: surface_format, width: clamped_width.max(1), height: clamped_height.max(1), - present_mode: wgpu::PresentMode::Fifo, + present_mode: config + .preferred_present_mode + .filter(|mode| surface_caps.present_modes.contains(mode)) + .unwrap_or(wgpu::PresentMode::Fifo), desired_maximum_frame_latency: 2, alpha_mode, view_formats: vec![], @@ -460,6 +477,7 @@ impl WgpuRenderer { max_buffer_size, storage_buffer_alignment, rendering_params, + is_bgr: false, dual_source_blending, adapter_info, transparent_alpha_mode, @@ -468,6 +486,8 @@ impl WgpuRenderer { last_error, failed_frame_count: 0, device_lost: context.device_lost_flag(), + surface_configured: true, + needs_redraw: false, }) } @@ -603,6 +623,28 @@ impl WgpuRenderer { path_sample_count: u32, dual_source_blending: bool, ) -> WgpuPipelines { + // Diagnostic guard: verify the device actually has + // DUAL_SOURCE_BLENDING. We have a crash report (ZED-5G1) where a + // feature mismatch caused a wgpu-hal abort, but we haven't + // identified the code path that produces the mismatch. This + // guard prevents the crash and logs more evidence. + // Remove this check once: + // a) We find and fix the root cause, or + // b) There are no reports of this warning appearing for some time. + let device_has_feature = device + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + if dual_source_blending && !device_has_feature { + log::error!( + "BUG: dual_source_blending flag is true but device does not \ + have DUAL_SOURCE_BLENDING enabled (device features: {:?}). \ + Falling back to mono text rendering. Please report this at \ + https://github.com/zed-industries/zed/issues", + device.features(), + ); + } + let dual_source_blending = dual_source_blending && device_has_feature; + let base_shader_source = include_str!("shaders.wgsl"); let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gpui_shaders"), @@ -945,10 +987,7 @@ impl WgpuRenderer { // Invalidate intermediate textures - they will be lazily recreated // in draw() after we confirm the surface is healthy. This avoids // panics when the device/surface is in an invalid state during resize. - resources.path_intermediate_texture = None; - resources.path_intermediate_view = None; - resources.path_msaa_texture = None; - resources.path_msaa_view = None; + resources.invalidate_intermediate_textures(); } } @@ -980,6 +1019,10 @@ impl WgpuRenderer { resources.path_msaa_view = path_msaa_view; } + pub fn set_subpixel_layout(&mut self, is_bgr: bool) { + self.is_bgr = is_bgr; + } + pub fn update_transparency(&mut self, transparent: bool) { let new_alpha_mode = if transparent { self.transparent_alpha_mode @@ -1036,16 +1079,34 @@ impl WgpuRenderer { self.max_texture_size } - pub fn draw(&mut self, scene: &Scene) { + pub fn draw(&mut self, scene: &Scene) -> bool { + // Bail out early if the surface has been unconfigured (e.g. during + // Android background/rotation transitions). Attempting to acquire + // a texture from an unconfigured surface can block indefinitely on + // some drivers (Adreno). + if !self.surface_configured { + return false; + } + let last_error = self.last_error.lock().unwrap().take(); if let Some(error) = last_error { self.failed_frame_count += 1; log::error!( - "GPU error during frame (failure {} of 20): {error}", + "GPU error during frame (failure {} of 10): {error}", self.failed_frame_count ); - if self.failed_frame_count > 20 { + + // TBD. Does retrying more actually help? + if self.failed_frame_count > 10 { panic!("Too many consecutive GPU errors. Last error: {error}"); + } else if self.failed_frame_count > 5 { + if let Some(res) = self.resources.as_mut() { + res.invalidate_intermediate_textures(); + } + self.atlas.clear(); + self.needs_redraw = true; + self.failed_frame_count = 0; + return false; } } else { self.failed_frame_count = 0; @@ -1063,7 +1124,7 @@ impl WgpuRenderer { resources .surface .configure(&resources.device, &surface_config); - return; + return false; } wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { let surface_config = self.surface_config.clone(); @@ -1071,15 +1132,15 @@ impl WgpuRenderer { resources .surface .configure(&resources.device, &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; } }; @@ -1094,7 +1155,8 @@ 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, - _pad: [0.0; 2], + is_bgr: self.is_bgr as u32, + _pad: 0, }; let globals = GlobalParams { @@ -1259,7 +1321,7 @@ impl WgpuRenderer { self.instance_buffer_capacity ); frame.present(); - return; + return true; } self.grow_instance_buffer(); continue; @@ -1269,7 +1331,7 @@ impl WgpuRenderer { .queue .submit(std::iter::once(encoder.finish())); frame.present(); - return; + return true; } } @@ -1621,6 +1683,75 @@ impl WgpuRenderer { }) } + /// Mark the surface as unconfigured so rendering is skipped until a new + /// surface is provided via [`replace_surface`](Self::replace_surface). + /// + /// This does **not** drop the renderer — the device, queue, atlas, and + /// pipelines stay alive. Use this when the native window is destroyed + /// (e.g. Android `TerminateWindow`) but you intend to re-create the + /// surface later without losing cached atlas textures. + pub fn unconfigure_surface(&mut self) { + self.surface_configured = false; + // Drop intermediate textures since they reference the old surface size. + if let Some(res) = self.resources.as_mut() { + res.invalidate_intermediate_textures(); + } + } + + /// Replace the wgpu surface with a new one (e.g. after Android destroys + /// and recreates the native window). Keeps the device, queue, atlas, and + /// all pipelines intact so cached `AtlasTextureId`s remain valid. + /// + /// The `instance` **must** be the same [`wgpu::Instance`] that was used to + /// create the adapter and device (i.e. from the [`WgpuContext`]). Using a + /// different instance will cause a "Device does not exist" panic because + /// the wgpu device is bound to its originating instance. + #[cfg(not(target_family = "wasm"))] + pub fn replace_surface( + &mut self, + window: &W, + config: WgpuSurfaceConfig, + instance: &wgpu::Instance, + ) -> anyhow::Result<()> { + let window_handle = window + .window_handle() + .map_err(|e| anyhow::anyhow!("Failed to get window handle: {e}"))?; + + let surface = create_surface(instance, window_handle.as_raw())?; + + let width = (config.size.width.0 as u32).max(1); + let height = (config.size.height.0 as u32).max(1); + + let alpha_mode = if config.transparent { + self.transparent_alpha_mode + } else { + self.opaque_alpha_mode + }; + + self.surface_config.width = width; + self.surface_config.height = height; + self.surface_config.alpha_mode = alpha_mode; + if let Some(mode) = config.preferred_present_mode { + self.surface_config.present_mode = mode; + } + + { + let res = self + .resources + .as_mut() + .expect("GPU resources not available"); + surface.configure(&res.device, &self.surface_config); + res.surface = surface; + + // Invalidate intermediate textures — they'll be recreated lazily. + res.invalidate_intermediate_textures(); + } + + self.surface_configured = true; + + Ok(()) + } + 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. @@ -1632,6 +1763,12 @@ impl WgpuRenderer { self.device_lost.load(std::sync::atomic::Ordering::SeqCst) } + /// Returns true if a redraw is needed because GPU state was cleared. + /// Calling this method clears the flag. + pub fn needs_redraw(&mut self) -> bool { + std::mem::take(&mut self.needs_redraw) + } + /// Recovers from a lost GPU device by recreating the renderer with a new context. /// /// Call this after detecting `device_lost()` returns true. @@ -1663,12 +1800,16 @@ impl WgpuRenderer { self.resources = None; *gpu_context.borrow_mut() = None; - // Wait for GPU driver to stabilize (350ms copied from windows :shrug:) + // Wait briefly for the GPU driver to stabilize, then try to + // recreate the context without software renderers. If this fails + // the caller should request another frame and retry — the real GPU + // may need more time to come back (e.g. after suspend/resume). std::thread::sleep(std::time::Duration::from_millis(350)); let instance = WgpuContext::instance(Box::new(window.clone())); let surface = create_surface(&instance, window_handle.as_raw())?; - let new_context = WgpuContext::new(instance, &surface, self.compositor_gpu)?; + let new_context = + WgpuContext::new_rejecting_software(instance, &surface, self.compositor_gpu)?; *gpu_context.borrow_mut() = Some(new_context); surface } else { @@ -1683,14 +1824,14 @@ impl WgpuRenderer { height: gpui::DevicePixels(self.surface_config.height as i32), }, transparent: self.surface_config.alpha_mode != wgpu::CompositeAlphaMode::Opaque, + preferred_present_mode: Some(self.surface_config.present_mode), }; let gpu_context = Rc::clone(gpu_context); let ctx_ref = gpu_context.borrow(); let context = ctx_ref.as_ref().expect("context should exist"); self.resources = None; - self.atlas - .handle_device_lost(Arc::clone(&context.device), Arc::clone(&context.queue)); + self.atlas.handle_device_lost(context); *self = Self::new_internal( Some(gpu_context.clone()), diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml new file mode 100644 index 0000000000..f5a93e7f5a --- /dev/null +++ b/crates/gpui_windows/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "gpui_windows" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "Apache-2.0" + +[lints] +workspace = true + +[lib] +path = "src/gpui_windows.rs" + +[features] +default = ["gpui/default"] +test-support = ["gpui/test-support"] +screen-capture = ["gpui/screen-capture", "scap"] + +[dependencies] +gpui.workspace = true + +[target.'cfg(target_os = "windows")'.dependencies] +accesskit.workspace = true +accesskit_windows.workspace = true +anyhow.workspace = true +collections.workspace = true +etagere = "0.2" +futures.workspace = true +image.workspace = true +itertools.workspace = true +log.workspace = true +parking_lot.workspace = true +rand.workspace = true +raw-window-handle = "0.6" +smallvec.workspace = true +util.workspace = true +uuid.workspace = true +windows.workspace = true +windows-core.workspace = true +windows-numerics = "0.2" +windows-registry = "0.5" + +[target.'cfg(target_os = "windows")'.dependencies.scap] +workspace = true +optional = true + +[target.'cfg(target_os = "windows")'.build-dependencies] +windows-registry = "0.5" diff --git a/crates/gpui_windows/LICENSE-APACHE b/crates/gpui_windows/LICENSE-APACHE new file mode 100644 index 0000000000..461a0fe5ba --- /dev/null +++ b/crates/gpui_windows/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/crates/gpui_windows/build.rs b/crates/gpui_windows/build.rs new file mode 100644 index 0000000000..1db95715e2 --- /dev/null +++ b/crates/gpui_windows/build.rs @@ -0,0 +1,242 @@ +#![allow(clippy::disallowed_methods, reason = "build scripts are exempt")] + +fn main() { + #[cfg(target_os = "windows")] + { + // Compile HLSL shaders + #[cfg(not(debug_assertions))] + compile_shaders(); + } +} + +#[cfg(all(target_os = "windows", not(debug_assertions)))] +mod shader_compilation { + use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + process::{self, Command}, + }; + + pub fn compile_shaders() { + let shader_path = + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("src/shaders.hlsl"); + let out_dir = std::env::var("OUT_DIR").unwrap(); + + println!("cargo:rerun-if-changed={}", shader_path.display()); + + // Check if fxc.exe is available + let fxc_path = find_fxc_compiler(); + + // Define all modules + let modules = [ + "quad", + "shadow", + "path_rasterization", + "path_sprite", + "underline", + "monochrome_sprite", + "subpixel_sprite", + "polychrome_sprite", + ]; + + let rust_binding_path = format!("{}/shaders_bytes.rs", out_dir); + if Path::new(&rust_binding_path).exists() { + fs::remove_file(&rust_binding_path) + .expect("Failed to remove existing Rust binding file"); + } + for module in modules { + compile_shader_for_module( + module, + &out_dir, + &fxc_path, + shader_path.to_str().unwrap(), + &rust_binding_path, + ); + } + + { + let shader_path = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("src/color_text_raster.hlsl"); + compile_shader_for_module( + "emoji_rasterization", + &out_dir, + &fxc_path, + shader_path.to_str().unwrap(), + &rust_binding_path, + ); + } + } + + /// Locate `binary` in the newest installed Windows SDK. + pub fn find_latest_windows_sdk_binary( + binary: &str, + ) -> Result, Box> { + let key = windows_registry::LOCAL_MACHINE + .open("SOFTWARE\\WOW6432Node\\Microsoft\\Microsoft SDKs\\Windows\\v10.0")?; + + let install_folder: String = key.get_string("InstallationFolder")?; // "C:\Program Files (x86)\Windows Kits\10\" + let install_folder_bin = Path::new(&install_folder).join("bin"); + + let mut versions: Vec<_> = std::fs::read_dir(&install_folder_bin)? + .flatten() + .filter(|entry| entry.path().is_dir()) + .filter_map(|entry| entry.file_name().into_string().ok()) + .collect(); + + versions.sort_by_key(|s| { + s.split('.') + .filter_map(|p| p.parse().ok()) + .collect::>() + }); + + let arch = match std::env::consts::ARCH { + "x86_64" => "x64", + "aarch64" => "arm64", + _ => Err(format!( + "Unsupported architecture: {}", + std::env::consts::ARCH + ))?, + }; + + if let Some(highest_version) = versions.last() { + return Ok(Some( + install_folder_bin + .join(highest_version) + .join(arch) + .join(binary), + )); + } + + Ok(None) + } + + /// You can set the `GPUI_FXC_PATH` environment variable to specify the path to the fxc.exe compiler. + fn find_fxc_compiler() -> String { + // Check environment variable + if let Ok(path) = std::env::var("GPUI_FXC_PATH") + && Path::new(&path).exists() + { + return path; + } + + // Try to find in PATH + // NOTE: This has to be `where.exe` on Windows, not `where`, it must be ended with `.exe` + if let Ok(output) = std::process::Command::new("where.exe") + .arg("fxc.exe") + .output() + && output.status.success() + { + let path = String::from_utf8_lossy(&output.stdout); + return path.trim().to_string(); + } + + if let Ok(Some(path)) = find_latest_windows_sdk_binary("fxc.exe") { + return path.to_string_lossy().into_owned(); + } + + panic!("Failed to find fxc.exe"); + } + + fn compile_shader_for_module( + module: &str, + out_dir: &str, + fxc_path: &str, + shader_path: &str, + rust_binding_path: &str, + ) { + // Compile vertex shader + let output_file = format!("{}/{}_vs.h", out_dir, module); + let const_name = format!("{}_VERTEX_BYTES", module.to_uppercase()); + compile_shader_impl( + fxc_path, + &format!("{module}_vertex"), + &output_file, + &const_name, + shader_path, + "vs_4_1", + ); + generate_rust_binding(&const_name, &output_file, rust_binding_path); + + // Compile fragment shader + let output_file = format!("{}/{}_ps.h", out_dir, module); + let const_name = format!("{}_FRAGMENT_BYTES", module.to_uppercase()); + compile_shader_impl( + fxc_path, + &format!("{module}_fragment"), + &output_file, + &const_name, + shader_path, + "ps_4_1", + ); + generate_rust_binding(&const_name, &output_file, rust_binding_path); + } + + fn compile_shader_impl( + fxc_path: &str, + entry_point: &str, + output_path: &str, + var_name: &str, + shader_path: &str, + target: &str, + ) { + let output = Command::new(fxc_path) + .args([ + "/T", + target, + "/E", + entry_point, + "/Fh", + output_path, + "/Vn", + var_name, + "/O3", + shader_path, + ]) + .output(); + + match output { + Ok(result) => { + if result.status.success() { + return; + } + println!( + "cargo::error=Shader compilation failed for {}:\n{}", + entry_point, + String::from_utf8_lossy(&result.stderr) + ); + process::exit(1); + } + Err(e) => { + println!("cargo::error=Failed to run fxc for {}: {}", entry_point, e); + process::exit(1); + } + } + } + + fn generate_rust_binding(const_name: &str, head_file: &str, output_path: &str) { + let header_content = fs::read_to_string(head_file).expect("Failed to read header file"); + let const_definition = { + let global_var_start = header_content.find("const BYTE").unwrap(); + let global_var = &header_content[global_var_start..]; + let equal = global_var.find('=').unwrap(); + global_var[equal + 1..].trim() + }; + let rust_binding = format!( + "const {}: &[u8] = &{}\n", + const_name, + const_definition.replace('{', "[").replace('}', "]") + ); + let mut options = fs::OpenOptions::new() + .create(true) + .append(true) + .open(output_path) + .expect("Failed to open Rust binding file"); + options + .write_all(rust_binding.as_bytes()) + .expect("Failed to write Rust binding file"); + } +} + +#[cfg(all(target_os = "windows", not(debug_assertions)))] +use shader_compilation::compile_shaders; diff --git a/src/platform/windows/alpha_correction.hlsl b/crates/gpui_windows/src/alpha_correction.hlsl similarity index 100% rename from src/platform/windows/alpha_correction.hlsl rename to crates/gpui_windows/src/alpha_correction.hlsl diff --git a/src/platform/windows/clipboard.rs b/crates/gpui_windows/src/clipboard.rs similarity index 100% rename from src/platform/windows/clipboard.rs rename to crates/gpui_windows/src/clipboard.rs diff --git a/src/platform/windows/color_text_raster.hlsl b/crates/gpui_windows/src/color_text_raster.hlsl similarity index 100% rename from src/platform/windows/color_text_raster.hlsl rename to crates/gpui_windows/src/color_text_raster.hlsl diff --git a/src/platform/windows/destination_list.rs b/crates/gpui_windows/src/destination_list.rs similarity index 100% rename from src/platform/windows/destination_list.rs rename to crates/gpui_windows/src/destination_list.rs diff --git a/crates/gpui_windows/src/direct_manipulation.rs b/crates/gpui_windows/src/direct_manipulation.rs new file mode 100644 index 0000000000..08a1e5243e --- /dev/null +++ b/crates/gpui_windows/src/direct_manipulation.rs @@ -0,0 +1,359 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use ::util::ResultExt; +use anyhow::Result; +use gpui::*; +use windows::Win32::{ + Foundation::*, + Graphics::{DirectManipulation::*, Gdi::*}, + System::Com::*, + UI::{Input::Pointer::*, WindowsAndMessaging::*}, +}; + +use crate::*; + +/// Default viewport size in pixels. The actual content size doesn't matter +/// because we're using the viewport only for gesture recognition, not for +/// visual output. +const DEFAULT_VIEWPORT_SIZE: i32 = 1000; + +pub(crate) struct DirectManipulationHandler { + manager: IDirectManipulationManager, + update_manager: IDirectManipulationUpdateManager, + viewport: IDirectManipulationViewport, + _handler_cookie: u32, + window: HWND, + scale_factor: Rc>, + pending_events: Rc>>, +} + +impl DirectManipulationHandler { + pub fn new(window: HWND, scale_factor: f32) -> Result { + unsafe { + let manager: IDirectManipulationManager = + CoCreateInstance(&DirectManipulationManager, None, CLSCTX_INPROC_SERVER)?; + + let update_manager: IDirectManipulationUpdateManager = manager.GetUpdateManager()?; + + let viewport: IDirectManipulationViewport = manager.CreateViewport(None, window)?; + + let configuration = DIRECTMANIPULATION_CONFIGURATION_INTERACTION + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_X + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_Y + | DIRECTMANIPULATION_CONFIGURATION_TRANSLATION_INERTIA + | DIRECTMANIPULATION_CONFIGURATION_RAILS_X + | DIRECTMANIPULATION_CONFIGURATION_RAILS_Y + | DIRECTMANIPULATION_CONFIGURATION_SCALING; + viewport.ActivateConfiguration(configuration)?; + + viewport.SetViewportOptions( + DIRECTMANIPULATION_VIEWPORT_OPTIONS_MANUALUPDATE + | DIRECTMANIPULATION_VIEWPORT_OPTIONS_DISABLEPIXELSNAPPING, + )?; + + let mut rect = RECT { + left: 0, + top: 0, + right: DEFAULT_VIEWPORT_SIZE, + bottom: DEFAULT_VIEWPORT_SIZE, + }; + viewport.SetViewportRect(&mut rect)?; + + manager.Activate(window)?; + viewport.Enable()?; + + let scale_factor = Rc::new(Cell::new(scale_factor)); + let pending_events = Rc::new(RefCell::new(Vec::new())); + + let event_handler: IDirectManipulationViewportEventHandler = + DirectManipulationEventHandler::new( + window, + Rc::clone(&scale_factor), + Rc::clone(&pending_events), + ) + .into(); + + let handler_cookie = viewport.AddEventHandler(Some(window), &event_handler)?; + + update_manager.Update(None)?; + + Ok(Self { + manager, + update_manager, + viewport, + _handler_cookie: handler_cookie, + window, + scale_factor, + pending_events, + }) + } + } + + pub fn set_scale_factor(&self, scale_factor: f32) { + self.scale_factor.set(scale_factor); + } + + pub fn on_pointer_hit_test(&self, wparam: WPARAM) { + unsafe { + let pointer_id = wparam.loword() as u32; + let mut pointer_type = POINTER_INPUT_TYPE::default(); + if GetPointerType(pointer_id, &mut pointer_type).is_ok() && pointer_type == PT_TOUCHPAD + { + self.viewport.SetContact(pointer_id).log_err(); + } + } + } + + pub fn update(&self) { + unsafe { + self.update_manager.Update(None).log_err(); + } + } + + pub fn drain_events(&self) -> Vec { + std::mem::take(&mut *self.pending_events.borrow_mut()) + } +} + +impl Drop for DirectManipulationHandler { + fn drop(&mut self) { + unsafe { + self.viewport.Stop().log_err(); + self.viewport.Abandon().log_err(); + self.manager.Deactivate(self.window).log_err(); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GestureKind { + None, + Scroll, + Pinch, +} + +#[windows_core::implement(IDirectManipulationViewportEventHandler)] +struct DirectManipulationEventHandler { + window: HWND, + scale_factor: Rc>, + gesture_kind: Cell, + last_scale: Cell, + last_x_offset: Cell, + last_y_offset: Cell, + scroll_phase: Cell, + pending_events: Rc>>, +} + +impl DirectManipulationEventHandler { + fn new( + window: HWND, + scale_factor: Rc>, + pending_events: Rc>>, + ) -> Self { + Self { + window, + scale_factor, + gesture_kind: Cell::new(GestureKind::None), + last_scale: Cell::new(1.0), + last_x_offset: Cell::new(0.0), + last_y_offset: Cell::new(0.0), + scroll_phase: Cell::new(TouchPhase::Started), + pending_events, + } + } + + fn end_gesture(&self) { + let position = self.mouse_position(); + let modifiers = current_modifiers(); + match self.gesture_kind.get() { + GestureKind::Scroll => { + self.pending_events + .borrow_mut() + .push(PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(0.0), px(0.0))), + modifiers, + touch_phase: TouchPhase::Ended, + })); + } + GestureKind::Pinch => { + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: 0.0, + modifiers, + phase: TouchPhase::Ended, + })); + } + GestureKind::None => {} + } + self.gesture_kind.set(GestureKind::None); + } + + fn mouse_position(&self) -> Point { + let scale_factor = self.scale_factor.get(); + unsafe { + let mut point: POINT = std::mem::zeroed(); + let _ = GetCursorPos(&mut point); + let _ = ScreenToClient(self.window, &mut point); + logical_point(point.x as f32, point.y as f32, scale_factor) + } + } +} + +impl IDirectManipulationViewportEventHandler_Impl for DirectManipulationEventHandler_Impl { + fn OnViewportStatusChanged( + &self, + viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + current: DIRECTMANIPULATION_STATUS, + previous: DIRECTMANIPULATION_STATUS, + ) -> windows_core::Result<()> { + if current == previous { + return Ok(()); + } + + // A new gesture interrupted inertia, so end the old sequence. + if current == DIRECTMANIPULATION_RUNNING && previous == DIRECTMANIPULATION_INERTIA { + self.end_gesture(); + } + + if current == DIRECTMANIPULATION_READY { + self.end_gesture(); + + // Reset the content transform so the viewport is ready for the next gesture. + // ZoomToRect triggers a second RUNNING -> READY cycle, so prevent an infinite loop here. + if self.last_scale.get() != 1.0 + || self.last_x_offset.get() != 0.0 + || self.last_y_offset.get() != 0.0 + { + if let Some(viewport) = viewport.as_ref() { + unsafe { + viewport + .ZoomToRect( + 0.0, + 0.0, + DEFAULT_VIEWPORT_SIZE as f32, + DEFAULT_VIEWPORT_SIZE as f32, + false, + ) + .log_err(); + } + } + } + + self.last_scale.set(1.0); + self.last_x_offset.set(0.0); + self.last_y_offset.set(0.0); + } + + Ok(()) + } + + fn OnViewportUpdated( + &self, + _viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + ) -> windows_core::Result<()> { + Ok(()) + } + + fn OnContentUpdated( + &self, + _viewport: windows_core::Ref<'_, IDirectManipulationViewport>, + content: windows_core::Ref<'_, IDirectManipulationContent>, + ) -> windows_core::Result<()> { + let content = content.as_ref().ok_or(E_POINTER)?; + + // Get the 6-element content transform: [scale, 0, 0, scale, tx, ty] + let mut xform = [0.0f32; 6]; + unsafe { + content.GetContentTransform(&mut xform)?; + } + + let scale = xform[0]; + let scale_factor = self.scale_factor.get(); + let x_offset = xform[4] / scale_factor; + let y_offset = xform[5] / scale_factor; + + if scale == 0.0 { + return Ok(()); + } + + let last_scale = self.last_scale.get(); + let last_x = self.last_x_offset.get(); + let last_y = self.last_y_offset.get(); + + if float_equals(scale, last_scale) + && float_equals(x_offset, last_x) + && float_equals(y_offset, last_y) + { + return Ok(()); + } + + let position = self.mouse_position(); + let modifiers = current_modifiers(); + + // Direct Manipulation reports both translation and scale in every content update. + // Translation values can shift during a pinch due to the zoom center shifting. + // We classify each gesture as either scroll or pinch and only emit one type of event. + // We allow Scroll -> Pinch (a pinch can start with a small pan) but not the reverse. + if !float_equals(scale, 1.0) { + if self.gesture_kind.get() != GestureKind::Pinch { + self.end_gesture(); + self.gesture_kind.set(GestureKind::Pinch); + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: 0.0, + modifiers, + phase: TouchPhase::Started, + })); + } + } else if self.gesture_kind.get() == GestureKind::None { + self.gesture_kind.set(GestureKind::Scroll); + self.scroll_phase.set(TouchPhase::Started); + } + + match self.gesture_kind.get() { + GestureKind::Scroll => { + let dx = x_offset - last_x; + let dy = y_offset - last_y; + let touch_phase = self.scroll_phase.get(); + self.scroll_phase.set(TouchPhase::Moved); + self.pending_events + .borrow_mut() + .push(PlatformInput::ScrollWheel(ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(point(px(dx), px(dy))), + modifiers, + touch_phase, + })); + } + GestureKind::Pinch => { + let scale_delta = scale / last_scale; + self.pending_events + .borrow_mut() + .push(PlatformInput::Pinch(PinchEvent { + position, + delta: scale_delta - 1.0, + modifiers, + phase: TouchPhase::Moved, + })); + } + GestureKind::None => {} + } + + self.last_scale.set(scale); + self.last_x_offset.set(x_offset); + self.last_y_offset.set(y_offset); + + Ok(()) + } +} + +fn float_equals(f1: f32, f2: f32) -> bool { + const EPSILON_SCALE: f32 = 0.00001; + (f1 - f2).abs() < EPSILON_SCALE * f1.abs().max(f2.abs()).max(EPSILON_SCALE) +} diff --git a/src/platform/windows/direct_write.rs b/crates/gpui_windows/src/direct_write.rs similarity index 100% rename from src/platform/windows/direct_write.rs rename to crates/gpui_windows/src/direct_write.rs diff --git a/src/platform/windows/directx_atlas.rs b/crates/gpui_windows/src/directx_atlas.rs similarity index 98% rename from src/platform/windows/directx_atlas.rs rename to crates/gpui_windows/src/directx_atlas.rs index a2ded660ca..a6642dc7dc 100644 --- a/src/platform/windows/directx_atlas.rs +++ b/crates/gpui_windows/src/directx_atlas.rs @@ -80,7 +80,7 @@ impl PlatformAtlas for DirectXAtlas { ) -> anyhow::Result> { let mut lock = self.0.lock(); if let Some(tile) = lock.tiles_by_key.get(key) { - Ok(Some(tile.clone())) + Ok(Some(*tile)) } else { let Some((size, bytes)) = build()? else { return Ok(None); @@ -90,7 +90,7 @@ impl PlatformAtlas for DirectXAtlas { .ok_or_else(|| anyhow::anyhow!("failed to allocate"))?; let texture = lock.texture(tile.texture_id); texture.upload(&lock.device_context, tile.bounds, &bytes); - lock.tiles_by_key.insert(key.clone(), tile.clone()); + lock.tiles_by_key.insert(key.clone(), tile); Ok(Some(tile)) } } @@ -116,7 +116,6 @@ impl PlatformAtlas for DirectXAtlas { texture.decrement_ref_count(); if texture.is_unreferenced() { textures.free_list.push(texture.id.index as usize); - lock.tiles_by_key.remove(key); } else { *texture_slot = Some(texture); } diff --git a/src/platform/windows/directx_devices.rs b/crates/gpui_windows/src/directx_devices.rs similarity index 100% rename from src/platform/windows/directx_devices.rs rename to crates/gpui_windows/src/directx_devices.rs diff --git a/src/platform/windows/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs similarity index 99% rename from src/platform/windows/directx_renderer.rs rename to crates/gpui_windows/src/directx_renderer.rs index 2955b23429..b5c4d3bf34 100644 --- a/src/platform/windows/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -32,6 +32,7 @@ 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 { @@ -195,6 +196,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 { @@ -741,6 +744,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, } }) } @@ -961,6 +965,8 @@ struct GlobalParams { viewport_size: [f32; 2], grayscale_enhanced_contrast: f32, subpixel_enhanced_contrast: f32, + is_bgr: u32, + _pad: [u32; 3], } struct PipelineState { diff --git a/src/platform/windows/dispatcher.rs b/crates/gpui_windows/src/dispatcher.rs similarity index 83% rename from src/platform/windows/dispatcher.rs rename to crates/gpui_windows/src/dispatcher.rs index a5cfd9dc10..2b2bf402d2 100644 --- a/src/platform/windows/dispatcher.rs +++ b/crates/gpui_windows/src/dispatcher.rs @@ -13,10 +13,7 @@ use windows::{ Win32::{ Foundation::{LPARAM, WPARAM}, Media::{timeBeginPeriod, timeEndPeriod}, - System::Threading::{ - GetCurrentThread, HIGH_PRIORITY_CLASS, SetPriorityClass, SetThreadPriority, - THREAD_PRIORITY_TIME_CRITICAL, - }, + System::Threading::{GetCurrentThread, SetThreadPriority, THREAD_PRIORITY_TIME_CRITICAL}, UI::WindowsAndMessaging::PostMessageW, }, }; @@ -24,7 +21,7 @@ use windows::{ 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, + TaskTiming, ThreadTaskTimings, TimerResolutionGuard, }; pub(crate) struct WindowsDispatcher { @@ -106,25 +103,7 @@ impl PlatformDispatcher for WindowsDispatcher { } 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, - } - }) + gpui::profiler::get_current_thread_task_timings() } fn is_main_thread(&self) -> bool { @@ -181,12 +160,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(); diff --git a/src/platform/windows/display.rs b/crates/gpui_windows/src/display.rs similarity index 59% rename from src/platform/windows/display.rs rename to crates/gpui_windows/src/display.rs index 1931a6949f..3b81dc63a0 100644 --- a/src/platform/windows/display.rs +++ b/crates/gpui_windows/src/display.rs @@ -35,102 +35,18 @@ unsafe impl Sync for WindowsDisplay {} impl WindowsDisplay { pub(crate) fn new(display_id: DisplayId) -> Option { - let screen = available_monitors() - .into_iter() - .nth(u32::from(display_id) as _)?; - let info = get_monitor_info(screen).log_err()?; + let handle = HMONITOR(u64::from(display_id) as _); + let info = get_monitor_info(handle).log_err()?; let monitor_size = info.monitorInfo.rcMonitor; let work_area = info.monitorInfo.rcWork; let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(screen).log_err()?; + let scale_factor = get_scale_factor_for_monitor(handle).log_err()?; let physical_size = size( (monitor_size.right - monitor_size.left).into(), (monitor_size.bottom - monitor_size.top).into(), ); Some(WindowsDisplay { - handle: screen, - display_id, - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) - } - - pub fn new_with_handle(monitor: HMONITOR) -> anyhow::Result { - let info = get_monitor_info(monitor)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let display_id = available_monitors() - .iter() - .position(|handle| handle.0 == monitor.0) - .unwrap(); - let scale_factor = get_scale_factor_for_monitor(monitor)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { - handle: monitor, - display_id: DisplayId::new(display_id as _), - scale_factor, - bounds: Bounds { - origin: logical_point( - monitor_size.left as f32, - monitor_size.top as f32, - scale_factor, - ), - size: physical_size.to_pixels(scale_factor), - }, - visible_bounds: Bounds { - origin: logical_point(work_area.left as f32, work_area.top as f32, scale_factor), - size: size( - (work_area.right - work_area.left) as f32 / scale_factor, - (work_area.bottom - work_area.top) as f32 / scale_factor, - ) - .map(gpui::px), - }, - physical_bounds: Bounds { - origin: point(monitor_size.left.into(), monitor_size.top.into()), - size: physical_size, - }, - uuid, - }) - } - - fn new_with_handle_and_id(handle: HMONITOR, display_id: DisplayId) -> anyhow::Result { - let info = get_monitor_info(handle)?; - let monitor_size = info.monitorInfo.rcMonitor; - let work_area = info.monitorInfo.rcWork; - let uuid = generate_uuid(&info.szDevice); - let scale_factor = get_scale_factor_for_monitor(handle)?; - let physical_size = size( - (monitor_size.right - monitor_size.left).into(), - (monitor_size.bottom - monitor_size.top).into(), - ); - - Ok(WindowsDisplay { handle, display_id, scale_factor, @@ -158,6 +74,10 @@ impl WindowsDisplay { }) } + pub(crate) fn display_id_for_monitor(monitor: HMONITOR) -> DisplayId { + DisplayId::new(monitor.0 as u64) + } + pub fn primary_monitor() -> Option { // https://devblogs.microsoft.com/oldnewthing/20070809-00/?p=25643 const POINT_ZERO: POINT = POINT { x: 0, y: 0 }; @@ -169,7 +89,7 @@ impl WindowsDisplay { ); return None; } - WindowsDisplay::new_with_handle(monitor).log_err() + WindowsDisplay::new(Self::display_id_for_monitor(monitor)) } /// Check if the center point of given bounds is inside this monitor @@ -183,7 +103,7 @@ impl WindowsDisplay { if monitor.is_invalid() { false } else { - let Ok(display) = WindowsDisplay::new_with_handle(monitor) else { + let Some(display) = WindowsDisplay::new(Self::display_id_for_monitor(monitor)) else { return false; }; display.uuid == self.uuid @@ -193,11 +113,11 @@ impl WindowsDisplay { pub fn displays() -> Vec> { available_monitors() .into_iter() - .enumerate() - .filter_map(|(id, handle)| { - Some(Rc::new( - WindowsDisplay::new_with_handle_and_id(handle, DisplayId::new(id as _)).ok()?, - ) as Rc) + .filter_map(|handle| { + Some( + Rc::new(WindowsDisplay::new(Self::display_id_for_monitor(handle))?) + as Rc, + ) }) .collect() } diff --git a/src/platform/windows/events.rs b/crates/gpui_windows/src/events.rs similarity index 92% rename from src/platform/windows/events.rs rename to crates/gpui_windows/src/events.rs index 3506ae2a2c..b04b819a02 100644 --- a/src/platform/windows/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -1,4 +1,4 @@ -use std::rc::Rc; +use std::{rc::Rc, sync::atomic::Ordering}; use ::util::ResultExt; use anyhow::Context as _; @@ -111,6 +111,8 @@ impl WindowsWindowInner { WM_GPUI_CURSOR_STYLE_CHANGED => self.handle_cursor_changed(lparam), WM_GPUI_FORCE_UPDATE_WINDOW => self.draw_window(handle, true), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), + DM_POINTERHITTEST => self.handle_dm_pointer_hit_test(wparam), + WM_GETOBJECT => self.handle_wm_getobject(wparam, lparam), _ => None, }; if let Some(n) = handled { @@ -142,9 +144,9 @@ impl WindowsWindowInner { // monitor is invalid, we do nothing. if !monitor.is_invalid() && self.state.display.get().handle != monitor { // we will get the same monitor if we only have one - self.state - .display - .set(WindowsDisplay::new_with_handle(monitor).log_err()?); + self.state.display.set(WindowsDisplay::new( + WindowsDisplay::display_id_for_monitor(monitor), + )?); } } if let Some(mut callback) = self.state.callbacks.moved.take() { @@ -296,6 +298,7 @@ impl WindowsWindowInner { fn handle_mouse_move_msg(&self, handle: HWND, lparam: LPARAM, wparam: WPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE); + self.restore_cursor_after_hide(); let Some(mut func) = self.state.callbacks.input.take() else { return Some(1); @@ -329,6 +332,9 @@ impl WindowsWindowInner { fn handle_mouse_leave_msg(&self) -> Option { self.state.hovered.set(false); + // The next window's `WM_SETCURSOR` picks its own cursor, so we just clear + // the flag for tight `is_cursor_visible()` semantics. + self.state.cursor_visible.store(true, Ordering::Relaxed); if let Some(mut callback) = self.state.callbacks.hovered_status_change.take() { callback(false); self.state @@ -723,7 +729,41 @@ impl WindowsWindowInner { fn handle_activate_msg(self: &Rc, wparam: WPARAM) -> Option { let activated = wparam.loword() > 0; + + let events = self + .state + .a11y + .try_borrow_mut() + .ok() + .and_then(|mut a11y| a11y.as_mut()?.adapter.update_window_focus_state(activated)); + if let Some(events) = events { + events.raise(); + } + let this = self.clone(); + + if !activated { + this.state.cursor_visible.store(true, Ordering::Relaxed); + } + + // When the window is activated (gains focus), reset the modifier tracking state. + // This fixes the issue where Alt-Tab away and back leaves stale modifier state + // (especially the Alt key) because Windows doesn't always send key-up events to + // windows that have lost focus. + 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() { + let input = PlatformInput::ModifiersChanged(ModifiersChangedEvent { + modifiers: current_modifiers(), + capslock: current_capslock(), + }); + func(input); + this.state.callbacks.input.set(Some(func)); + } + } + self.executor .spawn(async move { if let Some(mut func) = this.state.callbacks.active_status_change.take() { @@ -736,6 +776,23 @@ impl WindowsWindowInner { None } + fn handle_wm_getobject(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + let result = { + let mut a11y = self.state.a11y.borrow_mut(); + let a11y = a11y.as_mut()?; + a11y.adapter.handle_wm_getobject( + accesskit_windows::WPARAM(wparam.0), + accesskit_windows::LPARAM(lparam.0), + &mut a11y.activation_handler, + )? + }; + // The borrow above must be dropped before calling `.into()`, because + // it calls `UiaReturnRawElementProvider` which may send a nested + // `WM_GETOBJECT` back into this window procedure. + let lresult: accesskit_windows::LRESULT = result.into(); + Some(lresult.0) + } + fn handle_create_msg(&self, handle: HWND) -> Option { if self.hide_title_bar { notify_frame_changed(handle); @@ -758,6 +815,10 @@ impl WindowsWindowInner { self.state.scale_factor.set(new_scale_factor); self.state.border_offset.update(handle).log_err(); + self.state + .direct_manipulation + .set_scale_factor(new_scale_factor); + if is_maximized { // Get the monitor and its work area at the new DPI let monitor = unsafe { MonitorFromWindow(handle, MONITOR_DEFAULTTONEAREST) }; @@ -821,7 +882,7 @@ impl WindowsWindowInner { log::error!("No monitor detected!"); return None; } - let new_display = WindowsDisplay::new_with_handle(new_monitor).log_err()?; + let new_display = WindowsDisplay::new(WindowsDisplay::display_id_for_monitor(new_monitor))?; self.state.display.set(new_display); Some(0) } @@ -891,6 +952,7 @@ impl WindowsWindowInner { fn handle_nc_mouse_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { self.start_tracking_mouse(handle, TME_LEAVE | TME_NONCLIENT); + self.restore_cursor_after_hide(); let mut func = self.state.callbacks.input.take()?; let scale_factor = self.state.scale_factor.get(); @@ -1054,8 +1116,13 @@ impl WindowsWindowInner { { return None; } + let cursor = if self.state.cursor_visible.load(Ordering::Relaxed) { + self.state.current_cursor.get() + } else { + None + }; unsafe { - SetCursor(self.state.current_cursor.get()); + SetCursor(cursor); }; Some(0) } @@ -1136,17 +1203,41 @@ impl WindowsWindowInner { { panic!("Device lost: {err}"); } + // Make sure the first `draw_window` after recovery (whether it comes + // from the forced WM_GPUI_FORCE_UPDATE_WINDOW or a stray WM_PAINT in + // between) is treated as a forced render so it both clears + // `skip_draws` and bypasses the view cache. + 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); + None + } + #[inline] fn draw_window(&self, handle: HWND, force_render: bool) -> Option { let mut request_frame = self.state.callbacks.request_frame.take()?; - // 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(); + self.state.direct_manipulation.update(); + + let events = self.state.direct_manipulation.drain_events(); + if !events.is_empty() { + if let Some(mut func) = self.state.callbacks.input.take() { + for event in events { + func(event); + } + self.state.callbacks.input.set(Some(func)); + } + } + + let force_render = force_render || self.state.force_render_after_recovery.take(); + if force_render { + // Re-enable drawing after a device loss recovery. The forced render + // will rebuild the scene with fresh atlas textures. + self.state.renderer.borrow_mut().mark_drawable(); + } request_frame(RequestFrameOptions { require_presentation: false, force_render, @@ -1191,6 +1282,15 @@ impl WindowsWindowInner { } } + /// Clear the hidden flag and restore the cursor immediately + fn restore_cursor_after_hide(&self) { + if !self.state.cursor_visible.swap(true, Ordering::Relaxed) { + unsafe { + SetCursor(self.state.current_cursor.get()); + } + } + } + fn start_tracking_mouse(&self, handle: HWND, flags: TRACKMOUSEEVENT_FLAGS) { if !self.state.hovered.get() { self.state.hovered.set(true); diff --git a/src/platform/windows.rs b/crates/gpui_windows/src/gpui_windows.rs similarity index 93% rename from src/platform/windows.rs rename to crates/gpui_windows/src/gpui_windows.rs index d3ac9465ea..0af5411d20 100644 --- a/src/platform/windows.rs +++ b/crates/gpui_windows/src/gpui_windows.rs @@ -1,5 +1,8 @@ +#![cfg(target_os = "windows")] + mod clipboard; mod destination_list; +mod direct_manipulation; mod direct_write; mod directx_atlas; mod directx_devices; diff --git a/src/platform/windows/keyboard.rs b/crates/gpui_windows/src/keyboard.rs similarity index 100% rename from src/platform/windows/keyboard.rs rename to crates/gpui_windows/src/keyboard.rs diff --git a/src/platform/windows/platform.rs b/crates/gpui_windows/src/platform.rs similarity index 95% rename from src/platform/windows/platform.rs rename to crates/gpui_windows/src/platform.rs index ecf7b95d59..7b76c4d12a 100644 --- a/src/platform/windows/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -63,6 +63,8 @@ pub(crate) struct WindowsPlatformState { jump_list: RefCell, // NOTE: standard cursor handles don't need to close. pub(crate) current_cursor: Cell>, + /// Shared with each window so `WM_SETCURSOR` can read it directly. + pub(crate) cursor_visible: Arc, directx_devices: RefCell>, } @@ -87,6 +89,7 @@ impl WindowsPlatformState { callbacks, jump_list: RefCell::new(jump_list), current_cursor: Cell::new(current_cursor), + cursor_visible: Arc::new(AtomicBool::new(true)), directx_devices: RefCell::new(directx_devices), menus: RefCell::new(Vec::new()), } @@ -219,6 +222,7 @@ impl WindowsPlatform { icon: self.icon, executor: self.foreground_executor.clone(), current_cursor: self.inner.state.current_cursor.get(), + cursor_visible: self.inner.state.cursor_visible.clone(), drop_target_helper: self.drop_target_helper.clone().unwrap(), validation_number: self.inner.validation_number, main_receiver: self.inner.main_receiver.clone(), @@ -410,6 +414,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) { @@ -452,10 +467,11 @@ impl Platform for WindowsPlatform { clippy::disallowed_methods, reason = "We are restarting ourselves, using std command thus is fine" )] - let restart_process = crate::command::new_std_command("powershell") - .arg("-command") - .arg(script) - .spawn(); + let restart_process = + ::util::command::new_std_command(::util::shell::get_windows_system_shell()) + .arg("-command") + .arg(script) + .spawn(); match restart_process { Ok(_) => unsafe { PostQuitMessage(0) }, @@ -670,6 +686,31 @@ impl Platform for WindowsPlatform { } } + fn hide_cursor_until_mouse_moves(&self) { + if !self + .inner + .state + .cursor_visible + .swap(false, Ordering::Relaxed) + { + return; + } + + for handle in self.raw_window_handles.read().iter() { + let Some(window) = window_from_hwnd(handle.as_raw()) else { + continue; + }; + if window.state.hovered.get() { + unsafe { SetCursor(None) }; + break; + } + } + } + + fn is_cursor_visible(&self) -> bool { + self.inner.state.cursor_visible.load(Ordering::Relaxed) + } + fn should_auto_hide_scrollbars(&self) -> bool { should_auto_hide_scrollbars().log_err().unwrap_or(false) } @@ -1003,6 +1044,7 @@ pub(crate) struct WindowCreationInfo { pub(crate) icon: HICON, pub(crate) executor: ForegroundExecutor, pub(crate) current_cursor: Option, + pub(crate) cursor_visible: Arc, pub(crate) drop_target_helper: IDropTargetHelper, pub(crate) validation_number: usize, pub(crate) main_receiver: PriorityQueueReceiver, @@ -1325,7 +1367,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) } }; diff --git a/src/platform/windows/shaders.hlsl b/crates/gpui_windows/src/shaders.hlsl similarity index 94% rename from src/platform/windows/shaders.hlsl rename to crates/gpui_windows/src/shaders.hlsl index f508387daf..89c12489b2 100644 --- a/src/platform/windows/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,20 @@ float4 gradient_color(Background background, break; } } + + // Dither to reduce banding in gradients (especially dark/alpha). + // Triangular-distributed noise breaks up 8-bit quantization steps. + // ±2/255 for RGB (enough for dark-on-dark compositing), + // ±3/255 for alpha (needs more because alpha × dark color = tiny steps). + { + float2 seed = position * 0.6180339887; // golden ratio spread + 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; // triangular PDF, range [-1, +1] + color.rgb += tri * 2.0 / 255.0; + color.a += tri * 3.0 / 255.0; + } + break; } case 2: { @@ -406,11 +422,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; @@ -838,6 +854,10 @@ struct Shadow { Corners corner_radii; Bounds content_mask; Hsla color; + Bounds element_bounds; + Corners element_corner_radii; + uint inset; + uint pad; // align to 8 bytes }; struct ShadowVertexOutput { @@ -859,10 +879,16 @@ ShadowVertexOutput shadow_vertex(uint vertex_id: SV_VertexID, uint shadow_id: SV float2 unit_vertex = float2(float(vertex_id & 1u), 0.5 * float(vertex_id & 2u)); Shadow shadow = shadows[shadow_id]; - float margin = 3.0 * shadow.blur_radius; - Bounds bounds = shadow.bounds; - bounds.origin -= margin; - bounds.size += 2.0 * margin; + Bounds bounds; + if (shadow.inset != 0u) { + bounds = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + float margin = 3.0 * shadow.blur_radius; + bounds = shadow.bounds; + bounds.origin -= margin; + bounds.size += 2.0 * margin; + } float4 device_position = to_device_position(unit_vertex, bounds); float4 clip_distance = distance_from_clip_rect(unit_vertex, bounds, shadow.content_mask); @@ -885,21 +911,36 @@ float4 shadow_fragment(ShadowFragmentInput input): SV_TARGET { float2 point0 = input.position.xy - center; float corner_radius = pick_corner_radius(point0, shadow.corner_radii); - // The signal is only non-zero in a limited range, so don't waste samples - float low = point0.y - half_size.y; - float high = point0.y + half_size.y; - float start = clamp(-3. * shadow.blur_radius, low, high); - float end = clamp(3. * shadow.blur_radius, low, high); + float alpha; + if (shadow.blur_radius == 0.) { + float distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + float low = point0.y - half_size.y; + float high = point0.y + half_size.y; + float start = clamp(-3. * shadow.blur_radius, low, high); + float end = clamp(3. * shadow.blur_radius, low, high); - // Accumulate samples (we can get away with surprisingly few samples) - float step = (end - start) / 4.; - float y = start + step * 0.5; - float alpha = 0.; - for (int i = 0; i < 4; i++) { - alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, - corner_radius, half_size) * - gaussian(y, shadow.blur_radius) * step; - y += step; + // Accumulate samples (we can get away with surprisingly few samples) + float step = (end - start) / 4.; + float y = start + step * 0.5; + alpha = 0.; + for (int i = 0; i < 4; i++) { + alpha += blur_along_x(point0.x, point0.y - y, shadow.blur_radius, + corner_radius, half_size) * + gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + float element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); } return input.color * float4(1., 1., 1., alpha); @@ -1143,6 +1184,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; diff --git a/src/platform/windows/system_settings.rs b/crates/gpui_windows/src/system_settings.rs similarity index 100% rename from src/platform/windows/system_settings.rs rename to crates/gpui_windows/src/system_settings.rs diff --git a/src/platform/windows/util.rs b/crates/gpui_windows/src/util.rs similarity index 99% rename from src/platform/windows/util.rs rename to crates/gpui_windows/src/util.rs index fe5093dede..15dd4c6998 100644 --- a/src/platform/windows/util.rs +++ b/crates/gpui_windows/src/util.rs @@ -115,7 +115,6 @@ pub(crate) fn load_cursor(style: CursorStyle) -> Option { CursorStyle::ResizeUpLeftDownRight => (&SIZENWSE, IDC_SIZENWSE), CursorStyle::ResizeUpRightDownLeft => (&SIZENESW, IDC_SIZENESW), CursorStyle::OperationNotAllowed => (&NO, IDC_NO), - CursorStyle::None => return None, _ => (&ARROW, IDC_ARROW), }; Some( diff --git a/src/platform/windows/vsync.rs b/crates/gpui_windows/src/vsync.rs similarity index 100% rename from src/platform/windows/vsync.rs rename to crates/gpui_windows/src/vsync.rs diff --git a/src/platform/windows/window.rs b/crates/gpui_windows/src/window.rs similarity index 93% rename from src/platform/windows/window.rs rename to crates/gpui_windows/src/window.rs index 62e88c47df..b33d8702f2 100644 --- a/src/platform/windows/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -20,12 +20,15 @@ use windows::{ Foundation::*, Graphics::Dwm::*, Graphics::Gdi::*, - System::{Com::*, LibraryLoader::*, Ole::*, SystemServices::*}, + System::{ + Com::*, Diagnostics::Debug::MessageBeep, LibraryLoader::*, Ole::*, SystemServices::*, + }, UI::{Controls::*, HiDpi::*, Input::KeyboardAndMouse::*, Shell::*, WindowsAndMessaging::*}, }, core::*, }; +use crate::direct_manipulation::DirectManipulationHandler; use crate::*; use gpui::*; @@ -57,11 +60,20 @@ pub struct WindowsWindowState { pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, pub hovered: Cell, + pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, + /// Set after a GPU device-lost recovery so the next `draw_window` call is + /// treated as a forced render. This guarantees the next frame both + /// re-enables drawing (via `mark_drawable`) and bypasses the GPUI view + /// cache, which would otherwise replay stale atlas tile references from + /// the previous frame and panic in `DirectXAtlasState::texture`. + pub force_render_after_recovery: Cell, pub click_state: ClickState, pub current_cursor: Cell>, + /// Shared with [`WindowsPlatformState::cursor_visible`]. + pub cursor_visible: Arc, pub nc_button_pressed: Cell>, pub display: Cell, @@ -71,6 +83,7 @@ pub struct WindowsWindowState { fullscreen: Cell>, initial_placement: Cell>, hwnd: HWND, + pub(crate) a11y: RefCell>, } pub(crate) struct WindowsWindowInner { @@ -94,6 +107,7 @@ impl WindowsWindowState { directx_devices: &DirectXDevices, window_params: &CREATESTRUCTW, current_cursor: Option, + cursor_visible: Arc, display: WindowsDisplay, min_size: Option>, appearance: WindowAppearance, @@ -131,6 +145,9 @@ impl WindowsWindowState { let fullscreen = None; let initial_placement = None; + let direct_manipulation = DirectManipulationHandler::new(hwnd, scale_factor) + .context("initializing Direct Manipulation")?; + Ok(Self { origin: Cell::new(origin), logical_size: Cell::new(logical_size), @@ -149,14 +166,18 @@ 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, nc_button_pressed: Cell::new(nc_button_pressed), display: Cell::new(display), fullscreen: Cell::new(fullscreen), initial_placement: Cell::new(initial_placement), hwnd, invalidate_devices, + direct_manipulation, + a11y: RefCell::new(None), }) } @@ -226,6 +247,7 @@ impl WindowsWindowInner { &context.directx_devices, cs, context.current_cursor, + context.cursor_visible.clone(), context.display, context.min_size, context.appearance, @@ -365,6 +387,7 @@ struct WindowCreateContext { min_size: Option>, executor: ForegroundExecutor, current_cursor: Option, + cursor_visible: Arc, drop_target_helper: IDropTargetHelper, validation_number: usize, main_receiver: PriorityQueueReceiver, @@ -386,6 +409,7 @@ impl WindowsWindow { icon, executor, current_cursor, + cursor_visible, drop_target_helper, validation_number, main_receiver, @@ -450,11 +474,12 @@ impl WindowsWindow { let hinstance = get_module_handle(); let display = if let Some(display_id) = params.display_id { - // if we obtain a display_id, then this ID must be valid. - WindowsDisplay::new(display_id).unwrap() + WindowsDisplay::new(display_id) } else { - WindowsDisplay::primary_monitor().unwrap() - }; + None + } + .or_else(WindowsDisplay::primary_monitor) + .context("failed to find any monitor")?; let appearance = system_appearance().unwrap_or_default(); let mut context = WindowCreateContext { inner: None, @@ -465,6 +490,7 @@ impl WindowsWindow { min_size: params.window_min_size, executor, current_cursor, + cursor_visible, drop_target_helper, validation_number, main_receiver, @@ -532,10 +558,9 @@ impl rwh::HasWindowHandle for WindowsWindow { } } -// todo(windows) impl rwh::HasDisplayHandle for WindowsWindow { fn display_handle(&self) -> std::result::Result, rwh::HandleError> { - unimplemented!() + Ok(rwh::DisplayHandle::windows()) } } @@ -944,6 +969,74 @@ impl PlatformWindow for WindowsWindow { self.0.update_ime_position(self.0.hwnd, caret_position); } + + fn play_system_bell(&self) { + // MB_OK: The sound specified as the Windows Default Beep sound. + let _ = unsafe { MessageBeep(MB_OK) }; + } + + fn a11y_init(&self, callbacks: gpui::A11yCallbacks) { + let action_handler = A11yActionHandler(callbacks.action); + let is_focused = unsafe { GetForegroundWindow() } == self.0.hwnd; + + let adapter = accesskit_windows::Adapter::new( + accesskit_windows::HWND(self.0.hwnd.0), + is_focused, + action_handler, + ); + + let activation_handler = A11yActivationHandler { + callback: callbacks.activation, + }; + + *self.state.a11y.borrow_mut() = Some(A11yState { + adapter, + activation_handler, + }); + } + + fn a11y_tree_update(&self, tree_update: accesskit::TreeUpdate) { + let events = { + let mut a11y = self.state.a11y.borrow_mut(); + a11y.as_mut() + .and_then(|a11y| a11y.adapter.update_if_active(|| tree_update)) + }; + // The borrow must be dropped before raising events, because + // `events.raise()` calls `UiaRaiseAutomationPropertyChangedEvent` + // which may send a nested `WM_GETOBJECT` back into this window + // procedure, re-entering `handle_wm_getobject` which also borrows + // `self.state.a11y`. + if let Some(events) = events { + events.raise(); + } + } + + fn a11y_update_window_bounds(&self) { + // Windows UIA handles window bounds tracking automatically. + } +} + +pub(crate) struct A11yState { + pub(crate) adapter: accesskit_windows::Adapter, + pub(crate) activation_handler: A11yActivationHandler, +} + +pub(crate) struct A11yActivationHandler { + callback: Box Option + Send + 'static>, +} + +impl accesskit::ActivationHandler for A11yActivationHandler { + fn request_initial_tree(&mut self) -> Option { + (self.callback)() + } +} + +struct A11yActionHandler(Box); + +impl accesskit::ActionHandler for A11yActionHandler { + fn do_action(&mut self, request: accesskit::ActionRequest) { + (self.0)(request); + } } #[implement(IDropTarget)] diff --git a/src/platform/windows/wrapper.rs b/crates/gpui_windows/src/wrapper.rs similarity index 100% rename from src/platform/windows/wrapper.rs rename to crates/gpui_windows/src/wrapper.rs diff --git a/resources/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf deleted file mode 100644 index 1d66b1a2e9..0000000000 Binary files a/resources/fonts/ibm-plex-sans/IBMPlexSans-Bold.ttf and /dev/null differ diff --git a/resources/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf deleted file mode 100644 index e07bc1f527..0000000000 Binary files a/resources/fonts/ibm-plex-sans/IBMPlexSans-BoldItalic.ttf and /dev/null differ diff --git a/resources/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf deleted file mode 100644 index efe8a1fb9d..0000000000 Binary files a/resources/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf and /dev/null differ diff --git a/resources/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf b/resources/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf deleted file mode 100644 index bd6817d520..0000000000 Binary files a/resources/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf and /dev/null differ diff --git a/resources/fonts/ibm-plex-sans/license.txt b/resources/fonts/ibm-plex-sans/license.txt deleted file mode 100644 index f72f76504c..0000000000 --- a/resources/fonts/ibm-plex-sans/license.txt +++ /dev/null @@ -1,92 +0,0 @@ -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. \ No newline at end of file diff --git a/resources/fonts/lilex/Lilex-Bold.ttf b/resources/fonts/lilex/Lilex-Bold.ttf deleted file mode 100644 index 45930ee30b..0000000000 Binary files a/resources/fonts/lilex/Lilex-Bold.ttf and /dev/null differ diff --git a/resources/fonts/lilex/Lilex-BoldItalic.ttf b/resources/fonts/lilex/Lilex-BoldItalic.ttf deleted file mode 100644 index 10c6ab5f74..0000000000 Binary files a/resources/fonts/lilex/Lilex-BoldItalic.ttf and /dev/null differ diff --git a/resources/fonts/lilex/Lilex-Italic.ttf b/resources/fonts/lilex/Lilex-Italic.ttf deleted file mode 100644 index e7aef10f7e..0000000000 Binary files a/resources/fonts/lilex/Lilex-Italic.ttf and /dev/null differ diff --git a/resources/fonts/lilex/Lilex-Regular.ttf b/resources/fonts/lilex/Lilex-Regular.ttf deleted file mode 100644 index cb98a69b0f..0000000000 Binary files a/resources/fonts/lilex/Lilex-Regular.ttf and /dev/null differ diff --git a/resources/fonts/lilex/OFL.txt b/resources/fonts/lilex/OFL.txt deleted file mode 100644 index 156240bc90..0000000000 --- a/resources/fonts/lilex/OFL.txt +++ /dev/null @@ -1,93 +0,0 @@ -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/src/platform/linux/text_system.rs b/src/platform/linux/text_system.rs deleted file mode 100644 index 0f5490fc42..0000000000 --- a/src/platform/linux/text_system.rs +++ /dev/null @@ -1 +0,0 @@ -pub(crate) use crate::platform::wgpu::CosmicTextSystem; diff --git a/src/platform_scheduler.rs b/src/platform_scheduler.rs deleted file mode 100644 index 5662c8670c..0000000000 --- a/src/platform_scheduler.rs +++ /dev/null @@ -1,157 +0,0 @@ -use crate::scheduler::Instant; -#[cfg(any(test, feature = "test-support"))] -use crate::scheduler::TestScheduler; -use crate::scheduler::{Clock, Priority, Scheduler, SessionId, Timer}; -use crate::{PlatformDispatcher, RunnableMeta}; -use async_task::Runnable; -use chrono::{DateTime, Utc}; -use futures::channel::oneshot; -#[cfg(not(target_family = "wasm"))] -use std::task::{Context, Poll}; -use std::{ - future::Future, - pin::Pin, - sync::{ - Arc, - atomic::{AtomicU16, Ordering}, - }, - time::Duration, -}; - -/// A production implementation of [`Scheduler`] that wraps a [`PlatformDispatcher`]. -/// -/// This allows GPUI to use the scheduler crate's executor types with the platform's -/// native dispatch mechanisms (e.g., Grand Central Dispatch on macOS). -pub struct PlatformScheduler { - dispatcher: Arc, - clock: Arc, - next_session_id: AtomicU16, -} - -impl PlatformScheduler { - pub fn new(dispatcher: Arc) -> Self { - Self { - dispatcher: dispatcher.clone(), - clock: Arc::new(PlatformClock { dispatcher }), - next_session_id: AtomicU16::new(0), - } - } - - pub fn allocate_session_id(&self) -> SessionId { - SessionId::new(self.next_session_id.fetch_add(1, Ordering::SeqCst)) - } -} - -impl Scheduler for PlatformScheduler { - fn block( - &self, - _session_id: Option, - #[cfg_attr(target_family = "wasm", allow(unused_mut))] mut future: Pin< - &mut dyn Future, - >, - #[cfg_attr(target_family = "wasm", allow(unused_variables))] timeout: Option, - ) -> bool { - #[cfg(target_family = "wasm")] - { - let _ = (&future, &timeout); - panic!("Cannot block on wasm") - } - #[cfg(not(target_family = "wasm"))] - { - use waker_fn::waker_fn; - let deadline = timeout.map(|t| Instant::now() + t); - let parker = parking::Parker::new(); - let unparker = parker.unparker(); - let waker = waker_fn(move || { - unparker.unpark(); - }); - let mut cx = Context::from_waker(&waker); - if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { - return true; - } - - let park_deadline = |deadline: Instant| { - // Timer expirations are only delivered every ~15.6 milliseconds by default on Windows. - // We increase the resolution during this wait so that short timeouts stay reasonably short. - let _timer_guard = self.dispatcher.increase_timer_resolution(); - parker.park_deadline(deadline) - }; - - loop { - match deadline { - Some(deadline) if !park_deadline(deadline) && deadline <= Instant::now() => { - return false; - } - Some(_) => (), - None => parker.park(), - } - if let Poll::Ready(()) = future.as_mut().poll(&mut cx) { - break true; - } - } - } - } - - fn schedule_foreground(&self, _session_id: SessionId, runnable: Runnable) { - self.dispatcher - .dispatch_on_main_thread(runnable, Priority::default()); - } - - fn schedule_background_with_priority( - &self, - runnable: Runnable, - priority: Priority, - ) { - self.dispatcher.dispatch(runnable, priority); - } - - fn spawn_realtime(&self, f: Box) { - self.dispatcher.spawn_realtime(f); - } - - #[track_caller] - fn timer(&self, duration: Duration) -> Timer { - let (tx, rx) = oneshot::channel(); - let dispatcher = self.dispatcher.clone(); - - // 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 }) - .spawn( - move |_| async move { - let _ = tx.send(()); - }, - move |runnable| { - dispatcher.dispatch_after(duration, runnable); - }, - ); - runnable.schedule(); - - Timer::new(rx) - } - - fn clock(&self) -> Arc { - self.clock.clone() - } - - #[cfg(any(test, feature = "test-support"))] - fn as_test(&self) -> Option<&TestScheduler> { - None - } -} - -/// A production clock that uses the platform dispatcher's time. -struct PlatformClock { - dispatcher: Arc, -} - -impl Clock for PlatformClock { - fn utc_now(&self) -> DateTime { - Utc::now() - } - - fn now(&self) -> Instant { - self.dispatcher.now() - } -} diff --git a/src/scheduler/clock.rs b/src/scheduler/clock.rs deleted file mode 100644 index 8437ab1cd7..0000000000 --- a/src/scheduler/clock.rs +++ /dev/null @@ -1,57 +0,0 @@ -use chrono::{DateTime, Utc}; -use parking_lot::Mutex; -use std::time::Duration; - -pub use web_time::Instant; - -/// Interface for providing current time and monotonic instants. -pub trait Clock { - /// Returns the current UTC date and time. - fn utc_now(&self) -> DateTime; - - /// Returns the current monotonic instant. - fn now(&self) -> Instant; -} - -/// A mock clock implementation for use in tests. -pub struct TestClock(Mutex); - -struct TestClockState { - now: Instant, - utc_now: DateTime, -} - -impl TestClock { - /// Creates a new TestClock initialized to a fixed start time. - pub fn new() -> Self { - const START_TIME: &str = "2025-07-01T23:59:58-00:00"; - let utc_now = DateTime::parse_from_rfc3339(START_TIME).unwrap().to_utc(); - Self(Mutex::new(TestClockState { - now: Instant::now(), - utc_now, - })) - } - - /// Sets the current UTC time for the clock. - pub fn set_utc_now(&self, now: DateTime) { - let mut state = self.0.lock(); - state.utc_now = now; - } - - /// Advances the clock by the given duration. - pub fn advance(&self, duration: Duration) { - let mut state = self.0.lock(); - state.now += duration; - state.utc_now += duration; - } -} - -impl Clock for TestClock { - fn utc_now(&self) -> DateTime { - self.0.lock().utc_now - } - - fn now(&self) -> Instant { - self.0.lock().now - } -} diff --git a/src/scheduler/executor.rs b/src/scheduler/executor.rs deleted file mode 100644 index c973f1ab53..0000000000 --- a/src/scheduler/executor.rs +++ /dev/null @@ -1,396 +0,0 @@ -use super::{Instant, Priority, RunnableMeta, Scheduler, SessionId, Timer}; -use std::{ - future::Future, - marker::PhantomData, - mem::ManuallyDrop, - panic::Location, - pin::Pin, - rc::Rc, - sync::Arc, - task::{Context, Poll}, - thread::{self, ThreadId}, - time::Duration, -}; - -/// An executor for running tasks on the foreground (main/UI) thread. -#[derive(Clone)] -pub struct ForegroundExecutor { - session_id: SessionId, - scheduler: Arc, - not_send: PhantomData>, -} - -impl ForegroundExecutor { - /// Creates a new ForegroundExecutor for the given session. - pub fn new(session_id: SessionId, scheduler: Arc) -> Self { - Self { - session_id, - scheduler, - not_send: PhantomData, - } - } - - /// Returns the session ID associated with this executor. - pub fn session_id(&self) -> SessionId { - self.session_id - } - - /// Returns the underlying scheduler. - pub fn scheduler(&self) -> &Arc { - &self.scheduler - } - - /// Spawns a task to run on the foreground thread. - #[track_caller] - pub fn spawn(&self, future: F) -> Task - where - F: Future + 'static, - F::Output: 'static, - { - let session_id = self.session_id; - let scheduler = Arc::clone(&self.scheduler); - let location = Location::caller(); - let (runnable, task) = spawn_local_with_source_location( - future, - move |runnable| { - scheduler.schedule_foreground(session_id, runnable); - }, - RunnableMeta { location }, - ); - runnable.schedule(); - Task(TaskState::Spawned(task)) - } - - /// Blocks the current thread until the given future completes. - pub fn block_on(&self, future: Fut) -> Fut::Output { - use std::cell::Cell; - - let output = Cell::new(None); - - let future = async { - output.set(Some(future.await)); - }; - let mut future = std::pin::pin!(future); - - self.scheduler - .block(Some(self.session_id), future.as_mut(), None); - - output.take().expect("block_on future did not complete") - } - - /// Block until the future completes or timeout occurs. - /// Returns Ok(output) if completed, Err(future) if timed out. - pub fn block_with_timeout( - &self, - timeout: Duration, - future: Fut, - ) -> Result + use> { - use std::cell::Cell; - - let output = Cell::new(None); - let mut future = Box::pin(future); - - { - let future_ref = &mut future; - let wrapper = async { - output.set(Some(future_ref.await)); - }; - let mut wrapper = std::pin::pin!(wrapper); - - self.scheduler - .block(Some(self.session_id), wrapper.as_mut(), Some(timeout)); - } - - match output.take() { - Some(value) => Ok(value), - None => Err(future), - } - } - - /// Creates a timer that resolves after the given duration. - #[track_caller] - pub fn timer(&self, duration: Duration) -> Timer { - self.scheduler.timer(duration) - } - - /// Returns the current monotonic instant from the scheduler's clock. - pub fn now(&self) -> Instant { - self.scheduler.clock().now() - } -} - -/// An executor for running tasks in the background. -#[derive(Clone)] -pub struct BackgroundExecutor { - scheduler: Arc, -} - -impl BackgroundExecutor { - /// Creates a new BackgroundExecutor. - pub fn new(scheduler: Arc) -> Self { - Self { scheduler } - } - - /// Spawns a background task with default priority. - #[track_caller] - pub fn spawn(&self, future: F) -> Task - where - F: Future + Send + 'static, - F::Output: Send + 'static, - { - self.spawn_with_priority(Priority::default(), future) - } - - /// Spawns a background task with the given priority. - #[track_caller] - pub fn spawn_with_priority(&self, priority: Priority, future: F) -> Task - where - F: Future + Send + 'static, - F::Output: Send + 'static, - { - let scheduler = Arc::clone(&self.scheduler); - let location = Location::caller(); - let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn( - move |_| future, - move |runnable| { - scheduler.schedule_background_with_priority(runnable, priority); - }, - ); - runnable.schedule(); - Task(TaskState::Spawned(task)) - } - - /// Spawns a future on a dedicated realtime thread for audio processing. - #[track_caller] - pub fn spawn_realtime(&self, future: F) -> Task - where - F: Future + Send + 'static, - F::Output: Send + 'static, - { - let location = Location::caller(); - let (tx, rx) = flume::bounded::>(1); - - self.scheduler.spawn_realtime(Box::new(move || { - while let Ok(runnable) = rx.recv() { - runnable.run(); - } - })); - - let (runnable, task) = async_task::Builder::new() - .metadata(RunnableMeta { location }) - .spawn( - move |_| future, - move |runnable| { - let _ = tx.send(runnable); - }, - ); - runnable.schedule(); - Task(TaskState::Spawned(task)) - } - - /// Creates a timer that resolves after the given duration. - #[track_caller] - pub fn timer(&self, duration: Duration) -> Timer { - self.scheduler.timer(duration) - } - - /// Returns the current monotonic instant from the scheduler's clock. - pub fn now(&self) -> Instant { - self.scheduler.clock().now() - } - - /// Returns the underlying scheduler. - pub fn scheduler(&self) -> &Arc { - &self.scheduler - } -} - -/// Task is a primitive that allows work to happen in the background. -/// -/// It implements [`Future`] so you can `.await` on it. -/// -/// If you drop a task it will be cancelled immediately. Calling [`Task::detach`] allows -/// the task to continue running, but with no way to return a value. -#[must_use] -#[derive(Debug)] -pub struct Task(TaskState); - -#[derive(Debug)] -enum TaskState { - /// A task that is ready to return a value - Ready(Option), - - /// A task that is currently running. - Spawned(async_task::Task), -} - -impl Task { - /// Creates a new task that will resolve with the value - pub fn ready(val: T) -> Self { - Task(TaskState::Ready(Some(val))) - } - - /// Creates a Task from an async_task::Task - pub fn from_async_task(task: async_task::Task) -> Self { - Task(TaskState::Spawned(task)) - } - - /// Returns true if the task has completed and its value is ready. - pub fn is_ready(&self) -> bool { - match &self.0 { - TaskState::Ready(_) => true, - TaskState::Spawned(task) => task.is_finished(), - } - } - - /// Detaching a task runs it to completion in the background - pub fn detach(self) { - match self { - Task(TaskState::Ready(_)) => {} - Task(TaskState::Spawned(task)) => task.detach(), - } - } - - /// Converts this task into a fallible task that returns `Option`. - pub fn fallible(self) -> FallibleTask { - FallibleTask(match self.0 { - TaskState::Ready(val) => FallibleTaskState::Ready(val), - TaskState::Spawned(task) => FallibleTaskState::Spawned(task.fallible()), - }) - } -} - -/// A task that returns `Option` instead of panicking when cancelled. -#[must_use] -pub struct FallibleTask(FallibleTaskState); - -enum FallibleTaskState { - /// A task that is ready to return a value - Ready(Option), - - /// A task that is currently running (wraps async_task::FallibleTask). - Spawned(async_task::FallibleTask), -} - -impl FallibleTask { - /// Creates a new fallible task that will resolve with the value. - pub fn ready(val: T) -> Self { - FallibleTask(FallibleTaskState::Ready(Some(val))) - } - - /// Detaching a task runs it to completion in the background. - pub fn detach(self) { - match self.0 { - FallibleTaskState::Ready(_) => {} - FallibleTaskState::Spawned(task) => task.detach(), - } - } -} - -impl Future for FallibleTask { - type Output = Option; - - fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - match unsafe { self.get_unchecked_mut() } { - FallibleTask(FallibleTaskState::Ready(val)) => Poll::Ready(val.take()), - FallibleTask(FallibleTaskState::Spawned(task)) => Pin::new(task).poll(cx), - } - } -} - -impl std::fmt::Debug for FallibleTask { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match &self.0 { - FallibleTaskState::Ready(_) => f.debug_tuple("FallibleTask::Ready").finish(), - FallibleTaskState::Spawned(task) => { - f.debug_tuple("FallibleTask::Spawned").field(task).finish() - } - } - } -} - -impl Future for Task { - type Output = T; - - fn poll(self: Pin<&mut Self>, cx: &mut Context) -> Poll { - match unsafe { self.get_unchecked_mut() } { - Task(TaskState::Ready(val)) => Poll::Ready(val.take().unwrap()), - Task(TaskState::Spawned(task)) => Pin::new(task).poll(cx), - } - } -} - -/// Variant of `async_task::spawn_local` that includes the source location of the spawn in panics. -#[track_caller] -fn spawn_local_with_source_location( - future: Fut, - schedule: S, - metadata: RunnableMeta, -) -> ( - async_task::Runnable, - async_task::Task, -) -where - Fut: Future + 'static, - Fut::Output: 'static, - S: async_task::Schedule + Send + Sync + 'static, -{ - #[inline] - fn thread_id() -> ThreadId { - std::thread_local! { - static ID: ThreadId = thread::current().id(); - } - ID.try_with(|id| *id) - .unwrap_or_else(|_| thread::current().id()) - } - - struct Checked { - id: ThreadId, - inner: ManuallyDrop, - location: &'static Location<'static>, - } - - impl Drop for Checked { - fn drop(&mut self) { - assert_eq!( - self.id, - thread_id(), - "local task dropped by a thread that didn't spawn it. Task spawned at {}", - self.location - ); - unsafe { - ManuallyDrop::drop(&mut self.inner); - } - } - } - - impl Future for Checked { - type Output = F::Output; - - fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - assert!( - self.id == thread_id(), - "local task polled by a thread that didn't spawn it. Task spawned at {}", - self.location - ); - unsafe { self.map_unchecked_mut(|c| &mut *c.inner).poll(cx) } - } - } - - let location = metadata.location; - - unsafe { - async_task::Builder::new() - .metadata(metadata) - .spawn_unchecked( - move |_| Checked { - id: thread_id(), - inner: ManuallyDrop::new(future), - location, - }, - schedule, - ) - } -} diff --git a/src/scheduler/mod.rs b/src/scheduler/mod.rs deleted file mode 100644 index 53bb2eda66..0000000000 --- a/src/scheduler/mod.rs +++ /dev/null @@ -1,149 +0,0 @@ -mod clock; -mod executor; -#[cfg(any(test, feature = "test-support"))] -mod test_scheduler; -#[cfg(test)] -mod tests; - -pub use clock::*; -pub use executor::*; -#[cfg(any(test, feature = "test-support"))] -pub use test_scheduler::*; - -use async_task::Runnable; -use futures::channel::oneshot; -use std::{ - future::Future, - panic::Location, - pin::Pin, - sync::Arc, - task::{Context, Poll}, - time::Duration, -}; - -/// Task priority for background tasks. -/// -/// Higher priority tasks are more likely to be scheduled before lower priority tasks, -/// but this is not a strict guarantee - the scheduler may interleave tasks of different -/// priorities to prevent starvation. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -#[repr(u8)] -pub enum Priority { - /// Realtime priority - /// - /// Spawning a task with this priority will spin it off on a separate thread dedicated just to that task. Only use for audio. - RealtimeAudio, - /// High priority - use for tasks critical to user experience/responsiveness. - High, - /// Medium priority - suitable for most use cases. - #[default] - Medium, - /// Low priority - use for background work that can be deprioritized. - Low, -} - -impl Priority { - /// Returns the relative probability weight for this priority level. - /// Used by schedulers to determine task selection probability. - pub const fn weight(self) -> u32 { - match self { - Priority::High => 60, - Priority::Medium => 30, - Priority::Low => 10, - // realtime priorities are not considered for probability scheduling - Priority::RealtimeAudio => 0, - } - } -} - -/// Metadata attached to runnables for debugging and profiling. -#[derive(Clone)] -pub struct RunnableMeta { - /// The source location where the task was spawned. - pub location: &'static Location<'static>, -} - -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() - } -} - -/// Interface for scheduling tasks on different threads or execution contexts. -pub trait Scheduler: Send + Sync { - /// Block until the given future completes or timeout occurs. - /// - /// Returns `true` if the future completed, `false` if it timed out. - /// The future is passed as a pinned mutable reference so the caller - /// retains ownership and can continue polling or return it on timeout. - fn block( - &self, - session_id: Option, - future: Pin<&mut dyn Future>, - timeout: Option, - ) -> bool; - - /// Schedules a task to run on the foreground (main/UI) thread. - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable); - - /// Schedule a background task with the given priority. - fn schedule_background_with_priority( - &self, - runnable: Runnable, - priority: Priority, - ); - - /// Spawn a closure on a dedicated realtime thread for audio processing. - fn spawn_realtime(&self, f: Box); - - /// Schedule a background task with default (medium) priority. - fn schedule_background(&self, runnable: Runnable) { - self.schedule_background_with_priority(runnable, Priority::default()); - } - - /// Creates a timer that resolves after the given duration. - #[track_caller] - fn timer(&self, timeout: Duration) -> Timer; - - /// Returns the clock used by this scheduler. - fn clock(&self) -> Arc; - - #[cfg(any(test, feature = "test-support"))] - fn as_test(&self) -> Option<&TestScheduler> { - None - } -} - -/// A unique identifier for a window or application session. -#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub struct SessionId(u16); - -impl SessionId { - /// Creates a new SessionId from the given u16. - pub fn new(id: u16) -> Self { - SessionId(id) - } -} - -/// A future that resolves after a period of time. -pub struct Timer(oneshot::Receiver<()>); - -impl Timer { - /// Creates a new timer from a oneshot receiver. - pub fn new(rx: oneshot::Receiver<()>) -> Self { - Timer(rx) - } -} - -impl Future for Timer { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll<()> { - match Pin::new(&mut self.0).poll(cx) { - Poll::Ready(_) => Poll::Ready(()), - Poll::Pending => Poll::Pending, - } - } -} diff --git a/src/scheduler/test_scheduler.rs b/src/scheduler/test_scheduler.rs deleted file mode 100644 index e3f4400a7a..0000000000 --- a/src/scheduler/test_scheduler.rs +++ /dev/null @@ -1,885 +0,0 @@ -use super::{ - BackgroundExecutor, Clock, ForegroundExecutor, Instant, Priority, RunnableMeta, Scheduler, - SessionId, TestClock, Timer, -}; -use async_task::Runnable; -use backtrace::{Backtrace, BacktraceFrame}; -use futures::channel::oneshot; -use parking_lot::{Mutex, MutexGuard}; -use rand::{ - distr::{StandardUniform, uniform::SampleRange, uniform::SampleUniform}, - prelude::*, -}; -use std::{ - any::type_name_of_val, - collections::{BTreeMap, HashSet, VecDeque}, - env, - fmt::Write, - future::Future, - mem, - ops::RangeInclusive, - panic::{self, AssertUnwindSafe}, - pin::Pin, - sync::{ - Arc, - atomic::{AtomicBool, Ordering::SeqCst}, - }, - task::{Context, Poll, RawWaker, RawWakerVTable, Waker}, - thread::{self, Thread}, - time::Duration, -}; - -const PENDING_TRACES_VAR_NAME: &str = "PENDING_TRACES"; - -pub struct TestScheduler { - clock: Arc, - rng: Arc>, - state: Arc>, - thread: Thread, -} - -impl TestScheduler { - /// Run a test once with default configuration (seed 0) - pub fn once(f: impl AsyncFnOnce(Arc) -> R) -> R { - Self::with_seed(0, f) - } - - /// Run a test multiple times with sequential seeds (0, 1, 2, ...) - pub fn many( - default_iterations: usize, - mut f: impl AsyncFnMut(Arc) -> R, - ) -> Vec { - let num_iterations = std::env::var("ITERATIONS") - .map(|iterations| iterations.parse().unwrap()) - .unwrap_or(default_iterations); - - let seed = std::env::var("SEED") - .map(|seed| seed.parse().unwrap()) - .unwrap_or(0); - - (seed..seed + num_iterations as u64) - .map(|seed| { - let mut unwind_safe_f = AssertUnwindSafe(&mut f); - eprintln!("Running seed: {seed}"); - match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { - Ok(result) => result, - Err(error) => { - eprintln!("\x1b[31mFailing Seed: {seed}\x1b[0m"); - panic::resume_unwind(error); - } - } - }) - .collect() - } - - fn with_seed(seed: u64, f: impl AsyncFnOnce(Arc) -> R) -> R { - let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::with_seed(seed))); - let future = f(scheduler.clone()); - let result = scheduler.foreground().block_on(future); - scheduler.run(); // Ensure spawned tasks finish up before returning in tests - result - } - - pub fn new(config: TestSchedulerConfig) -> Self { - Self { - rng: Arc::new(Mutex::new(StdRng::seed_from_u64(config.seed))), - state: Arc::new(Mutex::new(SchedulerState { - runnables: VecDeque::new(), - timers: Vec::new(), - blocked_sessions: Vec::new(), - randomize_order: config.randomize_order, - allow_parking: config.allow_parking, - timeout_ticks: config.timeout_ticks, - next_session_id: SessionId(0), - capture_pending_traces: config.capture_pending_traces, - pending_traces: BTreeMap::new(), - next_trace_id: TraceId(0), - is_main_thread: true, - non_determinism_error: None, - finished: false, - parking_allowed_once: false, - unparked: false, - })), - clock: Arc::new(TestClock::new()), - thread: thread::current(), - } - } - - pub fn end_test(&self) { - let mut state = self.state.lock(); - if let Some((message, backtrace)) = &state.non_determinism_error { - panic!("{}\n{:?}", message, backtrace) - } - state.finished = true; - } - - pub fn clock(&self) -> Arc { - self.clock.clone() - } - - pub fn rng(&self) -> SharedRng { - SharedRng(self.rng.clone()) - } - - pub fn set_timeout_ticks(&self, timeout_ticks: RangeInclusive) { - self.state.lock().timeout_ticks = timeout_ticks; - } - - pub fn allow_parking(&self) { - let mut state = self.state.lock(); - state.allow_parking = true; - state.parking_allowed_once = true; - } - - pub fn forbid_parking(&self) { - self.state.lock().allow_parking = false; - } - - pub fn parking_allowed(&self) -> bool { - self.state.lock().allow_parking - } - - pub fn is_main_thread(&self) -> bool { - self.state.lock().is_main_thread - } - - /// Allocate a new session ID for foreground task scheduling. - /// This is used by GPUI's TestDispatcher to map dispatcher instances to sessions. - pub fn allocate_session_id(&self) -> SessionId { - let mut state = self.state.lock(); - state.next_session_id.0 += 1; - state.next_session_id - } - - /// Create a foreground executor for this scheduler - pub fn foreground(self: &Arc) -> ForegroundExecutor { - let session_id = self.allocate_session_id(); - ForegroundExecutor::new(session_id, self.clone()) - } - - /// Create a background executor for this scheduler - pub fn background(self: &Arc) -> BackgroundExecutor { - BackgroundExecutor::new(self.clone()) - } - - pub fn yield_random(&self) -> Yield { - let rng = &mut *self.rng.lock(); - if rng.random_bool(0.1) { - Yield(rng.random_range(10..20)) - } else { - Yield(rng.random_range(0..2)) - } - } - - pub fn run(&self) { - while self.step() { - // Continue until no work remains - } - } - - pub fn run_with_clock_advancement(&self) { - while self.step() || self.advance_clock_to_next_timer() { - // Continue until no work remains - } - } - - /// Execute one tick of the scheduler, processing expired timers and running - /// at most one task. Returns true if any work was done. - /// - /// This is the public interface for GPUI's TestDispatcher to drive task execution. - pub fn tick(&self) -> bool { - self.step_filtered(false) - } - - /// Execute one tick, but only run background tasks (no foreground/session tasks). - /// Returns true if any work was done. - pub fn tick_background_only(&self) -> bool { - self.step_filtered(true) - } - - /// Check if there are any pending tasks or timers that could run. - pub fn has_pending_tasks(&self) -> bool { - let state = self.state.lock(); - !state.runnables.is_empty() || !state.timers.is_empty() - } - - /// Returns counts of (foreground_tasks, background_tasks) currently queued. - /// Foreground tasks are those with a session_id, background tasks have none. - pub fn pending_task_counts(&self) -> (usize, usize) { - let state = self.state.lock(); - let foreground = state - .runnables - .iter() - .filter(|r| r.session_id.is_some()) - .count(); - let background = state - .runnables - .iter() - .filter(|r| r.session_id.is_none()) - .count(); - (foreground, background) - } - - fn step(&self) -> bool { - self.step_filtered(false) - } - - fn step_filtered(&self, background_only: bool) -> bool { - let (elapsed_count, runnables_before) = { - let mut state = self.state.lock(); - let end_ix = state - .timers - .partition_point(|timer| timer.expiration <= self.clock.now()); - let elapsed: Vec<_> = state.timers.drain(..end_ix).collect(); - let count = elapsed.len(); - let runnables = state.runnables.len(); - drop(state); - // Dropping elapsed timers here wakes the waiting futures - drop(elapsed); - (count, runnables) - }; - - if elapsed_count > 0 { - let runnables_after = self.state.lock().runnables.len(); - if std::env::var("DEBUG_SCHEDULER").is_ok() { - eprintln!( - "[scheduler] Expired {} timers at {:?}, runnables: {} -> {}", - elapsed_count, - self.clock.now(), - runnables_before, - runnables_after - ); - } - return true; - } - - let runnable = { - let state = &mut *self.state.lock(); - - // Find candidate tasks: - // - For foreground tasks (with session_id), only the first task from each session - // is a candidate (to preserve intra-session ordering) - // - For background tasks (no session_id), all are candidates - // - Tasks from blocked sessions are excluded - // - If background_only is true, skip foreground tasks entirely - let mut seen_sessions = HashSet::new(); - let candidate_indices: Vec = state - .runnables - .iter() - .enumerate() - .filter(|(_, runnable)| { - if let Some(session_id) = runnable.session_id { - // Skip foreground tasks if background_only mode - if background_only { - return false; - } - // Exclude tasks from blocked sessions - if state.blocked_sessions.contains(&session_id) { - return false; - } - // Only include first task from each session (insert returns true if new) - seen_sessions.insert(session_id) - } else { - // Background tasks are always candidates - true - } - }) - .map(|(ix, _)| ix) - .collect(); - - if candidate_indices.is_empty() { - None - } else if state.randomize_order { - // Use priority-weighted random selection - let weights: Vec = candidate_indices - .iter() - .map(|&ix| state.runnables[ix].priority.weight()) - .collect(); - let total_weight: u32 = weights.iter().sum(); - - if total_weight == 0 { - // Fallback to uniform random if all weights are zero - let choice = self.rng.lock().random_range(0..candidate_indices.len()); - state.runnables.remove(candidate_indices[choice]) - } else { - let mut target = self.rng.lock().random_range(0..total_weight); - let mut selected_idx = 0; - for (i, &weight) in weights.iter().enumerate() { - if target < weight { - selected_idx = i; - break; - } - target -= weight; - } - state.runnables.remove(candidate_indices[selected_idx]) - } - } else { - // Non-randomized: just take the first candidate task - state.runnables.remove(candidate_indices[0]) - } - }; - - if let Some(runnable) = runnable { - let is_foreground = runnable.session_id.is_some(); - let was_main_thread = self.state.lock().is_main_thread; - self.state.lock().is_main_thread = is_foreground; - runnable.run(); - self.state.lock().is_main_thread = was_main_thread; - return true; - } - - false - } - - /// Drops all runnable tasks from the scheduler. - /// - /// This is used by the leak detector to ensure that all tasks have been dropped as tasks may keep entities alive otherwise. - /// Why do we even have tasks left when tests finish you may ask. The reason for that is simple, the scheduler itself is the executor and it retains the scheduled runnables. - /// A lot of tasks, including every foreground task contain an executor handle that keeps the test scheduler alive, causing a reference cycle, thus the need for this function right now. - pub fn drain_tasks(&self) { - // dropping runnables may reschedule tasks - // due to drop impls with executors in them - // so drop until we reach a fixpoint - loop { - let mut state = self.state.lock(); - if state.runnables.is_empty() && state.timers.is_empty() { - break; - } - let runnables = std::mem::take(&mut state.runnables); - let timers = std::mem::take(&mut state.timers); - drop(state); - drop(timers); - drop(runnables); - } - } - - pub fn advance_clock_to_next_timer(&self) -> bool { - if let Some(timer) = self.state.lock().timers.first() { - self.clock.advance(timer.expiration - self.clock.now()); - true - } else { - false - } - } - - pub fn advance_clock(&self, duration: Duration) { - let debug = std::env::var("DEBUG_SCHEDULER").is_ok(); - let start = self.clock.now(); - let next_now = start + duration; - if debug { - let timer_count = self.state.lock().timers.len(); - eprintln!( - "[scheduler] advance_clock({:?}) from {:?}, {} pending timers", - duration, start, timer_count - ); - } - loop { - self.run(); - if let Some(timer) = self.state.lock().timers.first() - && timer.expiration <= next_now - { - let advance_to = timer.expiration; - if debug { - eprintln!( - "[scheduler] Advancing clock {:?} -> {:?} for timer", - self.clock.now(), - advance_to - ); - } - self.clock.advance(advance_to - self.clock.now()); - } else { - break; - } - } - self.clock.advance(next_now - self.clock.now()); - if debug { - eprintln!( - "[scheduler] advance_clock done, now at {:?}", - self.clock.now() - ); - } - } - - fn park(&self, deadline: Option) -> bool { - if self.state.lock().allow_parking { - let start = Instant::now(); - // Enforce a hard timeout to prevent tests from hanging indefinitely - let hard_deadline = start + Duration::from_secs(15); - - // Use the earlier of the provided deadline or the hard timeout deadline - let effective_deadline = deadline - .map(|d| d.min(hard_deadline)) - .unwrap_or(hard_deadline); - - // Park in small intervals to allow checking both deadlines - const PARK_INTERVAL: Duration = Duration::from_millis(100); - loop { - let now = Instant::now(); - if now >= effective_deadline { - // Check if we hit the hard timeout - if now >= hard_deadline { - panic!( - "Test timed out after 15 seconds while parking. \ - This may indicate a deadlock or missing waker.", - ); - } - // Hit the provided deadline - return false; - } - - let remaining = effective_deadline.saturating_duration_since(now); - let park_duration = remaining.min(PARK_INTERVAL); - let before_park = Instant::now(); - thread::park_timeout(park_duration); - let elapsed = before_park.elapsed(); - - // Advance the test clock by the real elapsed time while parking - self.clock.advance(elapsed); - - // Check if any timers have expired after advancing the clock. - // If so, return so the caller can process them. - if self - .state - .lock() - .timers - .first() - .map_or(false, |t| t.expiration <= self.clock.now()) - { - return true; - } - - // Check if we were woken up by a different thread. - // We use a flag because timing-based detection is unreliable: - // OS scheduling delays can cause elapsed >= park_duration even when - // we were woken early by unpark(). - if std::mem::take(&mut self.state.lock().unparked) { - return true; - } - } - } else if deadline.is_some() { - false - } else if self.state.lock().capture_pending_traces { - let mut pending_traces = String::new(); - for (_, trace) in mem::take(&mut self.state.lock().pending_traces) { - writeln!(pending_traces, "{:?}", exclude_wakers_from_trace(trace)).unwrap(); - } - panic!("Parking forbidden. Pending traces:\n{}", pending_traces); - } else { - panic!( - "Parking forbidden. Re-run with {PENDING_TRACES_VAR_NAME}=1 to show pending traces" - ); - } - } -} - -fn assert_correct_thread(expected: &Thread, state: &Arc>) { - let current_thread = thread::current(); - let mut state = state.lock(); - if state.parking_allowed_once { - return; - } - if current_thread.id() == expected.id() { - return; - } - - let message = format!( - "Detected activity on thread {:?} {:?}, but test scheduler is running on {:?} {:?}. Your test is not deterministic.", - current_thread.name(), - current_thread.id(), - expected.name(), - expected.id(), - ); - let backtrace = Backtrace::new(); - if state.finished { - panic!("{}", message); - } else { - state.non_determinism_error = Some((message, backtrace)) - } -} - -impl Scheduler for TestScheduler { - /// Block until the given future completes, with an optional timeout. If the - /// future is unable to make progress at any moment before the timeout and - /// no other tasks or timers remain, we panic unless parking is allowed. If - /// parking is allowed, we block up to the timeout or indefinitely if none - /// is provided. This is to allow testing a mix of deterministic and - /// non-deterministic async behavior, such as when interacting with I/O in - /// an otherwise deterministic test. - fn block( - &self, - session_id: Option, - mut future: Pin<&mut dyn Future>, - timeout: Option, - ) -> bool { - if let Some(session_id) = session_id { - self.state.lock().blocked_sessions.push(session_id); - } - - let deadline = timeout.map(|timeout| Instant::now() + timeout); - let awoken = Arc::new(AtomicBool::new(false)); - let waker = Box::new(TracingWaker { - id: None, - awoken: awoken.clone(), - thread: self.thread.clone(), - state: self.state.clone(), - }); - let waker = unsafe { Waker::new(Box::into_raw(waker) as *const (), &WAKER_VTABLE) }; - let max_ticks = if timeout.is_some() { - self.rng - .lock() - .random_range(self.state.lock().timeout_ticks.clone()) - } else { - usize::MAX - }; - let mut cx = Context::from_waker(&waker); - - let mut completed = false; - for _ in 0..max_ticks { - match future.as_mut().poll(&mut cx) { - Poll::Ready(()) => { - completed = true; - break; - } - Poll::Pending => {} - } - - let mut stepped = None; - while self.rng.lock().random() { - let stepped = stepped.get_or_insert(false); - if self.step() { - *stepped = true; - } else { - break; - } - } - - let stepped = stepped.unwrap_or(true); - let awoken = awoken.swap(false, SeqCst); - if !stepped && !awoken { - let parking_allowed = self.state.lock().allow_parking; - // In deterministic mode (parking forbidden), instantly jump to the next timer. - // In non-deterministic mode (parking allowed), let real time pass instead. - let advanced_to_timer = !parking_allowed && self.advance_clock_to_next_timer(); - if !advanced_to_timer && !self.park(deadline) { - break; - } - } - } - - if session_id.is_some() { - self.state.lock().blocked_sessions.pop(); - } - - completed - } - - fn schedule_foreground(&self, session_id: SessionId, runnable: Runnable) { - assert_correct_thread(&self.thread, &self.state); - let mut state = self.state.lock(); - let ix = if state.randomize_order { - let start_ix = state - .runnables - .iter() - .rposition(|task| task.session_id == Some(session_id)) - .map_or(0, |ix| ix + 1); - self.rng - .lock() - .random_range(start_ix..=state.runnables.len()) - } else { - state.runnables.len() - }; - state.runnables.insert( - ix, - ScheduledRunnable { - session_id: Some(session_id), - priority: Priority::default(), - runnable, - }, - ); - state.unparked = true; - drop(state); - self.thread.unpark(); - } - - fn schedule_background_with_priority( - &self, - runnable: Runnable, - priority: Priority, - ) { - assert_correct_thread(&self.thread, &self.state); - let mut state = self.state.lock(); - let ix = if state.randomize_order { - self.rng.lock().random_range(0..=state.runnables.len()) - } else { - state.runnables.len() - }; - state.runnables.insert( - ix, - ScheduledRunnable { - session_id: None, - priority, - runnable, - }, - ); - state.unparked = true; - drop(state); - self.thread.unpark(); - } - - fn spawn_realtime(&self, f: Box) { - std::thread::spawn(move || { - f(); - }); - } - - #[track_caller] - fn timer(&self, duration: Duration) -> Timer { - let (tx, rx) = oneshot::channel(); - let state = &mut *self.state.lock(); - state.timers.push(ScheduledTimer { - expiration: self.clock.now() + duration, - _notify: tx, - }); - state.timers.sort_by_key(|timer| timer.expiration); - Timer(rx) - } - - fn clock(&self) -> Arc { - self.clock.clone() - } - - fn as_test(&self) -> Option<&TestScheduler> { - Some(self) - } -} - -#[derive(Clone, Debug)] -pub struct TestSchedulerConfig { - pub seed: u64, - pub randomize_order: bool, - pub allow_parking: bool, - pub capture_pending_traces: bool, - pub timeout_ticks: RangeInclusive, -} - -impl TestSchedulerConfig { - pub fn with_seed(seed: u64) -> Self { - Self { - seed, - ..Default::default() - } - } -} - -impl Default for TestSchedulerConfig { - fn default() -> Self { - Self { - seed: 0, - randomize_order: true, - allow_parking: false, - capture_pending_traces: env::var(PENDING_TRACES_VAR_NAME) - .map_or(false, |var| var == "1" || var == "true"), - timeout_ticks: 1..=1000, - } - } -} - -struct ScheduledRunnable { - session_id: Option, - priority: Priority, - runnable: Runnable, -} - -impl ScheduledRunnable { - fn run(self) { - self.runnable.run(); - } -} - -struct ScheduledTimer { - expiration: Instant, - _notify: oneshot::Sender<()>, -} - -struct SchedulerState { - runnables: VecDeque, - timers: Vec, - blocked_sessions: Vec, - randomize_order: bool, - allow_parking: bool, - timeout_ticks: RangeInclusive, - next_session_id: SessionId, - capture_pending_traces: bool, - next_trace_id: TraceId, - pending_traces: BTreeMap, - is_main_thread: bool, - non_determinism_error: Option<(String, Backtrace)>, - parking_allowed_once: bool, - finished: bool, - unparked: bool, -} - -const WAKER_VTABLE: RawWakerVTable = RawWakerVTable::new( - TracingWaker::clone_raw, - TracingWaker::wake_raw, - TracingWaker::wake_by_ref_raw, - TracingWaker::drop_raw, -); - -#[derive(Copy, Clone, Eq, PartialEq, PartialOrd, Ord)] -struct TraceId(usize); - -struct TracingWaker { - id: Option, - awoken: Arc, - thread: Thread, - state: Arc>, -} - -impl Clone for TracingWaker { - fn clone(&self) -> Self { - let mut state = self.state.lock(); - let id = if state.capture_pending_traces { - let id = state.next_trace_id; - state.next_trace_id.0 += 1; - state.pending_traces.insert(id, Backtrace::new_unresolved()); - Some(id) - } else { - None - }; - Self { - id, - awoken: self.awoken.clone(), - thread: self.thread.clone(), - state: self.state.clone(), - } - } -} - -impl Drop for TracingWaker { - fn drop(&mut self) { - assert_correct_thread(&self.thread, &self.state); - - if let Some(id) = self.id { - self.state.lock().pending_traces.remove(&id); - } - } -} - -impl TracingWaker { - fn wake(self) { - self.wake_by_ref(); - } - - fn wake_by_ref(&self) { - assert_correct_thread(&self.thread, &self.state); - - let mut state = self.state.lock(); - if let Some(id) = self.id { - state.pending_traces.remove(&id); - } - state.unparked = true; - drop(state); - self.awoken.store(true, SeqCst); - self.thread.unpark(); - } - - fn clone_raw(waker: *const ()) -> RawWaker { - let waker = waker as *const TracingWaker; - let waker = unsafe { &*waker }; - RawWaker::new( - Box::into_raw(Box::new(waker.clone())) as *const (), - &WAKER_VTABLE, - ) - } - - fn wake_raw(waker: *const ()) { - let waker = unsafe { Box::from_raw(waker as *mut TracingWaker) }; - waker.wake(); - } - - fn wake_by_ref_raw(waker: *const ()) { - let waker = waker as *const TracingWaker; - let waker = unsafe { &*waker }; - waker.wake_by_ref(); - } - - fn drop_raw(waker: *const ()) { - let waker = unsafe { Box::from_raw(waker as *mut TracingWaker) }; - drop(waker); - } -} - -pub struct Yield(usize); - -/// A wrapper around `Arc>` that provides convenient methods -/// for random number generation without requiring explicit locking. -#[derive(Clone)] -pub struct SharedRng(Arc>); - -impl SharedRng { - /// Lock the inner RNG for direct access. Use this when you need multiple - /// random operations without re-locking between each one. - pub fn lock(&self) -> MutexGuard<'_, StdRng> { - self.0.lock() - } - - /// Generate a random value in the given range. - pub fn random_range(&self, range: R) -> T - where - T: SampleUniform, - R: SampleRange, - { - self.0.lock().random_range(range) - } - - /// Generate a random boolean with the given probability of being true. - pub fn random_bool(&self, p: f64) -> bool { - self.0.lock().random_bool(p) - } - - /// Generate a random value of the given type. - pub fn random(&self) -> T - where - StandardUniform: Distribution, - { - self.0.lock().random() - } - - /// Generate a random ratio - true with probability `numerator/denominator`. - pub fn random_ratio(&self, numerator: u32, denominator: u32) -> bool { - self.0.lock().random_ratio(numerator, denominator) - } -} - -impl Future for Yield { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context) -> Poll { - if self.0 == 0 { - Poll::Ready(()) - } else { - self.0 -= 1; - cx.waker().wake_by_ref(); - Poll::Pending - } - } -} - -fn exclude_wakers_from_trace(mut trace: Backtrace) -> Backtrace { - trace.resolve(); - let mut frames: Vec = trace.into(); - let waker_clone_frame_ix = frames.iter().position(|frame| { - frame.symbols().iter().any(|symbol| { - symbol - .name() - .is_some_and(|name| format!("{name:#?}") == type_name_of_val(&Waker::clone)) - }) - }); - - if let Some(waker_clone_frame_ix) = waker_clone_frame_ix { - frames.drain(..waker_clone_frame_ix + 1); - } - - Backtrace::from(frames) -} diff --git a/src/scheduler/tests.rs b/src/scheduler/tests.rs deleted file mode 100644 index 03fe8075f9..0000000000 --- a/src/scheduler/tests.rs +++ /dev/null @@ -1,670 +0,0 @@ -use super::*; -use futures::{ - FutureExt, - channel::{mpsc, oneshot}, - executor::block_on, - future, - sink::SinkExt, - stream::{FuturesUnordered, StreamExt}, -}; -use std::{ - cell::RefCell, - collections::{BTreeSet, HashSet}, - pin::Pin, - rc::Rc, - sync::Arc, - task::{Context, Poll, Waker}, -}; - -#[test] -fn test_foreground_executor_spawn() { - let result = TestScheduler::once(async |scheduler| { - let task = scheduler.foreground().spawn(async move { 42 }); - task.await - }); - assert_eq!(result, 42); -} - -#[test] -fn test_background_executor_spawn() { - TestScheduler::once(async |scheduler| { - let task = scheduler.background().spawn(async move { 42 }); - let result = task.await; - assert_eq!(result, 42); - }); -} - -#[test] -fn test_foreground_ordering() { - let mut traces = HashSet::new(); - - TestScheduler::many(100, async |scheduler| { - #[derive(Hash, PartialEq, Eq)] - struct TraceEntry { - session: usize, - task: usize, - } - - let trace = Rc::new(RefCell::new(Vec::new())); - - let foreground_1 = scheduler.foreground(); - for task in 0..10 { - foreground_1 - .spawn({ - let trace = trace.clone(); - async move { - trace.borrow_mut().push(TraceEntry { session: 0, task }); - } - }) - .detach(); - } - - let foreground_2 = scheduler.foreground(); - for task in 0..10 { - foreground_2 - .spawn({ - let trace = trace.clone(); - async move { - trace.borrow_mut().push(TraceEntry { session: 1, task }); - } - }) - .detach(); - } - - scheduler.run(); - - assert_eq!( - trace - .borrow() - .iter() - .filter(|entry| entry.session == 0) - .map(|entry| entry.task) - .collect::>(), - (0..10).collect::>() - ); - assert_eq!( - trace - .borrow() - .iter() - .filter(|entry| entry.session == 1) - .map(|entry| entry.task) - .collect::>(), - (0..10).collect::>() - ); - - traces.insert(trace.take()); - }); - - assert!(traces.len() > 1, "Expected at least two traces"); -} - -#[test] -fn test_timer_ordering() { - TestScheduler::many(1, async |scheduler| { - let background = scheduler.background(); - let futures = FuturesUnordered::new(); - futures.push( - async { - background.timer(Duration::from_millis(100)).await; - 2 - } - .boxed(), - ); - futures.push( - async { - background.timer(Duration::from_millis(50)).await; - 1 - } - .boxed(), - ); - futures.push( - async { - background.timer(Duration::from_millis(150)).await; - 3 - } - .boxed(), - ); - assert_eq!(futures.collect::>().await, vec![1, 2, 3]); - }); -} - -#[test] -fn test_send_from_bg_to_fg() { - TestScheduler::once(async |scheduler| { - let foreground = scheduler.foreground(); - let background = scheduler.background(); - - let (sender, receiver) = oneshot::channel::(); - - background - .spawn(async move { - sender.send(42).unwrap(); - }) - .detach(); - - let task = foreground.spawn(async move { receiver.await.unwrap() }); - let result = task.await; - assert_eq!(result, 42); - }); -} - -#[test] -fn test_randomize_order() { - // Test deterministic mode: different seeds should produce same execution order - let mut deterministic_results = HashSet::new(); - for seed in 0..10 { - let config = TestSchedulerConfig { - seed, - randomize_order: false, - ..Default::default() - }; - let order = block_on(capture_execution_order(config)); - assert_eq!(order.len(), 6); - deterministic_results.insert(order); - } - - // All deterministic runs should produce the same result - assert_eq!( - deterministic_results.len(), - 1, - "Deterministic mode should always produce same execution order" - ); - - // Test randomized mode: different seeds can produce different execution orders - let mut randomized_results = HashSet::new(); - for seed in 0..20 { - let config = TestSchedulerConfig::with_seed(seed); - let order = block_on(capture_execution_order(config)); - assert_eq!(order.len(), 6); - randomized_results.insert(order); - } - - // Randomized mode should produce multiple different execution orders - assert!( - randomized_results.len() > 1, - "Randomized mode should produce multiple different orders" - ); -} - -async fn capture_execution_order(config: TestSchedulerConfig) -> Vec { - let scheduler = Arc::new(TestScheduler::new(config)); - let foreground = scheduler.foreground(); - let background = scheduler.background(); - - let (sender, receiver) = mpsc::unbounded::(); - - // Spawn foreground tasks - for i in 0..3 { - let mut sender = sender.clone(); - foreground - .spawn(async move { - sender.send(format!("fg-{}", i)).await.ok(); - }) - .detach(); - } - - // Spawn background tasks - for i in 0..3 { - let mut sender = sender.clone(); - background - .spawn(async move { - sender.send(format!("bg-{}", i)).await.ok(); - }) - .detach(); - } - - drop(sender); // Close sender to signal no more messages - scheduler.run(); - - receiver.collect().await -} - -#[test] -fn test_block() { - let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::default())); - let (tx, rx) = oneshot::channel(); - - // Spawn background task to send value - let _ = scheduler - .background() - .spawn(async move { - tx.send(42).unwrap(); - }) - .detach(); - - // Block on receiving the value - let result = scheduler.foreground().block_on(async { rx.await.unwrap() }); - assert_eq!(result, 42); -} - -#[test] -#[should_panic(expected = "Parking forbidden. Pending traces:")] -fn test_parking_panics() { - let config = TestSchedulerConfig { - capture_pending_traces: true, - ..Default::default() - }; - let scheduler = Arc::new(TestScheduler::new(config)); - scheduler.foreground().block_on(async { - let (_tx, rx) = oneshot::channel::<()>(); - rx.await.unwrap(); // This will never complete - }); -} - -#[test] -fn test_block_with_parking() { - let config = TestSchedulerConfig { - allow_parking: true, - ..Default::default() - }; - let scheduler = Arc::new(TestScheduler::new(config)); - let (tx, rx) = oneshot::channel(); - - // Spawn background task to send value - let _ = scheduler - .background() - .spawn(async move { - tx.send(42).unwrap(); - }) - .detach(); - - // Block on receiving the value (will park if needed) - let result = scheduler.foreground().block_on(async { rx.await.unwrap() }); - assert_eq!(result, 42); -} - -#[test] -fn test_helper_methods() { - // Test the once method - let result = TestScheduler::once(async |scheduler: Arc| { - let background = scheduler.background(); - background.spawn(async { 42 }).await - }); - assert_eq!(result, 42); - - // Test the many method - let results = TestScheduler::many(3, async |scheduler: Arc| { - let background = scheduler.background(); - background.spawn(async { 10 }).await - }); - assert_eq!(results, vec![10, 10, 10]); -} - -#[test] -fn test_many_with_arbitrary_seed() { - for seed in [0u64, 1, 5, 42] { - let mut seeds_seen = Vec::new(); - let iterations = 3usize; - - for current_seed in seed..seed + iterations as u64 { - let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::with_seed( - current_seed, - ))); - let captured_seed = current_seed; - scheduler - .foreground() - .block_on(async { seeds_seen.push(captured_seed) }); - scheduler.run(); - } - - assert_eq!( - seeds_seen, - (seed..seed + iterations as u64).collect::>(), - "Expected {iterations} iterations starting at seed {seed}" - ); - } -} - -#[test] -fn test_block_with_timeout() { - // Test case: future completes within timeout - TestScheduler::once(async |scheduler| { - let foreground = scheduler.foreground(); - let future = future::ready(42); - let output = foreground.block_with_timeout(Duration::from_millis(100), future); - assert_eq!(output.ok(), Some(42)); - }); - - // Test case: future times out - TestScheduler::once(async |scheduler| { - // Make timeout behavior deterministic by forcing the timeout tick budget to be exactly 0. - // This prevents `block_with_timeout` from making progress via extra scheduler stepping and - // accidentally completing work that we expect to time out. - scheduler.set_timeout_ticks(0..=0); - - let foreground = scheduler.foreground(); - let future = future::pending::<()>(); - let output = foreground.block_with_timeout(Duration::from_millis(50), future); - assert!(output.is_err(), "future should not have finished"); - }); - - // Test case: future makes progress via timer but still times out - let mut results = BTreeSet::new(); - TestScheduler::many(100, async |scheduler| { - // Keep the existing probabilistic behavior here (do not force 0 ticks), since this subtest - // is explicitly checking that some seeds/timeouts can complete while others can time out. - let task = scheduler.background().spawn(async move { - Yield { polls: 10 }.await; - 42 - }); - let output = scheduler - .foreground() - .block_with_timeout(Duration::from_millis(50), task); - results.insert(output.ok()); - }); - assert_eq!( - results.into_iter().collect::>(), - vec![None, Some(42)] - ); - - // Regression test: - // A timed-out future must not be cancelled. The returned future should still be - // pollable to completion later. We also want to ensure time only advances when we - // explicitly advance it (not by yielding). - TestScheduler::once(async |scheduler| { - // Force immediate timeout: the timeout tick budget is 0 so we will not step or - // advance timers inside `block_with_timeout`. - scheduler.set_timeout_ticks(0..=0); - - let background = scheduler.background(); - - // This task should only complete once time is explicitly advanced. - let task = background.spawn({ - let scheduler = scheduler.clone(); - async move { - scheduler.timer(Duration::from_millis(100)).await; - 123 - } - }); - - // This should time out before we advance time enough for the timer to fire. - let timed_out = scheduler - .foreground() - .block_with_timeout(Duration::from_millis(50), task); - assert!( - timed_out.is_err(), - "expected timeout before advancing the clock enough for the timer" - ); - - // Now explicitly advance time and ensure the returned future can complete. - let mut task = timed_out.err().unwrap(); - scheduler.advance_clock(Duration::from_millis(100)); - scheduler.run(); - - let output = scheduler.foreground().block_on(&mut task); - assert_eq!(output, 123); - }); -} - -// When calling block, we shouldn't make progress on foreground-spawned futures with the same session id. -#[test] -fn test_block_does_not_progress_same_session_foreground() { - let mut task2_made_progress_once = false; - TestScheduler::many(1000, async |scheduler| { - let foreground1 = scheduler.foreground(); - let foreground2 = scheduler.foreground(); - - let task1 = foreground1.spawn(async move {}); - let task2 = foreground2.spawn(async move {}); - - foreground1.block_on(async { - scheduler.yield_random().await; - assert!(!task1.is_ready()); - task2_made_progress_once |= task2.is_ready(); - }); - - task1.await; - task2.await; - }); - - assert!( - task2_made_progress_once, - "Expected task from different foreground executor to make progress (at least once)" - ); -} - -struct Yield { - polls: usize, -} - -impl Future for Yield { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - self.polls -= 1; - if self.polls == 0 { - Poll::Ready(()) - } else { - cx.waker().wake_by_ref(); - Poll::Pending - } - } -} - -#[test] -fn test_nondeterministic_wake_detection() { - let config = TestSchedulerConfig { - allow_parking: false, - ..Default::default() - }; - let scheduler = Arc::new(TestScheduler::new(config)); - - // A future that captures its waker and sends it to an external thread - struct SendWakerToThread { - waker_tx: Option>, - } - - impl Future for SendWakerToThread { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - if let Some(tx) = self.waker_tx.take() { - tx.send(cx.waker().clone()).ok(); - } - Poll::Ready(()) - } - } - - let (waker_tx, waker_rx) = std::sync::mpsc::channel::(); - - // Get a waker by running a future that sends it - scheduler.foreground().block_on(SendWakerToThread { - waker_tx: Some(waker_tx), - }); - - // Spawn a real OS thread that will call wake() on the waker - let handle = std::thread::spawn(move || { - if let Ok(waker) = waker_rx.recv() { - // This should trigger the non-determinism detection - waker.wake(); - } - }); - - // Wait for the spawned thread to complete - handle.join().ok(); - - // The non-determinism error should be detected when end_test is called - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - scheduler.end_test(); - })); - assert!(result.is_err(), "Expected end_test to panic"); - let panic_payload = result.unwrap_err(); - let panic_message = panic_payload - .downcast_ref::() - .map(|s| s.as_str()) - .or_else(|| panic_payload.downcast_ref::<&str>().copied()) - .unwrap_or(""); - assert!( - panic_message.contains("Your test is not deterministic"), - "Expected panic message to contain non-determinism error, got: {}", - panic_message - ); -} - -#[test] -fn test_nondeterministic_wake_allowed_with_parking() { - let config = TestSchedulerConfig { - allow_parking: true, - ..Default::default() - }; - let scheduler = Arc::new(TestScheduler::new(config)); - - // A future that captures its waker and sends it to an external thread - struct WakeFromExternalThread { - waker_sent: bool, - waker_tx: Option>, - } - - impl Future for WakeFromExternalThread { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - if !self.waker_sent { - self.waker_sent = true; - if let Some(tx) = self.waker_tx.take() { - tx.send(cx.waker().clone()).ok(); - } - Poll::Pending - } else { - Poll::Ready(()) - } - } - } - - let (waker_tx, waker_rx) = std::sync::mpsc::channel::(); - - // Spawn a real OS thread that will call wake() on the waker - std::thread::spawn(move || { - if let Ok(waker) = waker_rx.recv() { - // With allow_parking, this should NOT panic - waker.wake(); - } - }); - - // This should complete without panicking - scheduler.foreground().block_on(WakeFromExternalThread { - waker_sent: false, - waker_tx: Some(waker_tx), - }); -} - -#[test] -fn test_nondeterministic_waker_drop_detection() { - let config = TestSchedulerConfig { - allow_parking: false, - ..Default::default() - }; - let scheduler = Arc::new(TestScheduler::new(config)); - - // A future that captures its waker and sends it to an external thread - struct SendWakerToThread { - waker_tx: Option>, - } - - impl Future for SendWakerToThread { - type Output = (); - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - if let Some(tx) = self.waker_tx.take() { - tx.send(cx.waker().clone()).ok(); - } - Poll::Ready(()) - } - } - - let (waker_tx, waker_rx) = std::sync::mpsc::channel::(); - - // Get a waker by running a future that sends it - scheduler.foreground().block_on(SendWakerToThread { - waker_tx: Some(waker_tx), - }); - - // Spawn a real OS thread that will drop the waker without calling wake - let handle = std::thread::spawn(move || { - if let Ok(waker) = waker_rx.recv() { - // This should trigger the non-determinism detection on drop - drop(waker); - } - }); - - // Wait for the spawned thread to complete - handle.join().ok(); - - // The non-determinism error should be detected when end_test is called - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - scheduler.end_test(); - })); - assert!(result.is_err(), "Expected end_test to panic"); - let panic_payload = result.unwrap_err(); - let panic_message = panic_payload - .downcast_ref::() - .map(|s| s.as_str()) - .or_else(|| panic_payload.downcast_ref::<&str>().copied()) - .unwrap_or(""); - assert!( - panic_message.contains("Your test is not deterministic"), - "Expected panic message to contain non-determinism error, got: {}", - panic_message - ); -} - -#[test] -fn test_background_priority_scheduling() { - use parking_lot::Mutex; - - // Run many iterations to get statistical significance - let mut high_before_low_count = 0; - let iterations = 100; - - for seed in 0..iterations { - let config = TestSchedulerConfig::with_seed(seed); - let scheduler = Arc::new(TestScheduler::new(config)); - let background = scheduler.background(); - - let execution_order = Arc::new(Mutex::new(Vec::new())); - - // Spawn low priority tasks first - for i in 0..3 { - let order = execution_order.clone(); - background - .spawn_with_priority(Priority::Low, async move { - order.lock().push(format!("low-{}", i)); - }) - .detach(); - } - - // Spawn high priority tasks second - for i in 0..3 { - let order = execution_order.clone(); - background - .spawn_with_priority(Priority::High, async move { - order.lock().push(format!("high-{}", i)); - }) - .detach(); - } - - scheduler.run(); - - // Count how many high priority tasks ran in the first half - let order = execution_order.lock(); - let high_in_first_half = order - .iter() - .take(3) - .filter(|s| s.starts_with("high")) - .count(); - - if high_in_first_half >= 2 { - high_before_low_count += 1; - } - } - - // High priority tasks should tend to run before low priority tasks - // With weights of 60 vs 10, high priority should dominate early execution - assert!( - high_before_low_count > iterations / 2, - "Expected high priority tasks to run before low priority tasks more often. \ - Got {} out of {} iterations", - high_before_low_count, - iterations - ); -} diff --git a/src/svg_renderer.rs b/src/svg_renderer.rs deleted file mode 100644 index f82530f8d1..0000000000 --- a/src/svg_renderer.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::{ - AssetSource, DevicePixels, IsZero, RenderImage, Result, SharedString, Size, - swap_rgba_pa_to_bgra, -}; -use image::Frame; -use resvg::tiny_skia::Pixmap; -use smallvec::SmallVec; -use std::{ - hash::Hash, - sync::{Arc, LazyLock}, -}; - -/// When rendering SVGs, we render them at twice the size to get a higher-quality result. -pub const SMOOTH_SVG_SCALE_FACTOR: f32 = 2.; - -#[derive(Clone, PartialEq, Hash, Eq)] -#[expect(missing_docs)] -pub struct RenderSvgParams { - pub path: SharedString, - pub size: Size, -} - -#[derive(Clone)] -/// A struct holding everything necessary to render SVGs. -pub struct SvgRenderer { - asset_source: Arc, - usvg_options: Arc>, -} - -/// The size in which to render the SVG. -pub enum SvgSize { - /// An absolute size in device pixels. - Size(Size), - /// A scaling factor to apply to the size provided by the SVG. - ScaleFactor(f32), -} - -impl SvgRenderer { - /// Creates a new SVG renderer with the provided asset source. - pub fn new(asset_source: Arc) -> Self { - static FONT_DB: LazyLock> = LazyLock::new(|| { - let mut db = usvg::fontdb::Database::new(); - db.load_system_fonts(); - Arc::new(db) - }); - let default_font_resolver = usvg::FontResolver::default_font_selector(); - let font_resolver = Box::new( - move |font: &usvg::Font, db: &mut Arc| { - if db.is_empty() { - *db = FONT_DB.clone(); - } - default_font_resolver(font, db) - }, - ); - let options = usvg::Options { - font_resolver: usvg::FontResolver { - select_font: font_resolver, - select_fallback: usvg::FontResolver::default_fallback_selector(), - }, - ..Default::default() - }; - Self { - asset_source, - usvg_options: Arc::new(options), - } - } - - /// Renders the given bytes into an image buffer. - pub fn render_single_frame( - &self, - bytes: &[u8], - scale_factor: f32, - to_brga: bool, - ) -> Result, usvg::Error> { - self.render_pixmap( - bytes, - SvgSize::ScaleFactor(scale_factor * SMOOTH_SVG_SCALE_FACTOR), - ) - .map(|pixmap| { - let mut buffer = - image::ImageBuffer::from_raw(pixmap.width(), pixmap.height(), pixmap.take()) - .unwrap(); - - if to_brga { - for pixel in buffer.chunks_exact_mut(4) { - swap_rgba_pa_to_bgra(pixel); - } - } - - let mut image = RenderImage::new(SmallVec::from_const([Frame::new(buffer)])); - image.scale_factor = SMOOTH_SVG_SCALE_FACTOR; - Arc::new(image) - }) - } - - pub(crate) fn render_alpha_mask( - &self, - params: &RenderSvgParams, - bytes: Option<&[u8]>, - ) -> Result, Vec)>> { - anyhow::ensure!(!params.size.is_zero(), "can't render at a zero size"); - - let render_pixmap = |bytes| { - let pixmap = self.render_pixmap(bytes, SvgSize::Size(params.size))?; - - // Convert the pixmap's pixels into an alpha mask. - let size = Size::new( - DevicePixels(pixmap.width() as i32), - DevicePixels(pixmap.height() as i32), - ); - let alpha_mask = pixmap - .pixels() - .iter() - .map(|p| p.alpha()) - .collect::>(); - - Ok(Some((size, alpha_mask))) - }; - - if let Some(bytes) = bytes { - render_pixmap(bytes) - } else if let Some(bytes) = self.asset_source.load(¶ms.path)? { - render_pixmap(&bytes) - } else { - Ok(None) - } - } - - 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 { - SvgSize::Size(size) => size.width.0 as f32 / svg_size.width(), - SvgSize::ScaleFactor(scale) => 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 transform = resvg::tiny_skia::Transform::from_scale(scale, scale); - - resvg::render(&tree, transform, &mut pixmap.as_mut()); - - Ok(pixmap) - } -} diff --git a/tooling/macros/Cargo.lock b/tooling/macros/Cargo.lock deleted file mode 100644 index 7b6fdb598b..0000000000 --- a/tooling/macros/Cargo.lock +++ /dev/null @@ -1,54 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "gpui-ce-macros" -version = "0.1.0" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "syn" -version = "2.0.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/tooling/macros/Cargo.toml b/tooling/macros/Cargo.toml deleted file mode 100644 index 266fd5e61d..0000000000 --- a/tooling/macros/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "gpui-ce-macros" -version = "0.1.0" -edition = "2024" -publish = false -license = "Apache-2.0" -description = "Macros used by gpui" - -[features] -inspector = [] - -[lib] -path = "src/gpui_macros.rs" -proc-macro = true -doctest = true - -[dependencies] -heck = "0.5.0" -proc-macro2 = "1.0.106" -quote = "1.0.45" -syn = "2.0.117" -