diff --git a/Cargo.lock b/Cargo.lock index f44b4aa..cb569c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -363,9 +363,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" +checksum = "515a1f282e33d55983c499d7e9e87082e81cbc32974825bf9032f928392d5844" dependencies = [ "compression-codecs", "compression-core", @@ -456,7 +456,7 @@ dependencies = [ [[package]] name = "async-process" version = "2.5.0" -source = "git+https://github.com/zed-industries/async-process.git?rev=0b6d6713570af61806e1e5cb40e0f757cb93fd9d#0b6d6713570af61806e1e5cb40e0f757cb93fd9d" +source = "git+https://github.com/smol-rs/async-process.git?rev=f4485f156f9294b86a5be37f7236bcf0cf93c76b#f4485f156f9294b86a5be37f7236bcf0cf93c76b" dependencies = [ "async-channel 2.5.0", "async-io", @@ -552,7 +552,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -675,7 +675,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.9", "object 0.37.3", "rustc-demangle", "windows-link 0.2.1", @@ -689,9 +689,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bindgen" -version = "0.71.1" +version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ "bitflags 2.13.1", "cexpr", @@ -709,33 +709,37 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec 0.8.0", + "bit-vec 0.9.1", ] [[package]] name = "bit-set" -version = "0.9.1" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" dependencies = [ - "bit-vec 0.9.1", + "bit-vec 0.10.1", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bit-vec" -version = "0.9.1" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] [[package]] name = "bit_field" @@ -817,9 +821,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel 2.5.0", "async-task", @@ -830,14 +834,28 @@ dependencies = [ [[package]] name = "borsh" -version = "1.8.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" dependencies = [ + "borsh-derive", "bytes", "cfg_aliases", ] +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "bstr" version = "1.13.1" @@ -877,7 +895,7 @@ checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -909,8 +927,9 @@ dependencies = [ [[package]] name = "calloop" -version = "0.14.3" -source = "git+https://github.com/zed-industries/calloop#eb6b4fd17b9af5ecc226546bdd04185391b3e265" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.13.1", "polling", @@ -966,9 +985,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" dependencies = [ "find-msvc-tools", "jobserver", @@ -1118,7 +1137,7 @@ checksum = "f79398230a6e2c08f5c9760610eb6924b52aa9e7950a619602baba59dcbbdbb2" dependencies = [ "bitflags 2.13.1", "block", - "cocoa-foundation 0.2.0", + "cocoa-foundation 0.2.1", "core-foundation 0.10.1", "core-graphics 0.24.0", "foreign-types", @@ -1142,15 +1161,14 @@ dependencies = [ [[package]] name = "cocoa-foundation" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14045fb83be07b5acf1c0884b2180461635b433455fa35d1cd6f17f1450679d" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ "bitflags 2.13.1", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "libc", "objc", ] @@ -1198,9 +1216,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "2fe67f2944eef52fc7b106b8c9450d243a88701a0c065f7f57235e76abaed7df" dependencies = [ "bzip2", "compression-core", @@ -1211,9 +1229,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "concurrent-queue" @@ -1394,6 +1412,12 @@ dependencies = [ "metal", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1438,18 +1462,18 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] @@ -1492,18 +1516,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.16" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1511,27 +1535,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.13" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.22" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1724,7 +1748,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -1777,9 +1801,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "embed-resource" @@ -1790,18 +1814,24 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.4+spec-1.1.0", + "toml 1.1.5+spec-1.1.0", "vswhom", "winreg", ] [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "2a7a45518d2863d18aa47f4a0cf9faec2aa4304cc09df5e41299f276b3ad135e" dependencies = [ "cfg-if", + "core_detect", + "multiversion", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1966,7 +1996,7 @@ dependencies = [ "bit_field", "half", "lebe", - "miniz_oxide", + "miniz_oxide 0.8.9", "num-complex", "pulp", "rayon-core", @@ -2030,9 +2060,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" [[package]] name = "fixedbitset" @@ -2042,12 +2072,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -2109,9 +2140,9 @@ dependencies = [ [[package]] name = "font-types" -version = "0.12.3" +version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" dependencies = [ "bytemuck", ] @@ -2157,7 +2188,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -2186,16 +2217,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "futf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" -dependencies = [ - "mac", - "new_debug_unreachable", -] - [[package]] name = "futures" version = "0.3.34" @@ -2293,7 +2314,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -2395,6 +2416,7 @@ dependencies = [ "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -2582,6 +2604,8 @@ dependencies = [ "util_macros", "uuid", "waker-fn", + "wasm-bindgen", + "web-sys", "web-time", "windows 0.61.3", "zed-font-kit", @@ -2790,7 +2814,7 @@ dependencies = [ "uuid", "windows 0.61.3", "windows-core 0.61.2", - "windows-numerics 0.2.0", + "windows-numerics 0.3.1", "windows-registry", "zed-scap", ] @@ -2899,9 +2923,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -3089,9 +3113,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -3164,15 +3188,15 @@ checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" [[package]] name = "imgref" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -3376,9 +3400,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" dependencies = [ "cfg-if", "futures-util", @@ -3490,9 +3514,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.20" +version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0a00925a9f930d679b6789b721e3a7f9ed110f41b86d2497caa780c3a070a" +checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" dependencies = [ "libc", ] @@ -3550,9 +3574,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", "value-bag", @@ -3593,9 +3617,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" +checksum = "cdfa8785f95e57914ddb35e3b59994aeba6f5e79e9cfd03da1c269f010f36009" dependencies = [ "lyon_path", "num-traits", @@ -3634,21 +3658,15 @@ dependencies = [ [[package]] name = "lyon_tessellation" -version = "1.0.20" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e43b7e44161571868f5c931d12583592c223c5583eef86b08aa02b7048a3552" +checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005" dependencies = [ "float_next_after", "lyon_path", "num-traits", ] -[[package]] -name = "mac" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" - [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -3794,6 +3812,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -3804,6 +3832,34 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edb7f0ff51249dfda9ab96b5823695e15a052dc15074c9dbf3d118afaf2c201" +dependencies = [ + "multiversion-macros", + "target-features", +] + +[[package]] +name = "multiversion-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b093064383341eb3271f42e381cb8f10a01459478446953953c75d24bd339fc0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "target-features", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "29.0.4" @@ -4359,9 +4415,9 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "open" -version = "5.4.1" +version = "5.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9cfef937e9c486488c7e3d949ae31c0f1d06bdacd75b99c086cb35356e30408" +checksum = "7c603ab8300cf18bc3b14146b19fe3dfcc4843ae5a400cd0e7a30b95aa366634" dependencies = [ "is-wsl", "libc", @@ -4375,9 +4431,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "5.3.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] @@ -4624,7 +4680,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4637,7 +4693,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4683,9 +4739,9 @@ checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] @@ -4796,16 +4852,17 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" -source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +version = "1.11.0" +source = "git+https://github.com/proptest-rs/proptest?rev=a6f033cf83adfd55557b86e6065e6f4df054ec70#a6f033cf83adfd55557b86e6065e6f4df054ec70" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.1", + "core_detect", "num-traits", "proptest-macro", - "rand 0.9.5", - "rand_chacha 0.9.0", + "rand 0.10.2", + "rand_chacha 0.10.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -4816,12 +4873,12 @@ dependencies = [ [[package]] name = "proptest-macro" version = "0.5.0" -source = "git+https://github.com/proptest-rs/proptest?rev=3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b#3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b" +source = "git+https://github.com/proptest-rs/proptest?rev=a6f033cf83adfd55557b86e6065e6f4df054ec70#a6f033cf83adfd55557b86e6065e6f4df054ec70" dependencies = [ "convert_case 0.11.0", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", ] [[package]] @@ -4916,9 +4973,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4935,6 +4992,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -4955,6 +5022,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -4973,13 +5050,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -5109,7 +5192,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ "bytemuck", - "font-types 0.12.3", + "font-types 0.12.4", "once_cell", ] @@ -5150,22 +5233,22 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5429,7 +5512,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5526,7 +5609,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5537,7 +5620,7 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5584,7 +5667,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5641,7 +5724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -5691,6 +5774,12 @@ dependencies = [ "quote", ] +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "simplecss" version = "0.2.2" @@ -5743,9 +5832,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "smol" @@ -5838,7 +5927,7 @@ checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -5900,15 +5989,15 @@ dependencies = [ [[package]] name = "sval" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" +checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" [[package]] name = "sval_buffer" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" +checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" dependencies = [ "sval", "sval_ref", @@ -5917,18 +6006,18 @@ dependencies = [ [[package]] name = "sval_dynamic" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" +checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" dependencies = [ "sval", ] [[package]] name = "sval_fmt" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" +checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" dependencies = [ "itoa", "ryu", @@ -5937,9 +6026,9 @@ dependencies = [ [[package]] name = "sval_json" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" +checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" dependencies = [ "itoa", "ryu", @@ -5948,9 +6037,9 @@ dependencies = [ [[package]] name = "sval_nested" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" +checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" dependencies = [ "sval", "sval_buffer", @@ -5959,18 +6048,18 @@ dependencies = [ [[package]] name = "sval_ref" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" +checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" dependencies = [ "sval", ] [[package]] name = "sval_serde" -version = "2.21.1" +version = "2.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" +checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" dependencies = [ "serde_core", "sval", @@ -6017,9 +6106,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.3" +version = "3.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" dependencies = [ "proc-macro2", "quote", @@ -6090,6 +6179,12 @@ dependencies = [ "objc", ] +[[package]] +name = "target-features" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bbb9f3c5c463a01705937a24fdabc5047929ac764b2d5b9cf681c1f5041ed5" + [[package]] name = "tauri-winrt-notification" version = "0.7.3" @@ -6116,13 +6211,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.4.3" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "futf", - "mac", - "utf-8", + "new_debug_unreachable", ] [[package]] @@ -6171,7 +6264,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] [[package]] @@ -6273,9 +6366,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ "tinyvec_macros", ] @@ -6300,9 +6393,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" dependencies = [ "indexmap", "serde_core", @@ -6453,9 +6546,9 @@ dependencies = [ [[package]] name = "tracy-client" -version = "0.18.4" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f6fc3baeac5d86ab90c772e9e30620fc653bf1864295029921a15ef478e6a5" +checksum = "6131992ff3e2cb96f407eb4eb005427ba15e2687260d99089b90e60e33169ce0" dependencies = [ "loom", "once_cell", @@ -6464,9 +6557,9 @@ dependencies = [ [[package]] name = "tracy-client-sys" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f7c95348f20c1c913d72157b3c6dee6ea3e30b3d19502c5a7f6d3f160dacbf" +checksum = "ab27f167b093214c68413a2e0bcd318f0591af475597405ff68138ba0a433379" dependencies = [ "cc", "windows-targets", @@ -6621,12 +6714,6 @@ dependencies = [ "xmlwriter", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -6692,9 +6779,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.24.1" +version = "1.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -6722,9 +6809,9 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "value-bag" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" dependencies = [ "value-bag-serde1", "value-bag-sval2", @@ -6732,9 +6819,9 @@ dependencies = [ [[package]] name = "value-bag-serde1" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" +checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" dependencies = [ "erased-serde", "serde_core", @@ -6743,9 +6830,9 @@ dependencies = [ [[package]] name = "value-bag-sval2" -version = "1.13.2" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" +checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" dependencies = [ "sval", "sval_buffer", @@ -6824,9 +6911,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" dependencies = [ "cfg-if", "once_cell", @@ -6837,9 +6924,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" dependencies = [ "js-sys", "wasm-bindgen", @@ -6847,9 +6934,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6857,22 +6944,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.5", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" dependencies = [ "unicode-ident", ] @@ -6896,6 +6983,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", + "log", "rustix 1.1.4", "scoped-tls", "smallvec", @@ -6988,9 +7076,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.105" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" dependencies = [ "js-sys", "wasm-bindgen", @@ -7437,13 +7525,13 @@ dependencies = [ [[package]] name = "windows-registry" -version = "0.5.3" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -7889,7 +7977,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", "zbus_names", "zvariant", "zvariant_utils", @@ -7961,7 +8049,7 @@ dependencies = [ "core-graphics-helmer-fork", "log", "objc", - "rand 0.8.7", + "rand 0.8.8", "screencapturekit", "screencapturekit-sys", "sysinfo", @@ -8065,9 +8153,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -8076,15 +8164,21 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.4" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zlog" version = "0.1.0" @@ -8148,9 +8242,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e28c25bd8bb8da5a1f3e7065d0c156b9ee9a7973adf78b0e35eaefdf3b1b5c" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" dependencies = [ "endi", "enumflags2", @@ -8164,26 +8258,26 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.14.0" +version = "5.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d496a145685283b67e232bd9e47377f6b60ad9d51e3601b23867f77c42477f96" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.3", + "syn 3.0.5", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "4.0.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "629d80ece222cad20fe0e8741be493c4ab166acf3b85341bdc2cdbcfd8f3c2d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.3", + "syn 3.0.5", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index f81c730..3b2efe9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,7 +34,7 @@ edition = "2024" [workspace.dependencies] collections = { path = "crates/collections", version = "0.1.0" } console = "0.16" -crossbeam = "0.8.4" +crossbeam = "0.8.5" derive_refineable = { path = "crates/refineable/derive_refineable" } dialoguer = { version = "0.12", default-features = false } gpui = { path = "crates/gpui", default-features = false } @@ -53,7 +53,7 @@ mime = "0.3.17" path = { path = "crates/path" } perf = { path = "tooling/perf" } refineable = { path = "crates/refineable" } -rodio = { git = "https://github.com/RustAudio/rodio", rev = "e50e726ddd0292f6ef9de0dda6b90af4ed1fb66a", features = ["wav", "playback", "wav_output", "recording"] } +rodio = { git = "https://github.com/RustAudio/rodio", rev = "685e05765c2aff8fbb51e32cda5b18428d09ffbe", features = ["wav", "playback", "wav_output", "recording"] } scheduler = { path = "crates/scheduler" } sum_tree = { path = "crates/sum_tree" } util = { path = "crates/util" } @@ -66,15 +66,15 @@ ztracing_macro = { path = "crates/ztracing_macro" } # External crates # -accesskit = { version = "0.24.0", features = ["enumn"] } -accesskit_macos = "0.26.0" -accesskit_unix = "0.21.0" +accesskit = { version = "0.24.1", features = ["enumn"] } +accesskit_macos = "0.26.3" +accesskit_unix = "0.21.1" accesskit_windows = "0.33.1" agent-client-protocol = { version = "=2.0.0", features = ["unstable"] } aho-corasick = "1.1" alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "4c129667ce56611becdc82de6e28218c80e2e88f" } any_vec = "0.14" -anyhow = "1.0.86" +anyhow = "1.0.104" ashpd = { version = "0.13", default-features = false, features = [ "async-io", "notification", @@ -84,66 +84,66 @@ ashpd = { version = "0.13", default-features = false, features = [ "trash" ] } async-channel = "2.5.0" -async-compat = "0.2.1" +async-compat = "0.2.5" async-compression = { version = "0.4", features = ["bzip2", "gzip", "futures-io"] } async-dispatcher = "0.1" -async-fs = "2.1" +async-fs = "2.2" async-io = "2.6.0" async-lock = "3.4.2" async-pipe = { git = "https://github.com/zed-industries/async-pipe-rs", rev = "82d00a04211cf4e1236029aa03e6b6ce2a74c553" } -async-recursion = "1.0.0" +async-recursion = "1.1.1" # `unstable` is required to compile async-tar's Windows symlink support. -async-std = { version = "1.12", features = ["unstable"] } +async-std = { version = "1.13", features = ["unstable"] } async-tar = "0.6" async-task = "4.7" async-trait = "0.1" async-tungstenite = "0.31.0" async-process = "2.5.0" async_zip = { version = "0.0.18", features = ["deflate", "deflate64"] } -aws-config = { version = "1.8.10", features = ["behavior-version-latest"] } -aws-credential-types = { version = "1.2.8", features = [ +aws-config = { version = "1.12.0", features = ["behavior-version-latest"] } +aws-credential-types = { version = "1.3.0", features = [ "hardcoded-credentials", ] } -aws-sdk-bedrockruntime = { version = "1.112.0", features = [ +aws-sdk-bedrockruntime = { version = "1.143.0", features = [ "behavior-version-latest", ] } -aws-sigv4 = { version = "1.4.0", features = ["http1"] } -aws-smithy-runtime-api = { version = "1.9.2", features = ["http-1x", "client"] } -aws-smithy-types = { version = "1.3.4", features = ["http-body-1-x"] } +aws-sigv4 = { version = "1.5.1", features = ["http1"] } +aws-smithy-runtime-api = { version = "1.16.0", features = ["http-1x", "client"] } +aws-smithy-types = { version = "1.6.3", features = ["http-body-1-x"] } backtrace = "0.3" base64 = "0.22" -bitflags = "2.6.0" +bitflags = "2.13.1" block = "0.1" block2 = "0.6" -brotli = "8.0.2" -bytes = "1.0" +brotli = "8.0.4" +bytes = "1.12" cargo_metadata = "0.19" cargo_toml = "0.21" brush-parser = "0.3" cbindgen = { version = "0.28.0", default-features = false } -cfg-if = "1.0.3" +cfg-if = "1.0.4" chardetng = "0.1" chrono = { version = "0.4", features = ["serde"] } ciborium = "0.2" -circular-buffer = "1.0" -clap = { version = "4.4", features = ["derive", "wrap_help"] } -clap_complete = { version = "4.4" } -clap_complete_nushell = { version = "4.4" } +circular-buffer = "1.2" +clap = { version = "4.6", features = ["derive", "wrap_help"] } +clap_complete = { version = "4.6" } +clap_complete_nushell = { version = "4.6" } cocoa = "=0.26.0" cocoa-foundation = "=0.2.0" const_format = "0.2" convert_case = "0.11.0" core-foundation = "0.10" -core-foundation-sys = "0.8.6" +core-foundation-sys = "0.8.7" core-graphics = "0.24" core-text = "21" core-video = { version = "0.5.2", features = ["metal"] } cpal = "0.17" crash-handler = "0.7" criterion = { version = "0.5", features = ["html_reports"] } -ctor = "1.0.12" +ctor = "1.0.13" dap-types = { git = "https://github.com/zed-industries/dap-types", rev = "1b461b310481d01e02b2603c16d7144b926339f8" } -dashmap = "6.0" +dashmap = "6.2" derive_more = { version = "2.1.1", features = [ "add", "add_assign", @@ -156,11 +156,11 @@ derive_more = { version = "2.1.1", features = [ "not", ] } dirs = "6.0" -documented = "0.9.1" -dotenvy = "0.15.0" +documented = "0.9.2" +dotenvy = "0.15.7" dunce = "1.0" ec4rs = { version = "1.2", features = ["allow-empty-values"] } -emojis = "0.6.1" +emojis = "0.6.4" env_logger = "0.11" encoding_rs = "0.8" etagere = "0.2" @@ -169,29 +169,29 @@ fancy-regex = "0.18.0" fork = "0.4.0" flume = "0.12" foreign-types = "0.5" -futures = "0.3.32" +futures = "0.3.34" futures-concurrency = "7.7.1" futures-lite = "1.13" -futures-util = "0.3.32" -gh-workflow = { git = "https://github.com/zed-industries/gh-workflow", rev = "37f3c0575d379c218a9c455ee67585184e40d43f" } +futures-util = "0.3.34" +gh-workflow = { git = "https://github.com/tailcallhq/gh-workflow", rev = "fcb9540827b969e20b33ec3c90f74e17ba2a95e5" } globset = "0.4" -heapless = "0.9.2" -handlebars = "4.3" +heapless = "0.9.3" +handlebars = "4.5" heck = "0.5" hdrhistogram = "7" heed = { version = "0.21.0", features = ["read-txn-no-tls"] } hex = "0.4.3" -human_bytes = "0.4.1" +human_bytes = "0.4.3" html5ever = "0.27.0" -http = "1.1" -http-body = "1.0" +http = "1.5" +http-body = "1.1" httparse = "1.10" -idna = "1.0" -ignore = "0.4.22" +idna = "1.1" +ignore = "0.4.33" # image's default features minus "avif", which only adds an encoder (rav1e); # decoding AVIF would additionally need the non-default "avif-native" feature. -image = { version = "0.25.1", default-features = false, features = [ +image = { version = "0.25.10", default-features = false, features = [ "bmp", "dds", "exr", @@ -209,29 +209,29 @@ image = { version = "0.25.1", default-features = false, features = [ "webp", ] } imara-diff = "0.2.0" -indexmap = { version = "2.7.0", features = ["serde"] } +indexmap = { version = "2.14.2", features = ["serde"] } indoc = "2" -inventory = "0.3.19" +inventory = "0.3.24" itertools = "0.14.0" -jsonschema = "0.37.0" -jsonwebtoken = "10.0" -jupyter-protocol = "1.4.0" +jsonschema = "0.37.4" +jsonwebtoken = "10.4" +jupyter-protocol = "1.5.0" jupyter-websocket-client = "1.1.0" libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" -libwebrtc = "0.3.26" -livekit = { version = "0.7.32", features = ["tokio", "rustls-tls-native-roots"] } -log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } +libwebrtc = "0.3.46" +livekit = { version = "0.7.53", features = ["tokio", "rustls-tls-native-roots"] } +log = { version = "0.4.34", features = ["kv_unstable_serde", "serde"] } lru = "0.16" lsp-types = { git = "https://github.com/zed-industries/lsp-types", rev = "f4dfa89a21ca35cd929b70354b1583fabae325f8" } mach2 = "0.5" markup5ever_rcdom = "0.3.0" metal = "0.33" minidumper = "0.9" -moka = { version = "0.12.10", features = ["sync"] } +moka = { version = "0.12.16", features = ["sync"] } nanoid = "0.4" -nbformat = "1.2.0" +nbformat = "1.2.2" nix = "0.29" notify-rust = "4" nucleo = "0.5" @@ -274,44 +274,44 @@ objc2-foundation = { version = "=0.3.2", default-features = false, features = [ "std", ] } objc2-user-notifications = "0.3" -open = "5.0.0" -ordered-float = "2.1.1" -palette = { version = "0.7.5", default-features = false, features = ["std"] } -parking_lot = "0.12.1" -partial-json-fixer = "0.5.3" +open = "5.4.3" +ordered-float = "2.10.1" +palette = { version = "0.7.7", default-features = false, features = ["std"] } +parking_lot = "0.12.5" +partial-json-fixer = "0.5.5" parse_int = "0.9" pathfinder_geometry = "0.5" -pciid-parser = "0.8.0" +pciid-parser = "0.8.1" pathdiff = "0.2" percent-encoding = "2.3.2" -pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } -pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "bb8e04607b96a3865d6aa4bb2a5a5a82ce05b5f0" } +pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } +pet-virtualenv = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "4e523bad8bb9be8a84c01800a3e400a6a771cf8e" } piper = "0.2" portable-pty = "0.9.0" postage = { version = "0.5", features = ["futures-traits"] } -pretty_assertions = { version = "1.3.0", features = ["unstable"] } -proc-macro2 = "1.0.93" +pretty_assertions = { version = "1.4.1", features = ["unstable"] } +proc-macro2 = "1.0.107" profiling = "1" # replace this with main when #635 is merged -proptest = { git = "https://github.com/proptest-rs/proptest", rev = "3dca198a8fef1b32e3a66f1e1897c955b4dc5b5b", features = ["attr-macro"] } +proptest = { git = "https://github.com/proptest-rs/proptest", rev = "a6f033cf83adfd55557b86e6065e6f4df054ec70", features = ["attr-macro"] } proptest-derive = "0.8.0" proxyvars = "0.2" prost = "0.9" prost-build = "0.9" prost-types = "0.9" pollster = "0.4.0" -pulldown-cmark = { version = "0.13.0", default-features = false } +pulldown-cmark = { version = "0.13.4", default-features = false } quick-xml = "0.38" -quote = "1.0.9" +quote = "1.0.47" rand = "0.9" -rayon = "1.8" +rayon = "1.12" raw-window-handle = "0.6" -regex = "1.5" +regex = "1.13" # WARNING: If you change this, you must also publish a new version of zed-reqwest to crates.io reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "c15662463bda39148ba154100dd44d3fba5873a4", default-features = false, features = [ "charset", @@ -328,49 +328,49 @@ resvg = { version = "0.46.0", default-features = false, features = [ "memmap-fonts", "raster-images", ] } -rsa = "0.9.6" -runtimelib = { version = "1.4.0", default-features = false, features = [ +rsa = "0.9.10" +runtimelib = { version = "1.6.0", default-features = false, features = [ "async-dispatcher-runtime", "aws-lc-rs" ] } -rust-embed = { version = "8.11", features = ["include-exclude"] } -rustc-hash = "2.1.0" +rust-embed = { version = "8.12", features = ["include-exclude"] } +rustc-hash = "2.1.3" rustix = { version = "1.1", features = ["fs"] } -rustls = { version = "0.23.26" } -rustls-platform-verifier = "0.5.0" +rustls = { version = "0.23.44" } +rustls-platform-verifier = "0.5.3" # WARNING: If you change this, you must also publish a new version of zed-scap to crates.io scap = { git = "https://github.com/zed-industries/scap", rev = "4afea48c3b002197176fb19cd0f9b180dd36eaac", default-features = false, package = "zed-scap", version = "0.0.8-zed" } -schemars = { version = "1.0", features = ["indexmap2"] } +schemars = { version = "1.2", features = ["indexmap2"] } seccompiler = "0.5" 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"] } +serde = { version = "1.0.229", features = ["derive", "rc"] } +serde_json = { version = "1.0.151", features = ["preserve_order", "raw_value"] } serde_yaml_ng = "0.10" serde_json_lenient = { version = "0.2", features = [ "preserve_order", "raw_value", ] } serde_yaml = "0.9.34" -serde_path_to_error = "0.1.17" +serde_path_to_error = "0.1.20" serde_urlencoded = "0.7" sha2 = "0.10" shellexpand = "3.1" shlex = "1.3.0" simplelog = "0.12.2" -slotmap = "1.0.6" -smallvec = { version = "1.6", features = ["union", "const_new"] } +slotmap = "1.1.1" +smallvec = { version = "1.16", features = ["union", "const_new"] } smol = "2.0" sqlformat = "0.2" stacksafe = "1.0" streaming-iterator = "0.1" strsim = "0.11" strum = { version = "0.27.2", features = ["derive"] } -swash = "0.2.6" -syn = { version = "2.0.101", features = ["full", "extra-traits", "visit-mut"] } -sys-locale = "0.3.1" -sysinfo = "0.37.0" +swash = "0.2.10" +syn = { version = "2.0.119", features = ["full", "extra-traits", "visit-mut"] } +sys-locale = "0.3.2" +sysinfo = "0.37.2" take-until = "0.2.0" -tempfile = "3.20.0" -thiserror = "2.0.12" +tempfile = "3.27.0" +thiserror = "2.0.20" time = { version = "0.3", features = [ "macros", "parsing", @@ -388,14 +388,14 @@ toml_edit = { version = "0.22", default-features = false, features = [ "serde", ] } tower-http = "0.4.4" -tree-sitter = "0.26.9" +tree-sitter = "0.26.13" tree-sitter-bash = "0.25.1" -tree-sitter-c = "0.24.1" +tree-sitter-c = "0.24.2" tree-sitter-cpp = { git = "https://github.com/tree-sitter/tree-sitter-cpp", rev = "5cb9b693cfd7bfacab1d9ff4acac1a4150700609" } tree-sitter-css = "0.23" tree-sitter-diff = "0.1.0" tree-sitter-elixir = "0.3" -tree-sitter-embedded-template = "0.23.0" +tree-sitter-embedded-template = "0.23.2" tree-sitter-gitcommit = { git = "https://github.com/zed-industries/tree-sitter-git-commit", rev = "88309716a69dd13ab83443721ba6e0b491d37ee9" } tree-sitter-go = "0.25" tree-sitter-go-mod = { git = "https://github.com/camdencheek/tree-sitter-go-mod", rev = "2e886870578eeba1927a2dc4bd2e2b3f598c5f9a", package = "tree-sitter-gomod" } @@ -411,19 +411,19 @@ tree-sitter-ruby = "0.23" tree-sitter-rust = "0.24.2" tree-sitter-typescript = { git = "https://github.com/zed-industries/tree-sitter-typescript", rev = "e2c53597d6a5d9cf7bbe8dccde576fe1e46c5899" } # https://github.com/tree-sitter/tree-sitter-typescript/pull/347 tree-sitter-yaml = { git = "https://github.com/zed-industries/tree-sitter-yaml", rev = "baff0b51c64ef6a1fb1f8390f3ad6015b83ec13a" } -tracing = "0.1.40" -unicase = "2.6" +tracing = "0.1.44" +unicase = "2.9" unicode-bidi = { version = "0.3.18", default-features = false, features = [ "hardcoded-data", ] } -unicode-script = "0.5.7" -unicode-segmentation = "1.10" +unicode-script = "0.5.8" +unicode-segmentation = "1.13" unicode-width = "0.2" -unindent = "0.2.0" -url = "2.2" -urlencoding = "2.1.2" +unindent = "0.2.4" +url = "2.5" +urlencoding = "2.1.3" usvg = { version = "0.46.0", default-features = false } -uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } +uuid = { version = "1.26.0", features = ["v4", "v5", "v7", "serde"] } vte = { version = "0.15.0", features = ["ansi"] } walkdir = "2.5" wasm-encoder = "0.252" @@ -439,19 +439,19 @@ wasmtime = { version = "36", default-features = false, features = [ ] } wasmtime-wasi = "36" wax = "0.7" -which = "6.0.0" -wasm-bindgen = "0.2.120" +which = "6.0.3" +wasm-bindgen = "0.2.128" wasm-bindgen-futures = "0.4" js-sys = "0.3" console_error_panic_hook = "0.1.7" web-time = "1.1.0" -webrtc-sys = "0.3.23" +webrtc-sys = "0.3.43" wgpu = "29.0.4" windows-core = "0.61" -windows-registry = "0.6.0" +windows-registry = "0.6.1" yaml-rust2 = "0.8" -yawc = { git = "https://github.com/zed-industries/yawc", rev = "71a452f551cac178367eaac5d7418a09afa1f3a2", version = "0.3.3" } -zeroize = "1.8" +yawc = { git = "https://github.com/infinitefield/yawc", rev = "0f11f4193f36bbd93d18b480fb632d4ff72cf35f", version = "0.3.4" } +zeroize = "1.9" zstd = "0.11" @@ -564,6 +564,5 @@ nonminimal_bool = "allow" [patch.crates-io] -async-process = { git = "https://github.com/zed-industries/async-process.git", rev = "0b6d6713570af61806e1e5cb40e0f757cb93fd9d" } -async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } -calloop = { git = "https://github.com/zed-industries/calloop" } +async-process = { git = "https://github.com/smol-rs/async-process.git", rev = "f4485f156f9294b86a5be37f7236bcf0cf93c76b" } +async-task = { git = "https://github.com/smol-rs/async-task.git", rev = "b4486cd71e4e94fbda54ce6302444de14f4d190e" } \ No newline at end of file diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 96905d0..f4ba4aa 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -26,7 +26,9 @@ test-support = [ "x11", "proptest", ] -bench = ["test-support", "profiler", "dep:criterion"] +bench-support = ["profiler", "dep:criterion"] +# Preserve the published feature name while consumers migrate to `bench-support`. +bench = ["bench-support"] inspector = ["gpui_macros/inspector"] leak-detection = ["backtrace"] wayland = [] @@ -38,6 +40,7 @@ screen-capture = [ ] windows-manifest = ["dep:embed-resource"] profiler = ["dep:hdrhistogram"] +stacker = ["dep:stacksafe"] [lib] path = "src/gpui.rs" @@ -64,8 +67,8 @@ image.workspace = true inventory.workspace = true itertools.workspace = true log.workspace = true -num_cpus = "1.13" -parking = "2.0.0" +num_cpus = "1.17" +parking = "2.2.1" parking_lot.workspace = true postage.workspace = true proptest = { workspace = true, optional = true } @@ -87,7 +90,7 @@ serde_json.workspace = true slotmap.workspace = true smallvec.workspace = true async-channel.workspace = true -stacksafe.workspace = true +stacksafe = { workspace = true, optional = true } strum.workspace = true sum_tree.workspace = true taffy = "=0.13.0" @@ -97,8 +100,8 @@ 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" +pin-project = "1.1.13" +spin = "0.10.1" pollster.workspace = true url.workspace = true uuid.workspace = true @@ -143,11 +146,15 @@ unicode-segmentation = { workspace = true } http_client = { workspace = true, features = ["test-support"] } proptest = { workspace = true } +[target.'cfg(target_family = "wasm")'.dev-dependencies] +wasm-bindgen = { workspace = true } +web-sys = { version = "0.3", features = ["console"] } + [build-dependencies] embed-resource = { version = "3.0", optional = true } [target.'cfg(target_os = "macos")'.build-dependencies] -bindgen = "0.71" +bindgen = "0.72" [package.metadata.cargo-shear] ignored = [ diff --git a/crates/gpui/examples/a11y.rs b/crates/gpui/examples/a11y.rs index 6e37354..ff389f3 100644 --- a/crates/gpui/examples/a11y.rs +++ b/crates/gpui/examples/a11y.rs @@ -30,6 +30,9 @@ //! - "2. Run tests" //! - "3. Ship it" +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ AccessibleAction, App, Bounds, Context, FocusHandle, KeyBinding, Role, SharedString, Toggled, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, text, @@ -226,6 +229,9 @@ impl Render for A11yDemo { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/crates/gpui/examples/anchor.rs b/crates/gpui/examples/anchor.rs index 7aa9bb4..b4b280b 100644 --- a/crates/gpui/examples/anchor.rs +++ b/crates/gpui/examples/anchor.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Anchor, AnchoredPositionMode, App, Axis, Bounds, Context, Half as _, InteractiveElement, ParentElement, Pixels, Point, Render, SharedString, Size, Window, WindowBounds, WindowOptions, @@ -168,6 +171,9 @@ impl Render for AnchorDemo { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( diff --git a/crates/gpui/examples/animation.rs b/crates/gpui/examples/animation.rs index 4e09d15..e055b7d 100644 --- a/crates/gpui/examples/animation.rs +++ b/crates/gpui/examples/animation.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::time::Duration; use anyhow::Result; @@ -306,6 +309,9 @@ impl Render for AnimationExample { fn run_example() { application().with_assets(Assets {}).run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( None, diff --git a/crates/gpui/examples/data_table.rs b/crates/gpui/examples/data_table.rs index b3f8737..2bee476 100644 --- a/crates/gpui/examples/data_table.rs +++ b/crates/gpui/examples/data_table.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{ops::Range, rc::Rc, time::Duration}; use gpui::{ @@ -451,6 +454,9 @@ impl Render for DataTable { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { focus: true, diff --git a/crates/gpui/examples/drag_drop.rs b/crates/gpui/examples/drag_drop.rs index b233bc4..8f038ef 100644 --- a/crates/gpui/examples/drag_drop.rs +++ b/crates/gpui/examples/drag_drop.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Half, Hsla, Pixels, Point, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -125,6 +128,9 @@ impl Render for DragDrop { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/example_support/fonts.rs b/crates/gpui/examples/example_support/fonts.rs new file mode 100644 index 0000000..39c3be1 --- /dev/null +++ b/crates/gpui/examples/example_support/fonts.rs @@ -0,0 +1,44 @@ +#[cfg(target_family = "wasm")] +use std::borrow::Cow; + +use gpui::App; + +#[cfg(target_family = "wasm")] +pub fn load_fonts(cx: &App) -> bool { + let fonts = [ + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Italic.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBold.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/ibm-plex-sans/IBMPlexSans-SemiBoldItalic.ttf") + .as_slice(), + ), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/lilex/Lilex-Regular.ttf").as_slice(), + ), + Cow::Borrowed(include_bytes!("../../../../assets/fonts/lilex/Lilex-Bold.ttf").as_slice()), + Cow::Borrowed(include_bytes!("../../../../assets/fonts/lilex/Lilex-Italic.ttf").as_slice()), + Cow::Borrowed( + include_bytes!("../../../../assets/fonts/lilex/Lilex-BoldItalic.ttf").as_slice(), + ), + ]; + if let Err(error) = cx.text_system().add_fonts(fonts.into()) { + web_sys::console::error_1(&format!("failed to load application fonts: {error:#}").into()); + return false; + } + true +} + +#[cfg(not(target_family = "wasm"))] +pub fn load_fonts(_cx: &App) -> bool { + true +} diff --git a/crates/gpui/examples/focus_visible.rs b/crates/gpui/examples/focus_visible.rs index 02a171d..c5b3d5d 100644 --- a/crates/gpui/examples/focus_visible.rs +++ b/crates/gpui/examples/focus_visible.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, @@ -196,6 +199,9 @@ impl Render for Example { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/crates/gpui/examples/gif_viewer.rs b/crates/gpui/examples/gif_viewer.rs index 59fb8d3..80b56aa 100644 --- a/crates/gpui/examples/gif_viewer.rs +++ b/crates/gpui/examples/gif_viewer.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{App, Context, Render, Window, WindowOptions, div, img, prelude::*}; use gpui_platform::application; use std::path::PathBuf; @@ -27,6 +30,9 @@ impl Render for GifViewer { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let gif_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/image/black-cat-typing.gif"); diff --git a/crates/gpui/examples/gradient.rs b/crates/gpui/examples/gradient.rs index 97321f1..df1398f 100644 --- a/crates/gpui/examples/gradient.rs +++ b/crates/gpui/examples/gradient.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, ColorSpace, Context, Half, Render, Window, WindowOptions, canvas, div, linear_color_stop, linear_gradient, point, prelude::*, px, size, @@ -247,6 +250,9 @@ impl Render for GradientViewer { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { focus: true, diff --git a/crates/gpui/examples/grid_layout.rs b/crates/gpui/examples/grid_layout.rs index 9c6d723..51f9b13 100644 --- a/crates/gpui/examples/grid_layout.rs +++ b/crates/gpui/examples/grid_layout.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Hsla, Window, WindowBounds, WindowOptions, container_query, div, prelude::*, px, rgb, size, @@ -63,6 +66,9 @@ impl Render for HolyGrailExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/hello_world.rs b/crates/gpui/examples/hello_world.rs index aa67ada..d18cb04 100644 --- a/crates/gpui/examples/hello_world.rs +++ b/crates/gpui/examples/hello_world.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, SharedString, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -91,6 +94,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/image/image.rs b/crates/gpui/examples/image/image.rs index 7e67d04..447e79d 100644 --- a/crates/gpui/examples/image/image.rs +++ b/crates/gpui/examples/image/image.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "../example_support/fonts.rs"] +mod example_support; + use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -157,6 +160,9 @@ fn run_example() { base: manifest_dir.join("examples"), }) .run(move |cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.activate(true); cx.on_action(|_: &Quit, cx| cx.quit()); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); diff --git a/crates/gpui/examples/image_gallery.rs b/crates/gpui/examples/image_gallery.rs index 76f6ae6..8473a1a 100644 --- a/crates/gpui/examples/image_gallery.rs +++ b/crates/gpui/examples/image_gallery.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use futures::FutureExt; use gpui::{ App, AppContext, Asset as _, AssetLogger, Bounds, ClickEvent, Context, ElementId, Entity, @@ -252,6 +255,9 @@ fn run_example() { let app = gpui_platform::single_threaded_web(); app.run(move |cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.activate(true); cx.on_action(|_: &Quit, cx| cx.quit()); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); diff --git a/crates/gpui/examples/image_loading.rs b/crates/gpui/examples/image_loading.rs index c2aab95..b682891 100644 --- a/crates/gpui/examples/image_loading.rs +++ b/crates/gpui/examples/image_loading.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{path::Path, sync::Arc, time::Duration}; use gpui::{ @@ -196,6 +199,9 @@ impl Render for ImageLoadingExample { fn run_example() { application().with_assets(Assets {}).run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(Bounds::centered( None, diff --git a/crates/gpui/examples/input.rs b/crates/gpui/examples/input.rs index 370e27d..eadc2d2 100644 --- a/crates/gpui/examples/input.rs +++ b/crates/gpui/examples/input.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::ops::Range; use gpui::{ @@ -696,6 +699,9 @@ impl Render for InputExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.bind_keys([ KeyBinding::new("backspace", Backspace, None), diff --git a/crates/gpui/examples/list_example.rs b/crates/gpui/examples/list_example.rs index 7aeff7c..542b7e3 100644 --- a/crates/gpui/examples/list_example.rs +++ b/crates/gpui/examples/list_example.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, ListAlignment, ListState, Render, Window, WindowBounds, WindowOptions, div, list, prelude::*, px, rgb, size, @@ -143,6 +146,9 @@ impl Render for BottomListDemo { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(400.), px(500.)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/mouse_pressure.rs b/crates/gpui/examples/mouse_pressure.rs index d3519b2..3470bf1 100644 --- a/crates/gpui/examples/mouse_pressure.rs +++ b/crates/gpui/examples/mouse_pressure.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, MousePressureEvent, PressureStage, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -48,6 +51,9 @@ impl MousePressureExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( diff --git a/crates/gpui/examples/move_entity_between_windows.rs b/crates/gpui/examples/move_entity_between_windows.rs index eaefabf..e0f1646 100644 --- a/crates/gpui/examples/move_entity_between_windows.rs +++ b/crates/gpui/examples/move_entity_between_windows.rs @@ -8,6 +8,9 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::time::Duration; use gpui::{ @@ -128,6 +131,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/on_window_close_quit.rs b/crates/gpui/examples/on_window_close_quit.rs index 347401c..d61e23e 100644 --- a/crates/gpui/examples/on_window_close_quit.rs +++ b/crates/gpui/examples/on_window_close_quit.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FocusHandle, KeyBinding, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -39,6 +42,9 @@ impl Render for ExampleWindow { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } 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/crates/gpui/examples/opacity.rs b/crates/gpui/examples/opacity.rs index 413c40c..c2af342 100644 --- a/crates/gpui/examples/opacity.rs +++ b/crates/gpui/examples/opacity.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{fs, path::PathBuf}; use anyhow::Result; @@ -163,6 +166,9 @@ fn run_example() { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), }) .run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.0), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/ownership_post.rs b/crates/gpui/examples/ownership_post.rs index a4421b9..04a0b6f 100644 --- a/crates/gpui/examples/ownership_post.rs +++ b/crates/gpui/examples/ownership_post.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{App, Context, Entity, EventEmitter, prelude::*}; use gpui_platform::application; @@ -15,6 +18,9 @@ impl EventEmitter for Counter {} fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let counter: Entity = cx.new(|_cx| Counter { count: 0 }); let subscriber = cx.new(|cx: &mut Context| { cx.subscribe(&counter, |subscriber, _emitter, event, _cx| { diff --git a/crates/gpui/examples/painting.rs b/crates/gpui/examples/painting.rs index 41cbcb2..3d89b0f 100644 --- a/crates/gpui/examples/painting.rs +++ b/crates/gpui/examples/painting.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Background, Bounds, ColorSpace, Context, MouseDownEvent, Path, PathBuilder, PathStyle, Pixels, Point, Render, StrokeOptions, Window, WindowOptions, canvas, div, linear_color_stop, @@ -443,6 +446,9 @@ impl Render for PaintingViewer { fn run_example() { application().run(|cx| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { focus: true, diff --git a/crates/gpui/examples/paths_bench.rs b/crates/gpui/examples/paths_bench.rs index 4e12f1e..236e70e 100644 --- a/crates/gpui/examples/paths_bench.rs +++ b/crates/gpui/examples/paths_bench.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Background, Bounds, ColorSpace, Context, Path, PathBuilder, Pixels, Render, TitlebarOptions, Window, WindowBounds, WindowOptions, canvas, div, linear_color_stop, linear_gradient, point, @@ -73,6 +76,9 @@ impl Render for PaintingViewer { fn run_example() { application().run(|cx| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window( WindowOptions { titlebar: Some(TitlebarOptions { diff --git a/crates/gpui/examples/pattern.rs b/crates/gpui/examples/pattern.rs index 3113d39..7d2e98d 100644 --- a/crates/gpui/examples/pattern.rs +++ b/crates/gpui/examples/pattern.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, AppContext, Bounds, Context, Window, WindowBounds, WindowOptions, div, linear_color_stop, linear_gradient, pattern_slash, prelude::*, px, rgb, size, @@ -103,6 +106,9 @@ impl Render for PatternExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/popover.rs b/crates/gpui/examples/popover.rs index e4f0ac0..7678a6a 100644 --- a/crates/gpui/examples/popover.rs +++ b/crates/gpui/examples/popover.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ Anchor, App, Context, Div, Hsla, Stateful, Window, WindowOptions, anchored, deferred, div, prelude::*, px, @@ -167,6 +170,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.open_window(WindowOptions::default(), |_, cx| { cx.new(|_| HelloWorld { open: false, diff --git a/crates/gpui/examples/scrollable.rs b/crates/gpui/examples/scrollable.rs index 39864c8..c817956 100644 --- a/crates/gpui/examples/scrollable.rs +++ b/crates/gpui/examples/scrollable.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, size}; use gpui_platform::application; @@ -46,6 +49,9 @@ impl Render for Scrollable { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/set_menus.rs b/crates/gpui/examples/set_menus.rs index a07f3c3..508a60e 100644 --- a/crates/gpui/examples/set_menus.rs +++ b/crates/gpui/examples/set_menus.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Context, Global, Menu, MenuItem, SharedString, SystemMenuType, Window, WindowOptions, actions, div, prelude::*, @@ -24,6 +27,9 @@ impl Render for SetMenus { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.set_global(AppState::new()); // Bring the menu bar to the foreground (so you can see the menu bar) diff --git a/crates/gpui/examples/shadow.rs b/crates/gpui/examples/shadow.rs index 375ccac..2d8dc09 100644 --- a/crates/gpui/examples/shadow.rs +++ b/crates/gpui/examples/shadow.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, BoxShadow, Context, Div, SharedString, Window, WindowBounds, WindowOptions, div, hsla, prelude::*, px, relative, rgb, size, @@ -589,6 +592,9 @@ impl Render for Shadow { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(1000.0), px(800.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/svg/svg.rs b/crates/gpui/examples/svg/svg.rs index e9d2341..c5ac9b4 100644 --- a/crates/gpui/examples/svg/svg.rs +++ b/crates/gpui/examples/svg/svg.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "../example_support/fonts.rs"] +mod example_support; + use std::fs; use std::path::PathBuf; @@ -76,6 +79,9 @@ fn run_example() { base: PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples"), }) .run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/system_notifications.rs b/crates/gpui/examples/system_notifications.rs index 70071dc..faf6919 100644 --- a/crates/gpui/examples/system_notifications.rs +++ b/crates/gpui/examples/system_notifications.rs @@ -2,6 +2,9 @@ //! Demonstrates posting, replacing, dismissing, and responding to system notifications. +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Div, SharedString, Stateful, SystemNotification, SystemNotificationAction, SystemNotificationResponse, Window, WindowBounds, WindowOptions, div, @@ -97,6 +100,9 @@ fn button(id: &'static str, label: &'static str) -> Stateful
{ fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.set_app_identity("dev.zed.gpui.system-notifications", "GPUI Notifications"); let view = cx.new(|_| SystemNotificationExample { diff --git a/crates/gpui/examples/tab_stop.rs b/crates/gpui/examples/tab_stop.rs index 3fec59a..cc7c34a 100644 --- a/crates/gpui/examples/tab_stop.rs +++ b/crates/gpui/examples/tab_stop.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Div, ElementId, FocusHandle, KeyBinding, SharedString, Stateful, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, size, @@ -182,6 +185,9 @@ impl Render for Example { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ KeyBinding::new("tab", Tab, None), KeyBinding::new("shift-tab", TabPrev, None), diff --git a/crates/gpui/examples/testing.rs b/crates/gpui/examples/testing.rs index f6e1579..76e3b77 100644 --- a/crates/gpui/examples/testing.rs +++ b/crates/gpui/examples/testing.rs @@ -1,4 +1,5 @@ #![cfg_attr(target_family = "wasm", no_main)] + //! Example demonstrating GPUI's testing infrastructure. //! //! When run normally, this displays an interactive counter window. @@ -7,6 +8,9 @@ //! Run the app: cargo run -p gpui --example testing //! Run tests: cargo test -p gpui --example testing --features test-support +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FocusHandle, Focusable, Render, Task, Window, WindowBounds, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -179,6 +183,9 @@ impl Render for Counter { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.bind_keys([ gpui::KeyBinding::new("up", Increment, Some("Counter")), gpui::KeyBinding::new("down", Decrement, Some("Counter")), diff --git a/crates/gpui/examples/text.rs b/crates/gpui/examples/text.rs index 418ebaa..a1244eb 100644 --- a/crates/gpui/examples/text.rs +++ b/crates/gpui/examples/text.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use std::{ borrow::Cow, ops::{Deref, DerefMut}, @@ -348,6 +351,9 @@ impl Render for TextExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } cx.set_menus(vec![Menu { name: "GPUI Typography".into(), disabled: false, diff --git a/crates/gpui/examples/text_layout.rs b/crates/gpui/examples/text_layout.rs index 4bb930e..07f560a 100644 --- a/crates/gpui/examples/text_layout.rs +++ b/crates/gpui/examples/text_layout.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FontStyle, FontWeight, StyledText, Window, WindowBounds, WindowOptions, div, prelude::*, px, size, @@ -85,6 +88,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/text_wrapper.rs b/crates/gpui/examples/text_wrapper.rs index 3750c3e..51e58fc 100644 --- a/crates/gpui/examples/text_wrapper.rs +++ b/crates/gpui/examples/text_wrapper.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, TextOverflow, Window, WindowBounds, WindowOptions, div, prelude::*, px, size, @@ -112,6 +115,9 @@ impl Render for HelloWorld { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/tree.rs b/crates/gpui/examples/tree.rs index 9c4ea2c..891be47 100644 --- a/crates/gpui/examples/tree.rs +++ b/crates/gpui/examples/tree.rs @@ -1,6 +1,10 @@ #![cfg_attr(target_family = "wasm", no_main)] + //! Renders a div with deep children hierarchy. This example is useful to exemplify that Zed can //! handle deep hierarchies (even though it cannot just yet!). +#[path = "example_support/fonts.rs"] +mod example_support; + use std::sync::LazyLock; use gpui::{App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, size}; @@ -32,6 +36,9 @@ impl Render for Tree { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/uniform_list.rs b/crates/gpui/examples/uniform_list.rs index fabcde5..4a2c478 100644 --- a/crates/gpui/examples/uniform_list.rs +++ b/crates/gpui/examples/uniform_list.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, uniform_list, @@ -40,6 +43,9 @@ impl Render for UniformListExample { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/examples/view_example/view_example_main.rs b/crates/gpui/examples/view_example/view_example_main.rs index 0eac849..d2e4991 100644 --- a/crates/gpui/examples/view_example/view_example_main.rs +++ b/crates/gpui/examples/view_example/view_example_main.rs @@ -13,6 +13,9 @@ //! //! Run: `cargo run -p gpui --example view_example` +#[path = "../example_support/fonts.rs"] +mod example_support; + mod example_editor; mod example_input; mod example_text_area; @@ -134,6 +137,9 @@ fn section(title: &str) -> Div { fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(560.0), px(480.0)), cx); cx.bind_keys([ KeyBinding::new("backspace", Backspace, None), diff --git a/crates/gpui/examples/window.rs b/crates/gpui/examples/window.rs index 959ea49..51dbe72 100644 --- a/crates/gpui/examples/window.rs +++ b/crates/gpui/examples/window.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, KeyBinding, PromptButton, PromptLevel, Window, WindowBounds, WindowKind, WindowOptions, actions, div, prelude::*, px, rgb, size, @@ -310,6 +313,9 @@ actions!(window, [Quit]); fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(800.0), px(600.0)), cx); cx.open_window( diff --git a/crates/gpui/examples/window_movable.rs b/crates/gpui/examples/window_movable.rs index 587a2db..b5fb260 100644 --- a/crates/gpui/examples/window_movable.rs +++ b/crates/gpui/examples/window_movable.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, FocusHandle, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -78,6 +81,9 @@ fn open_test_window( fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let window_size = size(px(420.), px(280.0)); let base = Bounds::centered(None, window_size, cx); diff --git a/crates/gpui/examples/window_positioning.rs b/crates/gpui/examples/window_positioning.rs index 036a2fc..22c9b35 100644 --- a/crates/gpui/examples/window_positioning.rs +++ b/crates/gpui/examples/window_positioning.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, DisplayId, Hsla, Pixels, SharedString, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowKind, WindowOptions, div, point, prelude::*, @@ -72,6 +75,9 @@ fn build_window_options(display_id: DisplayId, bounds: Bounds) -> Window fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } // Create several new windows, positioned in the top right corner of each screen let size = Size { width: px(350.), diff --git a/crates/gpui/examples/window_shadow.rs b/crates/gpui/examples/window_shadow.rs index f4a55b4..fba5e91 100644 --- a/crates/gpui/examples/window_shadow.rs +++ b/crates/gpui/examples/window_shadow.rs @@ -1,5 +1,8 @@ #![cfg_attr(target_family = "wasm", no_main)] +#[path = "example_support/fonts.rs"] +mod example_support; + use gpui::{ App, Bounds, Context, CursorStyle, Decorations, HitboxBehavior, Hsla, MouseButton, Pixels, Point, ResizeEdge, Size, Window, WindowBackgroundAppearance, WindowBounds, WindowDecorations, @@ -211,6 +214,9 @@ fn resize_edge(pos: Point, shadow_size: Pixels, size: Size) -> O fn run_example() { application().run(|cx: &mut App| { + if !example_support::load_fonts(cx) { + return; + } let bounds = Bounds::centered(None, size(px(600.0), px(600.0)), cx); cx.open_window( WindowOptions { diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 08f4be8..9811fc3 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -24,7 +24,7 @@ use parking_lot::RwLock; use slotmap::SlotMap; pub use async_context::*; -#[cfg(feature = "bench")] +#[cfg(feature = "bench-support")] pub use bench_context::{BenchAppContext, BenchReport, BenchWindowContext, bench_platform}; use collections::{FxHashMap, FxHashSet, HashMap, TypeIdHashMap, TypeIdHashSet, VecDeque}; pub use context::*; @@ -60,7 +60,7 @@ use crate::{ }; mod async_context; -#[cfg(feature = "bench")] +#[cfg(feature = "bench-support")] mod bench_context; mod context; mod entity_map; @@ -73,7 +73,8 @@ mod test_context; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] mod visual_test_context; -/// The duration for which futures returned from [Context::on_app_quit] can run before the application fully quits. +/// The duration for which native applications wait for futures returned from +/// [Context::on_app_quit] before fully quitting. pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(200); /// Temporary(?) wrapper around [`RefCell`] to help us debug any double borrows. @@ -918,8 +919,19 @@ impl App { platform.on_quit(Box::new({ let cx = Rc::downgrade(&app); move || { - if let Some(cx) = cx.upgrade() { - cx.borrow_mut().shutdown(); + let Some(cx) = cx.upgrade() else { + return true; + }; + match cx.try_borrow_mut() { + Ok(mut cx) => { + cx.shutdown(); + true + } + Err(_) => { + // Quit was requested while the AppCell was borrowed, so we can't shut down synchronously. + // The platform decides how to proceed. + false + } } } })); @@ -958,8 +970,11 @@ impl App { self.entities.assert_no_new_leaks(snapshot) } - /// Quit the application gracefully. Handlers registered with [`Context::on_app_quit`] - /// will be given `SHUTDOWN_TIMEOUT` to complete before exiting. + /// Quit the application gracefully. + /// + /// Native applications give handlers registered with [`Context::on_app_quit`] + /// [`SHUTDOWN_TIMEOUT`] to complete. WebAssembly runs them asynchronously as best-effort cleanup + /// because its event-loop thread cannot block. pub fn shutdown(&mut self) { let mut futures = Vec::new(); @@ -973,6 +988,7 @@ impl App { self.quitting = true; let futures = futures::future::join_all(futures); + #[cfg(not(target_family = "wasm"))] if self .foreground_executor .block_with_timeout(SHUTDOWN_TIMEOUT, futures) @@ -980,6 +996,8 @@ impl App { { log::error!("timed out waiting on app_will_quit"); } + #[cfg(target_family = "wasm")] + self.foreground_executor.spawn(futures).detach(); self.quitting = false; } @@ -1691,7 +1709,7 @@ impl App { } } } else { - #[cfg(any(test, feature = "test-support", feature = "bench"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] for window in self .windows .values() @@ -1753,9 +1771,9 @@ impl App { if focus.ref_count.load(SeqCst) == 0 { for window_handle in self.windows() { window_handle - .update(self, |_, window, _| { + .update(self, |_, window, cx| { if window.focus == Some(handle_id) { - window.blur(); + window.blur(cx); } }) .unwrap(); @@ -2379,10 +2397,7 @@ impl App { for window in self.windows() { window .update(self, |_, window, cx| { - if window.pending_input_keystrokes().is_some() { - window.clear_pending_keystrokes(); - window.pending_input_changed(cx); - } + window.clear_pending_keystrokes(cx); }) .ok(); } diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 7fc5831..278f26c 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -527,7 +527,11 @@ impl<'a, 'measurement> BenchAppContext<'a, 'measurement> { ); let foreground_executor = platform.foreground_executor(); let asset_source = Arc::new(()); - let http_client = http_client::FakeHttpClient::with_404_response(); + // Benchmark setup must not make accidental network requests. The + // production `BlockedHttpClient` reports them without enabling a + // configurable test double through `test-support`. + let http_client: Arc = + Arc::new(http_client::BlockedHttpClient::new()); let app = App::new_app(platform, asset_source, http_client); Self { diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 2d1b0bf..b472808 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -30,7 +30,6 @@ use collections::HashMap; use gpui_util::ResultExt; use refineable::Refineable; use smallvec::SmallVec; -use stacksafe::{StackSafe, stacksafe}; use std::{ any::{Any, TypeId}, cell::RefCell, @@ -45,6 +44,11 @@ use std::{ use super::ImageCacheProvider; +#[cfg(feature = "stacker")] +type StackSafe = stacksafe::StackSafe; +#[cfg(not(feature = "stacker"))] +type StackSafe = T; + const DRAG_THRESHOLD: f64 = 2.; const DEFAULT_TOOLTIP_SHOW_DELAY: Duration = Duration::from_millis(500); const HOVERABLE_TOOLTIP_HIDE_DELAY: Duration = Duration::from_millis(500); @@ -645,6 +649,10 @@ impl Interactivity { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The imperative API equivalent to [`StatefulInteractiveElement::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -659,6 +667,16 @@ impl Interactivity { self.hover_listener = Some(Box::new(listener)); } + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The imperative API + /// equivalent to [`StatefulInteractiveElement::hover_listener_mode`]. + pub fn hover_listener_mode(&mut self, mode: HoverListenerMode) + where + Self: Sized, + { + self.hover_listener_mode = mode; + } + /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The imperative API equivalent to [`StatefulInteractiveElement::tooltip`]. pub fn tooltip(&mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) @@ -1589,6 +1607,10 @@ pub trait StatefulInteractiveElement: InteractiveElement { /// Bind the given callback on the hover start and end events of this element. Note that the boolean /// passed to the callback is true when the hover starts and false when it ends. /// Transitions caused by layout changes under a stationary mouse also invoke the callback. + /// + /// By default, keyboard input suppresses hover until the next mouse move, mouse down, or touch. Set + /// [`HoverListenerMode::InputModalityIndependent`] with [`Self::hover_listener_mode`] to + /// continue hit-testing hover after keyboard input. /// The fluent API equivalent to [`Interactivity::on_hover`]. /// /// See [`Context::listener`](crate::Context::listener) to get access to a view's state from this callback. @@ -1600,6 +1622,17 @@ pub trait StatefulInteractiveElement: InteractiveElement { self } + /// Sets how [`Self::on_hover`] responds to key presses while the mouse is stationary. + /// This affects only the hover listener, not hover styles or tooltips. The fluent API + /// equivalent to [`Interactivity::hover_listener_mode`]. + fn hover_listener_mode(mut self, mode: HoverListenerMode) -> Self + where + Self: Sized, + { + self.interactivity().hover_listener_mode(mode); + self + } + /// Use the given callback to construct a new tooltip view when the mouse hovers over this element. /// The fluent API equivalent to [`Interactivity::tooltip`]. fn tooltip(mut self, build_tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self @@ -1654,6 +1687,29 @@ pub(crate) type PinchListener = pub(crate) type ClickListener = Rc; +/// Controls how [`StatefulInteractiveElement::on_hover`] responds to key presses while the mouse +/// is stationary. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum HoverListenerMode { + /// Use input-modality-aware hit testing. Keyboard input suppresses hover until the mouse moves + /// again, unless pointer capture or an active mouse-down interaction keeps the listener hovered. + #[default] + InputModalityAware, + /// Use hit testing even when the last input was from the keyboard. This changes only + /// keyboard-modality filtering; all other [`StatefulInteractiveElement::on_hover`] behavior + /// remains unchanged. + InputModalityIndependent, +} + +impl HoverListenerMode { + fn is_hovered(self, hitbox: &Hitbox, window: &Window) -> bool { + match self { + Self::InputModalityAware => hitbox.is_hovered(window), + Self::InputModalityIndependent => hitbox.id.is_hovered_ignoring_last_input(window), + } + } +} + pub(crate) struct DragListener { value: Arc, render: Box, &mut Window, &mut App) -> AnyView + 'static>, @@ -1777,8 +1833,11 @@ impl InteractiveElement for Div { impl ParentElement for Div { fn extend(&mut self, elements: impl IntoIterator) { + #[cfg(feature = "stacker")] self.children - .extend(elements.into_iter().map(StackSafe::new)) + .extend(elements.into_iter().map(StackSafe::new)); + #[cfg(not(feature = "stacker"))] + self.children.extend(elements); } } @@ -1816,7 +1875,7 @@ impl Element for Div { } } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] fn request_layout( &mut self, global_id: Option<&GlobalElementId>, @@ -1852,7 +1911,7 @@ impl Element for Div { (layout_id, DivFrameState { child_layout_ids }) } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] fn prepaint( &mut self, global_id: Option<&GlobalElementId>, @@ -1947,7 +2006,7 @@ impl Element for Div { ) } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] fn paint( &mut self, global_id: Option<&GlobalElementId>, @@ -2072,6 +2131,7 @@ pub struct Interactivity { pub(crate) aux_click_listeners: Vec, pub(crate) drag_listener: Option, pub(crate) hover_listener: Option>, + pub(crate) hover_listener_mode: HoverListenerMode, pub(crate) tooltip_builder: Option, pub(crate) tooltip_show_delay: Option, pub(crate) window_control: Option, @@ -2339,7 +2399,14 @@ impl Interactivity { } let rem_size = window.rem_size(); - let padding = style.padding.to_pixels(bounds.size.into(), rem_size); + // Taffy lays the box out with the padding snapped to the device pixel + // grid (`to_taffy`); recomputed unsnapped, e.g. py_1 at a fractional + // rem size, it exceeds `bounds` and leaves the box scrollable by the + // sub-pixel difference. + let padding = style + .padding + .to_pixels(bounds.size.into(), rem_size) + .map(|edge| window.pixel_snap(*edge)); let padding_size = size(padding.left + padding.right, padding.top + padding.bottom); // The floating point values produced by Taffy and ours often vary // slightly after ~5 decimal places. This can lead to cases where after @@ -3020,9 +3087,11 @@ impl Interactivity { hover_listener(&is_hovered, window, cx); } }; + let hover_listener_mode = self.hover_listener_mode; if has_mouse_down.borrow().is_none() { - let is_hovered = !cx.has_active_drag() && hitbox.is_hovered(window); + let is_hovered = + !cx.has_active_drag() && hover_listener_mode.is_hovered(hitbox, window); if is_hovered != *was_hovered.borrow() { let update_hover = update_hover.clone(); window.defer(cx, move |window, cx| { @@ -3038,7 +3107,7 @@ impl Interactivity { if phase == DispatchPhase::Bubble { let is_hovered = has_mouse_down.borrow().is_none() && !cx.has_active_drag() - && hitbox.is_hovered(window); + && hover_listener_mode.is_hovered(&hitbox, window); update_hover(is_hovered, window, cx); } } @@ -3302,13 +3371,14 @@ impl Interactivity { if let Some(group_hover) = self.group_hover_style.as_ref() { let is_group_hovered = if let Some(group_hitbox_id) = GroupHitboxes::get(&group_hover.group, cx) { - group_hitbox_id.is_hovered(window) + !window.last_input_was_touch() && group_hitbox_id.is_hovered(window) } else if let Some(element_state) = element_state.as_ref() { - element_state - .hover_state - .as_ref() - .map(|state| state.borrow().group) - .unwrap_or(false) + !window.last_input_was_touch() + && element_state + .hover_state + .as_ref() + .map(|state| state.borrow().group) + .unwrap_or(false) } else { false }; @@ -3320,13 +3390,14 @@ impl Interactivity { if let Some(hover_style) = self.hover_style.as_ref() { let is_hovered = if let Some(hitbox) = hitbox { - hitbox.is_hovered(window) + !window.last_input_was_touch() && hitbox.is_hovered(window) } else if let Some(element_state) = element_state.as_ref() { - element_state - .hover_state - .as_ref() - .map(|state| state.borrow().element) - .unwrap_or(false) + !window.last_input_was_touch() + && element_state + .hover_state + .as_ref() + .map(|state| state.borrow().element) + .unwrap_or(false) } else { false }; @@ -4369,7 +4440,9 @@ mod tests { } #[gpui::test] - fn hover_listeners_update_when_layout_changes_under_stationary_mouse(cx: &mut TestAppContext) { + fn default_hover_listener_updates_when_layout_changes_under_stationary_mouse( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); @@ -4409,7 +4482,114 @@ mod tests { } #[gpui::test] - fn hover_listeners_remain_hovered_during_stationary_mouse_press(cx: &mut TestAppContext) { + fn default_hover_listener_ends_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerLayoutTestView { + target_left: px(0.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(point(px(10.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "a"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + } + + struct HoverListenerModeLayoutTestView { + target_left: Pixels, + hover_transitions: Rc>>, + } + + impl Render for HoverListenerModeLayoutTestView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let hover_transitions = self.hover_transitions.clone(); + div().relative().size_full().child( + div() + .id("hover-target") + .absolute() + .left(self.target_left) + .top_0() + .size(px(20.)) + .hover_listener_mode(HoverListenerMode::InputModalityIndependent) + .on_hover(move |is_hovered, _, _| { + hover_transitions.borrow_mut().push(*is_hovered); + }), + ) + } + } + + #[gpui::test] + fn input_modality_independent_hover_listener_updates_after_key_press(cx: &mut TestAppContext) { + let hover_transitions = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let hover_transitions = hover_transitions.clone(); + move |_, _| HoverListenerModeLayoutTestView { + target_left: px(40.), + hover_transitions, + } + }); + let any_window = AnyWindowHandle::from(window); + let pointer_position = point(px(10.), px(10.)); + + cx.update_window(any_window, |_, window, cx| { + window.draw(cx).clear(cx); + window.simulate_mouse_move(pointer_position, cx); + }) + .unwrap(); + assert!(hover_transitions.borrow().is_empty()); + + key_down(cx, any_window, "a"); + window + .update(cx, |view, _, cx| { + view.target_left = px(0.); + cx.notify(); + }) + .unwrap(); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + key_down(cx, any_window, "b"); + cx.update_window(any_window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(point(px(30.), px(10.)), cx); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false]); + + cx.update_window(any_window, |_, window, cx| { + window.simulate_mouse_move(pointer_position, cx); + window.dispatch_event( + MouseExitEvent { + position: pointer_position, + ..Default::default() + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + assert_eq!(*hover_transitions.borrow(), [true, false, true, false]); + } + + #[gpui::test] + fn default_hover_listener_remains_hovered_during_stationary_mouse_press( + cx: &mut TestAppContext, + ) { let hover_transitions = Rc::new(RefCell::new(Vec::new())); let window = cx.add_window({ let hover_transitions = hover_transitions.clone(); @@ -5156,6 +5336,48 @@ mod tests { assert_eq!(focused, Some(item_b.id)); } + #[gpui::test] + fn test_fractional_padding_does_not_make_a_fitting_container_scrollable( + cx: &mut TestAppContext, + ) { + struct PaddedContainer { + scroll_handle: ScrollHandle, + } + + impl Render for PaddedContainer { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl IntoElement { + // 4.25px of padding snaps to 4px in layout, so a 42px child + // fits the 50px box exactly. + div().size_full().child( + div() + .id("container") + .h(px(50.)) + .w(px(100.)) + .py(px(4.25)) + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .child(div().w_full().h(px(42.))), + ) + } + } + + let scroll_handle = ScrollHandle::new(); + let window: AnyWindowHandle = cx + .add_window({ + let scroll_handle = scroll_handle.clone(); + move |_, _| PaddedContainer { scroll_handle } + }) + .into(); + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + + assert_eq!(scroll_handle.max_offset().y, px(0.)); + } + struct ContentSizedGrid; impl Render for ContentSizedGrid { diff --git a/crates/gpui/src/executor.rs b/crates/gpui/src/executor.rs index fb8eefd..444bb07 100644 --- a/crates/gpui/src/executor.rs +++ b/crates/gpui/src/executor.rs @@ -1,10 +1,13 @@ use crate::{App, PlatformDispatcher, PlatformScheduler}; +#[cfg(not(target_family = "wasm"))] use futures::channel::mpsc; use futures::prelude::*; 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}; +use std::{future::Future, marker::PhantomData, rc::Rc, sync::Arc, time::Duration}; +#[cfg(not(target_family = "wasm"))] +use std::{mem, pin::Pin}; pub use scheduler::{ DedicatedExecutor, FallibleTask, LocalExecutor as SchedulerLocalExecutor, Priority, Task, @@ -138,8 +141,11 @@ impl BackgroundExecutor { } } - /// Scoped lets you start a number of tasks and waits - /// for all of them to complete before returning. + /// Runs background tasks that may borrow from their environment and waits for all of them to complete. + /// + /// Dropping the returned future cancels its tasks and synchronously waits for their futures to + /// be destroyed before returning. + #[cfg(not(target_family = "wasm"))] pub async fn scoped<'scope, F>(&self, scheduler: F) where F: FnOnce(&mut Scope<'scope>), @@ -155,8 +161,12 @@ impl BackgroundExecutor { } } - /// Scoped lets you start a number of tasks and waits - /// for all of them to complete before returning. + /// Runs prioritized background tasks that may borrow from their environment and waits for all + /// of them to complete. + /// + /// Dropping the returned future cancels its tasks and synchronously waits for their futures to + /// be destroyed before returning. + #[cfg(not(target_family = "wasm"))] pub async fn scoped_priority<'scope, F>(&self, priority: Priority, scheduler: F) where F: FnOnce(&mut Scope<'scope>), @@ -408,7 +418,7 @@ impl ForegroundExecutor { } /// Used by the test harness to run an async test in a synchronous fashion. - #[cfg(any(test, feature = "test-support"))] + #[cfg(all(not(target_family = "wasm"), any(test, feature = "test-support")))] #[track_caller] pub fn block_test(&self, future: impl Future) -> R { use std::cell::Cell; @@ -431,11 +441,13 @@ impl ForegroundExecutor { /// Block the current thread until the given future resolves. /// Consider using `block_with_timeout` instead. + #[cfg(not(target_family = "wasm"))] pub fn block_on(&self, future: impl Future) -> R { self.inner.block_on(future) } /// Block the current thread until the given future resolves or the timeout elapses. + #[cfg(not(target_family = "wasm"))] pub fn block_with_timeout>( &self, duration: Duration, @@ -456,6 +468,7 @@ impl ForegroundExecutor { } /// Scope manages a set of tasks that are enqueued and waited on together. See [`BackgroundExecutor::scoped`]. +#[cfg(not(target_family = "wasm"))] pub struct Scope<'a> { executor: BackgroundExecutor, priority: Priority, @@ -465,6 +478,7 @@ pub struct Scope<'a> { lifetime: PhantomData<&'a ()>, } +#[cfg(not(target_family = "wasm"))] impl<'a> Scope<'a> { fn new(executor: BackgroundExecutor, priority: Priority) -> Self { let (tx, rx) = mpsc::channel(1); @@ -506,6 +520,7 @@ impl<'a> Scope<'a> { } } +#[cfg(not(target_family = "wasm"))] impl Drop for Scope<'_> { fn drop(&mut self) { self.tx.take().unwrap(); diff --git a/crates/gpui/src/gestures.rs b/crates/gpui/src/gestures.rs index 99f0efc..ec61157 100644 --- a/crates/gpui/src/gestures.rs +++ b/crates/gpui/src/gestures.rs @@ -10,12 +10,40 @@ //! [`PinchEvent`](crate::PinchEvent)s — so components written against //! `on_click` and scroll containers work untouched on mobile. -use std::time::{Duration, Instant}; +use std::collections::VecDeque; +use std::mem; +use std::time::Duration; -use crate::{Axis, IsZero, Pixels, Point, TouchPhase, px}; +use scheduler::Instant; +use smallvec::SmallVec; + +use crate::{ + Axis, GestureEvent, InputEvent, IsZero, Modifiers, MouseButton, MouseDownEvent, MouseEvent, + MouseUpEvent, Pixels, PlatformInput, Point, ScrollDelta, ScrollWheelEvent, TouchEvent, TouchId, + TouchPhase, point, px, seal::Sealed, +}; const SCROLL_EVENT_SEPARATION: Duration = Duration::from_millis(28); +fn dominant_axis(delta: Point) -> Axis { + if delta.x.abs() <= delta.y.abs() { + Axis::Vertical + } else { + Axis::Horizontal + } +} + +fn lock_delta_to_axis(delta: &mut Point, axis: Axis) { + match axis { + Axis::Vertical => delta.x = Pixels::ZERO, + Axis::Horizontal => delta.y = Pixels::ZERO, + } +} + +fn movements_oppose(left: Point, right: Point) -> bool { + f32::from(left.x) * f32::from(right.x) + f32::from(left.y) * f32::from(right.y) < 0. +} + /// Tracks the dominant axis across the events in a scroll gesture. #[derive(Clone, Copy, Debug, Default)] pub struct OngoingScroll { @@ -58,11 +86,7 @@ impl OngoingScroll { .is_none_or(|last_event| now.duration_since(last_event) >= SCROLL_EVENT_SEPARATION); let mut axis = self.axis; if starts_new_gesture { - axis = if x <= y { - Some(Axis::Vertical) - } else { - Some(Axis::Horizontal) - }; + axis = Some(dominant_axis(*delta)); } else if x.max(y) >= UNLOCK_LOWER_BOUND { match axis { Some(Axis::Vertical) if x > y && x >= y * UNLOCK_PERCENT => { @@ -77,10 +101,8 @@ impl OngoingScroll { self.last_event = Some(now); self.axis = axis; - match axis { - Some(Axis::Vertical) => delta.x = Pixels::ZERO, - Some(Axis::Horizontal) => delta.y = Pixels::ZERO, - None => {} + if let Some(axis) = axis { + lock_delta_to_axis(delta, axis); } } } @@ -100,10 +122,8 @@ pub struct GestureTuning { /// How long a touch must remain within [`Self::touch_slop`] to be /// recognized as a long press. pub long_press_duration: Duration, - /// Per-millisecond decay factor applied to scroll momentum after a fling. - /// (`UIScrollView` uses `0.998` per millisecond for its normal - /// deceleration rate.) - pub momentum_decay_per_ms: f32, + /// How scroll momentum decelerates after a fling. + pub scroll_physics: ScrollPhysics, /// Minimum release velocity, in pixels per second, required to start /// scroll momentum. pub min_fling_velocity: f32, @@ -116,12 +136,214 @@ impl Default for GestureTuning { multi_tap_interval: Duration::from_millis(400), multi_tap_slop: px(16.), long_press_duration: Duration::from_millis(500), - momentum_decay_per_ms: 0.998, + scroll_physics: ScrollPhysics::ios(), min_fling_velocity: 50., } } } +/// How free scrolling decelerates after a fling. +/// +/// This models deceleration only. Boundary behavior — bouncing, edge glow, +/// clamping — is the scroll container's policy: the container is the one that +/// knows its extents. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ScrollPhysics { + /// Exponential velocity decay, the `UIScrollView` model: + /// `velocity(t) = v₀ · decay_per_msᵐˢ`. + Exponential { + /// Per-millisecond velocity decay factor. `UIScrollView`'s normal + /// deceleration rate is `0.998`. + decay_per_ms: f32, + }, + /// The friction spline of Android's `OverScroller`: fling duration and + /// distance follow a logarithmic deceleration law, and progress along + /// the fling follows a cubic-Bezier ease-out curve. Transcribed from + /// AOSP's `SplineOverScroller` (Apache-2.0). + FrictionSpline { + /// The scroll friction coefficient; + /// `ViewConfiguration.getScrollFriction()` is `0.015` on Android. + friction: f32, + /// Pixels per physical inch of the display, in the coordinate space + /// the fling runs in. Android folds display density into its + /// deceleration coefficient, so the same finger speed flings + /// further in pixels on a denser screen. + pixels_per_inch: f32, + }, +} + +impl ScrollPhysics { + /// iOS scroll feel: `UIScrollView`'s normal deceleration rate. + pub fn ios() -> Self { + Self::Exponential { + decay_per_ms: 0.998, + } + } + + /// Android scroll feel: `OverScroller` with stock friction, at Android's + /// nominal density of 160 density-independent pixels per inch — the + /// right pairing when fling distances are in logical pixels. Platforms + /// that fling in physical pixels, or know the display's true density in + /// their logical space, should construct + /// [`ScrollPhysics::FrictionSpline`] directly. + pub fn android() -> Self { + Self::FrictionSpline { + friction: 0.015, + pixels_per_inch: 160., + } + } + + /// How long a fling released at `speed` pixels per second coasts before + /// it stops. + fn fling_duration(self, speed: f32) -> Duration { + match self { + Self::Exponential { decay_per_ms } => { + if speed <= MOMENTUM_STOP_VELOCITY { + return Duration::ZERO; + } + let milliseconds = (MOMENTUM_STOP_VELOCITY / speed).ln() / decay_per_ms.ln(); + Duration::from_secs_f32(milliseconds / 1000.) + } + Self::FrictionSpline { + friction, + pixels_per_inch, + } => { + if speed <= 0. { + return Duration::ZERO; + } + let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch); + let seconds = (deceleration / (friction_spline::deceleration_rate() - 1.)).exp(); + Duration::from_secs_f64(seconds) + } + } + } + + /// Distance traveled `elapsed` into a fling released at `speed` pixels + /// per second, in pixels along the fling direction. Evaluated in closed + /// form so the trajectory is independent of tick timing. + fn fling_distance(self, speed: f32, elapsed: Duration) -> f32 { + let duration = self.fling_duration(speed); + if duration.is_zero() { + return 0.; + } + let elapsed = elapsed.min(duration); + match self { + Self::Exponential { decay_per_ms } => { + // ∫₀ᵗ v₀·kᵐˢ dms, with speed converted to pixels per + // millisecond. + let milliseconds = elapsed.as_secs_f32() * 1000.; + (speed / 1000.) * (decay_per_ms.powf(milliseconds) - 1.) / decay_per_ms.ln() + } + Self::FrictionSpline { + friction, + pixels_per_inch, + } => { + let deceleration = friction_spline::deceleration(speed, friction, pixels_per_inch); + let rate = friction_spline::deceleration_rate(); + let total_distance = friction as f64 + * friction_spline::physical_coefficient(pixels_per_inch) + * (rate / (rate - 1.) * deceleration).exp(); + let progress = elapsed.as_secs_f64() / duration.as_secs_f64(); + total_distance as f32 * friction_spline::distance_coefficient(progress as f32) + } + } + } +} + +/// The fling model of Android's `OverScroller.SplineOverScroller`, +/// transcribed from AOSP (Apache-2.0). `SPLINE_TIME`, which AOSP uses for +/// programmatic scroll animations rather than flings, is intentionally not +/// transcribed. +mod friction_spline { + use std::sync::LazyLock; + + const NB_SAMPLES: usize = 100; + const INFLEXION: f32 = 0.35; + const START_TENSION: f32 = 0.5; + const END_TENSION: f32 = 1.0; + const P1: f32 = START_TENSION * INFLEXION; + const P2: f32 = 1.0 - END_TENSION * (1.0 - INFLEXION); + + /// Android's `DECELERATION_RATE`: `ln(0.78) / ln(0.9)`. + pub(super) fn deceleration_rate() -> f64 { + 0.78f64.ln() / 0.9f64.ln() + } + + /// `SPLINE_POSITION` from AOSP's static initializer: fractional fling + /// distance sampled at 100 evenly spaced fractions of the fling + /// duration, from a cubic Bezier with control points shaped by + /// `INFLEXION` and the start/end tensions. + static SPLINE_POSITION: LazyLock<[f32; NB_SAMPLES + 1]> = LazyLock::new(|| { + let mut spline_position = [0f32; NB_SAMPLES + 1]; + let mut x_min = 0f32; + for (i, sample) in spline_position.iter_mut().take(NB_SAMPLES).enumerate() { + let alpha = i as f32 / NB_SAMPLES as f32; + let mut x_max = 1f32; + let (x, coefficient) = loop { + let x = x_min + (x_max - x_min) / 2.; + let coefficient = 3. * x * (1. - x); + let time = coefficient * ((1. - x) * P1 + x * P2) + x * x * x; + if (time - alpha).abs() < 1e-5 { + break (x, coefficient); + } + if time > alpha { + x_max = x; + } else { + x_min = x; + } + }; + *sample = coefficient * ((1. - x) * START_TENSION + x) + x * x * x; + } + spline_position[NB_SAMPLES] = 1.; + spline_position + }); + + /// `SensorManager.GRAVITY_EARTH · 39.37 in/m · ppi · 0.84`, AOSP's + /// `mPhysicalCoeff`: gravity expressed in pixels, times an empirical + /// "look and feel" tuning factor. + pub(super) fn physical_coefficient(pixels_per_inch: f32) -> f64 { + 9.80665 * 39.37 * pixels_per_inch as f64 * 0.84 + } + + /// AOSP's `getSplineDeceleration`. + pub(super) fn deceleration(speed: f32, friction: f32, pixels_per_inch: f32) -> f64 { + (INFLEXION as f64 * speed as f64 + / (friction as f64 * physical_coefficient(pixels_per_inch))) + .ln() + } + + /// Fraction of the total fling distance covered at fraction `time` of + /// the fling duration: table lookup plus linear interpolation, as in + /// `SplineOverScroller.update`. + pub(super) fn distance_coefficient(time: f32) -> f32 { + if time >= 1. { + return 1.; + } + let index = ((NB_SAMPLES as f32 * time) as usize).min(NB_SAMPLES - 1); + let time_lower = index as f32 / NB_SAMPLES as f32; + let time_upper = (index + 1) as f32 / NB_SAMPLES as f32; + let distance_lower = SPLINE_POSITION[index]; + let distance_upper = SPLINE_POSITION[index + 1]; + let velocity_coefficient = (distance_upper - distance_lower) / (time_upper - time_lower); + distance_lower + (time - time_lower) * velocity_coefficient + } + + #[cfg(test)] + pub(super) fn bezier_time_and_position(parameter: f32) -> (f32, f32) { + let coefficient = 3. * parameter * (1. - parameter); + let cubed = parameter * parameter * parameter; + ( + coefficient * ((1. - parameter) * P1 + parameter * P2) + cubed, + coefficient * ((1. - parameter) * START_TENSION + parameter) + cubed, + ) + } + + #[cfg(test)] + pub(super) fn spline_position_samples() -> &'static [f32; NB_SAMPLES + 1] { + &SPLINE_POSITION + } +} + /// The set of gesture kinds that participate in recognition. /// /// Used by [`PlatformGestures::native_recognizers`] to declare which gestures @@ -158,19 +380,57 @@ impl GestureKinds { }; } -/// A long-press gesture, mobile's context-menu trigger. -/// -/// A bare long press is surfaced as a [`ClickEvent`](crate::ClickEvent) with -/// `long_press: true`, delivered to aux-click listeners alongside right -/// clicks. This event is the raw hook for elements that need the gesture -/// itself (e.g. long-press to start a drag); the registration API ships -/// together with the gesture arena. -#[derive(Clone, Debug, Default)] +/// A direct touch drag claimed by an element before touch input becomes a tap, +/// long press, or scrolling gesture. +#[derive(Clone, Debug)] +pub struct TouchDragEvent { + /// The phase of the touch drag. + pub phase: TouchPhase, + /// The position where the touch started. + pub start_position: Point, + /// The touch's current position. + pub position: Point, +} + +impl Sealed for TouchDragEvent {} +impl InputEvent for TouchDragEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::TouchDrag(self) + } +} +impl GestureEvent for TouchDragEvent {} +impl MouseEvent for TouchDragEvent {} + +/// A phased long-press gesture recognized from a touch. +#[derive(Clone, Debug)] pub struct LongPressEvent { - /// The position of the touch that was recognized as a long press. + /// The phase of the long press. + pub phase: TouchPhase, + /// The position where the touch started. + pub start_position: Point, + /// The touch's current position. pub position: Point, } +impl Default for LongPressEvent { + fn default() -> Self { + Self { + phase: TouchPhase::Started, + start_position: Point::default(), + position: Point::default(), + } + } +} + +impl Sealed for LongPressEvent {} +impl InputEvent for LongPressEvent { + fn to_platform_input(self) -> PlatformInput { + PlatformInput::LongPress(self) + } +} +impl GestureEvent for LongPressEvent {} +impl MouseEvent for LongPressEvent {} + /// Platform gesture recognition services. /// /// If your mobile platform supports native gesture recognition, use this @@ -193,6 +453,669 @@ pub struct NullPlatformGestures; impl PlatformGestures for NullPlatformGestures {} +/// Ceiling on recognized fling velocity, in pixels per second (matches +/// Flutter's `kMaxFlingVelocity`). +const MAX_FLING_VELOCITY: f32 = 8000.; + +/// Momentum below this speed, in pixels per second, is imperceptible. The +/// exponential model, which never mathematically stops, treats reaching this +/// speed as the end of the fling. (The friction spline has a finite duration +/// of its own.) +const MOMENTUM_STOP_VELOCITY: f32 = 10.; + +/// How far back the release-velocity estimate looks. Samples older than this +/// reflect an earlier part of the gesture, not the speed at release. +const VELOCITY_WINDOW: Duration = Duration::from_millis(100); + +/// A pause between samples longer than this means the finger stopped: +/// anything before the pause describes an earlier motion, not the release +/// (Flutter's `kAssumePointerMoveStoppedMilliseconds`). Touch hardware +/// reports movement every 8–16ms while the finger is in motion. +const VELOCITY_ASSUME_STOPPED_GAP: Duration = Duration::from_millis(40); + +const VELOCITY_MAX_SAMPLES: usize = 20; + +/// The portable recognizer behind raw touch input: it watches the +/// [`TouchEvent`] stream for one touch at a time and resolves it into either +/// a tap or a pan, following the competition model described in the module +/// docs. Pans continue into post-release momentum when the touch lifts at +/// speed; the window drives that phase through [`Self::tick_momentum`]. +/// +/// Taps are currently surfaced as synthesized mouse presses rather than +/// [`ClickEvent::Touch`](crate::ClickEvent), which keeps every existing +/// mouse-driven behavior (click listeners, caret placement, double-tap +/// selection) working before elements grow a direct tap-delivery path. +/// Pinch recognition is not implemented yet, and additional touches are ignored +/// while one is being recognized. +pub(crate) struct TouchGestureRecognizer { + tuning: GestureTuning, + state: TouchGestureState, + momentum: Option, + last_tap: Option, +} + +/// A semantic event recognized from raw touches, ready to dispatch through +/// the window's existing input paths. +#[derive(Debug)] +pub(crate) enum RecognizedTouchGesture { + /// One step of a pan (or of its post-release momentum), delivered to + /// scroll listeners at the pan's starting position. + Scroll(ScrollWheelEvent), + /// A recognized tap, delivered as a synthesized mouse press and release. + Tap { + down: MouseDownEvent, + up: MouseUpEvent, + }, + TouchDrag(TouchDragEvent), + LongPress(LongPressEvent), +} + +enum TouchGestureState { + Idle, + /// The touch is still within `touch_slop` of where it started: it can + /// still resolve into either a tap or a pan. + Pending { + touch: ActiveTouch, + deadline: Instant, + long_press_offered: bool, + touch_drag_offered: bool, + }, + /// The touch exceeded `touch_slop`: it is a pan until it ends, and its + /// movement flows out as scroll events. + Panning { + touch: ActiveTouch, + axis: Axis, + }, + LongPressing(ActiveTouch), + TouchDragging(ActiveTouch), +} + +struct ActiveTouch { + id: TouchId, + start_position: Point, + /// The latest raw position reported for this touch. + last_position: Point, + /// The position pan output has scrolled to so far. While panning this + /// may run ahead of the raw touch by the event's predicted position; + /// the release event targets the raw position again, so the total + /// scrolled distance always converges to the finger's actual travel. + emitted_position: Point, + /// Retained across stationary samples so prediction corrections cannot + /// reverse a pan when integer browser coordinates repeat. + last_movement: Point, + velocity_tracker: VelocityTracker, +} + +struct CompletedTap { + position: Point, + time: Instant, + count: usize, +} + +/// One fling in progress. The trajectory is a closed-form curve of elapsed +/// time — each tick evaluates it and emits the increment — so the fling is +/// exactly frame-rate independent: a stalled frame simply resumes further +/// along the same curve. +struct Momentum { + /// Where the pan started; synthesized scroll events keep hit-testing + /// there so momentum stays with the container the gesture began on. + position: Point, + /// Unit vector of the release velocity. + direction: Point, + axis: Axis, + /// Release speed in pixels per second. + speed: f32, + started_at: Instant, + duration: Duration, + /// Distance already emitted along `direction`, in pixels. + emitted_distance: f32, +} + +impl TouchGestureRecognizer { + pub(crate) fn new(tuning: GestureTuning) -> Self { + Self { + tuning, + state: TouchGestureState::Idle, + momentum: None, + last_tap: None, + } + } + + pub(crate) fn handle_event( + &mut self, + event: &TouchEvent, + ) -> SmallVec<[RecognizedTouchGesture; 2]> { + self.handle_event_at(event, Instant::now()) + } + + fn handle_event_at( + &mut self, + event: &TouchEvent, + now: Instant, + ) -> SmallVec<[RecognizedTouchGesture; 2]> { + let mut recognized = SmallVec::new(); + match event.phase { + TouchPhase::Started => { + let caught_fling = if let Some(momentum) = self.momentum.take() { + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + momentum.position, + Point::default(), + TouchPhase::Ended, + ))); + Some(momentum.axis) + } else { + None + }; + if matches!(self.state, TouchGestureState::Idle) { + let mut velocity_tracker = VelocityTracker::default(); + velocity_tracker.push(now, event.position); + let touch = ActiveTouch { + id: event.id, + start_position: event.position, + last_position: event.position, + emitted_position: event.position, + last_movement: Point::default(), + velocity_tracker, + }; + if let Some(axis) = caught_fling { + // A touch that catches a fling is a drag from the + // first pixel: waiting out the slop would freeze the + // content mid-scroll and then jump. It can also never + // be a tap; releasing it just leaves the content + // stopped, as on Android and iOS. + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + Point::default(), + TouchPhase::Started, + ))); + self.state = TouchGestureState::Panning { touch, axis }; + } else { + self.state = TouchGestureState::Pending { + touch, + deadline: now + self.tuning.long_press_duration, + long_press_offered: false, + touch_drag_offered: false, + }; + } + } + } + TouchPhase::Moved => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending { + mut touch, + deadline, + long_press_offered, + touch_drag_offered, + } if touch.id == event.id => { + touch.velocity_tracker.push(now, event.position); + touch.last_position = event.position; + let accumulated = event.position - touch.start_position; + if accumulated.magnitude() > f64::from(self.tuning.touch_slop) { + // Carry the full movement so far into the first scroll + // step: the content catches up to the finger instead + // of losing the slop distance. + let mut target = event.predicted_position.unwrap_or(event.position); + let axis = dominant_axis(accumulated); + let mut delta = target - touch.start_position; + lock_delta_to_axis(&mut delta, axis); + touch.last_movement = accumulated; + lock_delta_to_axis(&mut touch.last_movement, axis); + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = accumulated; + lock_delta_to_axis(&mut delta, axis); + } + touch.emitted_position = target; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + delta, + TouchPhase::Started, + ))); + self.state = TouchGestureState::Panning { touch, axis }; + } else { + self.state = TouchGestureState::Pending { + touch, + deadline, + long_press_offered, + touch_drag_offered, + }; + } + } + TouchGestureState::Panning { mut touch, axis } if touch.id == event.id => { + let mut raw_delta = event.position - touch.last_position; + lock_delta_to_axis(&mut raw_delta, axis); + if raw_delta != Point::default() { + touch.last_movement = raw_delta; + } + touch.velocity_tracker.push(now, event.position); + touch.last_position = event.position; + let mut target = event.predicted_position.unwrap_or(event.position); + let mut delta = target - touch.emitted_position; + lock_delta_to_axis(&mut delta, axis); + // Prediction error must not reverse content while the raw + // touch still advances. Fall back to the raw position so a + // real finger reversal remains responsive. + if movements_oppose(delta, touch.last_movement) { + target = event.position; + delta = target - touch.emitted_position; + lock_delta_to_axis(&mut delta, axis); + if movements_oppose(delta, touch.last_movement) { + target = touch.emitted_position; + delta = Point::default(); + } + } + touch.emitted_position = target; + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + delta, + TouchPhase::Moved, + ))); + self.state = TouchGestureState::Panning { touch, axis }; + } + TouchGestureState::LongPressing(mut touch) if touch.id == event.id => { + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Moved, + start_position: touch.start_position, + position: event.position, + })); + self.state = TouchGestureState::LongPressing(touch); + } + TouchGestureState::TouchDragging(mut touch) if touch.id == event.id => { + touch.last_position = event.position; + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Moved, + start_position: touch.start_position, + position: event.position, + })); + self.state = TouchGestureState::TouchDragging(touch); + } + other => self.state = other, + }, + TouchPhase::Ended => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending { touch, .. } if touch.id == event.id => { + let tap_count = match &self.last_tap { + Some(tap) + if now.duration_since(tap.time) <= self.tuning.multi_tap_interval + && (event.position - tap.position).magnitude() + <= f64::from(self.tuning.multi_tap_slop) => + { + tap.count + 1 + } + _ => 1, + }; + self.last_tap = Some(CompletedTap { + position: event.position, + time: now, + count: tap_count, + }); + recognized.push(RecognizedTouchGesture::Tap { + down: MouseDownEvent { + button: MouseButton::Left, + position: event.position, + modifiers: Modifiers::default(), + click_count: tap_count, + first_mouse: false, + }, + up: MouseUpEvent { + button: MouseButton::Left, + position: event.position, + modifiers: Modifiers::default(), + click_count: tap_count, + }, + }); + } + TouchGestureState::Panning { touch, axis } if touch.id == event.id => { + // The release deliberately contributes no velocity + // sample: it usually repeats the last movement's position + // with a later timestamp, which would dilute the + // estimate. But a release long after the last movement + // means the finger had already stopped, so nothing + // flings. + let finger_stopped = + touch + .velocity_tracker + .latest_sample_time() + .is_none_or(|latest| { + now.duration_since(latest) > VELOCITY_ASSUME_STOPPED_GAP + }); + let mut velocity = if finger_stopped { + Point::default() + } else { + touch.velocity_tracker.velocity() + }; + match axis { + Axis::Vertical => velocity.x = 0., + Axis::Horizontal => velocity.y = 0., + } + let speed = (velocity.x.powi(2) + velocity.y.powi(2)).sqrt(); + let mut release_delta = event.position - touch.emitted_position; + lock_delta_to_axis(&mut release_delta, axis); + if speed >= self.tuning.min_fling_velocity { + let direction = point(velocity.x / speed, velocity.y / speed); + let speed = speed.min(MAX_FLING_VELOCITY); + let duration = self.tuning.scroll_physics.fling_duration(speed); + if !duration.is_zero() { + let total_distance = + self.tuning.scroll_physics.fling_distance(speed, duration); + // Prediction may have left the content ahead of + // the raw release position. Emitting that + // correction here would visibly snap the content + // backwards just as the fling launches, so fold + // it into the fling instead: start the curve + // already advanced by the overshoot, keeping the + // total travel exact while staying monotonic. + let overshoot = -(f32::from(release_delta.x) * direction.x + + f32::from(release_delta.y) * direction.y); + let emitted_distance = if overshoot > 0. && overshoot < total_distance { + release_delta += + point(px(direction.x * overshoot), px(direction.y * overshoot)); + overshoot + } else { + 0. + }; + self.momentum = Some(Momentum { + position: touch.start_position, + direction, + axis, + speed, + started_at: now, + duration, + emitted_distance, + }); + } + } + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + release_delta, + TouchPhase::Ended, + ))); + } + TouchGestureState::LongPressing(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Ended, + start_position: touch.start_position, + position: event.position, + })); + } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Ended, + start_position: touch.start_position, + position: event.position, + })); + } + other => self.state = other, + }, + TouchPhase::Cancelled => match mem::replace(&mut self.state, TouchGestureState::Idle) { + TouchGestureState::Pending { touch, .. } if touch.id == event.id => {} + TouchGestureState::Panning { touch, .. } if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::Scroll(scroll_event( + touch.start_position, + Point::default(), + TouchPhase::Cancelled, + ))); + } + TouchGestureState::LongPressing(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Cancelled, + start_position: touch.start_position, + position: event.position, + })); + } + TouchGestureState::TouchDragging(touch) if touch.id == event.id => { + recognized.push(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Cancelled, + start_position: touch.start_position, + position: event.position, + })); + } + other => self.state = other, + }, + } + recognized + } + + pub(crate) fn pending_long_press(&self) -> Option<(TouchId, Duration)> { + let TouchGestureState::Pending { + touch, + deadline, + long_press_offered: false, + .. + } = &self.state + else { + return None; + }; + Some((touch.id, deadline.saturating_duration_since(Instant::now()))) + } + + pub(crate) fn offer_long_press(&mut self, id: TouchId) -> Option { + let TouchGestureState::Pending { + touch, + long_press_offered, + .. + } = &mut self.state + else { + return None; + }; + if touch.id != id || *long_press_offered { + return None; + } + *long_press_offered = true; + Some(RecognizedTouchGesture::LongPress(LongPressEvent { + phase: TouchPhase::Started, + start_position: touch.start_position, + position: touch.last_position, + })) + } + + pub(crate) fn resolve_long_press(&mut self, claimed: bool) { + if !claimed { + return; + } + let state = mem::replace(&mut self.state, TouchGestureState::Idle); + self.state = match state { + TouchGestureState::Pending { + touch, + long_press_offered: true, + .. + } => TouchGestureState::LongPressing(touch), + other => other, + }; + } + + pub(crate) fn offer_touch_drag(&mut self, id: TouchId) -> Option { + let TouchGestureState::Pending { + touch, + touch_drag_offered, + .. + } = &mut self.state + else { + return None; + }; + if touch.id != id || *touch_drag_offered { + return None; + } + *touch_drag_offered = true; + Some(RecognizedTouchGesture::TouchDrag(TouchDragEvent { + phase: TouchPhase::Started, + start_position: touch.start_position, + position: touch.last_position, + })) + } + + pub(crate) fn resolve_touch_drag(&mut self, claimed: bool) { + if !claimed { + return; + } + let state = mem::replace(&mut self.state, TouchGestureState::Idle); + self.state = match state { + TouchGestureState::Pending { + touch, + touch_drag_offered: true, + .. + } => TouchGestureState::TouchDragging(touch), + other => other, + }; + } + + pub(crate) fn has_momentum(&self) -> bool { + self.momentum.is_some() + } + + /// Advances post-fling momentum by one frame, returning the scroll step + /// to dispatch, or `None` when no momentum is in progress. The final step + /// carries [`TouchPhase::Ended`] to close the synthetic scroll stream. + pub(crate) fn tick_momentum(&mut self) -> Option { + self.tick_momentum_at(Instant::now()) + } + + fn tick_momentum_at(&mut self, now: Instant) -> Option { + let momentum = self.momentum.as_mut()?; + let elapsed = now.duration_since(momentum.started_at); + let distance = self + .tuning + .scroll_physics + .fling_distance(momentum.speed, elapsed); + // Prediction overshoot can start momentum ahead of its curve. Hold + // that position until the curve catches up instead of stepping back. + let step = (distance - momentum.emitted_distance).max(0.); + momentum.emitted_distance = momentum.emitted_distance.max(distance); + let delta = point( + px(momentum.direction.x * step), + px(momentum.direction.y * step), + ); + let position = momentum.position; + if elapsed >= momentum.duration { + self.momentum = None; + Some(RecognizedTouchGesture::Scroll(scroll_event( + position, + delta, + TouchPhase::Ended, + ))) + } else { + Some(RecognizedTouchGesture::Scroll(scroll_event( + position, + delta, + TouchPhase::Moved, + ))) + } + } +} + +fn scroll_event( + position: Point, + delta: Point, + touch_phase: TouchPhase, +) -> ScrollWheelEvent { + ScrollWheelEvent { + position, + delta: ScrollDelta::Pixels(delta), + modifiers: Modifiers::default(), + touch_phase, + } +} + +/// Estimates the velocity a touch had at its newest sample. +#[derive(Default)] +struct VelocityTracker { + samples: VecDeque<(Instant, Point)>, +} + +impl VelocityTracker { + fn push(&mut self, time: Instant, position: Point) { + self.samples.push_back((time, position)); + while self.samples.len() > VELOCITY_MAX_SAMPLES { + self.samples.pop_front(); + } + } + + fn latest_sample_time(&self) -> Option { + self.samples.back().map(|(time, _)| *time) + } + + /// The velocity at the newest sample, in pixels per second. + /// + /// Fits a second-degree polynomial by least squares over the trailing + /// [`VELOCITY_WINDOW`] and takes its derivative at the newest sample, + /// like Flutter's `VelocityTracker` and Android's `lsq2` strategy. An + /// endpoint difference over the same window would report the window's + /// *average* speed, which for a flick — still accelerating at lift-off — + /// is roughly half the speed the finger actually had at release. + fn velocity(&self) -> Point { + let Some((newest_time, _)) = self.samples.back() else { + return Point::default(); + }; + let mut times_seconds: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut horizontal: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut vertical: SmallVec<[f64; VELOCITY_MAX_SAMPLES]> = SmallVec::new(); + let mut previous_time = *newest_time; + for (time, position) in self.samples.iter().rev() { + let age = newest_time.duration_since(*time); + if age > VELOCITY_WINDOW + || previous_time.duration_since(*time) > VELOCITY_ASSUME_STOPPED_GAP + { + break; + } + previous_time = *time; + times_seconds.push(-age.as_secs_f64()); + horizontal.push(f64::from(f32::from(position.x))); + vertical.push(f64::from(f32::from(position.y))); + } + + let endpoint_estimate = |values: &[f64]| -> f32 { + let elapsed = -times_seconds.last().copied().unwrap_or(0.); + if elapsed <= f64::EPSILON { + return 0.; + } + ((values.first().copied().unwrap_or(0.) - values.last().copied().unwrap_or(0.)) + / elapsed) as f32 + }; + if times_seconds.len() < 3 { + return point(endpoint_estimate(&horizontal), endpoint_estimate(&vertical)); + } + point( + quadratic_velocity_at_newest(×_seconds, &horizontal).map_or_else( + || endpoint_estimate(&horizontal), + |velocity| velocity as f32, + ), + quadratic_velocity_at_newest(×_seconds, &vertical) + .map_or_else(|| endpoint_estimate(&vertical), |velocity| velocity as f32), + ) + } +} + +/// Least-squares fit of `value = a0 + a1·t + a2·t²` returning `a1`: the +/// fitted curve's velocity at `t = 0`, which callers place at the newest +/// sample. `None` when the samples are too degenerate to fit (all +/// simultaneous, for example). +fn quadratic_velocity_at_newest(times: &[f64], values: &[f64]) -> Option { + let count = times.len() as f64; + let (mut sum_t1, mut sum_t2, mut sum_t3, mut sum_t4) = (0., 0., 0., 0.); + let (mut sum_v, mut sum_vt, mut sum_vt2) = (0., 0., 0.); + for (&time, &value) in times.iter().zip(values) { + let time_squared = time * time; + sum_t1 += time; + sum_t2 += time_squared; + sum_t3 += time_squared * time; + sum_t4 += time_squared * time_squared; + sum_v += value; + sum_vt += value * time; + sum_vt2 += value * time_squared; + } + // Cramer's rule on the 3×3 normal equations, solved for the linear + // coefficient only. + let determinant = count * (sum_t2 * sum_t4 - sum_t3 * sum_t3) + - sum_t1 * (sum_t1 * sum_t4 - sum_t3 * sum_t2) + + sum_t2 * (sum_t1 * sum_t3 - sum_t2 * sum_t2); + if determinant.abs() < 1e-12 { + return None; + } + let linear_determinant = count * (sum_vt * sum_t4 - sum_t3 * sum_vt2) + - sum_v * (sum_t1 * sum_t4 - sum_t3 * sum_t2) + + sum_t2 * (sum_t1 * sum_vt2 - sum_vt * sum_t2); + Some(linear_determinant / determinant) +} + #[cfg(test)] mod tests { use super::*; @@ -309,4 +1232,980 @@ mod tests { assert_eq!(ongoing_scroll.axis, Some(Axis::Horizontal)); assert_eq!(horizontal_delta, point(px(10.), px(0.))); } + + #[test] + fn touch_within_slop_resolves_to_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + let recognized = + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 10.), now); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 12., 11.), + now + Duration::from_millis(20), + ); + assert!(recognized.is_empty()); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 12., 11.), + now + Duration::from_millis(60), + ); + let [RecognizedTouchGesture::Tap { down, up }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 1); + assert_eq!(down.position, point(px(12.), px(11.))); + assert_eq!(up.click_count, 1); + } + + #[test] + fn consecutive_taps_accumulate_tap_count() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at(&touch_event(TouchId(1), TouchPhase::Started, 10., 10.), now); + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 10., 10.), + now + Duration::from_millis(40), + ); + + let second_down = now + Duration::from_millis(200); + recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 14., 10.), + second_down, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 14., 10.), + second_down + Duration::from_millis(40), + ); + let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 2); + + let late_down = second_down + Duration::from_secs(2); + recognizer.handle_event_at( + &touch_event(TouchId(3), TouchPhase::Started, 14., 10.), + late_down, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(3), TouchPhase::Ended, 14., 10.), + late_down + Duration::from_millis(40), + ); + let [RecognizedTouchGesture::Tap { down, .. }] = recognized.as_slice() else { + panic!("expected tap, got {recognized:?}"); + }; + assert_eq!(down.click_count, 1); + } + + #[test] + fn touch_beyond_slop_resolves_to_pan() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 120.), + now + Duration::from_millis(16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Started); + assert_eq!(scroll.position, point(px(100.), px(100.))); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 135.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.position, point(px(100.), px(100.))); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(15.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 135.), + now + Duration::from_millis(48), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + } + + #[test] + fn touch_pan_stays_locked_to_its_initial_dominant_axis() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 104., 120.), + now + Duration::from_millis(16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(20.))); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 134., 125.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(5.))); + } + + #[test] + fn touch_pan_locks_to_horizontal_axis() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 120., 104.), + now + Duration::from_millis(16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(20.), px(0.))); + } + + #[test] + fn predicted_positions_lead_the_pan_but_totals_converge_on_release() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + // The first pan step scrolls to the predicted position, not the raw one. + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); + moved.predicted_position = Some(point(px(106.), px(128.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(28.))); + + // The next step is measured from where the previous prediction left + // the content, so an overshoot is paid back here. + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 130.); + moved.predicted_position = Some(point(px(104.), px(134.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.))); + + // A release without a fling (the finger stopped long before lifting) + // targets the raw position: the total scrolled distance equals the + // finger's actual travel despite the predictions. + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 130.), + now + Duration::from_millis(120), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + assert!(!recognizer.has_momentum()); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-4.))); + } + + #[test] + fn predicted_positions_do_not_emit_false_reversals() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 120.); + moved.predicted_position = Some(point(px(100.), px(130.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(16)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(30.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(127.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(32)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 125.); + moved.predicted_position = Some(point(px(100.), px(126.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(40)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!( + scroll.delta.pixel_delta(px(16.)), + Point::::default() + ); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 132.); + moved.predicted_position = Some(point(px(100.), px(136.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(48)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(6.))); + + let mut moved = touch_event(touch, TouchPhase::Moved, 100., 124.); + moved.predicted_position = Some(point(px(100.), px(140.))); + let recognized = recognizer.handle_event_at(&moved, now + Duration::from_millis(64)); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-12.))); + } + + #[test] + fn predicted_overshoot_folds_into_the_fling_without_scrolling_backwards() { + let now = Instant::now(); + let mut total_with_prediction = 0f32; + let mut total_without_prediction = 0f32; + for use_prediction in [true, false] { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let mut total = 0f32; + let mut drain = |recognized: &[RecognizedTouchGesture], upward_only: bool| { + for gesture in recognized { + let RecognizedTouchGesture::Scroll(scroll) = gesture else { + panic!("expected scroll, got {gesture:?}"); + }; + let delta = scroll.delta.pixel_delta(px(16.)).y; + if upward_only { + assert!( + delta <= px(0.), + "content moved backwards by {delta:?} during an upward gesture" + ); + } + total += f32::from(delta); + } + }; + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 500.), + now, + ); + for step in 1..=5u64 { + let raw_y = 500. - step as f32 * 40.; + let mut moved = touch_event(TouchId(1), TouchPhase::Moved, 100., raw_y); + if use_prediction { + moved.predicted_position = Some(point(px(100.), px(raw_y - 25.))); + } + let recognized = + recognizer.handle_event_at(&moved, now + Duration::from_millis(step * 16)); + drain(&recognized, use_prediction); + } + // The release leaves the emitted position 25px ahead of the raw + // one; with prediction the correction must not scroll backwards. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 300.), + now + Duration::from_millis(90), + ); + drain(&recognized, use_prediction); + assert!(recognizer.has_momentum()); + let mut tick = now + Duration::from_millis(91); + while recognizer.has_momentum() { + if let Some(gesture) = recognizer.tick_momentum_at(tick) { + drain(&[gesture], use_prediction); + } + tick += Duration::from_millis(16); + } + + if use_prediction { + total_with_prediction = total; + } else { + total_without_prediction = total; + } + } + // Folding the overshoot into the fling redistributes the travel but + // must not change where the content comes to rest. + assert!( + (total_with_prediction - total_without_prediction).abs() < 0.01, + "totals diverged: {total_with_prediction} vs {total_without_prediction}" + ); + } + + #[test] + fn fast_release_starts_momentum_that_decays_to_a_stop() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + for step in 1..=5 { + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 300. - step as f32 * 20.), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(6 * 16), + ); + assert!(recognizer.has_momentum()); + + let tick = now + Duration::from_millis(6 * 16 + 16); + let recognized = recognizer.tick_momentum_at(tick); + let Some(RecognizedTouchGesture::Scroll(scroll)) = recognized else { + panic!("expected momentum scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.position, point(px(100.), px(300.))); + let delta = scroll.delta.pixel_delta(px(16.)); + assert!( + delta.y < px(0.), + "momentum should continue upward, got {delta:?}" + ); + // The least-squares fit may leave float residue on the motionless axis. + assert!( + delta.x.abs() < px(0.001), + "expected no x motion, got {delta:?}" + ); + + let mut last_phase = TouchPhase::Moved; + let mut ticks = 0; + let mut time = tick; + while recognizer.has_momentum() { + time += Duration::from_millis(16); + ticks += 1; + assert!(ticks < 1000, "momentum never stopped"); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time) + { + last_phase = scroll.touch_phase; + } + } + assert_eq!(last_phase, TouchPhase::Ended); + assert!(recognizer.tick_momentum_at(time).is_none()); + } + + #[test] + fn diagonal_release_flings_only_on_locked_axis() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + for step in 1..=5 { + let recognized = recognizer.handle_event_at( + &touch_event( + touch, + TouchPhase::Moved, + 100. + step as f32 * 3., + 300. - step as f32 * 20., + ), + now + Duration::from_millis(step * 16), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.delta.pixel_delta(px(16.)).x, px(0.)); + } + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 115., 200.), + now + Duration::from_millis(6 * 16), + ); + assert!(recognizer.has_momentum()); + + let mut time = now + Duration::from_millis(6 * 16); + while recognizer.has_momentum() { + time += Duration::from_millis(16); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = recognizer.tick_momentum_at(time) + { + let delta = scroll.delta.pixel_delta(px(16.)); + assert_eq!(delta.x, px(0.)); + assert!(delta.y <= px(0.)); + } + } + } + + #[test] + fn slow_release_does_not_start_momentum() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 300.), now); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 280.), + now + Duration::from_millis(16), + ); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 279.), + now + Duration::from_millis(500), + ); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 100., 279.), + now + Duration::from_millis(600), + ); + assert!(!recognizer.has_momentum()); + } + + #[test] + fn new_touch_interrupts_momentum() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 300.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 300. - step as f32 * 33., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 100., 200.), + now + Duration::from_millis(200), + ); + assert!(!recognizer.has_momentum()); + let [ + RecognizedTouchGesture::Scroll(closing), + RecognizedTouchGesture::Scroll(opening), + ] = recognized.as_slice() + else { + panic!("expected closing and opening scrolls, got {recognized:?}"); + }; + assert_eq!(closing.touch_phase, TouchPhase::Ended); + assert!(closing.delta.pixel_delta(px(16.)).is_zero()); + assert_eq!(opening.touch_phase, TouchPhase::Started); + assert!(opening.delta.pixel_delta(px(16.)).is_zero()); + } + + #[test] + fn catching_a_fling_pans_immediately_and_never_taps() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 300.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 300. - step as f32 * 33., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 200.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 100., 200.), + now + Duration::from_millis(200), + ); + + // A movement well within the slop scrolls immediately. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Moved, 100., 197.), + now + Duration::from_millis(216), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Moved); + assert_eq!(scroll.delta.pixel_delta(px(16.)), point(px(0.), px(-3.))); + + // Releasing the catch is not a tap. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 100., 197.), + now + Duration::from_millis(232), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Ended); + } + + #[test] + fn cancelled_pan_emits_cancelled_scroll_and_no_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + let touch = TouchId(1); + + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 100., 150.), + now + Duration::from_millis(16), + ); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Cancelled, 100., 150.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected cancelled scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Cancelled); + assert!(!recognizer.has_momentum()); + + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 100., 100.), now); + let recognized = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Cancelled, 100., 102.), + now + Duration::from_millis(16), + ); + assert!(recognized.is_empty(), "cancelled tap must not click"); + } + + #[test] + fn concurrent_touches_are_ignored_while_one_is_active() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let now = Instant::now(); + + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 100.), + now, + ); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Started, 200., 200.), + now + Duration::from_millis(8), + ); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Moved, 200., 300.), + now + Duration::from_millis(16), + ); + assert!(recognized.is_empty()); + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(2), TouchPhase::Ended, 200., 300.), + now + Duration::from_millis(24), + ); + assert!(recognized.is_empty()); + + // The first touch still resolves normally. + let recognized = recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Moved, 100., 150.), + now + Duration::from_millis(32), + ); + let [RecognizedTouchGesture::Scroll(scroll)] = recognized.as_slice() else { + panic!("expected scroll, got {recognized:?}"); + }; + assert_eq!(scroll.touch_phase, TouchPhase::Started); + } + + #[test] + fn spline_position_table_matches_the_bezier_curve() { + let samples = friction_spline::spline_position_samples(); + // AOSP's initializer solves sample 0 numerically like every other + // sample, so it lands within solver tolerance of zero, not at zero. + assert!(samples[0].abs() < 1e-4); + assert_eq!(samples[100], 1.); + for window in samples.windows(2) { + assert!(window[0] < window[1], "table must be strictly increasing"); + } + // Each table entry must lie on the defining parametric Bezier: for + // sample i there must be a curve parameter whose time component is + // i/100 and whose position component is the stored value. + for (i, &stored_position) in samples.iter().enumerate().take(100) { + let alpha = i as f32 / 100.; + let (mut lower, mut upper) = (0f32, 1f32); + for _ in 0..50 { + let middle = (lower + upper) / 2.; + let (time, _) = friction_spline::bezier_time_and_position(middle); + if time > alpha { + upper = middle; + } else { + lower = middle; + } + } + let (time, position) = friction_spline::bezier_time_and_position((lower + upper) / 2.); + assert!( + (time - alpha).abs() < 1e-4, + "sample {i}: time {time} != {alpha}" + ); + assert!( + (position - stored_position).abs() < 1e-3, + "sample {i}: position {position} != stored {stored_position}" + ); + } + } + + #[test] + fn fling_curves_are_sane_for_both_physics() { + for physics in [ScrollPhysics::ios(), ScrollPhysics::android()] { + let slow = physics.fling_duration(500.); + let fast = physics.fling_duration(4000.); + assert!(slow > Duration::ZERO, "{physics:?}"); + assert!(fast > slow, "faster flings must coast longer: {physics:?}"); + + let halfway = physics.fling_distance(4000., fast / 2); + let total = physics.fling_distance(4000., fast); + assert!(halfway > 0. && halfway < total, "{physics:?}"); + assert!( + physics.fling_distance(4000., fast * 2) == total, + "distance must not grow past the fling duration: {physics:?}" + ); + assert!( + physics.fling_distance(4000., fast) > physics.fling_distance(500., slow), + "faster flings must travel further: {physics:?}" + ); + } + } + + #[test] + fn momentum_is_frame_rate_independent() { + // The same fling ticked at 60Hz and as one huge stalled frame must + // cover identical ground. + let total_distance_with_tick_length = |tick: Duration| -> f32 { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning { + scroll_physics: ScrollPhysics::android(), + ..GestureTuning::default() + }); + let now = Instant::now(); + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Started, 100., 500.), + now, + ); + for step in 1..=3 { + recognizer.handle_event_at( + &touch_event( + TouchId(1), + TouchPhase::Moved, + 100., + 500. - step as f32 * 40., + ), + now + Duration::from_millis(step * 16), + ); + } + recognizer.handle_event_at( + &touch_event(TouchId(1), TouchPhase::Ended, 100., 380.), + now + Duration::from_millis(64), + ); + assert!(recognizer.has_momentum()); + + let mut total = 0f32; + let mut time = now + Duration::from_millis(64); + let mut guard = 0; + while recognizer.has_momentum() { + time += tick; + guard += 1; + assert!(guard < 10_000, "momentum never stopped"); + if let Some(RecognizedTouchGesture::Scroll(scroll)) = + recognizer.tick_momentum_at(time) + { + total += f32::from(scroll.delta.pixel_delta(px(16.)).y); + } + } + total + }; + + let smooth = total_distance_with_tick_length(Duration::from_millis(16)); + let stalled = total_distance_with_tick_length(Duration::from_secs(10)); + assert!( + (smooth - stalled).abs() < 0.01, + "expected identical fling distance, got {smooth} vs {stalled}" + ); + } + + #[test] + fn flick_velocity_reflects_release_speed_not_window_average() { + // A uniformly accelerating flick: position grows quadratically, so + // the speed at the newest sample (2·k·t) is twice the window + // average (k·t). The estimator must report the former. + let mut velocity_tracker = VelocityTracker::default(); + let start = Instant::now(); + for step in 0..=6 { + let t = step as f32 * 0.016; + velocity_tracker.push( + start + Duration::from_millis(step * 16), + point(px(0.), px(1000. * t * t)), + ); + } + let velocity = velocity_tracker.velocity(); + let release_speed = 2. * 1000. * 0.096; + assert!( + (velocity.y - release_speed).abs() < 1., + "expected ≈{release_speed} px/s at release, got {} px/s", + velocity.y + ); + assert_eq!(velocity.x, 0.); + } + + #[test] + fn samples_before_a_pause_do_not_contribute_velocity() { + // Fast motion, then a hold longer than the stopped-finger gap, then + // a slow nudge: only the motion after the pause describes the + // release. + let mut velocity_tracker = VelocityTracker::default(); + let start = Instant::now(); + velocity_tracker.push(start, point(px(0.), px(0.))); + velocity_tracker.push(start + Duration::from_millis(16), point(px(0.), px(50.))); + velocity_tracker.push(start + Duration::from_millis(80), point(px(0.), px(52.))); + velocity_tracker.push(start + Duration::from_millis(96), point(px(0.), px(54.))); + let velocity = velocity_tracker.velocity(); + assert!( + velocity.y < 200., + "pre-pause motion leaked into the estimate: {} px/s", + velocity.y + ); + } + + #[test] + fn claimed_touch_drag_emits_phased_stream_without_pan_or_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + let Some(RecognizedTouchGesture::TouchDrag(started)) = recognizer.offer_touch_drag(touch) + else { + panic!("expected touch drag"); + }; + assert_eq!(started.phase, TouchPhase::Started); + assert_eq!(started.start_position, point(px(10.), px(20.))); + recognizer.resolve_touch_drag(true); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 40., 50.), + now + Duration::from_millis(10), + ); + let [RecognizedTouchGesture::TouchDrag(moved)] = moved.as_slice() else { + panic!("expected moved touch drag, got {moved:?}"); + }; + assert_eq!(moved.phase, TouchPhase::Moved); + assert_eq!(moved.position, point(px(40.), px(50.))); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 45., 55.), + now + Duration::from_millis(20), + ); + let [RecognizedTouchGesture::TouchDrag(ended)] = ended.as_slice() else { + panic!("expected ended touch drag, got {ended:?}"); + }; + assert_eq!(ended.phase, TouchPhase::Ended); + assert_eq!(ended.position, point(px(45.), px(55.))); + } + + #[test] + fn unclaimed_touch_drag_remains_a_pan_candidate() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + assert!(recognizer.offer_touch_drag(touch).is_some()); + recognizer.resolve_touch_drag(false); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 20., 0.), + now + Duration::from_millis(10), + ); + assert!(matches!( + moved.as_slice(), + [RecognizedTouchGesture::Scroll(ScrollWheelEvent { + touch_phase: TouchPhase::Started, + .. + })] + )); + } + + #[test] + fn claimed_long_press_emits_phased_stream_without_tap() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + let Some(RecognizedTouchGesture::LongPress(started)) = recognizer.offer_long_press(touch) + else { + panic!("expected long press"); + }; + assert_eq!(started.phase, TouchPhase::Started); + assert_eq!(started.start_position, point(px(10.), px(20.))); + recognizer.resolve_long_press(true); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 12., 21.), + now + Duration::from_millis(510), + ); + let [RecognizedTouchGesture::LongPress(moved)] = moved.as_slice() else { + panic!("expected moved long press, got {moved:?}"); + }; + assert_eq!(moved.phase, TouchPhase::Moved); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 12., 21.), + now + Duration::from_millis(520), + ); + let [RecognizedTouchGesture::LongPress(ended)] = ended.as_slice() else { + panic!("expected ended long press, got {ended:?}"); + }; + assert_eq!(ended.phase, TouchPhase::Ended); + } + + #[test] + fn unclaimed_long_press_remains_a_tap_candidate() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 10., 20.), now); + assert!(recognizer.offer_long_press(touch).is_some()); + recognizer.resolve_long_press(false); + + let ended = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Ended, 10., 20.), + now + Duration::from_millis(510), + ); + assert!(matches!( + ended.as_slice(), + [RecognizedTouchGesture::Tap { .. }] + )); + } + + #[test] + fn unclaimed_long_press_can_still_become_a_pan() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + assert!(recognizer.offer_long_press(touch).is_some()); + recognizer.resolve_long_press(false); + + let moved = recognizer.handle_event_at( + &touch_event(touch, TouchPhase::Moved, 20., 0.), + now + Duration::from_millis(510), + ); + assert!(matches!( + moved.as_slice(), + [RecognizedTouchGesture::Scroll(ScrollWheelEvent { + touch_phase: TouchPhase::Started, + .. + })] + )); + } + + #[test] + fn long_press_offer_is_one_shot_and_specific_to_pending_touch() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 0., 0.)); + + assert!( + recognizer + .handle_event(&touch_event(TouchId(2), TouchPhase::Moved, 20., 0.)) + .is_empty() + ); + assert!(recognizer.offer_long_press(TouchId(2)).is_none()); + assert!(recognizer.offer_long_press(touch).is_some()); + assert!(recognizer.offer_long_press(touch).is_none()); + } + + #[test] + fn long_press_cannot_be_offered_after_pending_touch_resolves() { + for phase in [TouchPhase::Ended, TouchPhase::Cancelled, TouchPhase::Moved] { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + let now = Instant::now(); + recognizer.handle_event_at(&touch_event(touch, TouchPhase::Started, 0., 0.), now); + let position = if phase == TouchPhase::Moved { 20. } else { 0. }; + recognizer.handle_event_at( + &touch_event(touch, phase, position, 0.), + now + Duration::from_millis(10), + ); + assert!(recognizer.offer_long_press(touch).is_none()); + } + } + + #[test] + fn claimed_long_press_emits_cancelled_for_its_touch_only() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 4., 5.)); + assert!(recognizer.offer_long_press(touch).is_some()); + recognizer.resolve_long_press(true); + + assert!( + recognizer + .handle_event(&touch_event(TouchId(2), TouchPhase::Cancelled, 9., 9.)) + .is_empty() + ); + let cancelled = recognizer.handle_event(&touch_event(touch, TouchPhase::Cancelled, 6., 7.)); + let [RecognizedTouchGesture::LongPress(cancelled)] = cancelled.as_slice() else { + panic!("expected cancelled long press, got {cancelled:?}"); + }; + assert_eq!(cancelled.phase, TouchPhase::Cancelled); + assert_eq!(cancelled.start_position, point(px(4.), px(5.))); + assert_eq!(cancelled.position, point(px(6.), px(7.))); + } + + #[test] + fn unrelated_touch_cannot_end_or_cancel_pending_touch() { + for phase in [TouchPhase::Ended, TouchPhase::Cancelled] { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let touch = TouchId(1); + recognizer.handle_event(&touch_event(touch, TouchPhase::Started, 4., 5.)); + + assert!( + recognizer + .handle_event(&touch_event(TouchId(2), phase, 9., 9.)) + .is_empty() + ); + assert!(recognizer.offer_long_press(touch).is_some()); + } + } + + #[test] + fn completed_touch_id_cannot_claim_replacement_touch() { + let mut recognizer = TouchGestureRecognizer::new(GestureTuning::default()); + let completed_touch = TouchId(1); + let replacement_touch = TouchId(2); + recognizer.handle_event(&touch_event(completed_touch, TouchPhase::Started, 0., 0.)); + recognizer.handle_event(&touch_event(completed_touch, TouchPhase::Cancelled, 0., 0.)); + recognizer.handle_event(&touch_event(replacement_touch, TouchPhase::Started, 5., 5.)); + + assert!(recognizer.offer_long_press(completed_touch).is_none()); + assert!(recognizer.offer_long_press(replacement_touch).is_some()); + } + + fn touch_event(id: TouchId, phase: TouchPhase, x: f32, y: f32) -> TouchEvent { + TouchEvent { + id, + phase, + position: point(px(x), px(y)), + predicted_position: None, + force: None, + } + } } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index 927f634..51585b5 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -5,8 +5,6 @@ #![allow(unused_mut)] // False positives in platform specific code extern crate self as gpui; -#[doc(hidden)] -pub static GPUI_MANIFEST_DIR: &'static str = env!("CARGO_MANIFEST_DIR"); #[macro_use] mod action; mod app; @@ -43,7 +41,8 @@ pub mod profiler; target_os = "windows", target_os = "linux", target_family = "wasm", - feature = "test-support" + feature = "test-support", + feature = "bench-support" ))] #[expect(missing_docs)] pub mod queue; @@ -165,6 +164,7 @@ pub use util::{FutureExt, Timeout}; pub use view::*; pub use window::*; +#[cfg(not(target_family = "wasm"))] pub use pollster::block_on; /// The context trait, allows the different contexts in GPUI to be used diff --git a/crates/gpui/src/input.rs b/crates/gpui/src/input.rs index 6de5719..4167aa0 100644 --- a/crates/gpui/src/input.rs +++ b/crates/gpui/src/input.rs @@ -1,5 +1,6 @@ use crate::{ - App, Bounds, ClipboardItem, Context, Entity, InputHandler, Pixels, UTF16Selection, Window, + App, Bounds, ClipboardItem, Context, Entity, InputHandler, Pixels, TextInputConfiguration, + UTF16Selection, Window, }; use std::ops::Range; @@ -102,6 +103,24 @@ pub trait EntityInputHandler: 'static + Sized { fn accepts_text_input(&self, _window: &mut Window, _cx: &mut Context) -> bool { true } + + /// See [`InputHandler::text_input_configuration`] for details + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> TextInputConfiguration { + TextInputConfiguration::default() + } + + /// See [`InputHandler::text_input_editable_range`] for details + fn text_input_editable_range( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } } /// The canonical implementation of [`crate::PlatformInputHandler`]. Call [`Window::handle_input`] @@ -244,4 +263,223 @@ impl InputHandler for ElementInputHandler { self.view .update(cx, |view, cx| view.accepts_text_input(window, cx)) } + + fn text_input_configuration( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> TextInputConfiguration { + self.view + .update(cx, |view, cx| view.text_input_configuration(window, cx)) + } + + fn text_input_editable_range( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> Option> { + self.view + .update(cx, |view, cx| view.text_input_editable_range(window, cx)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + AnyWindowHandle, AppContext as _, FocusHandle, InteractiveElement as _, IntoElement, + ParentElement as _, Render, Styled as _, TestAppContext, TextInputAction, + TextInputStateChange, canvas, div, + }; + + #[gpui::test] + fn text_input_configuration_and_focus_state_are_forwarded_on_change(cx: &mut TestAppContext) { + let custom = TextInputConfiguration { + autocorrect: true, + input_action: TextInputAction::Send, + ..Default::default() + }; + let window = cx.add_window({ + let custom = custom.clone(); + move |_, cx| ConfigurationTestView { + focus_handle: cx.focus_handle(), + configuration: custom, + } + }); + let view = window.root(cx).unwrap(); + let test_window = cx.test_window(window.into()); + let window = AnyWindowHandle::from(window); + let draw = |cx: &mut TestAppContext| { + cx.update_window(window, |_, window, cx| window.draw(cx).clear(cx)) + .unwrap(); + }; + + // Nothing is focused, so the platform learns the default configuration. + draw(cx); + assert_eq!( + test_window.text_input_configurations(), + vec![TextInputConfiguration::default()] + ); + assert!(test_window.text_input_state_changes().is_empty()); + + // Focusing the view routes its configuration to the platform. + cx.update_window(window, |_, window, cx| { + let focus_handle = view.read(cx).focus_handle.clone(); + window.focus(&focus_handle, cx); + }) + .unwrap(); + draw(cx); + assert_eq!( + test_window.text_input_configurations(), + vec![TextInputConfiguration::default(), custom.clone()] + ); + assert_eq!( + test_window.text_input_state_changes(), + vec![TextInputStateChange::FocusGained] + ); + + // Redrawing without a change forwards nothing. + draw(cx); + assert_eq!(test_window.text_input_configurations().len(), 2); + assert_eq!(test_window.text_input_state_changes().len(), 1); + + // Changing the configuration forwards the new value. + let updated = TextInputConfiguration { + suggestions: true, + ..custom + }; + view.update(cx, { + let updated = updated.clone(); + |view, cx| { + view.configuration = updated; + cx.notify(); + } + }); + draw(cx); + assert_eq!( + test_window.text_input_configurations().last(), + Some(&updated) + ); + assert_eq!(test_window.text_input_configurations().len(), 3); + assert_eq!(test_window.text_input_state_changes().len(), 1); + + // Losing focus reverts the platform to the default configuration. + cx.update_window(window, |_, window, cx| window.blur(cx)) + .unwrap(); + draw(cx); + assert_eq!( + test_window.text_input_configurations().last(), + Some(&TextInputConfiguration::default()) + ); + assert_eq!(test_window.text_input_configurations().len(), 4); + assert_eq!( + test_window.text_input_state_changes(), + vec![ + TextInputStateChange::FocusGained, + TextInputStateChange::FocusLost + ] + ); + } + + struct ConfigurationTestView { + focus_handle: FocusHandle, + configuration: TextInputConfiguration, + } + + impl Render for ConfigurationTestView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let focus_handle = self.focus_handle.clone(); + div().size_full().track_focus(&self.focus_handle).child( + canvas( + |_, _, _| {}, + move |bounds, _, window, cx| { + window.handle_input( + &focus_handle, + ElementInputHandler::new(bounds, view), + cx, + ); + }, + ) + .size_full(), + ) + } + } + + impl EntityInputHandler for ConfigurationTestView { + fn text_for_range( + &mut self, + _range: std::ops::Range, + _adjusted_range: &mut Option>, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn selected_text_range( + &mut self, + _ignore_disabled_input: bool, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn marked_text_range( + &self, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn unmark_text(&mut self, _window: &mut Window, _cx: &mut Context) {} + + fn replace_text_in_range( + &mut self, + _range: Option>, + _text: &str, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn replace_and_mark_text_in_range( + &mut self, + _range: Option>, + _new_text: &str, + _new_selected_range: Option>, + _window: &mut Window, + _cx: &mut Context, + ) { + } + + fn bounds_for_range( + &mut self, + _range_utf16: std::ops::Range, + _element_bounds: Bounds, + _window: &mut Window, + _cx: &mut Context, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _point: crate::Point, + _window: &mut Window, + _cx: &mut Context, + ) -> Option { + None + } + + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> TextInputConfiguration { + self.configuration.clone() + } + } } diff --git a/crates/gpui/src/interactive.rs b/crates/gpui/src/interactive.rs index 790593c..913cf1b 100644 --- a/crates/gpui/src/interactive.rs +++ b/crates/gpui/src/interactive.rs @@ -1,6 +1,6 @@ use crate::{ - Bounds, Capslock, Context, Empty, IntoElement, Keystroke, Modifiers, Pixels, Point, Render, - Window, point, seal::Sealed, + Bounds, Capslock, Context, Empty, IntoElement, Keystroke, LongPressEvent, Modifiers, Pixels, + Point, Render, TouchDragEvent, Window, point, seal::Sealed, }; use smallvec::SmallVec; use std::{any::Any, fmt::Debug, ops::Deref, path::PathBuf}; @@ -103,8 +103,8 @@ pub enum TouchPhase { /// [`TouchPhase::Started`] through [`TouchPhase::Ended`] or /// [`TouchPhase::Cancelled`]. /// -/// The value is opaque and platform-defined; it is only guaranteed to be -/// stable for the duration of the touch and unique among concurrent touches. +/// The value is opaque and assigned by the platform. A platform window must +/// not reuse an identifier for a later touch. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TouchId(pub u64); @@ -123,6 +123,15 @@ pub struct TouchEvent { pub phase: TouchPhase, /// The position of the touch in window coordinates. pub position: Point, + /// Where the platform predicts the touch will be roughly one frame from + /// now, in the same coordinate space as `position`, when the platform + /// offers a prediction for a [`TouchPhase::Moved`] event. + /// + /// Best-effort latency compensation only: it may influence how far a + /// recognized pan scrolls within a frame, but never hit testing, gesture + /// classification, or velocity estimation, and any error it introduces + /// must be corrected by later events for the same touch. + pub predicted_position: Option>, /// Normalized touch force in `0.0..=1.0`, if the hardware reports it. pub force: Option, } @@ -780,6 +789,10 @@ pub enum PlatformInput { ScrollWheel(ScrollWheelEvent), /// A pinch gesture was performed. Pinch(PinchEvent), + /// A long-press gesture recognized from touch input. + LongPress(LongPressEvent), + /// A direct touch drag claimed by an element. + TouchDrag(TouchDragEvent), /// Files were dragged and dropped onto the window. FileDrop(FileDropEvent), /// A raw touch event on a touch screen. @@ -799,6 +812,8 @@ impl PlatformInput { PlatformInput::MouseExited(event) => Some(event), PlatformInput::ScrollWheel(event) => Some(event), PlatformInput::Pinch(event) => Some(event), + PlatformInput::LongPress(event) => Some(event), + PlatformInput::TouchDrag(event) => Some(event), PlatformInput::FileDrop(event) => Some(event), PlatformInput::Touch(_) => None, } @@ -816,6 +831,8 @@ impl PlatformInput { PlatformInput::MouseExited(_) => None, PlatformInput::ScrollWheel(_) => None, PlatformInput::Pinch(_) => None, + PlatformInput::LongPress(_) => None, + PlatformInput::TouchDrag(_) => None, PlatformInput::FileDrop(_) => None, PlatformInput::Touch(_) => None, } @@ -835,6 +852,8 @@ impl PlatformInput { PlatformInput::MouseExited(_) => "mouse_exited", PlatformInput::ScrollWheel(_) => "scroll_wheel", PlatformInput::Pinch(_) => "pinch", + PlatformInput::LongPress(_) => "long_press", + PlatformInput::TouchDrag(_) => "touch_drag", PlatformInput::FileDrop(_) => "file_drop", PlatformInput::Touch(_) => "touch", } diff --git a/crates/gpui/src/key_dispatch.rs b/crates/gpui/src/key_dispatch.rs index 7b18d41..f18bd0e 100644 --- a/crates/gpui/src/key_dispatch.rs +++ b/crates/gpui/src/key_dispatch.rs @@ -650,6 +650,248 @@ mod tests { ) } + struct PendingInputTestView { + focus_handle: FocusHandle, + action_count: Rc>, + secondary_action_count: Rc>, + } + + #[derive(Clone)] + struct PendingTextInputTestView { + focus_handle: FocusHandle, + text: Rc>, + action_count: Rc>, + } + + impl PendingTextInputTestView { + fn new(cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + text: Rc::default(), + action_count: Rc::default(), + } + } + } + + impl Element for PendingTextInputTestView { + type RequestLayoutState = (); + type PrepaintState = (); + + fn id(&self) -> Option { + Some("pending-text-input-test".into()) + } + + fn source_location(&self) -> Option<&'static panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + (window.request_layout(Style::default(), [], cx), ()) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + window: &mut Window, + cx: &mut App, + ) -> Self::PrepaintState { + window.set_focus_handle(&self.focus_handle, cx); + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut Self::RequestLayoutState, + _: &mut Self::PrepaintState, + window: &mut Window, + cx: &mut App, + ) { + let mut key_context = KeyContext::default(); + key_context.add("Terminal"); + window.set_key_context(key_context); + window.handle_input(&self.focus_handle, self.clone(), cx); + let action_count = self.action_count.clone(); + window.on_action( + std::any::TypeId::of::(), + move |_, phase, _, _| { + if phase == DispatchPhase::Bubble { + action_count.set(action_count.get() + 1); + } + }, + ); + } + } + + impl IntoElement for PendingTextInputTestView { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } + } + + impl InputHandler for PendingTextInputTestView { + fn selected_text_range( + &mut self, + _: bool, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { + None + } + + fn text_for_range( + &mut self, + _: Range, + _: &mut Option>, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + + fn replace_text_in_range( + &mut self, + replacement_range: Option>, + text: &str, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(text) + } + + fn replace_and_mark_text_in_range( + &mut self, + replacement_range: Option>, + new_text: &str, + _: Option>, + _: &mut Window, + _: &mut App, + ) { + if replacement_range.is_some() { + unimplemented!() + } + self.text.borrow_mut().push_str(new_text) + } + + fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + + fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { + true + } + + fn bounds_for_range( + &mut self, + _: Range, + _: &mut Window, + _: &mut App, + ) -> Option> { + None + } + + fn character_index_for_point( + &mut self, + _: Point, + _: &mut Window, + _: &mut App, + ) -> Option { + None + } + } + + impl Render for PendingTextInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + self.clone() + } + } + + struct PendingInputTimeoutPauseOwner; + + impl Render for PendingInputTestView { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + use crate::{InteractiveElement as _, Styled as _}; + let action_count = self.action_count.clone(); + let secondary_action_count = self.secondary_action_count.clone(); + crate::div() + .key_context("Terminal") + .track_focus(&self.focus_handle) + .size_full() + .on_action(move |_: &TestAction, _, _| { + action_count.set(action_count.get() + 1); + }) + .on_action(move |_: &SecondaryTestAction, _, _| { + secondary_action_count.set(secondary_action_count.get() + 1); + }) + } + } + + fn setup_pending_input_test( + cx: &mut TestAppContext, + bindings: impl IntoIterator, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + cx.update(|cx| cx.bind_keys(bindings)); + + let action_count = Rc::new(Cell::new(0)); + let secondary_action_count = Rc::new(Cell::new(0)); + let (view, cx) = cx.add_window_view(|_, cx| PendingInputTestView { + focus_handle: cx.focus_handle(), + action_count: action_count.clone(), + secondary_action_count: secondary_action_count.clone(), + }); + let focus_handle = cx.update(|_, cx| view.read(cx).focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); + + (cx, action_count, secondary_action_count) + } + + fn setup_pending_input_timeout_test( + cx: &mut TestAppContext, + ) -> (&mut VisualTestContext, Rc>, Rc>) { + setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ) + } + + fn query_prefers_ime_for_printable_keys(cx: &mut VisualTestContext) -> Option { + let mut platform_window = cx.test_window(cx.window_handle()); + let mut input_handler = platform_window.take_input_handler()?; + let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); + platform_window.set_input_handler(input_handler); + Some(prefers_ime) + } + + fn simulate_pending_binding(cx: &mut VisualTestContext) { + cx.simulate_modifiers_change(crate::Modifiers::control()); + cx.simulate_keystrokes("ctrl-b"); + cx.simulate_modifiers_change(crate::Modifiers::default()); + } + #[test] fn test_keybinding_for_action_bounds() { let tree = test_dispatch_tree(vec![KeyBinding::new( @@ -830,7 +1072,7 @@ mod tests { } #[crate::test] - fn test_pending_input_observers_notified_on_focus_change(cx: &mut TestAppContext) { + fn test_pending_input_observers_notified_on_focus_change_and_blur(cx: &mut TestAppContext) { #[derive(Clone)] struct CustomElement { focus_handle: FocusHandle, @@ -988,6 +1230,8 @@ mod tests { cx.update(|cx| { cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); + cx.bind_keys([KeyBinding::new("ctrl-d", TestAction, None)]); + cx.bind_keys([KeyBinding::new("ctrl-d h", TestAction, None)]); }); let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); @@ -1034,190 +1278,379 @@ mod tests { let count_after_focus_change = *pending_input_changed_count.borrow(); assert!(count_after_focus_change > *count_after_pending_for_assertion.borrow()); }); + + cx.update(|window, cx| window.focus(&focus_handle, cx)); + cx.simulate_keystrokes("ctrl-b"); + let count_before_blur = *pending_input_changed_count.borrow(); + + cx.update(|window, cx| { + assert!(window.has_pending_keystrokes()); + window.blur(cx); + assert!(!window.has_pending_keystrokes()); + assert!(window.pending_input_is_none()); + }); + + cx.update(|_, _| { + assert!(*pending_input_changed_count.borrow() > count_before_blur); + }); + + cx.update(|window, cx| window.disable_focus(cx)); + cx.simulate_keystrokes("ctrl-d"); + + cx.update(|window, cx| { + assert!(window.has_pending_keystrokes()); + window.blur(cx); + assert!(window.pending_input_is_none()); + }); } #[crate::test] - fn test_input_handler_pending(cx: &mut TestAppContext) { - #[derive(Clone)] - struct CustomElement { - focus_handle: FocusHandle, - text: Rc>, - action_count: Rc>, - } - impl CustomElement { - fn new(cx: &mut Context) -> Self { - Self { - focus_handle: cx.focus_handle(), - text: Rc::default(), - action_count: Rc::default(), - } - } - } - impl Element for CustomElement { - type RequestLayoutState = (); + fn test_printable_pending_input_replays_on_timeout(cx: &mut TestAppContext) { + cx.update(|cx| { + cx.bind_keys([KeyBinding::new("j k", TestAction, Some("Terminal"))]); + }); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); + let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); + cx.update(|window, cx| { + window.focus(&focus_handle, cx); + window.activate_window(); + }); - type PrepaintState = (); + cx.simulate_keystrokes("j"); - fn id(&self) -> Option { - Some("custom".into()) - } - fn source_location(&self) -> Option<&'static panic::Location<'static>> { - None - } - fn request_layout( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - window: &mut Window, - cx: &mut App, - ) -> (LayoutId, Self::RequestLayoutState) { - (window.request_layout(Style::default(), [], cx), ()) - } - fn prepaint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - window: &mut Window, - cx: &mut App, - ) -> Self::PrepaintState { - window.set_focus_handle(&self.focus_handle, cx); - } - fn paint( - &mut self, - _: Option<&GlobalElementId>, - _: Option<&InspectorElementId>, - _: Bounds, - _: &mut Self::RequestLayoutState, - _: &mut Self::PrepaintState, - window: &mut Window, - cx: &mut App, - ) { - let mut key_context = KeyContext::default(); - key_context.add("Terminal"); - window.set_key_context(key_context); - window.handle_input(&self.focus_handle, self.clone(), cx); - let action_count = self.action_count.clone(); - window.on_action( - std::any::TypeId::of::(), - move |_, phase, _, _| { - if phase == DispatchPhase::Bubble { - action_count.set(action_count.get() + 1); - } - }, - ); - } - } - impl IntoElement for CustomElement { - type Element = Self; + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_some()); + }); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), ""); + }); - fn into_element(self) -> Self::Element { - self - } + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + test.update(cx, |test, _| { + assert_eq!(test.action_count.get(), 0); + assert_eq!(test.text.borrow().as_str(), "j"); + }); + } + + #[crate::test] + fn test_pending_input_timeout_dispatches_shorter_binding(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.update(|window, _| { + assert_eq!( + window + .pending_input() + .map(|pending_input| pending_input.keystrokes().len()), + Some(1) + ); + assert_eq!( + window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .map(|timeout| timeout.duration()), + Some(crate::PENDING_INPUT_TIMEOUT) + ); + }); + assert_eq!(action_count.get(), 0); + + // Emulate a countdown indicator re-rendering the window while waiting for the timeout. + for _ in 0..10 { + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 10); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); } - impl InputHandler for CustomElement { - fn selected_text_range( - &mut self, - _: bool, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } - fn marked_text_range(&mut self, _: &mut Window, _: &mut App) -> Option> { - None - } + #[crate::test] + fn test_running_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); - fn text_for_range( - &mut self, - _: Range, - _: &mut Option>, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.simulate_keystrokes("h"); + cx.run_until_parked(); - fn replace_text_in_range( - &mut self, - replacement_range: Option>, - text: &str, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(text) - } + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); - fn replace_and_mark_text_in_range( - &mut self, - replacement_range: Option>, - new_text: &str, - _: Option>, - _: &mut Window, - _: &mut App, - ) { - if replacement_range.is_some() { - unimplemented!() - } - self.text.borrow_mut().push_str(new_text) - } + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); - fn unmark_text(&mut self, _: &mut Window, _: &mut App) {} + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 4 / 5); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } - fn prefers_ime_for_printable_keys(&mut self, _: &mut Window, _: &mut App) -> bool { - true - } + #[crate::test] + fn test_pending_input_timeout_starts_when_binding_becomes_ambiguous(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h j", TestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); - fn bounds_for_range( - &mut self, - _: Range, - _: &mut Window, - _: &mut App, - ) -> Option> { - None - } + cx.update(|window, _| { + let pending_input = window.pending_input().expect("pending input"); + assert_eq!(pending_input.keystrokes().len(), 1); + assert!(pending_input.timeout().is_none()); + }); - fn character_index_for_point( - &mut self, - _: Point, - _: &mut Window, - _: &mut App, - ) -> Option { - None - } - } - impl Render for CustomElement { - fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - self.clone() - } - } + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_invalid_continuation_while_timeout_paused_replays_pending_input( + cx: &mut TestAppContext, + ) { + let (cx, action_count, secondary_action_count) = setup_pending_input_test( + cx, + [ + KeyBinding::new("ctrl-b", TestAction, Some("Terminal")), + KeyBinding::new("ctrl-b h", SecondaryTestAction, Some("Terminal")), + KeyBinding::new("x", SecondaryTestAction, Some("Terminal")), + ], + ); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + cx.simulate_keystrokes("x"); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_pending_input_timeout_pauses_and_resumes(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + let other_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + assert!(!window.set_pending_input_timeout_paused(&other_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resumes_when_owner_is_released(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 7 / 10); + cx.run_until_parked(); + + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert!(!timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT * 3 / 10); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 3 / 10); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 1); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_pending_input_timeout_resets_when_binding_advances(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT / 2); + cx.run_until_parked(); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + }); + cx.run_until_parked(); + cx.update(|window, cx| { + let timeout = window + .pending_input() + .and_then(|pending_input| pending_input.timeout()) + .expect("pending input timeout"); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT / 2); + }); + + cx.simulate_keystrokes("h"); + cx.run_until_parked(); + cx.update(|window, cx| { + let pending_input = window.pending_input().expect("pending input"); + let timeout = pending_input.timeout().expect("pending input timeout"); + assert_eq!(pending_input.keystrokes().len(), 2); + assert!(timeout.is_paused()); + assert_eq!(timeout.remaining(cx), crate::PENDING_INPUT_TIMEOUT); + }); + + cx.executor() + .advance_clock(crate::PENDING_INPUT_TIMEOUT * 2); + cx.run_until_parked(); + cx.update(|window, _| assert!(window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, false, cx)); + }); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(!window.has_pending_keystrokes())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 1); + } + + #[crate::test] + fn test_clearing_pending_input_invalidates_timeout_pause(cx: &mut TestAppContext) { + let (cx, action_count, secondary_action_count) = setup_pending_input_timeout_test(cx); + simulate_pending_binding(cx); + let pause_owner = cx.update(|_, cx| cx.new(|_| PendingInputTimeoutPauseOwner)); + cx.update(|window, cx| { + assert!(window.set_pending_input_timeout_paused(&pause_owner, true, cx)); + window.focus(&cx.focus_handle(), cx); + assert!(!window.has_pending_keystrokes()); + }); + + drop(pause_owner); + cx.update(|_, _| {}); + cx.run_until_parked(); + cx.executor().advance_clock(crate::PENDING_INPUT_TIMEOUT); + cx.run_until_parked(); + + cx.update(|window, _| assert!(window.pending_input_is_none())); + assert_eq!(action_count.get(), 0); + assert_eq!(secondary_action_count.get(), 0); + } + + #[crate::test] + fn test_input_handler_pending(cx: &mut TestAppContext) { cx.update(|cx| { cx.bind_keys([KeyBinding::new("ctrl-b", TestAction, Some("Terminal"))]); cx.bind_keys([KeyBinding::new("ctrl-b h", TestAction, Some("Terminal"))]); cx.bind_keys([KeyBinding::new("ctrl-x k", TestAction, Some("Terminal"))]); }); - let (test, cx) = cx.add_window_view(|_, cx| CustomElement::new(cx)); + let (test, cx) = cx.add_window_view(|_, cx| PendingTextInputTestView::new(cx)); let focus_handle = test.update(cx, |test, _| test.focus_handle.clone()); cx.update(|window, cx| { window.focus(&focus_handle, cx); window.activate_window(); }); - let query_prefers_ime_for_printable_keys = |cx: &mut VisualTestContext| { - let mut platform_window = cx.test_window(cx.window_handle()); - let mut input_handler = platform_window.take_input_handler()?; - let prefers_ime = input_handler.query_prefers_ime_for_printable_keys(); - platform_window.set_input_handler(input_handler); - Some(prefers_ime) - }; - assert_eq!(query_prefers_ime_for_printable_keys(cx), Some(true)); cx.simulate_keystrokes("ctrl-x"); cx.update(|window, _| assert!(window.has_pending_keystrokes())); @@ -1226,10 +1659,10 @@ mod tests { let prefers_ime_after_blur = { let mut platform_window = cx.test_window(cx.window_handle()); let mut input_handler = platform_window.take_input_handler(); - cx.update(|window, _| { - window.blur(); + cx.update(|window, cx| { + window.blur(cx); assert!(!window.has_pending_keystrokes()); - assert!(window.pending_input_keystrokes().is_none()); + assert!(window.pending_input_is_none()); }); let prefers_ime = input_handler .as_mut() diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index ab4b500..eeba999 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -9,10 +9,10 @@ pub mod layer_shell; /// Types for configuring parent-anchored popup windows such as menus, dropdowns and tooltips. pub mod popup; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] mod threaded_dispatcher; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] mod test; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] @@ -47,7 +47,7 @@ use anyhow::bail; use anyhow::{Context as _, Result}; use async_task::Runnable; use futures::channel::oneshot; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] use image::RgbaImage; use image::codecs::gif::GifDecoder; use image::{AnimationDecoder as _, DynamicImage, Frame}; @@ -78,13 +78,13 @@ pub use app_menu::*; pub use keyboard::*; pub use keystroke::*; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub(crate) use test::*; #[cfg(any(test, feature = "test-support"))] pub use test::{TestDispatcher, TestScreenCaptureSource, TestScreenCaptureStream}; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub use threaded_dispatcher::ThreadedDispatcher; #[cfg(all(target_os = "macos", any(test, feature = "test-support")))] @@ -200,7 +200,7 @@ pub trait Platform: 'static { fn reveal_path(&self, path: &Path); fn open_with_system(&self, path: &Path); - fn on_quit(&self, callback: Box); + fn on_quit(&self, callback: Box bool>); fn on_reopen(&self, callback: Box); fn on_system_wake(&self, callback: Box); @@ -827,6 +827,11 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn capslock(&self) -> Capslock; fn set_input_handler(&mut self, input_handler: PlatformInputHandler); fn take_input_handler(&mut self) -> Option; + /// Apply the focused text region's [`TextInputConfiguration`] to the + /// platform's text input session (e.g. attributes of the hidden editable + /// element on web). Called only when the configuration changes, because + /// reconfiguring a live input session can restart the IME connection. + fn set_text_input_configuration(&mut self, _configuration: TextInputConfiguration) {} fn prompt( &self, level: PromptLevel, @@ -974,7 +979,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { /// Inform the adapter of updated window bounds. fn a11y_update_window_bounds(&self) {} - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_test(&mut self) -> Option<&mut TestWindow> { None } @@ -989,7 +994,7 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { } /// A renderer for headless windows that can produce real rendered output. -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub trait PlatformHeadlessRenderer { /// Render a scene and return the result as an RGBA image. fn render_scene_to_image( @@ -1055,14 +1060,14 @@ pub trait PlatformDispatcher: Send + Sync { gpui_util::defer(Box::new(|| {})) } - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_test(&self) -> Option<&TestDispatcher> { None } // This cfg must match the `threaded_dispatcher` module's, which implements // this method whenever it compiles. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn as_threaded(&self) -> Option<&ThreadedDispatcher> { None } @@ -1075,6 +1080,8 @@ pub trait PlatformTextSystem: Send + Sync { fn all_font_names(&self) -> Vec; /// Get the font ID for a font descriptor. fn font_id(&self, descriptor: &Font) -> Result; + /// Prewarm any system font caches needed to shape text. + fn prewarm_fonts(&self, _font_ids: &[FontId]) {} /// Get metrics for a font. fn font_metrics(&self, font_id: FontId) -> FontMetrics; /// Get typographic bounds for a glyph. @@ -1329,7 +1336,7 @@ pub trait PlatformAtlas { ) -> Result>; fn remove(&self, key: &AtlasKey); - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] fn contains(&self, _key: &AtlasKey) -> bool { false } @@ -1652,6 +1659,23 @@ impl PlatformInputHandler { }) .unwrap_or(false) } + + /// See [`InputHandler::text_input_configuration`]. + pub fn text_input_configuration( + &mut self, + window: &mut Window, + cx: &mut App, + ) -> TextInputConfiguration { + self.handler.text_input_configuration(window, cx) + } + + /// See [`InputHandler::text_input_editable_range`]. + pub fn text_input_editable_range(&mut self) -> Option> { + self.cx + .update(|window, cx| self.handler.text_input_editable_range(window, cx)) + .ok() + .flatten() + } } /// A struct representing a selection in a text buffer, in UTF16 characters. @@ -1810,6 +1834,24 @@ pub trait InputHandler: 'static { true } + /// The contiguous range of text, in UTF-16 code units, that platform text + /// input may read and edit around the current selection. + /// + /// Platforms that mirror document text into an IME-editable buffer clamp + /// the mirrored window to this range, so multi-step IME edit gestures + /// (word deletion, autocorrect rewrites, suggestion picks) cannot reach + /// content outside it. The range should contain the current selection; + /// when it cannot (a selection spanning a region boundary), platforms + /// degrade the mirrored IME context rather than widening the range. + /// `None` places no bound. + fn text_input_editable_range( + &mut self, + _window: &mut Window, + _cx: &mut App, + ) -> Option> { + None + } + /// 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 @@ -1821,6 +1863,84 @@ pub trait InputHandler: 'static { fn prefers_ime_for_printable_keys(&mut self, _window: &mut Window, _cx: &mut App) -> bool { false } + + /// Get this handler's preferences for platform text assistance. + /// + /// GPUI re-queries this every frame and forwards it to the platform window + /// only when it changes, so implementations must be cheap and may vary the + /// result with application state (e.g. with the cursor's position). + fn text_input_configuration( + &mut self, + _window: &mut Window, + _cx: &mut App, + ) -> TextInputConfiguration { + TextInputConfiguration::default() + } +} + +/// Platform text-assistance preferences for the focused text region. +/// +/// Returned by [`InputHandler::text_input_configuration`] and forwarded to the +/// platform whenever it changes; the platform maps the fields onto its native +/// input-session attributes (on web, DOM attributes of the hidden editable +/// element such as `autocorrect` and `enterkeyhint`). +/// +/// The default disables all text assistance and requests no particular action +/// key presentation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TextInputConfiguration { + /// Whether the platform may automatically correct entered text. + pub autocorrect: bool, + /// How software keyboards automatically capitalize entered text. + pub autocapitalize: Autocapitalize, + /// Whether software keyboards may offer word suggestions and spellcheck. + pub suggestions: bool, + /// The action advertised on a software keyboard's confirm ("enter") key. + pub input_action: TextInputAction, +} + +/// Automatic capitalization applied by software keyboards. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Autocapitalize { + /// No automatic capitalization. + #[default] + None, + /// Capitalize the first letter of each word. + Words, + /// Capitalize the first letter of each sentence. + Sentences, + /// Capitalize every letter. + Characters, +} + +/// The action a software keyboard advertises on its confirm ("enter") key. +/// +/// This affects only how the key is presented (icon or label); pressing it is +/// still delivered as ordinary input. +/// +/// The variants are the HTML `enterkeyhint` attribute's value set +/// (), +/// which also maps onto Android's `IME_ACTION_*` constants and iOS's +/// `UIReturnKeyType`; [`TextInputAction::Unspecified`] means "emit no hint". +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum TextInputAction { + /// Let the platform choose its default presentation. + #[default] + Unspecified, + /// Inserting a line break. + Enter, + /// Committing the field's value. + Done, + /// Navigating to the typed target. + Go, + /// Moving to the next field. + Next, + /// Moving to the previous field. + Previous, + /// Executing a search. + Search, + /// Sending a message. + Send, } /// The variables that can be configured when creating a new window diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index 9227df5..a327d8f 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -8,4 +8,5 @@ pub(crate) use display::*; pub(crate) use platform::*; pub(crate) use window::*; +#[cfg(any(test, feature = "test-support"))] pub use platform::{TestScreenCaptureSource, TestScreenCaptureStream}; diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 3982190..df1d0f4 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,12 +1,17 @@ +#[cfg(any(test, feature = "test-support"))] +use crate::NoopTextSystem; +#[cfg(any(test, feature = "test-support"))] +use crate::PathPromptOptions; use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, PathPromptOptions, Platform, - PlatformDisplay, PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, - PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, - SharedString, SourceMetadata, SystemNotification, SystemNotificationResponse, Task, - TestDisplay, TestWindow, ThermalState, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, OwnedMenu, Platform, PlatformDisplay, + PlatformHeadlessRenderer, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SharedString, + SourceMetadata, SystemNotification, SystemNotificationResponse, Task, TestDisplay, TestWindow, + ThermalState, WindowAppearance, WindowParams, size, }; use anyhow::Result; +#[cfg(any(test, feature = "test-support"))] use collections::VecDeque; use futures::channel::oneshot; use parking_lot::Mutex; @@ -30,6 +35,7 @@ pub(crate) struct TestPlatform { current_primary_item: Mutex>, #[cfg(target_os = "macos")] current_find_pasteboard_item: Mutex>, + #[cfg(any(test, feature = "test-support"))] pub(crate) prompts: RefCell, screen_capture_sources: RefCell>, pub opened_url: RefCell>, @@ -39,6 +45,7 @@ pub(crate) struct TestPlatform { RefCell, Vec)>>>, headless_renderer_factory: Option Option>>>, weak: Weak, + menus: RefCell>, } #[derive(Clone)] @@ -77,6 +84,7 @@ impl ScreenCaptureStream for TestScreenCaptureStream { } } +#[cfg(any(test, feature = "test-support"))] struct TestPrompt { msg: String, detail: Option, @@ -93,6 +101,7 @@ pub(crate) struct TestSystemNotifications { response_callback: Option>, } +#[cfg(any(test, feature = "test-support"))] #[derive(Default)] pub(crate) struct TestPrompts { multiple_choice: VecDeque, @@ -104,6 +113,7 @@ pub(crate) struct TestPrompts { } impl TestPlatform { + #[cfg(any(test, feature = "test-support"))] pub fn new(executor: BackgroundExecutor, foreground_executor: ForegroundExecutor) -> Rc { Self::with_platform( executor, @@ -113,6 +123,7 @@ impl TestPlatform { ) } + #[cfg(any(test, feature = "test-support"))] pub fn with_text_system( executor: BackgroundExecutor, foreground_executor: ForegroundExecutor, @@ -132,6 +143,7 @@ impl TestPlatform { Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, foreground_executor, + #[cfg(any(test, feature = "test-support"))] prompts: Default::default(), screen_capture_sources: Default::default(), active_cursor: Default::default(), @@ -148,9 +160,11 @@ impl TestPlatform { system_notifications: Default::default(), text_system, headless_renderer_factory, + menus: Default::default(), }) } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn simulate_new_path_selection( &self, select_path: impl FnOnce(&std::path::Path) -> Option, @@ -164,6 +178,7 @@ impl TestPlatform { tx.send(Ok(select_path(&path))).ok(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn simulate_path_prompt_response( &self, select_paths: impl FnOnce(&PathPromptOptions) -> Option>, @@ -187,10 +202,12 @@ impl TestPlatform { tx.send(Ok(selection)).ok(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn did_prompt_for_paths(&self) -> bool { !self.prompts.borrow().paths.is_empty() } + #[cfg(any(test, feature = "test-support"))] #[track_caller] pub(crate) fn simulate_prompt_answer(&self, response: &str) { let prompt = self @@ -208,10 +225,12 @@ impl TestPlatform { prompt.tx.send(ix).ok(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn has_pending_prompt(&self) -> bool { !self.prompts.borrow().multiple_choice.is_empty() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn pending_prompt(&self) -> Option<(String, String)> { let prompts = self.prompts.borrow(); let prompt = prompts.multiple_choice.front()?; @@ -221,10 +240,14 @@ impl TestPlatform { )) } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn set_screen_capture_sources(&self, sources: Vec) { *self.screen_capture_sources.borrow_mut() = sources; } + /// Queues the prompt so a test can later inspect or answer it through + /// [`Self::pending_prompt`] and [`Self::simulate_prompt_answer`]. + #[cfg(any(test, feature = "test-support"))] pub(crate) fn prompt( &self, msg: &str, @@ -245,6 +268,19 @@ impl TestPlatform { rx } + /// Benchmarks have no API to answer a prompt, so this doesn't retain it + /// for later inspection; dropping the sender immediately cancels the + /// returned receiver instead of leaving it pending indefinitely. + #[cfg(not(any(test, feature = "test-support")))] + pub(crate) fn prompt( + &self, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> oneshot::Receiver { + oneshot::channel().1 + } + pub(crate) fn set_active_window(&self, window: Option) { let executor = self.foreground_executor(); let previous_window = self.active_window.borrow_mut().take(); @@ -267,26 +303,32 @@ impl TestPlatform { .detach(); } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn did_prompt_for_new_path(&self) -> bool { !self.prompts.borrow().new_path.is_empty() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn app_identity(&self) -> Option<(SharedString, SharedString)> { self.system_notifications.borrow().app_identity.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn shown_system_notifications(&self) -> Vec { self.system_notifications.borrow().shown.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn delivered_system_notifications(&self) -> Vec { self.system_notifications.borrow().delivered.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn dismissed_system_notifications(&self) -> Vec { self.system_notifications.borrow().dismissed.clone() } + #[cfg(any(test, feature = "test-support"))] pub(crate) fn simulate_system_notification_response( &self, response: SystemNotificationResponse, @@ -424,6 +466,9 @@ impl Platform for TestPlatform { unimplemented!() } + /// Queues the prompt so a test can later answer it through + /// [`Self::simulate_path_prompt_response`]. + #[cfg(any(test, feature = "test-support"))] fn prompt_for_paths( &self, options: crate::PathPromptOptions, @@ -433,6 +478,21 @@ impl Platform for TestPlatform { rx } + /// Benchmarks have no API to answer a path prompt, so this doesn't + /// retain it for later inspection; dropping the sender immediately + /// cancels the returned receiver instead of leaving it pending + /// indefinitely. + #[cfg(not(any(test, feature = "test-support")))] + fn prompt_for_paths( + &self, + _options: crate::PathPromptOptions, + ) -> oneshot::Receiver>>> { + oneshot::channel().1 + } + + /// Queues the prompt so a test can later answer it through + /// [`Self::simulate_new_path_selection`]. + #[cfg(any(test, feature = "test-support"))] fn prompt_for_new_path( &self, directory: &std::path::Path, @@ -446,6 +506,19 @@ impl Platform for TestPlatform { rx } + /// Benchmarks have no API to answer a new-path prompt, so this doesn't + /// retain it for later inspection; dropping the sender immediately + /// cancels the returned receiver instead of leaving it pending + /// indefinitely. + #[cfg(not(any(test, feature = "test-support")))] + fn prompt_for_new_path( + &self, + _directory: &std::path::Path, + _suggested_name: Option<&str>, + ) -> oneshot::Receiver>> { + oneshot::channel().1 + } + fn can_select_mixed_files_and_dirs(&self) -> bool { true } @@ -454,7 +527,7 @@ impl Platform for TestPlatform { unimplemented!() } - fn on_quit(&self, _callback: Box) {} + fn on_quit(&self, _callback: Box bool>) {} fn on_reopen(&self, _callback: Box) { unimplemented!() @@ -502,7 +575,14 @@ impl Platform for TestPlatform { self.system_notifications.borrow_mut().response_callback = Some(callback); } - fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} + fn set_menus(&self, menus: Vec, _keymap: &Keymap) { + *self.menus.borrow_mut() = menus.into_iter().map(|menu| menu.owned()).collect() + } + + fn get_menus(&self) -> Option> { + Some(self.menus.borrow().clone()) + } + fn set_dock_menu(&self, _menu: Vec, _keymap: &Keymap) {} fn add_recent_document(&self, _paths: &Path) {} @@ -586,6 +666,7 @@ impl Platform for TestPlatform { impl TestScreenCaptureSource { /// Create a fake screen capture source, for testing. + #[cfg(any(test, feature = "test-support"))] pub fn new() -> Self { Self {} } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 939c8a8..38926b6 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -2,11 +2,13 @@ use crate::{ AnyWindowHandle, AtlasKey, AtlasTextureId, AtlasTile, Bounds, DevicePixels, DispatchEventResult, GpuSpecs, Pixels, PlatformAtlas, PlatformDisplay, PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, - PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TileId, WindowAppearance, - WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowParams, + PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, + TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, + WindowControlArea, WindowParams, }; use collections::HashMap; use gpui_util::ResultExt as _; +#[cfg(any(test, feature = "test-support"))] use image::RgbaImage; use parking_lot::Mutex; use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; @@ -41,6 +43,8 @@ pub(crate) struct TestWindowState { frame_scheduled: bool, frame_callback_pending: bool, input_handler: Option, + text_input_configurations: Vec, + text_input_state_changes: Vec, is_fullscreen: bool, appearance: WindowAppearance, external_drag_files: Vec<(PathBuf, bool)>, @@ -103,6 +107,8 @@ impl TestWindow { frame_scheduled: false, frame_callback_pending: false, input_handler: None, + text_input_configurations: Vec::new(), + text_input_state_changes: Vec::new(), is_fullscreen: false, appearance: WindowAppearance::Light, external_drag_files: Vec::new(), @@ -132,6 +138,15 @@ impl TestWindow { self.0.lock().frame_scheduled } + /// Every [`TextInputConfiguration`] forwarded to this window, in order. + pub fn text_input_configurations(&self) -> Vec { + self.0.lock().text_input_configurations.clone() + } + + pub fn text_input_state_changes(&self) -> Vec { + self.0.lock().text_input_state_changes.clone() + } + pub fn simulate_resize(&mut self, size: Size) { let scale_factor = self.scale_factor(); let mut lock = self.0.lock(); @@ -257,6 +272,14 @@ impl PlatformWindow for TestWindow { self.0.lock().input_handler.take() } + fn set_text_input_configuration(&mut self, configuration: TextInputConfiguration) { + self.0.lock().text_input_configurations.push(configuration); + } + + fn text_input_state_changed(&self, change: TextInputStateChange) { + self.0.lock().text_input_state_changes.push(change); + } + fn prompt( &self, _level: crate::PromptLevel, diff --git a/crates/gpui/src/platform/threaded_dispatcher.rs b/crates/gpui/src/platform/threaded_dispatcher.rs index ff45e1c..762464a 100644 --- a/crates/gpui/src/platform/threaded_dispatcher.rs +++ b/crates/gpui/src/platform/threaded_dispatcher.rs @@ -268,7 +268,7 @@ impl ThreadedDispatcher { /// drains — deferred work that re-queues itself (idle sweeps, pollers) /// must not extend a benchmark's measured interval past the completion it /// awaits. - #[cfg(any(test, feature = "bench"))] + #[cfg(any(test, feature = "bench-support"))] pub(crate) fn run_until(&self, mut ready: impl FnMut() -> Option) -> R { assert!( self.is_main_thread(), @@ -296,7 +296,7 @@ impl ThreadedDispatcher { /// readiness between them: a task that perpetually re-queues itself (like /// an idle-time sweep) would otherwise keep [`Self::drain_main_queue`] /// looping past the completion the caller is waiting for. - #[cfg(any(test, feature = "bench"))] + #[cfg(any(test, feature = "bench-support"))] fn run_one_main_task(&self) -> bool { let runnable = self.main_receiver.lock().try_pop(); match runnable { @@ -373,7 +373,7 @@ impl ThreadedDispatcher { /// Whether no main-thread work is queued, no background or timer /// runnables are queued or running, and no armed timer is due. Timers /// that aren't due yet are ignored, as in [`Self::run_until_idle`]. - #[cfg(any(test, feature = "bench"))] + #[cfg(any(test, feature = "bench-support"))] pub(crate) fn is_idle(&self) -> bool { !self.main_queue_has_work() && !self.has_due_timer() && *self.idle.inflight.lock() == 0 } diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 3ff561b..042a757 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -172,7 +172,7 @@ impl Platform for VisualTestPlatform { self.platform.open_with_system(path) } - fn on_quit(&self, _callback: Box) {} + fn on_quit(&self, _callback: Box bool>) {} fn on_reopen(&self, _callback: Box) {} diff --git a/crates/gpui/src/platform_scheduler.rs b/crates/gpui/src/platform_scheduler.rs index 311f452..a555a5a 100644 --- a/crates/gpui/src/platform_scheduler.rs +++ b/crates/gpui/src/platform_scheduler.rs @@ -66,51 +66,42 @@ impl PlatformScheduler { } impl Scheduler for PlatformScheduler { + #[cfg(not(target_family = "wasm"))] 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, + mut future: Pin<&mut dyn Future>, + timeout: Option, ) -> bool { - #[cfg(target_family = "wasm")] - { - let _ = (&future, &timeout); - panic!("Cannot block on 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; } - #[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; + 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; } } } diff --git a/crates/gpui/src/profiler.rs b/crates/gpui/src/profiler.rs index 1c4f09a..340f054 100644 --- a/crates/gpui/src/profiler.rs +++ b/crates/gpui/src/profiler.rs @@ -734,10 +734,10 @@ pub fn set_trace_enabled(enabled: bool) -> bool { } } -#[cfg(any(feature = "bench", all(test, feature = "profiler")))] +#[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] pub(crate) struct TraceGuard; -#[cfg(any(feature = "bench", all(test, feature = "profiler")))] +#[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] pub(crate) fn trace_scope() -> TraceGuard { let incremented = TRACE_STATE.fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| { (state & TRACE_SCOPE_COUNT_MASK < TRACE_SCOPE_COUNT_MASK).then_some(state + 1) @@ -746,7 +746,7 @@ pub(crate) fn trace_scope() -> TraceGuard { TraceGuard } -#[cfg(any(feature = "bench", all(test, feature = "profiler")))] +#[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] impl Drop for TraceGuard { fn drop(&mut self) { let previous_state = @@ -852,6 +852,8 @@ pub enum FrameEvent { #[cfg(feature = "profiler")] #[derive(Clone)] pub struct FrameDurationSnapshot { + /// Histogram of durations from the first invalidation through presentation, in nanoseconds. + pub dirty_to_present_histogram: Histogram, /// Histogram of `Window::draw` durations, in nanoseconds. pub draw_duration_histogram: Histogram, /// Histogram of intervals between consecutively presented frames while the @@ -894,6 +896,7 @@ pub struct WindowProfiler { window_id: WindowId, active_activities: SmallVec<[WindowActivity; 4]>, active_actions: SmallVec<[(&'static str, Instant); 2]>, + dirty_to_present_histogram: Histogram, draw_duration_histogram: Histogram, present_interval_histogram: Histogram, first_input_at: Option, @@ -914,6 +917,9 @@ impl WindowProfiler { window_id, active_activities: SmallVec::new(), active_actions: SmallVec::new(), + dirty_to_present_histogram: Histogram::new(3).map_err(|error| { + anyhow::anyhow!("Failed to create dirty-to-present histogram: {error}") + })?, draw_duration_histogram: Histogram::new(3).map_err(|error| { anyhow::anyhow!("Failed to create draw duration histogram: {error}") })?, @@ -1070,6 +1076,7 @@ impl WindowProfiler { /// Returns a snapshot of the current frame-duration histograms. pub fn frame_duration_snapshot(&self) -> FrameDurationSnapshot { FrameDurationSnapshot { + dirty_to_present_histogram: self.dirty_to_present_histogram.clone(), draw_duration_histogram: self.draw_duration_histogram.clone(), present_interval_histogram: self.present_interval_histogram.clone(), } @@ -1109,8 +1116,16 @@ impl WindowProfiler { }; journal::record_present(present_timing, frame); - if frame.is_none() { + let Some(frame) = frame else { return; + }; + + if let Some(dirty_at) = frame.dirty_at + && let Err(error) = self + .dirty_to_present_histogram + .record(present_end.duration_since(dirty_at).as_nanos() as u64) + { + log::error!("failed to record dirty-to-present frame timing: {error}"); } if let Some(animation_interval) = animation_interval { @@ -1408,6 +1423,22 @@ mod tests { assert_eq!(window_profiler.present_interval_histogram.len(), 1); } + #[test] + fn records_dirty_to_present_durations() { + let mut window_profiler = + WindowProfiler::new(WindowId::from(8)).expect("window profiler should initialize"); + let draw_end = Instant::now(); + let present_end = draw_end + Duration::from_millis(6); + + record_test_draw(&mut window_profiler, draw_end); + window_profiler.record_present_at(present_end, present_end, true, false); + + let snapshot = window_profiler.frame_duration_snapshot(); + let histogram = snapshot.dirty_to_present_histogram; + assert_eq!(histogram.len(), 1); + assert!(histogram.max() >= Duration::from_millis(10).as_nanos() as u64); + } + #[cfg(feature = "profiler")] #[test] fn records_every_draw_duration() { diff --git a/crates/gpui/src/profiler/actions.rs b/crates/gpui/src/profiler/actions.rs index be82af2..a4892f8 100644 --- a/crates/gpui/src/profiler/actions.rs +++ b/crates/gpui/src/profiler/actions.rs @@ -1,6 +1,7 @@ -use std::time::{Duration, Instant}; +use std::time::Duration; use itertools::Itertools; +use scheduler::Instant; #[cfg(feature = "profiler")] use crate::action::Action; diff --git a/crates/gpui/src/profiler/hang.rs b/crates/gpui/src/profiler/hang.rs index 9514931..7aea645 100644 --- a/crates/gpui/src/profiler/hang.rs +++ b/crates/gpui/src/profiler/hang.rs @@ -40,13 +40,30 @@ pub struct HangIncident { /// The interval the hangs occurred in, including all non-hang foreground /// work recorded alongside them. pub snapshot: FrameSnapshot, - /// The events that blocked the foreground for at least the detector's - /// threshold, longest first. When the incident was triggered by the - /// frame budget alone, no event crossed the threshold and this instead + /// Which detection rule qualified the interval. + pub trigger: HangTrigger, + /// For [`HangTrigger::Threshold`], the events that blocked the foreground + /// for at least the detector's threshold, longest first. For + /// [`HangTrigger::Budget`], no event crossed the threshold and this instead /// holds every event in the interval, longest first. pub contributors: Vec, } +/// The detection rule that qualified an interval as a [`HangIncident`]. +/// +/// Recorded explicitly so consumers can separate the two classes without +/// re-deriving them from `stall_ms`, which stops working whenever the +/// detector's thresholds change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HangTrigger { + /// A single event blocked the foreground for at least the hang threshold. + Threshold, + /// No single event crossed the threshold, but the interval's total + /// foreground spend reached the frame budget. + Budget, +} + impl HangDetector { /// Creates a detector reporting single events at or above `threshold` /// and intervals whose total foreground spend reached `frame_budget`. @@ -98,6 +115,8 @@ pub struct SerializedHangIncident { /// newly drawn frame finished platform submission (see /// [`HangDetector::first_present_at`]), otherwise `"steady"`. pub phase: &'static str, + /// `"threshold"` or `"budget"` (see [`HangTrigger`]). + pub trigger: HangTrigger, /// When the incident's active window started, in milliseconds since app /// startup: the sealing frame's first invalidation, or the earliest /// contributor's start when nothing was pending a repaint. Foreground @@ -250,6 +269,7 @@ impl SerializedHangIncident { Some(first_present_at) if active_start >= first_present_at => "steady", _ => "startup", }, + trigger: incident.trigger, start_ms: since_startup(active_start), active_ms: as_millis(active), stall_ms: incident @@ -407,7 +427,7 @@ impl HangIncident { .filter(|event| event.duration() >= threshold) .copied() .collect(); - if contributors.is_empty() { + let trigger = if contributors.is_empty() { if snapshot.journal_discontinuous { return None; } @@ -416,10 +436,14 @@ impl HangIncident { return None; } contributors = snapshot.events.clone(); - } + HangTrigger::Budget + } else { + HangTrigger::Threshold + }; contributors.sort_by_key(|event| std::cmp::Reverse(event.duration())); Some(Self { snapshot, + trigger, contributors, }) } @@ -449,7 +473,9 @@ mod tests { InputTiming, IntervalBoundary, PollSummary, PresentedFrame, SmallPollFlush, install_test_foreground_journal, record_present, }; - use super::{HangDetector, HangIncident, SerializedHangContributor, SerializedHangIncident}; + use super::{ + HangDetector, HangIncident, HangTrigger, SerializedHangContributor, SerializedHangIncident, + }; actions!(hang_test, [HangyAction]); @@ -567,6 +593,7 @@ mod tests { let serialized = SerializedHangIncident::convert(startup, &incident, 1, Some(at(50))); assert_eq!(serialized.phase, "steady"); + assert_eq!(serialized.trigger, HangTrigger::Threshold); // The frame's first invalidation anchors the active window, not the // interval start or the first contributor. assert_eq!(serialized.start_ms, 100.0); @@ -755,12 +782,14 @@ mod tests { let incident = HangIncident::detect(snapshot, HANG_THRESHOLD, FRAME_BUDGET) .expect("foreground spend exceeded the frame budget"); + assert_eq!(incident.trigger, HangTrigger::Budget); assert_eq!(incident.contributors.len(), 3); assert_eq!( incident.contributors[0].duration(), Duration::from_millis(8) ); let serialized = SerializedHangIncident::convert(startup, &incident, 8, Some(startup)); + assert_eq!(serialized.trigger, HangTrigger::Budget); assert_eq!(serialized.stall_ms, 8.0); assert_eq!(serialized.dirty_to_present_ms, Some(150.0)); assert_eq!(serialized.sealed_by, "present"); diff --git a/crates/gpui/src/taffy.rs b/crates/gpui/src/taffy.rs index 13d3bc4..b9e583a 100644 --- a/crates/gpui/src/taffy.rs +++ b/crates/gpui/src/taffy.rs @@ -7,7 +7,6 @@ use crate::{ }, }; use collections::{FxHashMap, FxHashSet}; -use stacksafe::{StackSafe, stacksafe}; use std::{fmt::Debug, ops::Range}; use taffy::{ TaffyTree, TraversePartialTree as _, @@ -17,16 +16,14 @@ use taffy::{ tree::NodeId, }; -type NodeMeasureFn = StackSafe< - Box< - dyn FnMut( - Size>, - Size, - &mut Window, - &mut App, - ) -> Size, - >, ->; +#[cfg(feature = "stacker")] +type StackSafe = stacksafe::StackSafe; +#[cfg(not(feature = "stacker"))] +type StackSafe = T; + +type MeasureFn = + dyn FnMut(Size>, Size, &mut Window, &mut App) -> Size; +type NodeMeasureFn = StackSafe>; struct NodeContext { measure: NodeMeasureFn, @@ -99,14 +96,12 @@ impl TaffyLayoutEngine { + 'static, ) -> LayoutId { let taffy_style = style.to_taffy(rem_size, scale_factor); + let measure = Box::new(measure) as Box; + #[cfg(feature = "stacker")] + let measure = StackSafe::new(measure); self.taffy - .new_leaf_with_context( - taffy_style, - NodeContext { - measure: StackSafe::new(Box::new(measure)), - }, - ) + .new_leaf_with_context(taffy_style, NodeContext { measure }) .expect(EXPECT_MESSAGE) .into() } @@ -188,7 +183,7 @@ impl TaffyLayoutEngine { Ok(edges) } - #[stacksafe] + #[cfg_attr(feature = "stacker", stacksafe::stacksafe)] pub fn compute_layout( &mut self, id: LayoutId, diff --git a/crates/gpui/src/text_system.rs b/crates/gpui/src/text_system.rs index cfddc54..b68ee4b 100644 --- a/crates/gpui/src/text_system.rs +++ b/crates/gpui/src/text_system.rs @@ -166,6 +166,22 @@ impl TextSystem { ); } + /// Prewarm any system font caches needed to shape text. + /// + /// This may be expensive, so callers should generally invoke it on a + /// background executor. Missing entries are still populated on demand by + /// the normal shaping path. + pub fn prewarm_fonts(&self, fonts: &[Font]) { + let mut font_ids = SmallVec::<[FontId; 8]>::new(); + for font in fonts { + let font_id = self.resolve_font(font); + if !font_ids.contains(&font_id) { + font_ids.push(font_id); + } + } + self.platform_text_system.prewarm_fonts(&font_ids); + } + /// Get the bounding box for the given font and font size. /// A font's bounding box is the smallest rectangle that could enclose all glyphs /// in the font. superimposed over one another. diff --git a/crates/gpui/src/text_system/line_wrapper.rs b/crates/gpui/src/text_system/line_wrapper.rs index dc2b866..678e9e0 100644 --- a/crates/gpui/src/text_system/line_wrapper.rs +++ b/crates/gpui/src/text_system/line_wrapper.rs @@ -476,6 +476,12 @@ impl LineWrapper { // `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, '-' | '_' | '.' | '\'' | '’' | '‘' | '$' | '%' | '@' | '#' | '^' | '~' | ',' | '=' | ':' | ';') || + // Closing punctuation never starts a line (UAX #14 LB13: no break + // before `!`, `)`, `]`, `}`, closing quotes or an ellipsis) — `plz!`, + // `see)`, `quoted”` wrap as one word instead of orphaning the mark on + // the next line. `/` and `?` stay break opportunities so long paths + // and URLs (`a/b`, `foo?b=2`) can wrap. + matches!(c, '!' | ')' | ']' | '}' | '"' | '”' | '»' | '…') || // `⋯` character is special used in Zed, to keep this at the end of the line. matches!(c, '⋯') || @@ -1152,6 +1158,10 @@ mod tests { assert_word("more⋯"); assert_word("won’t"); assert_word("‘twas"); + assert_word("plz!"); + assert_word("see)"); + assert_word("quoted”"); + assert_word("well…"); // Space assert_not_word("foo bar"); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2f7b589..6eadf9e 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -19,15 +19,18 @@ use crate::{ 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, + TextInputConfiguration, TextInputStateChange, TextRenderingMode, TextStyle, + TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, + WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, + WindowOptions, WindowParams, WindowTextSystem, point, prelude::*, px, rems, size, + transparent_black, }; /// A gaussian is cut off after three standard deviations. const BLUR_REACH: f32 = 3.; +use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}; +use crate::interactive::TouchEvent; use anyhow::{Context as _, Result, anyhow}; use collections::{FxHashMap, FxHashSet}; #[cfg(target_os = "macos")] @@ -859,6 +862,17 @@ impl Hitbox { self.id.is_hovered(window) } + /// Checks whether this hitbox would be hovered at `position`, regardless of the current input + /// modality or mouse position. + pub fn is_hovered_at(&self, position: Point, window: &Window) -> bool { + let hit_test = window.rendered_frame.hit_test(position); + hit_test + .ids + .iter() + .take(hit_test.hover_hitbox_count) + .any(|id| self.id == *id) + } + /// Checks if the hitbox contains the mouse and should handle scroll events. Typically this /// should only be used when handling `ScrollWheelEvent`, and otherwise `is_hovered` should be /// used. See the documentation of `Hitbox::is_hovered` for details about this distinction. @@ -1165,6 +1179,11 @@ pub struct Window { pub(crate) element_opacity: f32, pub(crate) content_mask_stack: Vec>, pub(crate) requested_autoscroll: Option>, + /// The [`TextInputConfiguration`] most recently forwarded to the platform + /// window, so that only actual changes are forwarded (reconfiguring a live + /// input session can restart the IME connection). + last_text_input_configuration: Option, + focused_text_input_active: bool, pub(crate) image_cache_stack: Vec, pub(crate) rendered_frame: Frame, pub(crate) next_frame: Frame, @@ -1195,6 +1214,10 @@ pub struct Window { #[cfg(feature = "profiler")] window_profiler: profiler::WindowProfiler, last_input_modality: InputModality, + touch_gestures: TouchGestureRecognizer, + touch_prediction_enabled: bool, + long_press_timer: Option>, + long_press_capture: Option, pub(crate) refreshing: bool, pub(crate) activation_observers: SubscriberSet<(), AnyObserver>, pub(crate) focus: Option, @@ -1276,12 +1299,139 @@ pub(crate) enum DrawPhase { Focus, } +pub(crate) const PENDING_INPUT_TIMEOUT: Duration = Duration::from_secs(1); + +/// Pending input for a potential multi-stroke key binding. +pub struct PendingInputStatus<'a> { + keystrokes: &'a [Keystroke], + timeout: Option, +} + +impl<'a> PendingInputStatus<'a> { + /// Returns the keystrokes entered so far. + pub fn keystrokes(&self) -> &'a [Keystroke] { + self.keystrokes + } + + /// Returns the timeout state for flushing this input, if it needs a timeout. + pub fn timeout(&self) -> Option { + self.timeout + } +} + +/// The timeout state for pending input. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct PendingInputTimeoutStatus { + duration: Duration, + remaining: Duration, + started_at: Option, + paused: bool, +} + +impl PendingInputTimeoutStatus { + /// Returns the full timeout duration. + pub fn duration(&self) -> Duration { + self.duration + } + + /// Returns the duration remaining before pending input is flushed. + pub fn remaining(&self, cx: &App) -> Duration { + self.started_at + .map(|started_at| { + self.remaining + .saturating_sub(cx.background_executor().now() - started_at) + }) + .unwrap_or(self.remaining) + } + + /// Returns whether the timeout is paused. + pub fn is_paused(&self) -> bool { + self.paused + } +} + +#[derive(Debug)] +struct PendingInputTimeout { + duration: Duration, + remaining: Duration, + state: PendingInputTimeoutState, +} + +#[derive(Debug)] +enum PendingInputTimeoutState { + Running { started_at: Instant, task: Task<()> }, + Paused { pause: PendingInputTimeoutPause }, +} + +#[derive(Debug)] +struct PendingInputTimeoutPause { + owner_id: EntityId, + _release_subscription: Subscription, +} + +impl PendingInputTimeout { + fn is_paused(&self) -> bool { + matches!(&self.state, PendingInputTimeoutState::Paused { .. }) + } + + fn pause(&mut self, pause: PendingInputTimeoutPause, now: Instant) -> bool { + match std::mem::replace(&mut self.state, PendingInputTimeoutState::Paused { pause }) { + PendingInputTimeoutState::Running { started_at, task } => { + self.remaining = self.remaining.saturating_sub(now - started_at); + drop(task); + true + } + previous_state @ PendingInputTimeoutState::Paused { .. } => { + self.state = previous_state; + false + } + } + } + + fn pause_owner_id(&self) -> Option { + match &self.state { + PendingInputTimeoutState::Running { .. } => None, + PendingInputTimeoutState::Paused { pause } => Some(pause.owner_id), + } + } + + fn resume(&mut self, owner_id: EntityId, started_at: Instant, task: Task<()>) -> bool { + match std::mem::replace( + &mut self.state, + PendingInputTimeoutState::Running { started_at, task }, + ) { + PendingInputTimeoutState::Paused { pause } if pause.owner_id == owner_id => true, + previous_state => { + self.state = previous_state; + false + } + } + } + + fn reset_duration(&mut self, duration: Duration) { + self.duration = duration; + self.remaining = duration; + } + + fn status(&self) -> PendingInputTimeoutStatus { + let (started_at, paused) = match &self.state { + PendingInputTimeoutState::Running { started_at, .. } => (Some(*started_at), false), + PendingInputTimeoutState::Paused { .. } => (None, true), + }; + PendingInputTimeoutStatus { + duration: self.duration, + remaining: self.remaining, + started_at, + paused, + } + } +} + #[derive(Default, Debug)] struct PendingInput { keystrokes: SmallVec<[Keystroke; 1]>, focus: Option, - timer: Option>, - needs_timeout: bool, + timeout: Option, } pub(crate) struct ElementStateBox { @@ -1858,6 +2008,8 @@ impl Window { content_mask_stack: Vec::new(), element_opacity: 1.0, requested_autoscroll: None, + last_text_input_configuration: None, + focused_text_input_active: false, rendered_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), next_frame_callbacks, @@ -1885,6 +2037,14 @@ impl Window { #[cfg(feature = "profiler")] window_profiler: profiler::WindowProfiler::new(handle.window_id())?, last_input_modality: InputModality::Mouse, + touch_gestures: TouchGestureRecognizer::new( + cx.platform + .gestures() + .map_or_else(GestureTuning::default, |gestures| gestures.tuning()), + ), + touch_prediction_enabled: true, + long_press_timer: None, + long_press_capture: None, refreshing: false, activation_observers: SubscriberSet::new(), focus: None, @@ -2068,24 +2228,15 @@ impl Window { self.focus = Some(handle.id); self.focus_generation = self.focus_generation.wrapping_add(1); - self.clear_pending_keystrokes(); - - // Avoid re-entrant entity updates by deferring observer notifications to the end of the - // current effect cycle, and only for this window. - let window_handle = self.handle; - cx.defer(move |cx| { - window_handle - .update(cx, |_, window, cx| { - window.pending_input_changed(cx); - }) - .ok(); - }); + self.clear_pending_keystrokes(cx); self.refresh(); } /// Remove focus from all elements within this context's window. - pub fn blur(&mut self) { + pub fn blur(&mut self, cx: &mut App) { + self.clear_pending_keystrokes(cx); + if !self.focus_enabled { return; } @@ -2098,8 +2249,8 @@ impl Window { } /// Blur the window and don't allow anything in it to be focused again. - pub fn disable_focus(&mut self) { - self.blur(); + pub fn disable_focus(&mut self, cx: &mut App) { + self.blur(cx); self.focus_enabled = false; } @@ -2846,6 +2997,20 @@ impl Window { self.captured_hitbox } + /// Captures the current long press for the given entity. + /// + /// The capture is released when the gesture ends or is cancelled, or when + /// a replacement touch begins. A listener must also call + /// [`Self::prevent_default`] on the started event to claim the gesture. + pub fn capture_long_press(&mut self, entity: &Entity) { + self.long_press_capture = Some(entity.entity_id()); + } + + /// Returns whether the given entity has captured the current long press. + pub fn has_long_press_capture(&self, entity: &Entity) -> bool { + self.long_press_capture == Some(entity.entity_id()) + } + /// The current state of the keyboard's modifiers pub fn modifiers(&self) -> Modifiers { self.modifiers @@ -2857,6 +3022,10 @@ impl Window { self.last_input_modality == InputModality::Keyboard } + pub(crate) fn last_input_was_touch(&self) -> bool { + self.last_input_modality == InputModality::Touch + } + /// The current state of the keyboard's capslock pub fn capslock(&self) -> Capslock { self.capslock @@ -2921,14 +3090,28 @@ impl Window { // 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 + let focused_text_input_active = if let Some(mut input_handler) = self .next_frame .input_handlers .iter_mut() .rev() .find_map(|h| h.take()) { + let accepts_text_input = input_handler.accepts_text_input(self, cx); self.platform_window.set_input_handler(input_handler); + accepts_text_input + } else { + false + }; + self.apply_text_input_configuration(cx); + if focused_text_input_active != self.focused_text_input_active { + self.focused_text_input_active = focused_text_input_active; + self.platform_window + .text_input_state_changed(if focused_text_input_active { + TextInputStateChange::FocusGained + } else { + TextInputStateChange::FocusLost + }); } self.layout_engine.as_mut().unwrap().clear(); @@ -3069,7 +3252,7 @@ impl Window { /// Benchmarks drive drawing synchronously rather than through a platform /// frame-request loop, so they call this after each measured update to /// submit the frame like production presentation would. - #[cfg(any(feature = "bench", all(test, feature = "profiler")))] + #[cfg(any(feature = "bench-support", all(test, feature = "profiler")))] pub fn present_if_needed(&mut self) { if self.needs_present.get() { self.present(); @@ -4261,11 +4444,13 @@ impl Window { let element_bounds = bounds.scale(scale_factor); let transform_origin = match grows_from { - Some(origin) => element_bounds.origin - + point( - element_bounds.size.width * origin.x, - element_bounds.size.height * origin.y, - ), + Some(origin) => { + element_bounds.origin + + point( + element_bounds.size.width * origin.x, + element_bounds.size.height * origin.y, + ) + } None => element_bounds.center(), }; let source_bounds = element_bounds.dilate(ScaledPixels(filter.blur * BLUR_REACH)); @@ -5058,6 +5243,26 @@ impl Window { } } + /// Forwards the focused input handler's [`TextInputConfiguration`] to the + /// platform window when it differs from the last forwarded value. With no + /// input handler the default configuration applies, so a field's + /// preferences don't outlive its focus. + fn apply_text_input_configuration(&mut self, cx: &mut App) { + let configuration = match self.platform_window.take_input_handler() { + Some(mut input_handler) => { + let configuration = input_handler.text_input_configuration(self, cx); + self.platform_window.set_input_handler(input_handler); + configuration + } + None => TextInputConfiguration::default(), + }; + if self.last_text_input_configuration.as_ref() != Some(&configuration) { + self.platform_window + .set_text_input_configuration(configuration.clone()); + self.last_text_input_configuration = Some(configuration); + } + } + /// Register a mouse event listener on the window for the next frame. The type of event /// is determined by the first parameter of the given listener. When the next frame is rendered /// the listener will be cleared. @@ -5222,7 +5427,7 @@ impl Window { .unwrap_or_else(|| action.name().to_string()) } - /// Dispatch a mouse or keyboard event on the window. + /// Dispatch a mouse, keyboard, or touch event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput, cx: &mut App) -> DispatchEventResult { #[cfg(feature = "profiler")] @@ -5340,6 +5545,21 @@ impl Window { } }, PlatformInput::Touch(touch) => PlatformInput::Touch(touch), + PlatformInput::LongPress(long_press) => { + self.mouse_position = if long_press.phase == crate::TouchPhase::Started { + long_press.start_position + } else { + long_press.position + }; + if long_press.phase == crate::TouchPhase::Started { + self.long_press_capture = None; + } + PlatformInput::LongPress(long_press) + } + PlatformInput::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + PlatformInput::TouchDrag(touch_drag) + } PlatformInput::KeyDown(_) | PlatformInput::KeyUp(_) => event, }; @@ -5347,6 +5567,19 @@ impl Window { self.dispatch_mouse_event(any_mouse_event, cx); } else if let Some(any_key_event) = event.keyboard_event() { self.dispatch_key_event(any_key_event, cx); + } else if let Some(touch_event) = event.touch_event() { + self.dispatch_touch_event(touch_event, cx); + } + if let PlatformInput::LongPress(long_press) = &event { + match long_press.phase { + crate::TouchPhase::Started if !self.default_prevented => { + self.long_press_capture = None; + } + crate::TouchPhase::Ended | crate::TouchPhase::Cancelled => { + self.long_press_capture = None; + } + crate::TouchPhase::Started | crate::TouchPhase::Moved => {} + } } // Must run after the move is dispatched: the platform owns the gesture afterwards, so this @@ -5396,6 +5629,143 @@ impl Window { } } + /// Whether recognized touch pans may use the platform's predicted touch + /// positions ([`TouchEvent::predicted_position`]) to compensate for input + /// latency. Defaults to true. + pub fn touch_prediction_enabled(&self) -> bool { + self.touch_prediction_enabled + } + + /// Sets whether recognized touch pans may use the platform's predicted + /// touch positions. Disabling drops [`TouchEvent::predicted_position`] + /// before gesture recognition, so pans track only raw touch positions. + pub fn set_touch_prediction_enabled(&mut self, enabled: bool) { + self.touch_prediction_enabled = enabled; + } + + /// Runs the portable gesture recognizer over a raw touch event and + /// dispatches whatever it resolves (scroll steps, synthesized taps) + /// through the ordinary mouse-event path. + fn dispatch_touch_event(&mut self, event: &TouchEvent, cx: &mut App) { + let mut event = event.clone(); + if !self.touch_prediction_enabled { + event.predicted_position = None; + } + let recognized_gestures = self.touch_gestures.handle_event(&event); + if event.phase == crate::TouchPhase::Started + && let Some(touch_drag) = self.touch_gestures.offer_touch_drag(event.id) + { + self.dispatch_recognized_touch_gesture(touch_drag, cx); + } + if event.phase == crate::TouchPhase::Started + && self.touch_gestures.pending_long_press().is_some() + { + self.long_press_capture = None; + } + let mut tapped = false; + for gesture in recognized_gestures { + tapped |= matches!(gesture, RecognizedTouchGesture::Tap { .. }); + self.dispatch_recognized_touch_gesture(gesture, cx); + } + if event.phase == crate::TouchPhase::Started { + self.schedule_long_press_timer(cx); + } else if self.touch_gestures.pending_long_press().is_none() { + self.long_press_timer.take(); + } + // The platform's touch-release handler may inspect the input handler + // as soon as this dispatch returns (the web platform decides virtual + // keyboard visibility there, inside the user gesture). Input handlers + // are registered during draw, so draw now to make them reflect any + // focus change the tap just caused. + if tapped && self.invalidator.is_dirty() { + self.draw(cx).clear(cx); + } + if self.touch_gestures.has_momentum() { + self.schedule_touch_momentum_tick(); + } + } + + fn dispatch_recognized_touch_gesture(&mut self, gesture: RecognizedTouchGesture, cx: &mut App) { + match gesture { + RecognizedTouchGesture::Scroll(scroll_wheel) => { + self.mouse_position = scroll_wheel.position; + cx.propagate_event = true; + self.dispatch_mouse_event(&scroll_wheel, cx); + } + RecognizedTouchGesture::Tap { down, up } => { + self.mouse_position = up.position; + cx.propagate_event = true; + self.dispatch_mouse_event(&down, cx); + cx.propagate_event = true; + self.dispatch_mouse_event(&up, cx); + } + RecognizedTouchGesture::TouchDrag(touch_drag) => { + self.mouse_position = touch_drag.start_position; + cx.propagate_event = true; + self.default_prevented = false; + let started = touch_drag.phase == crate::TouchPhase::Started; + self.dispatch_mouse_event(&touch_drag, cx); + if started { + self.touch_gestures + .resolve_touch_drag(self.default_prevented); + } + } + RecognizedTouchGesture::LongPress(long_press) => { + self.mouse_position = if long_press.phase == crate::TouchPhase::Started { + long_press.start_position + } else { + long_press.position + }; + cx.propagate_event = true; + self.default_prevented = false; + let started = long_press.phase == crate::TouchPhase::Started; + let ended = matches!( + long_press.phase, + crate::TouchPhase::Ended | crate::TouchPhase::Cancelled + ); + self.dispatch_mouse_event(&long_press, cx); + if started { + let claimed = self.default_prevented; + self.touch_gestures.resolve_long_press(claimed); + if !claimed { + self.long_press_capture = None; + } + } + if ended { + self.long_press_capture = None; + } + } + } + } + + fn schedule_long_press_timer(&mut self, cx: &mut App) { + self.long_press_timer.take(); + let Some((touch_id, duration)) = self.touch_gestures.pending_long_press() else { + return; + }; + self.long_press_timer = Some(self.spawn(cx, async move |cx| { + cx.background_executor.timer(duration).await; + cx.update(move |window, cx| { + window.long_press_timer.take(); + if let Some(gesture) = window.touch_gestures.offer_long_press(touch_id) { + window.dispatch_recognized_touch_gesture(gesture, cx); + } + }) + .log_err(); + })); + } + + fn schedule_touch_momentum_tick(&mut self) { + self.on_next_frame(|window, cx| { + if let Some(gesture) = window.touch_gestures.tick_momentum() { + window.dispatch_recognized_touch_gesture(gesture, cx); + } + if window.touch_gestures.has_momentum() { + window.schedule_touch_momentum_tick(); + } + }); + } + fn dispatch_mouse_event(&mut self, event: &dyn Any, cx: &mut App) { let hit_test = self.rendered_frame.hit_test(self.mouse_position()); if hit_test != self.mouse_hit_test { @@ -5536,7 +5906,7 @@ impl Window { } if !match_result.pending.is_empty() { - currently_pending.timer.take(); + let previous_timeout = currently_pending.timeout.take(); currently_pending.keystrokes = match_result.pending; currently_pending.focus = self.focus; @@ -5550,38 +5920,23 @@ impl Window { accepts }); - currently_pending.needs_timeout |= - match_result.pending_has_binding || text_input_requires_timeout; - - if currently_pending.needs_timeout { - currently_pending.timer = Some(self.spawn(cx, async move |cx| { - cx.background_executor.timer(Duration::from_secs(1)).await; - cx.update(move |window, cx| { - let Some(currently_pending) = window - .pending_input - .take() - .filter(|pending| pending.focus == window.focus) - else { - return; - }; - - let node_id = window.focus_node_id_in_rendered_frame(window.focus); - let dispatch_path = - window.rendered_frame.dispatch_tree.dispatch_path(node_id); - - let to_replay = window - .rendered_frame - .dispatch_tree - .flush_dispatch(currently_pending.keystrokes, &dispatch_path); - - window.pending_input_changed(cx); - window.replay_pending_input(to_replay, cx) - }) - .log_err(); - })); + let needs_timeout = previous_timeout.is_some() + || match_result.pending_has_binding + || text_input_requires_timeout; + currently_pending.timeout = if needs_timeout { + match previous_timeout { + Some(mut timeout) if timeout.is_paused() => { + timeout.reset_duration(PENDING_INPUT_TIMEOUT); + Some(timeout) + } + previous_timeout => { + drop(previous_timeout); + Some(self.new_pending_input_timeout(PENDING_INPUT_TIMEOUT, cx)) + } + } } else { - currently_pending.timer = None; - } + None + }; self.pending_input = Some(currently_pending); self.pending_input_changed(cx); cx.propagate_event = false; @@ -5624,6 +5979,44 @@ impl Window { self.pending_input_changed(cx); } + fn new_pending_input_timeout(&self, duration: Duration, cx: &App) -> PendingInputTimeout { + let (started_at, task) = self.start_pending_input_timeout(duration, cx); + PendingInputTimeout { + duration, + remaining: duration, + state: PendingInputTimeoutState::Running { started_at, task }, + } + } + + fn start_pending_input_timeout(&self, remaining: Duration, cx: &App) -> (Instant, Task<()>) { + let started_at = cx.background_executor().now(); + let task = self.spawn(cx, async move |cx| { + cx.background_executor.timer(remaining).await; + cx.update(move |window, cx| { + let Some(currently_pending) = window + .pending_input + .take() + .filter(|pending| pending.focus == window.focus) + else { + return; + }; + + let node_id = window.focus_node_id_in_rendered_frame(window.focus); + let dispatch_path = window.rendered_frame.dispatch_tree.dispatch_path(node_id); + + let to_replay = window + .rendered_frame + .dispatch_tree + .flush_dispatch(currently_pending.keystrokes, &dispatch_path); + + window.pending_input_changed(cx); + window.replay_pending_input(to_replay, cx) + }) + .log_err(); + }); + (started_at, task) + } + fn finish_dispatch_key_event( &mut self, event: &dyn Any, @@ -5650,6 +6043,19 @@ impl Window { .retain(&(), |callback| callback(self, cx)); } + fn defer_pending_input_changed(&self, cx: &mut App) { + // Avoid re-entrant entity updates by deferring observer notifications to the end of the + // current effect cycle, and only for this window. + let window_handle = self.handle; + cx.defer(move |cx| { + window_handle + .update(cx, |_, window, cx| { + window.pending_input_changed(cx); + }) + .ok(); + }); + } + fn dispatch_key_down_up_event( &mut self, event: &dyn Any, @@ -5701,27 +6107,117 @@ impl Window { } } - /// Pending input that can still complete a binding. Input left over from a previous focus can - /// never complete one. - fn active_pending_input(&self) -> Option<&PendingInput> { + /// Determine whether a potential multi-stroke key binding is in progress on this window. + pub fn has_pending_keystrokes(&self) -> bool { + self.pending_input().is_some() + } + + #[cfg(test)] + pub(crate) fn pending_input_is_none(&self) -> bool { + self.pending_input.is_none() + } + + pub(crate) fn clear_pending_keystrokes(&mut self, cx: &mut App) { + if self.pending_input.take().is_some() { + self.defer_pending_input_changed(cx); + } + } + + /// Returns pending input that can still complete a multi-stroke key binding. Input left over + /// from a previous focus can never complete one. + pub fn pending_input(&self) -> Option> { self.pending_input .as_ref() .filter(|pending_input| pending_input.focus == self.focus) + .map(|pending_input| PendingInputStatus { + keystrokes: pending_input.keystrokes.as_slice(), + timeout: pending_input + .timeout + .as_ref() + .map(PendingInputTimeout::status), + }) } - /// Determine whether a potential multi-stroke key binding is in progress on this window. - pub fn has_pending_keystrokes(&self) -> bool { - self.active_pending_input().is_some() + /// Pauses or resumes the current pending input timeout on behalf of `owner`. + /// + /// A paused timeout resumes automatically if `owner` is released. Returns whether the timeout + /// state changed. A timeout paused by one owner cannot be resumed by another. + pub fn set_pending_input_timeout_paused( + &mut self, + owner: &Entity, + paused: bool, + cx: &mut App, + ) -> bool { + let owner_id = owner.entity_id(); + if !paused { + return self.resume_pending_input_timeout(owner_id, cx); + } + + let timeout = self + .pending_input + .as_ref() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_ref()); + let Some(timeout) = timeout else { + return false; + }; + if timeout.is_paused() { + return false; + } + + let release_subscription = self.observe_release(owner, cx, move |_, window, cx| { + window.resume_pending_input_timeout(owner_id, cx); + }); + let now = cx.background_executor().now(); + let changed = self + .pending_input + .as_mut() + .filter(|pending_input| pending_input.focus == self.focus) + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| { + timeout.pause( + PendingInputTimeoutPause { + owner_id, + _release_subscription: release_subscription, + }, + now, + ) + }); + + if changed { + self.defer_pending_input_changed(cx); + } + changed } - pub(crate) fn clear_pending_keystrokes(&mut self) { - self.pending_input.take(); + fn resume_pending_input_timeout(&mut self, owner_id: EntityId, cx: &mut App) -> bool { + let Some(remaining) = self + .pending_input + .as_ref() + .and_then(|pending_input| pending_input.timeout.as_ref()) + .filter(|timeout| timeout.pause_owner_id() == Some(owner_id)) + .map(|timeout| timeout.remaining) + else { + return false; + }; + + let (started_at, task) = self.start_pending_input_timeout(remaining, cx); + let changed = self + .pending_input + .as_mut() + .and_then(|pending_input| pending_input.timeout.as_mut()) + .is_some_and(|timeout| timeout.resume(owner_id, started_at, task)); + + if changed { + self.defer_pending_input_changed(cx); + } + changed } /// Returns the currently pending input keystrokes that might result in a multi-stroke key binding. pub fn pending_input_keystrokes(&self) -> Option<&[Keystroke]> { - self.active_pending_input() - .map(|pending_input| pending_input.keystrokes.as_slice()) + self.pending_input() + .map(|pending_input| pending_input.keystrokes()) } fn replay_pending_input(&mut self, replays: SmallVec<[Replay; 1]>, cx: &mut App) { @@ -6373,7 +6869,7 @@ impl Window { } } accesskit::Action::Blur => { - self.blur(); + self.blur(cx); } _ => { log::debug!( @@ -6640,6 +7136,7 @@ impl WindowHandle { any_handle: AnyWindowHandle { id, state_type: TypeId::of::(), + root_entity_type_name: std::any::type_name::(), }, state_type: PhantomData, } @@ -6762,6 +7259,7 @@ impl From> for AnyWindowHandle { pub struct AnyWindowHandle { pub(crate) id: WindowId, state_type: TypeId, + root_entity_type_name: &'static str, } impl AnyWindowHandle { @@ -6770,6 +7268,11 @@ impl AnyWindowHandle { self.id } + /// Returns the name of the window's declared root entity type. + pub fn root_entity_type_name(&self) -> &'static str { + self.root_entity_type_name + } + /// Attempt to convert this handle to a window handle with a specific root view type. /// If the types do not match, this will return `None`. pub fn downcast(&self) -> Option> { @@ -7100,15 +7603,16 @@ mod tests { cell::{Cell, RefCell}, path::PathBuf, rc::Rc, + time::Duration, }; use crate::{ - AnyWindowHandle, AppContext as _, Bounds, Context, DragMoveEvent, Empty, + AnyWindowHandle, AppContext as _, Bounds, Context, DispatchPhase, DragMoveEvent, Empty, ExternalDragPayload, ExternalPaths, FileDragPaths, FileDropEvent, FocusHandle, - InputEvent as _, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, - MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, - StatefulInteractiveElement as _, Styled, TestAppContext, Window, WindowAppearance, - WindowOptions, canvas, div, point, px, size, + InputEvent as _, InteractiveElement as _, IntoElement, LongPressEvent, MouseButton, + MouseDownEvent, MouseMoveEvent, ParentElement, Pixels, Point, Render, RequestFrameOptions, + StatefulInteractiveElement as _, Styled, TestAppContext, TouchDragEvent, TouchEvent, + TouchId, TouchPhase, Window, WindowAppearance, WindowOptions, canvas, div, point, px, size, }; struct EmptyView; @@ -7810,4 +8314,230 @@ mod tests { .unwrap(); assert_eq!(b_focus_count.get(), 1); } + + #[gpui::test] + fn claimed_touch_drag_receives_movement_and_release(cx: &mut TestAppContext) { + let events = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let events = events.clone(); + move |_, _| TouchDragListener { events } + }); + let touch = TouchId(1); + + dispatch_touch(window, cx, touch, TouchPhase::Started, 10.); + dispatch_touch(window, cx, touch, TouchPhase::Moved, 30.); + dispatch_touch(window, cx, touch, TouchPhase::Ended, 40.); + + assert_eq!( + events.borrow().as_slice(), + [ + (TouchPhase::Started, px(10.)), + (TouchPhase::Moved, px(30.)), + (TouchPhase::Ended, px(40.)), + ] + ); + } + + struct TouchDragListener { + events: Rc>>, + } + + impl Render for TouchDragListener { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let events = self.events.clone(); + canvas( + |_, _, _| {}, + move |_, _, window, _| { + window.on_mouse_event(move |event: &TouchDragEvent, phase, window, _cx| { + if phase != DispatchPhase::Bubble { + return; + } + events.borrow_mut().push((event.phase, event.position.x)); + if event.phase == TouchPhase::Started { + window.prevent_default(); + } + }); + }, + ) + } + } + + #[gpui::test] + fn long_press_is_claimed_only_when_started_prevents_default(cx: &mut TestAppContext) { + for response in [ + LongPressResponse::PreventDefault, + LongPressResponse::StopPropagation, + LongPressResponse::None, + ] { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { phases, response } + }); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Started, 0.); + cx.executor().advance_clock(Duration::from_millis(501)); + cx.executor().run_until_parked(); + window + .update(cx, |_, window, _| { + assert_eq!( + window.long_press_capture.is_some(), + response == LongPressResponse::PreventDefault + ); + }) + .unwrap(); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Moved, 2.); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Ended, 2.); + window + .update(cx, |_, window, _| { + assert!(window.long_press_capture.is_none()); + }) + .unwrap(); + + let phases = phases.borrow(); + if response == LongPressResponse::PreventDefault { + assert_eq!( + phases.as_slice(), + [TouchPhase::Started, TouchPhase::Moved, TouchPhase::Ended] + ); + } else { + assert_eq!(phases.as_slice(), [TouchPhase::Started]); + } + } + } + + #[gpui::test] + fn stale_default_prevention_does_not_claim_long_press(cx: &mut TestAppContext) { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { + phases, + response: LongPressResponse::None, + } + }); + window + .update(cx, |_, window, _| { + window.prevent_default(); + }) + .unwrap(); + + dispatch_touch(window, cx, TouchId(1), TouchPhase::Started, 0.); + cx.executor().advance_clock(Duration::from_millis(501)); + cx.executor().run_until_parked(); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Moved, 2.); + + assert_eq!(phases.borrow().as_slice(), [TouchPhase::Started]); + } + + #[gpui::test] + fn resolved_touch_cancels_scheduled_long_press(cx: &mut TestAppContext) { + for (phase, position) in [ + (TouchPhase::Ended, 0.), + (TouchPhase::Cancelled, 0.), + (TouchPhase::Moved, 20.), + ] { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { + phases, + response: LongPressResponse::PreventDefault, + } + }); + dispatch_touch(window, cx, TouchId(1), TouchPhase::Started, 0.); + dispatch_touch(window, cx, TouchId(1), phase, position); + cx.executor().advance_clock(Duration::from_millis(501)); + cx.executor().run_until_parked(); + + assert!(phases.borrow().is_empty(), "{phase:?} allowed long press"); + } + } + + #[gpui::test] + fn stale_long_press_timer_cannot_affect_replacement_touch(cx: &mut TestAppContext) { + let phases = Rc::new(RefCell::new(Vec::new())); + let window = cx.add_window({ + let phases = phases.clone(); + move |_, _| LongPressListener { + phases, + response: LongPressResponse::PreventDefault, + } + }); + let first_touch = TouchId(1); + dispatch_touch(window, cx, first_touch, TouchPhase::Started, 0.); + cx.executor().advance_clock(Duration::from_millis(250)); + dispatch_touch(window, cx, first_touch, TouchPhase::Cancelled, 0.); + dispatch_touch(window, cx, TouchId(2), TouchPhase::Started, 10.); + + cx.executor().advance_clock(Duration::from_millis(251)); + cx.executor().run_until_parked(); + assert!(phases.borrow().is_empty()); + + cx.executor().advance_clock(Duration::from_millis(250)); + cx.executor().run_until_parked(); + assert_eq!(phases.borrow().as_slice(), [TouchPhase::Started]); + } + + #[derive(Clone, Copy, PartialEq)] + enum LongPressResponse { + PreventDefault, + StopPropagation, + None, + } + + struct LongPressListener { + phases: Rc>>, + response: LongPressResponse, + } + + impl Render for LongPressListener { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity(); + let phases = self.phases.clone(); + let response = self.response; + canvas( + |_, _, _| {}, + move |_, _, window, _| { + window.on_mouse_event(move |event: &LongPressEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble { + return; + } + phases.borrow_mut().push(event.phase); + match response { + LongPressResponse::PreventDefault => { + window.capture_long_press(&entity); + window.prevent_default(); + } + LongPressResponse::StopPropagation => cx.stop_propagation(), + LongPressResponse::None => {} + } + }); + }, + ) + } + } + + fn dispatch_touch( + window: crate::WindowHandle, + cx: &mut TestAppContext, + id: TouchId, + phase: TouchPhase, + x: f32, + ) { + window + .update(cx, |_, window, cx| { + window.dispatch_event( + TouchEvent { + id, + phase, + position: point(px(x), px(0.)), + predicted_position: None, + force: None, + } + .to_platform_input(), + cx, + ); + }) + .unwrap(); + } } diff --git a/crates/gpui_apple/Cargo.toml b/crates/gpui_apple/Cargo.toml index fb5f07a..fc04bcc 100644 --- a/crates/gpui_apple/Cargo.toml +++ b/crates/gpui_apple/Cargo.toml @@ -13,6 +13,7 @@ path = "src/gpui_apple.rs" [features] default = [] +bench-support = ["gpui/bench-support"] test-support = ["gpui/test-support"] runtime_shaders = [] @@ -37,7 +38,6 @@ parking_lot.workspace = true [target.'cfg(target_os = "macos")'.build-dependencies] cbindgen.workspace = true -gpui.workspace = true [target.'cfg(target_os = "macos")'.dev-dependencies] gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/gpui_apple/build.rs b/crates/gpui_apple/build.rs index 956cf94..ef89a66 100644 --- a/crates/gpui_apple/build.rs +++ b/crates/gpui_apple/build.rs @@ -100,9 +100,11 @@ mod macos_build { output_path } - /// Locate the gpui crate directory relative to this crate. + /// Locate the gpui crate directory relative to this crate. Resolved at + /// build-script runtime against this crate's manifest dir, so no checkout + /// path is baked into a compiled artifact (which corgi rejects). fn find_gpui_crate_dir() -> PathBuf { - gpui::GPUI_MANIFEST_DIR.into() + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("../gpui") } /// To enable runtime compilation, we need to "stitch" the shaders file with the generated header @@ -133,6 +135,13 @@ mod macos_build { PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metallib"); println!("cargo:rerun-if-changed={}", shader_path); + // The metal compiler records the resolved absolute path of its input + // unconditionally. Compile a copy staged in OUT_DIR so the recorded + // location is the build's canonical output directory, never the + // checkout (corgi rejects artifacts that embed the build path). + let staged_shader_path = PathBuf::from(env::var("OUT_DIR").unwrap()).join("shaders.metal"); + std::fs::copy(shader_path, &staged_shader_path).unwrap(); + let output = Command::new("xcrun") .args([ "-sdk", @@ -142,11 +151,9 @@ mod macos_build { "-mmacosx-version-min=10.15.7", "-MO", "-c", - shader_path, - "-include", - (header_path.to_str().unwrap()), - "-o", ]) + .arg(&staged_shader_path) + .args(["-include", header_path.to_str().unwrap(), "-o"]) .arg(&air_output_path) .output() .unwrap(); @@ -161,7 +168,7 @@ mod macos_build { let output = Command::new("xcrun") .args(["-sdk", "macosx", "metallib"]) - .arg(air_output_path) + .arg(&air_output_path) .arg("-o") .arg(metallib_output_path) .output() @@ -174,5 +181,11 @@ mod macos_build { ); process::exit(1); } + + // The .air intermediate records the compiler's working directory in + // its debug info; the metallib built from it does not. Nothing reads + // the .air after this point, so drop it rather than leave a + // checkout-path-bearing file in OUT_DIR. + std::fs::remove_file(&air_output_path).unwrap(); } } diff --git a/crates/gpui_apple/src/metal_renderer.rs b/crates/gpui_apple/src/metal_renderer.rs index 245a065..f1822a1 100644 --- a/crates/gpui_apple/src/metal_renderer.rs +++ b/crates/gpui_apple/src/metal_renderer.rs @@ -10,7 +10,7 @@ use gpui::{ AtlasTextureId, Background, Bounds, ContentMask, DevicePixels, LayerEffect, PaintSurface, Path, Point, PrimitiveBatch, ScaledPixels, Scene, Size, point, size, }; -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] use image::RgbaImage; use core_foundation::base::TCFType; @@ -149,7 +149,7 @@ pub struct MetalRenderer { filter_targets: Option, /// Offscreen render target reused across `render_scene` calls when /// rendering headlessly without reading pixels back. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] headless_render_target: Option, } @@ -193,7 +193,7 @@ impl MetalRenderer { /// /// This renderer can render scenes to images without requiring a CAMetalLayer, /// window, or AppKit. Use `render_scene_to_image()` to render scenes. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub fn new_headless(instance_buffer_pool: Arc>) -> Self { let device = Self::create_device(); Self::new_internal(device, None, true, instance_buffer_pool) @@ -417,7 +417,7 @@ impl MetalRenderer { path_intermediate_msaa_texture: None, path_sample_count: PATH_SAMPLE_COUNT, filter_targets: None, - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] headless_render_target: None, } } @@ -630,7 +630,7 @@ impl MetalRenderer { /// /// This is the primary method for headless rendering. It creates an offscreen /// texture, renders the scene to it, and returns the pixel data as an RGBA image. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub fn render_scene_to_image( &mut self, scene: &Scene, @@ -678,7 +678,7 @@ impl MetalRenderer { /// encoding, instance buffer writes, command submission) and is used by /// headless benchmark rendering, where the produced pixels are never /// inspected. - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub fn render_scene(&mut self, scene: &Scene, size: Size) -> Result<()> { if size.width.0 <= 0 || size.height.0 <= 0 { anyhow::bail!("Invalid size for render_scene: {:?}", size); @@ -1752,7 +1752,7 @@ fn new_command_encoder_for_texture<'a>( command_encoder } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] fn read_texture_to_image(texture: &metal::TextureRef) -> Result { let width = texture.width() as u32; let height = texture.height() as u32; @@ -2231,12 +2231,12 @@ pub struct SurfaceBounds { pub content_mask: ContentMask, } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub struct MetalHeadlessRenderer { renderer: MetalRenderer, } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] impl MetalHeadlessRenderer { pub fn new() -> Self { let instance_buffer_pool = Arc::new(Mutex::new(InstanceBufferPool::default())); @@ -2245,7 +2245,7 @@ impl MetalHeadlessRenderer { } } -#[cfg(any(test, feature = "test-support"))] +#[cfg(any(test, feature = "bench-support", feature = "test-support"))] impl gpui::PlatformHeadlessRenderer for MetalHeadlessRenderer { fn render_scene_to_image( &mut self, diff --git a/crates/gpui_linux/Cargo.toml b/crates/gpui_linux/Cargo.toml index a5fa0a7..f6ec33d 100644 --- a/crates/gpui_linux/Cargo.toml +++ b/crates/gpui_linux/Cargo.toml @@ -76,15 +76,15 @@ oo7 = { version = "0.6", default-features = false, features = [ "async-std", "native_crypto", ] } -calloop = "0.14.3" +calloop = "0.14.4" notify-rust.workspace = true raw-window-handle.workspace = true # Used in both windowing options ashpd = { workspace = true, optional = true } bitflags = { workspace = true, optional = true } -filedescriptor = { version = "0.8.2", optional = true } -open = { version = "5.2.0", optional = true } +filedescriptor = { version = "0.8.3", optional = true } +open = { version = "5.4.3", optional = true } xkbcommon = { version = "0.8.0", default-features = false, optional = true } # Screen capture @@ -92,27 +92,28 @@ scap = { workspace = true, optional = true } # Wayland calloop-wayland-source = { version = "0.4.1", optional = true } -wayland-backend = { version = "0.3.15", features = [ +wayland-backend = { version = "0.3.17", features = [ "client_system", "dlopen", + "log", ], 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 = [ +wayland-client = { version = "0.31.15", optional = true } +wayland-cursor = { version = "0.31.14", optional = true } +wayland-protocols = { version = "0.32.13", features = [ "client", "staging", "unstable", ], optional = true } -wayland-protocols-plasma = { version = "0.3.9", features = [ +wayland-protocols-plasma = { version = "0.3.12", features = [ "client", ], optional = true } -wayland-protocols-wlr = { version = "0.3.9", features = [ +wayland-protocols-wlr = { version = "0.3.12", features = [ "client", ], optional = true } # X11 as-raw-xcb-connection = { version = "1", optional = true } -x11rb = { version = "0.13.1", features = [ +x11rb = { version = "0.13.2", features = [ "allow-unsafe-code", "xkb", "randr", diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index 7e8ec22..53dec5d 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -106,7 +106,7 @@ pub(crate) trait LinuxClient { #[derive(Default)] pub(crate) struct PlatformHandlers { pub(crate) open_urls: Option)>>, - pub(crate) quit: Option>, + pub(crate) quit: Option bool>>, pub(crate) reopen: Option>, pub(crate) app_menu_action: Option>, pub(crate) will_open_app_menu: Option>, @@ -547,7 +547,7 @@ impl Platform for LinuxPlatform

{ .detach(); } - fn on_quit(&self, callback: Box) { + fn on_quit(&self, callback: Box bool>) { self.inner.with_common(|common| { common.callbacks.quit = Some(callback); }); diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index f9423a3..d6db24e 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -1568,10 +1568,11 @@ impl PlatformWindow for X11Window { } fn set_app_id(&mut self, app_id: &str) { - let mut data = Vec::with_capacity(app_id.len() * 2 + 1); + let mut data = Vec::with_capacity(app_id.len() * 2 + 2); data.extend(app_id.bytes()); // instance https://unix.stackexchange.com/a/494170 data.push(b'\0'); data.extend(app_id.bytes()); // class + data.push(b'\0'); check_reply( || "X11 ChangeProperty8 for WM_CLASS failed.", diff --git a/crates/gpui_macos/Cargo.toml b/crates/gpui_macos/Cargo.toml index ff10902..22bc2ab 100644 --- a/crates/gpui_macos/Cargo.toml +++ b/crates/gpui_macos/Cargo.toml @@ -13,6 +13,7 @@ path = "src/gpui_macos.rs" [features] default = ["gpui/default"] +bench-support = ["gpui/bench-support", "gpui_apple/bench-support"] test-support = ["gpui/test-support", "gpui_apple/test-support"] runtime_shaders = ["gpui_apple/runtime_shaders"] font-kit = ["dep:font-kit"] diff --git a/crates/gpui_macos/src/gpui_macos.rs b/crates/gpui_macos/src/gpui_macos.rs index 1ee1872..1e393e4 100644 --- a/crates/gpui_macos/src/gpui_macos.rs +++ b/crates/gpui_macos/src/gpui_macos.rs @@ -20,7 +20,7 @@ use gpui_apple::metal_renderer as renderer; pub mod metal_renderer { pub use gpui_apple::metal_renderer::{PathRasterizationVertex, PathSprite, SurfaceBounds}; - #[cfg(any(test, feature = "test-support"))] + #[cfg(any(test, feature = "bench-support", feature = "test-support"))] pub use gpui_apple::metal_renderer::MetalHeadlessRenderer; } diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index 3ec54e8..c6f75a8 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -44,6 +44,7 @@ use objc::{ runtime::{Class, Object, Sel}, sel, sel_impl, }; +use objc2::MainThreadMarker; use parking_lot::Mutex; use ptr::null_mut; use semver::Version; @@ -163,7 +164,7 @@ unsafe fn build_classes() { } } -pub struct MacPlatform(Mutex); +pub struct MacPlatform(Mutex, MainThreadMarker); pub(crate) struct MacPlatformState { background_executor: BackgroundExecutor, @@ -178,7 +179,7 @@ pub(crate) struct MacPlatformState { on_thermal_state_change: Option>, on_system_wake: Option>, system_wake_observer_registered: bool, - quit: Option>, + quit: Option bool>>, menu_command: Option>, validate_menu_command: Option bool>>, will_open_menu: Option>, @@ -195,6 +196,7 @@ pub(crate) struct MacPlatformState { impl MacPlatform { pub fn new(headless: bool) -> Self { + let marker = MainThreadMarker::new().expect("Mac platform not created on main thread"); let dispatcher = Arc::new(MacDispatcher::new()); #[cfg(feature = "font-kit")] @@ -213,7 +215,7 @@ impl MacPlatform { let keyboard_layout = MacKeyboardLayout::new(); let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); - Self(Mutex::new(MacPlatformState { + let state = Mutex::new(MacPlatformState { headless, text_system, background_executor: BackgroundExecutor::new(dispatcher.clone()), @@ -238,7 +240,8 @@ impl MacPlatform { keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), system_notifications: crate::system_notifications::SystemNotificationState::new(), - })) + }); + Self(state, marker) } unsafe fn create_menu_bar( @@ -674,6 +677,7 @@ impl Platform for MacPlatform { foreground_executor, background_executor, renderer_context, + self.1, ))) } @@ -940,7 +944,7 @@ impl Platform for MacPlatform { .detach(); } - fn on_quit(&self, callback: Box) { + fn on_quit(&self, callback: Box bool>) { self.0.lock().quit = Some(callback); } diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index faf27e0..d8f5b3c 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -7,6 +7,7 @@ use crate::{ #[cfg(any(test, feature = "test-support"))] use anyhow::Result; use block::ConcreteBlock; +use block2::RcBlock; use cocoa::{ appkit::{ NSApplication, NSBackingStoreBuffered, NSColor, NSEvent, NSEventModifierFlags, NSEventType, @@ -49,10 +50,10 @@ use objc::{ runtime::{BOOL, Class, NO, Object, Protocol, Sel, YES}, sel, sel_impl, }; -use objc2::{rc::Retained, runtime::AnyObject as Objc2Object}; +use objc2::{MainThreadMarker, rc::Retained, runtime::AnyObject as Objc2Object}; use objc2_app_kit::{ - NSBeep, NSButton as Objc2NSButton, NSView as Objc2NSView, NSWindow as Objc2NSWindow, - NSWindowButton as Objc2NSWindowButton, + NSAlert, NSAlertStyle, NSBeep, NSButton as Objc2NSButton, NSView as Objc2NSView, + NSWindow as Objc2NSWindow, NSWindowButton as Objc2NSWindowButton, }; use objc2_foundation::{NSPoint as Objc2NSPoint, NSRect as Objc2NSRect}; use parking_lot::Mutex; @@ -878,7 +879,7 @@ impl MacWindowState { unsafe impl Send for MacWindowState {} -pub(crate) struct MacWindow(Arc>); +pub(crate) struct MacWindow(Arc>, MainThreadMarker); impl MacWindow { pub fn open( @@ -902,6 +903,7 @@ impl MacWindow { foreground_executor: ForegroundExecutor, background_executor: BackgroundExecutor, renderer_context: renderer::Context, + marker: MainThreadMarker, ) -> Self { unsafe { let pool = NSAutoreleasePool::new(nil); @@ -1011,7 +1013,7 @@ impl MacWindow { let native_view = NSView::initWithFrame_(native_view, NSView::bounds(content_view)); assert!(!native_view.is_null()); - let mut window = Self(Arc::new(Mutex::new(MacWindowState { + let state = Arc::new(Mutex::new(MacWindowState { handle, foreground_executor, background_executor, @@ -1065,7 +1067,8 @@ impl MacWindow { closed: Arc::new(AtomicBool::new(false)), accesskit_adapter: None, sheet_parent: None, - }))); + })); + let mut window = Self(state, marker); (*native_window).set_ivar( WINDOW_STATE_IVAR, @@ -1511,6 +1514,8 @@ impl PlatformWindow for MacWindow { detail: Option<&str>, answers: &[PromptButton], ) -> Option> { + use objc2_foundation::{NSInteger, NSString}; + // 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 @@ -1523,69 +1528,67 @@ impl PlatformWindow for MacWindow { .map(|(ix, _)| ix) .filter(|&ix| ix > 0); - unsafe { - let alert: id = msg_send![class!(NSAlert), alloc]; - let alert: id = msg_send![alert, init]; - let alert_style = match level { - PromptLevel::Info => 1, - PromptLevel::Warning => 0, - PromptLevel::Critical => 2, - }; - let _: () = msg_send![alert, setAlertStyle: alert_style]; - let _: () = msg_send![alert, setMessageText: ns_string(msg)]; - if let Some(detail) = detail { - let _: () = msg_send![alert, setInformativeText: ns_string(detail)]; - } + let alert = NSAlert::new(self.1); + alert.setAlertStyle(match level { + PromptLevel::Critical => NSAlertStyle::Critical, + PromptLevel::Warning => NSAlertStyle::Warning, + PromptLevel::Info => NSAlertStyle::Informational, + }); + let message = NSString::from_str(msg); + alert.setMessageText(message.as_ref()); - 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 let Some(detail) = detail { + let detail_text = NSString::from_str(detail); + alert.setInformativeText(detail_text.as_ref()); + } - if answer.is_cancel() { - 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); + let mut initial_focus_button: Option> = None; + for (ix, answer) in answers.iter().enumerate() { + let title = NSString::from_str(answer.label()); + let button = alert.addButtonWithTitle(&title); + button.setTag(ix as NSInteger); + + if answer.is_cancel() { + if let Some(key) = core::char::from_u32(crate::events::ESCAPE_KEY as u32) { + let key = NSString::from_str(&key.to_string()); + button.setKeyEquivalent(&key); } + } else if Some(ix) == initial_focus_ix { + initial_focus_button = Some(button); } + } + + if let Some(button) = initial_focus_button { + alert.window().setInitialFirstResponder(Some(&button)); + } - 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(); + let done_tx = Cell::new(Some(done_tx)); + + let block = RcBlock::new(move |answer: NSInteger| { + if let Some(done_tx) = done_tx.take() { + let _ = done_tx.send(answer.try_into().unwrap()); } + }); + + 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 { + if !closed.load(Ordering::Acquire) { + // SAFETY: `native_window` is an Objective-C `NSWindow` pointer + // owned by the platform window; bridge it into objc2. + let sheet_window: &Objc2NSWindow = + unsafe { &*(native_window as *const Objc2NSWindow) }; - let (done_tx, done_rx) = oneshot::channel(); - let done_tx = Cell::new(Some(done_tx)); - let block = ConcreteBlock::new(move |answer: NSInteger| { - let _: () = msg_send![alert, release]; - if let Some(done_tx) = done_tx.take() { - let _ = done_tx.send(answer.try_into().unwrap()); + alert.beginSheetModalForWindow_completionHandler(sheet_window, Some(&block)); } - }); - let block = block.copy(); - 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 { - if !closed.load(Ordering::Acquire) { - let _: () = msg_send![ - alert, - beginSheetModalForWindow: native_window - completionHandler: block - ]; - } else { - let _: () = msg_send![alert, release]; - } - }) - .detach(); + }) + .detach(); - Some(done_rx) - } + Some(done_rx) } fn activate(&self) { @@ -1975,9 +1978,16 @@ impl PlatformWindow for MacWindow { } } "Fill" => { - // There is no documented API for "Fill" action, so we'll just zoom the window if is_resizable { - window.zoom_(nil); + // Unlike `zoom:`, AppKit's private Fill action honors the system's + // "Tiled windows have margins" setting. + let responds_to_zoom_fill: BOOL = + msg_send![window, respondsToSelector: sel!(_zoomFill:)]; + if responds_to_zoom_fill == YES { + let _: () = msg_send![window, _zoomFill: nil]; + } else { + window.zoom_(nil); + } } } _ => { diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index 5c9c14a..052ecd5 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -13,6 +13,7 @@ path = "src/gpui_platform.rs" [features] default = [] +bench-support = ["gpui/bench-support", "gpui_macos/bench-support"] font-kit = ["gpui_linux/font-kit", "gpui_macos/font-kit"] runtime_shaders = ["gpui_macos/runtime_shaders"] test-support = ["gpui/test-support"] diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index 7e37b0f..289fba6 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -48,7 +48,7 @@ pub fn current_platform(headless: bool) -> Rc { } /// Returns a new [`HeadlessRenderer`] for the current platform, if available. -#[cfg(feature = "test-support")] +#[cfg(any(feature = "bench-support", feature = "test-support"))] pub fn current_headless_renderer() -> Option> { #[cfg(target_os = "macos")] { diff --git a/crates/gpui_shared_string/Cargo.toml b/crates/gpui_shared_string/Cargo.toml index e1e975a..36ce41a 100644 --- a/crates/gpui_shared_string/Cargo.toml +++ b/crates/gpui_shared_string/Cargo.toml @@ -3,6 +3,7 @@ name = "gpui_shared_string" version = "0.1.0" publish.workspace = true edition.workspace = true +license = "Apache-2.0" [lib] path = "gpui_shared_string.rs" diff --git a/crates/gpui_util/Cargo.toml b/crates/gpui_util/Cargo.toml index cad692b..06810be 100644 --- a/crates/gpui_util/Cargo.toml +++ b/crates/gpui_util/Cargo.toml @@ -3,6 +3,7 @@ name = "gpui_util" version = "0.1.0" publish.workspace = true edition.workspace = true +license = "Apache-2.0" [dependencies] log.workspace = true diff --git a/crates/gpui_util/src/lib.rs b/crates/gpui_util/src/lib.rs index 4463712..109ef6f 100644 --- a/crates/gpui_util/src/lib.rs +++ b/crates/gpui_util/src/lib.rs @@ -32,7 +32,7 @@ pub fn new_std_command(program: impl AsRef) -> std::process::Command { } #[cfg(target_os = "windows")] -pub fn get_windows_system_shell() -> String { +pub fn get_powershell() -> Option { use std::path::PathBuf; fn find_pwsh_in_programfiles(find_alternate: bool, find_preview: bool) -> Option { @@ -71,7 +71,7 @@ pub fn get_windows_system_shell() -> String { }; let exe_path = entry.path().join("pwsh.exe"); - if exe_path.exists() { + if exe_path.is_file() { Some((version, exe_path)) } else { None @@ -84,41 +84,34 @@ pub fn get_windows_system_shell() -> String { fn find_pwsh_in_msix(find_preview: bool) -> Option { let msix_app_dir = PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join("Microsoft\\WindowsApps"); - if !msix_app_dir.exists() { - return None; - } - - let prefix = if find_preview { - "Microsoft.PowerShellPreview_" + let package_family_name = if find_preview { + "Microsoft.PowerShellPreview_8wekyb3d8bbwe" } else { - "Microsoft.PowerShell_" + "Microsoft.PowerShell_8wekyb3d8bbwe" }; - msix_app_dir - .read_dir() - .ok()? - .filter_map(|entry| { - let entry = entry.ok()?; - if !matches!(entry.file_type(), Ok(ft) if ft.is_dir()) { - return None; - } - - if !entry.file_name().to_string_lossy().starts_with(prefix) { - return None; - } - - let exe_path = entry.path().join("pwsh.exe"); - exe_path.exists().then_some(exe_path) - }) - .next() + let pwsh_exe = msix_app_dir.join(package_family_name).join("pwsh.exe"); + pwsh_exe.exists().then_some(pwsh_exe) } fn find_pwsh_in_scoop() -> Option { let pwsh_exe = PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\pwsh.exe"); - pwsh_exe.exists().then_some(pwsh_exe) + pwsh_exe.is_file().then_some(pwsh_exe) + } + + fn find_pwsh_in_dotnet_tools() -> Option { + let pwsh_exe = + PathBuf::from(std::env::var_os("USERPROFILE")?).join(".dotnet\\tools\\pwsh.exe"); + pwsh_exe.is_file().then_some(pwsh_exe) + } + + fn find_windows_powershell() -> Option { + let system_root = PathBuf::from(std::env::var_os("SystemRoot")?); + let powershell = system_root.join("System32\\WindowsPowerShell\\v1.0\\powershell.exe"); + powershell.is_file().then_some(powershell) } - static SYSTEM_SHELL: std::sync::LazyLock = std::sync::LazyLock::new(|| { + static POWERSHELL: std::sync::LazyLock> = std::sync::LazyLock::new(|| { let locations = [ || find_pwsh_in_programfiles(false, false), || find_pwsh_in_programfiles(true, false), @@ -127,8 +120,10 @@ pub fn get_windows_system_shell() -> String { || find_pwsh_in_msix(true), || find_pwsh_in_programfiles(true, true), || find_pwsh_in_scoop(), + || find_pwsh_in_dotnet_tools(), || which::which_global("pwsh.exe").ok(), || which::which_global("powershell.exe").ok(), + || find_windows_powershell(), ]; locations @@ -136,13 +131,22 @@ pub fn get_windows_system_shell() -> String { .find_map(|f| f()) .map(|p| p.to_string_lossy().trim().to_owned()) .inspect(|shell| log::info!("Found powershell in: {}", shell)) - .unwrap_or_else(|| { - log::warn!("Powershell not found, falling back to `cmd`"); - "cmd.exe".to_string() - }) }); - (*SYSTEM_SHELL).clone() + (*POWERSHELL).clone() +} + +#[cfg(target_os = "windows")] +pub fn get_windows_system_shell() -> String { + static CMD: std::sync::LazyLock = std::sync::LazyLock::new(|| { + log::warn!("Powershell not found, falling back to `cmd`"); + let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into()); + std::path::PathBuf::from(system_root) + .join("System32\\cmd.exe") + .to_string_lossy() + .into_owned() + }); + get_powershell().unwrap_or_else(|| (*CMD).clone()) } pub fn post_inc + AddAssign + Copy>(value: &mut T) -> T { diff --git a/crates/gpui_wgpu/src/cosmic_text_system.rs b/crates/gpui_wgpu/src/cosmic_text_system.rs index f8ffe77..79bb5bc 100644 --- a/crates/gpui_wgpu/src/cosmic_text_system.rs +++ b/crates/gpui_wgpu/src/cosmic_text_system.rs @@ -2,11 +2,12 @@ use anyhow::{Context as _, Ok, Result}; use collections::HashMap; use cosmic_text::{ Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont, - FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Weight as CosmicWeight, + FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Stretch, Style, + Weight as CosmicTextWeight, Weight as CosmicWeight, }; use gpui::{ Bounds, DevicePixels, Font, FontFallbacks, FontFeatures, FontId, FontMetrics, FontRun, GlyphId, - LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, + IsZero as _, LineLayout, Pixels, PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ShapedGlyph, ShapedRun, SharedString, Size, TextRenderingMode, point, size, }; @@ -53,6 +54,7 @@ struct CosmicTextSystemState { font_system: FontSystem, scratch: ShapeBuffer, swash_scale_context: ScaleContext, + pending_glyph_images: HashMap, /// Contains all already loaded fonts, including all faces. Indexed by `FontId`. loaded_fonts: Vec, /// Caches the `FontId`s associated with a specific family to avoid iterating the font database @@ -91,6 +93,27 @@ fn wght_instance( Some((CosmicWeight(value.round() as u16), coords)) } +struct FontMatchProperties { + primary_family_name: SharedString, + stretch: Stretch, + style: Style, + weight: CosmicTextWeight, + features: CosmicFontFeatures, + fallback_chain: Arc<[(FontId, SharedString)]>, +} + +impl FontMatchProperties { + fn attributes<'a>(&'a self, font_id: FontId, family_name: &'a str) -> Attrs<'a> { + Attrs::new() + .metadata(font_id.0) + .family(Family::Name(family_name)) + .stretch(self.stretch) + .style(self.style) + .weight(self.weight) + .font_features(self.features.clone()) + } +} + impl CosmicTextSystem { pub fn new(system_font_fallback: &str) -> Self { let font_system = FontSystem::new(); @@ -99,6 +122,7 @@ impl CosmicTextSystem { font_system, scratch: ShapeBuffer::default(), swash_scale_context: ScaleContext::new(), + pending_glyph_images: HashMap::default(), loaded_fonts: Vec::new(), font_ids_by_family_cache: HashMap::default(), system_font_fallback: system_font_fallback.to_string(), @@ -115,6 +139,7 @@ impl CosmicTextSystem { font_system, scratch: ShapeBuffer::default(), swash_scale_context: ScaleContext::new(), + pending_glyph_images: HashMap::default(), loaded_fonts: Vec::new(), font_ids_by_family_cache: HashMap::default(), system_font_fallback: system_font_fallback.to_string(), @@ -168,6 +193,10 @@ impl PlatformTextSystem for CosmicTextSystem { Ok(candidates[ix]) } + fn prewarm_fonts(&self, font_ids: &[FontId]) { + self.0.write().prewarm_fonts(font_ids); + } + fn font_metrics(&self, font_id: FontId) -> FontMetrics { let lock = self.0.read(); let loaded_font = lock.loaded_font(font_id); @@ -247,6 +276,43 @@ impl CosmicTextSystemState { &self.loaded_fonts[font_id.0] } + fn font_match_properties(&self, font_id: FontId) -> Option { + let loaded_font = self.loaded_font(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 {:?}", font_id); + return None; + }; + let Some(first_family) = face.families.first() else { + log::warn!("font face has no family names for font_id {:?}", font_id); + return None; + }; + + Some(FontMatchProperties { + primary_family_name: first_family.0.clone().into(), + stretch: face.stretch, + style: face.style, + weight: loaded_font.instantiated_weight, + features: loaded_font.features.clone(), + fallback_chain: Arc::clone(&loaded_font.user_fallback_chain), + }) + } + + fn prewarm_fonts(&mut self, font_ids: &[FontId]) { + for &font_id in font_ids { + let Some(properties) = self.font_match_properties(font_id) else { + continue; + }; + let primary_attributes = + properties.attributes(font_id, &properties.primary_family_name); + self.font_system.get_font_matches(&primary_attributes); + + for (fallback_id, fallback_name) in &*properties.fallback_chain { + let fallback_attributes = properties.attributes(*fallback_id, fallback_name); + self.font_system.get_font_matches(&fallback_attributes); + } + } + } + #[profiling::function] fn add_fonts(&mut self, fonts: Vec>) -> Result<()> { let db = self.font_system.db_mut(); @@ -399,10 +465,14 @@ impl CosmicTextSystemState { fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result> { let image = self.render_glyph_image(params)?; - Ok(Bounds { + let bounds = Bounds { origin: point(image.placement.left.into(), (-image.placement.top).into()), size: size(image.placement.width.into(), image.placement.height.into()), - }) + }; + if !bounds.is_zero() { + self.pending_glyph_images.insert(params.clone(), image); + } + Ok(bounds) } #[profiling::function] @@ -415,7 +485,10 @@ impl CosmicTextSystemState { anyhow::bail!("glyph bounds are empty"); } - let mut image = self.render_glyph_image(params)?; + let mut image = match self.pending_glyph_images.remove(params) { + Some(image) => image, + None => self.render_glyph_image(params)?, + }; let bitmap_size = glyph_bounds.size; match image.content { swash::scale::image::Content::Color | swash::scale::image::Content::SubpixelMask => { @@ -653,55 +726,19 @@ impl CosmicTextSystemState { 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_end; - continue; - }; - let Some(first_family) = face.families.first() else { - log::warn!( - "font face has no family names for font_id {:?}", - run.font_id - ); + let Some(properties) = self.font_match_properties(run.font_id) else { offs = run_end; continue; }; - let primary_family_name: SharedString = first_family.0.clone().into(); - let primary_stretch = face.stretch; - let primary_style = face.style; - // pinned instance weight - let primary_weight = loaded_font.instantiated_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 + let primary_attrs = properties.attributes(run.font_id, &properties.primary_family_name); + let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = properties + .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()) - }) + .map(|(font_id, family_name)| properties.attributes(*font_id, family_name)) .collect(); - let spans = if fallback_chain.is_empty() { + let spans = if properties.fallback_chain.is_empty() { let mut spans = SmallVec::<[RunSpan; 4]>::new(); spans.push(RunSpan { start: offs, @@ -713,7 +750,14 @@ impl CosmicTextSystemState { } 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) + compute_run_spans( + text, + offs, + run.len, + run.font_id, + &properties.fallback_chain, + &covers, + ) }; for span in spans { diff --git a/crates/gpui_windows/Cargo.toml b/crates/gpui_windows/Cargo.toml index 823e4d1..76f13ad 100644 --- a/crates/gpui_windows/Cargo.toml +++ b/crates/gpui_windows/Cargo.toml @@ -38,15 +38,15 @@ smallvec.workspace = true uuid.workspace = true windows.workspace = true windows-core.workspace = true -windows-numerics = "0.2" -windows-registry = "0.5" +windows-numerics = "0.3" +windows-registry.workspace = true [target.'cfg(target_os = "windows")'.dependencies.scap] workspace = true optional = true [target.'cfg(target_os = "windows")'.build-dependencies] -windows-registry = "0.5" +windows-registry.workspace = true [package.metadata.cargo-shear] ignored = ["scap"] diff --git a/crates/gpui_windows/src/direct_write.rs b/crates/gpui_windows/src/direct_write.rs index 08ae7a9..5a761cc 100644 --- a/crates/gpui_windows/src/direct_write.rs +++ b/crates/gpui_windows/src/direct_write.rs @@ -998,24 +998,6 @@ impl DirectWriteState { } let gpu_state = &self.gpu_state; - let params_buffer = { - let desc = D3D11_BUFFER_DESC { - ByteWidth: std::mem::size_of::() as u32, - Usage: D3D11_USAGE_DYNAMIC, - BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, - CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, - MiscFlags: 0, - StructureByteStride: 0, - }; - - let mut buffer = None; - unsafe { - gpu_state - .device - .CreateBuffer(&desc, None, Some(&mut buffer)) - }?; - buffer - }; let render_target_texture = { let mut texture = None; @@ -1061,6 +1043,41 @@ impl DirectWriteState { rtv }; + Self::composite_color_layers( + gpu_state, + &glyph_layers, + bitmap_size, + &render_target_texture, + &render_target_view, + ) + } + + fn composite_color_layers( + gpu_state: &GPUState, + glyph_layers: &[GlyphLayerTexture], + bitmap_size: Size, + render_target_texture: &ID3D11Texture2D, + render_target_view: &Option, + ) -> Result> { + let params_buffer = { + let desc = D3D11_BUFFER_DESC { + ByteWidth: std::mem::size_of::() as u32, + Usage: D3D11_USAGE_DYNAMIC, + BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, + CPUAccessFlags: D3D11_CPU_ACCESS_WRITE.0 as u32, + MiscFlags: 0, + StructureByteStride: 0, + }; + + let mut buffer = None; + unsafe { + gpu_state + .device + .CreateBuffer(&desc, None, Some(&mut buffer)) + }?; + buffer + }; + let staging_texture = { let mut texture = None; let desc = D3D11_TEXTURE2D_DESC { @@ -1097,8 +1114,13 @@ impl DirectWriteState { device_context.PSSetConstantBuffers(0, Some(std::slice::from_ref(¶ms_buffer))) }; unsafe { - device_context.OMSetRenderTargets(Some(std::slice::from_ref(&render_target_view)), None) + device_context.OMSetRenderTargets(Some(std::slice::from_ref(render_target_view)), None) }; + unsafe { + if let Some(render_target_view) = render_target_view.as_ref() { + device_context.ClearRenderTargetView(render_target_view, &[0.0, 0.0, 0.0, 0.0]); + } + } unsafe { device_context.PSSetSamplers(0, Some(std::slice::from_ref(&gpu_state.sampler))) }; unsafe { device_context.OMSetBlendState(&gpu_state.blend_state, None, 0xffffffff) }; @@ -1131,7 +1153,7 @@ impl DirectWriteState { .Unmap(params_buffer.as_ref().unwrap(), 0); }; - let texture = [Some(layer.texture_view)]; + let texture = [Some(layer.texture_view.clone())]; unsafe { device_context.PSSetShaderResources(0, Some(&texture)) }; let viewport = [D3D11_VIEWPORT { @@ -1147,7 +1169,7 @@ impl DirectWriteState { unsafe { device_context.Draw(4, 0) }; } - unsafe { device_context.CopyResource(&staging_texture, &render_target_texture) }; + unsafe { device_context.CopyResource(&staging_texture, render_target_texture) }; let mapped_data = { let mut mapped_data = D3D11_MAPPED_SUBRESOURCE::default(); @@ -1931,7 +1953,20 @@ const DEFAULT_LOCALE_NAME: PCWSTR = windows::core::w!("en-US"); #[cfg(test)] mod tests { + use super::{DirectWriteState, DirectWriteTextSystem, GPUState, GlyphLayerTexture}; use crate::direct_write::ClusterAnalyzer; + use crate::directx_devices::DirectXDevices; + use anyhow::Result; + use gpui::{ + DevicePixels, Font, PlatformTextSystem, RenderGlyphParams, Rgba, bounds, point, px, size, + }; + use std::ffi::c_void; + use windows::Win32::Graphics::Direct3D11::{ + D3D11_BIND_RENDER_TARGET, D3D11_RENDER_TARGET_VIEW_DESC, D3D11_RENDER_TARGET_VIEW_DESC_0, + D3D11_RTV_DIMENSION_TEXTURE2D, D3D11_SUBRESOURCE_DATA, D3D11_TEX2D_RTV, + D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT, + }; + use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC}; #[test] fn test_cluster_map() { @@ -1969,4 +2004,158 @@ mod tests { let next = analyzer.next(); assert_eq!(next, None); } + + #[test] + fn color_emoji_rasterization_is_stable_across_batches() -> Result<()> { + let devices = DirectXDevices::new()?; + let text_system = DirectWriteTextSystem::new(&devices)?; + + let font = Font { + family: "Segoe UI Emoji".into(), + ..Default::default() + }; + let font_id = text_system.font_id(&font)?; + + let mut params_list = Vec::new(); + for ch in ['🫠', '🥹', '🧗', '🏋', '🚀', '🥺'] { + let Some(glyph_id) = text_system.glyph_for_char(font_id, ch) else { + log::info!("no glyph found for {ch}"); + continue; + }; + let params = RenderGlyphParams { + font_id, + glyph_id, + font_size: px(48.0), + subpixel_variant: point(0u8, 0u8), + scale_factor: 1.0, + is_emoji: true, + subpixel_rendering: false, + dilation: 0, + }; + let raster_bounds = text_system.glyph_raster_bounds(¶ms)?; + if raster_bounds.size.width.0 == 0 || raster_bounds.size.height.0 == 0 { + log::info!("raster bounds are empty for {ch}"); + continue; + } + params_list.push((params, raster_bounds)); + } + assert!(!params_list.is_empty()); + + let first: Vec<_> = params_list + .iter() + .map(|(params, bounds)| text_system.rasterize_glyph(params, *bounds)) + .collect::>()?; + + // Churn the texture heap with further rasterization passes. If the color + // compositing leaks leftover texture data (the render target is not cleared), + // the second batch can pick up different contents and differ from the first. + // With an explicit clear both batches are deterministic and identical. + for _ in 0..3 { + for (params, bounds) in ¶ms_list { + text_system.rasterize_glyph(params, *bounds)?; + } + } + let second: Vec<_> = params_list + .iter() + .map(|(params, bounds)| text_system.rasterize_glyph(params, *bounds)) + .collect::>()?; + + assert_eq!( + first, second, + "color glyph rasterization changed between batches; \ + render target contents are leaking into the glyph bitmaps" + ); + Ok(()) + } + + #[test] + fn color_emoji_composites_over_cleared_texture() -> Result<()> { + let devices = DirectXDevices::new()?; + let gpu_state = GPUState::new(&devices)?; + + const SIZE: u32 = 32; + // Seed the render target with solid red so that any texel which the + // compositing pass fails to clear/overwrite remains identifiable after + // the readback. + let poison = { + let mut v = vec![0u8; (SIZE * SIZE * 4) as usize]; + for pixel in v.chunks_exact_mut(4) { + pixel[2] = 255; + pixel[3] = 255; + } + v + }; + let desc = D3D11_TEXTURE2D_DESC { + Width: SIZE, + Height: SIZE, + MipLevels: 1, + ArraySize: 1, + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + Usage: D3D11_USAGE_DEFAULT, + BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32, + CPUAccessFlags: 0, + MiscFlags: 0, + }; + let initial_data = D3D11_SUBRESOURCE_DATA { + pSysMem: poison.as_ptr() as *const c_void, + SysMemPitch: SIZE * 4, + SysMemSlicePitch: 0, + }; + let texture = unsafe { + let mut texture = None; + gpu_state + .device + .CreateTexture2D(&desc, Some(&initial_data), Some(&mut texture))?; + texture.unwrap() + }; + let render_target_view = unsafe { + let desc = D3D11_RENDER_TARGET_VIEW_DESC { + Format: DXGI_FORMAT_B8G8R8A8_UNORM, + ViewDimension: D3D11_RTV_DIMENSION_TEXTURE2D, + Anonymous: D3D11_RENDER_TARGET_VIEW_DESC_0 { + Texture2D: D3D11_TEX2D_RTV { MipSlice: 0 }, + }, + }; + let mut rtv = None; + gpu_state + .device + .CreateRenderTargetView(&texture, Some(&desc), Some(&mut rtv))?; + rtv.unwrap() + }; + + // A single opaque layer in the top-left corner; the bottom-right corner + // of the texture is covered by no layer at all. + let layer_alpha = vec![255u8; 4 * 4]; + let layer = GlyphLayerTexture::new( + &gpu_state, + Rgba { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }, + bounds(point(0, 0), size(4, 4)), + &layer_alpha, + )?; + + let rasterized = DirectWriteState::composite_color_layers( + &gpu_state, + std::slice::from_ref(&layer), + size(DevicePixels(SIZE as i32), DevicePixels(SIZE as i32)), + &texture, + &Some(render_target_view), + )?; + + let corner = (SIZE as usize - 1 + (SIZE as usize - 1) * SIZE as usize) * 4; + assert_eq!( + &rasterized[corner..corner + 4], + &[0, 0, 0, 0], + "uncovered texel retained the poison from the uninitialized render target" + ); + Ok(()) + } } diff --git a/crates/gpui_windows/src/directx_renderer.rs b/crates/gpui_windows/src/directx_renderer.rs index 681e390..6e83043 100644 --- a/crates/gpui_windows/src/directx_renderer.rs +++ b/crates/gpui_windows/src/directx_renderer.rs @@ -795,11 +795,24 @@ impl DirectXRenderer { // and so likely do not have the textures anymore that are required for drawing return Ok(()); } + self.render(scene, background_appearance)?; + self.present() + } + + /// Clear the render target for `background_appearance` and encode every + /// primitive batch of `scene` into it, without presenting. Shared by + /// [`draw`](Self::draw) (which then presents) and + /// [`render_to_image`](Self::render_to_image) (which reads the target back + /// instead), so the two cannot drift. + fn render( + &mut self, + scene: &Scene, + background_appearance: WindowBackgroundAppearance, + ) -> Result<()> { self.pre_draw(&match background_appearance { WindowBackgroundAppearance::Opaque => [1.0f32; 4], _ => [0.0f32; 4], })?; - self.upload_scene_buffers(scene)?; // Backdrops read what the frame has drawn so far, which the swap chain cannot be sampled @@ -930,7 +943,6 @@ impl DirectXRenderer { ) })?; } - while let Some(index) = stack.pop() { let layer = scene.effects[index]; let clip = spans.pop().unwrap_or_else(|| layer.destination_clip()); @@ -941,7 +953,85 @@ impl DirectXRenderer { self.blit_frame()?; } - self.present() + Ok(()) + } + + /// Render `scene` to an offscreen CPU image **without presenting** so + /// the window need never be shown or visible (the macOS headless path + /// goes through MetalRenderer; this is the Windows analogue). Draws into + /// the existing render target, copies it into a `D3D11_USAGE_STAGING` + /// texture, maps it, and converts BGRA to RGBA. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn render_to_image( + &mut self, + scene: &Scene, + background_appearance: WindowBackgroundAppearance, + ) -> Result { + // A pending device-lost recovery (`skip_draws`) leaves the atlas holding + // tile references from the previous device; drawing before the forced + // re-render rebuilds them panics in `DirectXAtlasState::texture`. + anyhow::ensure!( + !self.skip_draws, + "render_to_image unavailable while recovering from a lost device" + ); + self.render(scene, background_appearance)?; + + let devices = self.devices.as_ref().context("devices missing")?; + let device = &devices.device; + let context = &devices.device_context; + let resources = self.resources.as_ref().context("resources missing")?; + let render_target = resources + .render_target + .as_ref() + .context("render target missing")?; + + // A CPU-readable copy of the render target. + let mut desc = D3D11_TEXTURE2D_DESC::default(); + unsafe { render_target.GetDesc(&mut desc) }; + let width = desc.Width; + let height = desc.Height; + let staging_desc = D3D11_TEXTURE2D_DESC { + Usage: D3D11_USAGE_STAGING, + BindFlags: 0, + CPUAccessFlags: D3D11_CPU_ACCESS_READ.0 as u32, + MiscFlags: 0, + MipLevels: 1, + ArraySize: 1, + SampleDesc: DXGI_SAMPLE_DESC { + Count: 1, + Quality: 0, + }, + ..desc + }; + let mut staging: Option = None; + unsafe { device.CreateTexture2D(&staging_desc, None, Some(&mut staging))? }; + let staging = staging.context("creating staging texture")?; + unsafe { context.CopyResource(&staging, render_target) }; + + let mut mapped = D3D11_MAPPED_SUBRESOURCE::default(); + unsafe { context.Map(&staging, 0, D3D11_MAP_READ, 0, Some(&mut mapped))? }; + let row_bytes = (width as usize) * 4; + let mut pixels = vec![0u8; row_bytes * height as usize]; + // SAFETY: `Map` succeeded, so `pData` points at `RowPitch * height` + // readable bytes for as long as the mapping is held, and `RowPitch >= + // row_bytes` (it only ever adds trailing padding). `pixels` is sized + // `row_bytes * height`, so every copy stays in bounds on both sides, + // and the regions cannot overlap (`pixels` is a fresh allocation). + unsafe { + let src = mapped.pData as *const u8; + for row in 0..height as usize { + let s = src.add(row * mapped.RowPitch as usize); + let d = pixels.as_mut_ptr().add(row * row_bytes); + std::ptr::copy_nonoverlapping(s, d, row_bytes); + } + context.Unmap(&staging, 0); + } + // The render target is BGRA; image::RgbaImage expects RGBA. + for px in pixels.chunks_exact_mut(4) { + px.swap(0, 2); + } + image::RgbaImage::from_raw(width, height, pixels) + .context("Failed to build RgbaImage from staging readback") } pub(crate) fn resize(&mut self, new_size: Size) -> Result<()> { diff --git a/crates/gpui_windows/src/display.rs b/crates/gpui_windows/src/display.rs index bb75bf7..ee02309 100644 --- a/crates/gpui_windows/src/display.rs +++ b/crates/gpui_windows/src/display.rs @@ -92,6 +92,12 @@ impl WindowsDisplay { WindowsDisplay::new(Self::display_id_for_monitor(monitor)) } + /// The DPI scale factor of this monitor, independent of whatever monitor + /// a not-yet-positioned window currently happens to be on. + pub(crate) fn scale_factor(&self) -> f32 { + self.scale_factor + } + /// Check if the center point of given bounds is inside this monitor pub fn check_given_bounds(&self, bounds: Bounds) -> bool { let center = bounds.center(); diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index 1bd1849..e700cb0 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -28,6 +28,7 @@ pub(crate) const WM_GPUI_FORCE_UPDATE_WINDOW: u32 = WM_USER + 5; pub(crate) const WM_GPUI_KEYBOARD_LAYOUT_CHANGED: u32 = WM_USER + 6; pub(crate) const WM_GPUI_GPU_DEVICE_LOST: u32 = WM_USER + 7; pub(crate) const WM_GPUI_KEYDOWN: u32 = WM_USER + 8; +pub(crate) const WM_GPUI_END_SESSION: u32 = WM_USER + 9; const SIZE_MOVE_LOOP_TIMER_ID: usize = 1; @@ -108,6 +109,8 @@ impl WindowsWindowInner { WM_PAINT => self.handle_paint_msg(handle), WM_CLOSE => self.handle_close_msg(), WM_DESTROY => self.handle_destroy_msg(handle), + WM_QUERYENDSESSION => Some(1), + WM_ENDSESSION => self.handle_end_session_msg(wparam), WM_MOUSEMOVE => self.handle_mouse_move_msg(handle, lparam, wparam), WM_MOUSELEAVE | WM_NCMOUSELEAVE => self.handle_mouse_leave_msg(), WM_NCMOUSEMOVE => self.handle_nc_mouse_move_msg(handle, lparam), @@ -170,6 +173,20 @@ impl WindowsWindowInner { } } + fn handle_end_session_msg(&self, wparam: WPARAM) -> Option { + if wparam.0 != 0 { + unsafe { + SendMessageW( + self.platform_window_handle, + WM_GPUI_END_SESSION, + Some(WPARAM(self.validation_number)), + None, + ); + } + } + Some(0) + } + fn handle_move_msg(&self, handle: HWND, lparam: LPARAM) -> Option { let origin = logical_point( lparam.signed_loword() as f32, diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 0c0f18a..d0ae3c7 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -12,7 +12,7 @@ use std::{ use anyhow::{Context as _, Result, anyhow}; use futures::channel::oneshot::{self, Receiver}; -use gpui_util::{ResultExt, get_windows_system_shell, new_std_command}; +use gpui_util::{ResultExt, get_powershell, new_std_command}; use itertools::Itertools; use parking_lot::RwLock; use smallvec::SmallVec; @@ -79,7 +79,7 @@ pub(crate) struct WindowsPlatformState { #[derive(Default)] struct PlatformCallbacks { open_urls: Cell)>>>, - quit: Cell>>, + quit: Cell bool>>>, reopen: Cell>>, app_menu_action: Cell>>, will_open_app_menu: Cell>>, @@ -460,8 +460,12 @@ impl Platform for WindowsPlatform { } } - self.inner - .with_callback(|callbacks| &callbacks.quit, |callback| callback()); + self.inner.with_callback( + |callbacks| &callbacks.quit, + |callback| { + callback(); + }, + ); } fn quit(&self) { @@ -503,9 +507,13 @@ impl Platform for WindowsPlatform { // can pump the Win32 message loop (via `CreateProcessW`), which // re-enters message handling possibly resulting in another mutable // borrow of the `AppCell` ending up with a double borrow panic + let Some(powershell) = get_powershell() else { + log::error!("failed to restart: PowerShell is unavailable"); + return; + }; self.foreground_executor .spawn(async move { - let mut command = new_std_command(get_windows_system_shell()); + let mut command = new_std_command(powershell); let arguments = encode_restart_arguments(&arguments); command .arg("-command") @@ -667,7 +675,7 @@ impl Platform for WindowsPlatform { .detach(); } - fn on_quit(&self, callback: Box) { + fn on_quit(&self, callback: Box bool>) { self.inner.state.callbacks.quit.set(Some(callback)); } @@ -997,7 +1005,8 @@ impl WindowsPlatformInner { | WM_GPUI_TASK_DISPATCHED_ON_MAIN_THREAD | WM_GPUI_DOCK_MENU_ACTION | WM_GPUI_KEYBOARD_LAYOUT_CHANGED - | WM_GPUI_GPU_DEVICE_LOST => self.handle_gpui_events(msg, wparam, lparam), + | WM_GPUI_GPU_DEVICE_LOST + | WM_GPUI_END_SESSION => self.handle_gpui_events(msg, wparam, lparam), WM_POWERBROADCAST => self.handle_power_broadcast(wparam), _ => None, }; @@ -1022,10 +1031,29 @@ impl WindowsPlatformInner { WM_GPUI_DOCK_MENU_ACTION => self.handle_dock_action_event(lparam.0 as _), WM_GPUI_KEYBOARD_LAYOUT_CHANGED => self.handle_keyboard_layout_change(), WM_GPUI_GPU_DEVICE_LOST => self.handle_device_lost(lparam), + WM_GPUI_END_SESSION => self.handle_end_session(), _ => unreachable!(), } } + fn handle_end_session(&self) -> Option { + let mut shutdown_completed = false; + self.with_callback( + |callbacks| &callbacks.quit, + |callback| shutdown_completed = callback(), + ); + log::logger().flush(); + if shutdown_completed { + std::process::exit(0); + } + + // Shutdown couldn't run synchronously, since the AppCell is already borrowed. + // Windows may terminate the application as soon as we return from this handler, but if we post a WM_QUIT message now, + // we may get to gracefully shut down the app before we're terminated by the OS. + unsafe { PostQuitMessage(0) }; + Some(0) + } + fn close_one_window(&self, target_window: HWND) -> bool { let Some(all_windows) = self.raw_window_handles.upgrade() else { log::error!("Failed to upgrade raw window handles"); diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index af24071..37e9d9a 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -552,13 +552,8 @@ impl WindowsWindow { set_non_rude_hwnd(hwnd, true); configure_dwm_dark_mode(hwnd, appearance); this.state.border_offset.update(hwnd)?; - let placement = retrieve_window_placement( - hwnd, - display, - params.bounds, - this.state.scale_factor.get(), - &this.state.border_offset, - )?; + let placement = + retrieve_window_placement(hwnd, display, params.bounds, &this.state.border_offset)?; if params.show { let mut placement = placement; if !params.focus { @@ -996,6 +991,14 @@ impl PlatformWindow for WindowsWindow { .log_err(); } + #[cfg(any(test, feature = "test-support"))] + fn render_to_image(&self, scene: &Scene) -> anyhow::Result { + self.state + .renderer + .borrow_mut() + .render_to_image(scene, self.state.background_appearance.get()) + } + fn sprite_atlas(&self) -> Arc { self.state.renderer.borrow().sprite_atlas() } @@ -1520,7 +1523,6 @@ fn retrieve_window_placement( hwnd: HWND, display: WindowsDisplay, initial_bounds: Bounds, - scale_factor: f32, border_offset: &WindowBorderOffset, ) -> Result { let mut placement = WINDOWPLACEMENT { @@ -1534,7 +1536,14 @@ fn retrieve_window_placement( } else { display.default_bounds() }; - let bounds = bounds.to_device_pixels(scale_factor); + // `bounds` is expressed in logical pixels for `display`, so it must be converted + // to device pixels using that display's own scale factor. The window's current + // scale factor can't be used here: `CreateWindowExW` was called with + // `CW_USEDEFAULT`, so at this point the window may still be sitting on whichever + // monitor Windows picked by default, which can have a different DPI than `display` + // and would otherwise throw off the physical position (e.g. leaving the window + // partially off-screen when moved to a monitor with a different scale factor). + let bounds = bounds.to_device_pixels(display.scale_factor()); placement.rcNormalPosition = calculate_window_rect(bounds, border_offset); Ok(placement) } diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 330fc26..908dd86 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -24,4 +24,4 @@ core-video.workspace = true objc.workspace = true [build-dependencies] -bindgen = "0.71" +bindgen = "0.72" diff --git a/crates/scheduler/src/executor.rs b/crates/scheduler/src/executor.rs index 53f3ba5..bacf9fb 100644 --- a/crates/scheduler/src/executor.rs +++ b/crates/scheduler/src/executor.rs @@ -106,6 +106,7 @@ impl LocalExecutor { Task(TaskState::Spawned(task)) } + #[cfg(not(target_family = "wasm"))] pub fn block_on(&self, future: Fut) -> Fut::Output { use std::cell::Cell; @@ -123,6 +124,7 @@ impl LocalExecutor { /// Block until the future completes or timeout occurs. /// Returns Ok(output) if completed, Err(future) if timed out. + #[cfg(not(target_family = "wasm"))] pub fn block_with_timeout( &self, timeout: Duration, diff --git a/crates/scheduler/src/scheduler.rs b/crates/scheduler/src/scheduler.rs index 98c5613..325ec7b 100644 --- a/crates/scheduler/src/scheduler.rs +++ b/crates/scheduler/src/scheduler.rs @@ -83,6 +83,7 @@ pub trait Scheduler: Send + Sync { /// 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. + #[cfg(not(target_family = "wasm"))] fn block( &self, session_id: Option, diff --git a/crates/scheduler/src/test_scheduler.rs b/crates/scheduler/src/test_scheduler.rs index 6c27239..bd34203 100644 --- a/crates/scheduler/src/test_scheduler.rs +++ b/crates/scheduler/src/test_scheduler.rs @@ -19,7 +19,6 @@ use std::{ future::Future, mem, ops::RangeInclusive, - panic::{self, AssertUnwindSafe}, pin::Pin, sync::{ Arc, Weak, @@ -62,15 +61,15 @@ impl TestScheduler { (seed..seed + num_iterations as u64) .map(|seed| { - let mut unwind_safe_f = AssertUnwindSafe(&mut f); + let mut unwind_safe_f = std::panic::AssertUnwindSafe(&mut f); if interactive { eprintln!("Running seed: {seed}"); } - match panic::catch_unwind(move || Self::with_seed(seed, &mut *unwind_safe_f)) { + match std::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); + std::panic::resume_unwind(error); } } }) @@ -79,12 +78,86 @@ impl TestScheduler { fn with_seed(seed: u64, f: impl AsyncFnOnce(Arc) -> R) -> R { let scheduler = Arc::new(TestScheduler::new(TestSchedulerConfig::with_seed(seed))); + let output = std::cell::Cell::new(None); let future = f(scheduler.clone()); - let result = scheduler.foreground().block_on(future); + let future = async { + output.set(Some(future.await)); + }; + let mut future = std::pin::pin!(future); + scheduler.block_until(None, future.as_mut(), None); + let result = output.take().expect("test future did not complete"); scheduler.run(); // Ensure spawned tasks finish up before returning in tests result } + fn block_until( + &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 + } + pub fn new(config: TestSchedulerConfig) -> Self { Self { rng: Arc::new(Mutex::new(StdRng::seed_from_u64(config.seed))), @@ -520,72 +593,14 @@ impl Scheduler for TestScheduler { /// 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. + #[cfg(not(target_family = "wasm"))] fn block( &self, session_id: Option, - mut future: Pin<&mut dyn Future>, + 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 + self.block_until(session_id, future, timeout) } fn schedule_local(&self, session_id: SessionId, runnable: Runnable) { diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 0ab698b..8aa5daa 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -16,6 +16,8 @@ doctest = true [features] test-support = ["rand", "util_macros", "path/test-support"] +# Embed `fs_embed!` assets at compile time even in debug builds. +debug-embed = ["rust-embed/debug-embed"] [dependencies] anyhow.workspace = true @@ -52,7 +54,7 @@ walkdir.workspace = true dirs.workspace = true [target.'cfg(unix)'.dependencies] -command-fds = "0.3.1" +command-fds = "0.3.3" libc.workspace = true nix = { workspace = true, features = ["user"] } @@ -60,7 +62,7 @@ nix = { workspace = true, features = ["user"] } mach2.workspace = true [target.'cfg(windows)'.dependencies] -tendril = "0.4.3" +tendril = "0.5" windows.workspace = true [dev-dependencies] diff --git a/crates/util/src/redact.rs b/crates/util/src/redact.rs index ad11f76..a9a82bf 100644 --- a/crates/util/src/redact.rs +++ b/crates/util/src/redact.rs @@ -20,6 +20,13 @@ pub fn should_redact(env_var_name: &str) -> bool { .any(|suffix| env_var_name.ends_with(suffix)) } +pub fn is_valid_environment_name(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|character| character != '=' && !character.is_control()) +} + /// Redact a string which could include a command with environment variables pub fn redact_command(command: &str) -> String { REDACT_REGEX diff --git a/crates/util/src/shell.rs b/crates/util/src/shell.rs index e9b6d05..5b91ec5 100644 --- a/crates/util/src/shell.rs +++ b/crates/util/src/shell.rs @@ -1,6 +1,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::{borrow::Cow, fmt, path::Path, sync::LazyLock}; +use std::{borrow::Cow, fmt, path::Path}; +#[cfg(windows)] +use std::{path::PathBuf, sync::LazyLock}; /// Shell configuration to open the terminal with. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Hash)] @@ -84,33 +86,42 @@ pub fn get_default_system_shell() -> String { /// Get the default system shell, preferring bash on Windows. pub fn get_default_system_shell_preferring_bash() -> String { - if cfg!(windows) { + #[cfg(windows)] + { get_windows_bash().unwrap_or_else(|| get_windows_system_shell()) - } else { + } + + #[cfg(not(windows))] + { "/bin/sh".to_string() } } +#[cfg(windows)] pub fn get_windows_bash() -> Option { - use std::path::PathBuf; - - fn find_bash_in_scoop() -> Option { - let bash_exe = - PathBuf::from(std::env::var_os("USERPROFILE")?).join("scoop\\shims\\bash.exe"); - bash_exe.exists().then_some(bash_exe) + fn find_bash_in_installation(install_root: &Path) -> Option { + if !install_root.join("git-bash.exe").is_file() { + return None; + } + let bash = install_root.join("bin").join("bash.exe"); + bash.is_file().then_some(bash) } fn find_bash_in_git() -> Option { - // /path/to/git/cmd/git.exe/../../bin/bash.exe + if let Some(bash) = std::env::var_os("GIT_INSTALL_ROOT") + .map(PathBuf::from) + .and_then(|path| find_bash_in_installation(&path)) + { + return Some(bash); + } let git = which::which("git").ok()?; - let git_bash = git.parent()?.parent()?.join("bin").join("bash.exe"); - git_bash.exists().then_some(git_bash) + let binary_directory = git.parent()?; + let parent = binary_directory.parent()?; + find_bash_in_installation(parent).or_else(|| find_bash_in_installation(parent.parent()?)) } static BASH: LazyLock> = LazyLock::new(|| { - let bash = find_bash_in_scoop() - .or_else(|| find_bash_in_git()) - .map(|p| p.to_string_lossy().into_owned()); + let bash = find_bash_in_git().map(|p| p.to_string_lossy().into_owned()); if let Some(ref path) = bash { log::info!("Found bash at {}", path); } @@ -234,13 +245,13 @@ impl ShellKind { fn to_cmd_variable(input: &str) -> String { if let Some(var_str) = input.strip_prefix("${") { - if var_str.find(':').is_none() { - // If the input starts with "${", remove the trailing "}" - format!("%{}%", &var_str[..var_str.len() - 1]) - } else { + match var_str.strip_suffix('}') { + Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => { + format!("%{var_name}%") + } // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, // which will result in the task failing to run in such cases. - input.into() + _ => input.into(), } } else if let Some(var_str) = input.strip_prefix('$') { // If the input starts with "$", directly append to "$env:" @@ -253,13 +264,13 @@ impl ShellKind { fn to_powershell_variable(input: &str) -> String { if let Some(var_str) = input.strip_prefix("${") { - if var_str.find(':').is_none() { - // If the input starts with "${", remove the trailing "}" - format!("$env:{}", &var_str[..var_str.len() - 1]) - } else { + match var_str.strip_suffix('}') { + Some(var_name) if !var_name.is_empty() && !var_name.contains(':') => { + format!("$env:{var_name}") + } // `${SOME_VAR:-SOME_DEFAULT}`, we currently do not handle this situation, // which will result in the task failing to run in such cases. - input.into() + _ => input.into(), } } else if let Some(var_str) = input.strip_prefix('$') { // If the input starts with "$", directly append to "$env:" @@ -943,4 +954,40 @@ mod tests { assert!(quoted.contains("O''Brien")); } } + + #[test] + fn test_to_shell_variable() { + assert_eq!( + ShellKind::PowerShell.to_shell_variable("${FOO}"), + "$env:FOO" + ); + assert_eq!(ShellKind::Pwsh.to_shell_variable("${FOO}"), "$env:FOO"); + assert_eq!(ShellKind::Cmd.to_shell_variable("${FOO}"), "%FOO%"); + assert_eq!(ShellKind::Nushell.to_shell_variable("${FOO}"), "$env.FOO"); + assert_eq!(ShellKind::Posix.to_shell_variable("${FOO}"), "${FOO}"); + + assert_eq!(ShellKind::PowerShell.to_shell_variable("$FOO"), "$env:FOO"); + assert_eq!( + ShellKind::PowerShell.to_shell_variable("${日本}"), + "$env:日本" + ); + assert_eq!( + ShellKind::PowerShell.to_shell_variable("${FOO:-bar}"), + "${FOO:-bar}" + ); + } + + #[test] + fn test_to_shell_variable_malformed_is_passed_through() { + for input in ["${", "${FOO", "${café", "${}", "${日本"] { + for shell_kind in [ + ShellKind::PowerShell, + ShellKind::Pwsh, + ShellKind::Cmd, + ShellKind::Nushell, + ] { + assert_eq!(shell_kind.to_shell_variable(input), input); + } + } + } } diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 6ab84f0..88d8746 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -1,3 +1,6 @@ +#[cfg(test)] +extern crate self as util; + #[cfg(not(target_family = "wasm"))] pub mod archive; #[cfg(not(target_family = "wasm"))] @@ -634,6 +637,229 @@ pub fn asset_str(path: &str) -> Cow<'static, str> { } } +/// The checkout that produced this binary, resolved at runtime by walking up to +/// the first ancestor that contains a `.git` entry (a directory in a normal +/// clone, a file in a git worktree or submodule). Cargo and corgi both place +/// built binaries under `/target//`, so the repository root is +/// always an ancestor of the executable. +/// +/// Dev-only affordances use this instead of baking a build-time path into the +/// artifact: such a path points at the wrong checkout from any other worktree +/// and, under corgi, is rejected because artifacts must be checkout-independent +/// to be shared across worktrees. +/// +/// The executable's launch path is tried first, then its canonical form, then +/// the working directory. In CI, `target/` (or the checkout root) can be a +/// symlink onto another volume, so canonicalizing the executable alone can walk +/// off the checkout and miss `.git`; the launch path and the test runner's cwd +/// (a crate dir under the checkout) stay inside it. +pub fn dev_repo_root() -> Option<&'static std::path::Path> { + use std::path::PathBuf; + static ROOT: std::sync::OnceLock> = std::sync::OnceLock::new(); + ROOT.get_or_init(|| { + let exe = std::env::current_exe().ok(); + let candidates = [ + exe.clone(), + exe.and_then(|exe| exe.canonicalize().ok()), + std::env::current_dir().ok(), + ]; + candidates.into_iter().flatten().find_map(|start| { + Some( + start + .ancestors() + .find(|dir| dir.join(".git").exists())? + .to_path_buf(), + ) + }) + }) + .as_deref() +} + +/// Re-exports that back [`fs_embed!`] so a caller only needs to depend on `util`, +/// not on `rust_embed` directly (the macro's dependency is an implementation +/// detail). Hidden from the public API. +#[doc(hidden)] +pub mod __rust_embed { + pub use rust_embed::{EmbeddedFile, Filenames, Metadata, RustEmbed, utils}; +} + +/// Backs the dev arm of [`fs_embed!`]'s `iter`: every file under the root-relative +/// directory that passes the same rust_embed include/exclude globs the +/// release derive uses, so the dev and release file sets are identical. Reuses +/// rust_embed's own matcher rather than reimplementing glob semantics. +#[cfg(all(debug_assertions, not(feature = "debug-embed")))] +#[doc(hidden)] +pub fn __fs_embed_iter( + root_relative: &str, + includes: &[&str], + excludes: &[&str], +) -> impl Iterator> + 'static { + fs_embed_file_names(root_relative, includes, excludes) + .into_iter() + .map(std::borrow::Cow::Owned) +} + +#[cfg(all(debug_assertions, not(feature = "debug-embed")))] +fn fs_embed_file_names(root_relative: &str, includes: &[&str], excludes: &[&str]) -> Vec { + let Some(root) = dev_repo_root().map(|root| root.join(root_relative)) else { + return Vec::new(); + }; + let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); + rust_embed::utils::get_files(root.to_string_lossy().into_owned(), matcher) + .map(|entry| entry.rel_path) + .collect() +} + +/// Backs the dev arm of [`fs_embed!`]'s `get`: reads a single file from the +/// checkout, returning `None` when the include/exclude globs filter it out so +/// `get` matches release's embedded set exactly. +#[cfg(all(debug_assertions, not(feature = "debug-embed")))] +#[doc(hidden)] +pub fn __fs_embed_get( + root_relative: &str, + file_path: &str, + includes: &[&str], + excludes: &[&str], +) -> Option { + let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); + if !matcher.is_path_included(file_path) { + return None; + } + let root = dev_repo_root() + .expect("dev asset loading requires running from within the checkout") + .join(root_relative); + rust_embed::utils::read_file_from_fs(&root.join(file_path)).ok() +} + +#[cfg(feature = "debug-embed")] +#[doc(hidden)] +#[macro_export] +macro_rules! __fs_embed { + ( + $vis:vis struct $name:ident, + crate_relative = $crate_relative:literal, + root_relative = $root_relative:literal + $(, include = [$($include:literal),* $(,)?])? + $(, exclude = [$($exclude:literal),* $(,)?])? + $(,)? + ) => { + #[derive($crate::__rust_embed::RustEmbed)] + #[crate_path = "::util::__rust_embed"] + #[folder = $crate_relative] + $($(#[include = $include])*)? + $($(#[exclude = $exclude])*)? + $vis struct $name; + }; +} + +#[cfg(not(feature = "debug-embed"))] +#[doc(hidden)] +#[macro_export] +macro_rules! __fs_embed { + ( + $vis:vis struct $name:ident, + crate_relative = $crate_relative:literal, + root_relative = $root_relative:literal + $(, include = [$($include:literal),* $(,)?])? + $(, exclude = [$($exclude:literal),* $(,)?])? + $(,)? + ) => { + // `crate_path` points the derive's generated code at util's re-export so + // the caller needs no direct `rust_embed` dependency. + #[cfg(not(debug_assertions))] + #[derive($crate::__rust_embed::RustEmbed)] + #[crate_path = "::util::__rust_embed"] + #[folder = $crate_relative] + $($(#[include = $include])*)? + $($(#[exclude = $exclude])*)? + $vis struct $name; + + #[cfg(debug_assertions)] + $vis struct $name; + + // Mirror the derive's public surface: inherent `get`/`iter` (callable + // without the trait in scope) plus the trait impl (for generic bounds + // like `util::asset_str` and `handlebars::register_embed_templates`), so + // the two arms are interchangeable at call sites. + #[cfg(debug_assertions)] + impl $name { + pub fn get( + file_path: &str, + ) -> ::core::option::Option<$crate::__rust_embed::EmbeddedFile> { + $crate::__fs_embed_get( + $root_relative, + file_path, + &[$($($include),*)?], + &[$($($exclude),*)?], + ) + } + + pub fn iter( + ) -> impl ::core::iter::Iterator> + 'static + { + $crate::__fs_embed_iter( + $root_relative, + &[$($($include),*)?], + &[$($($exclude),*)?], + ) + } + } + + #[cfg(debug_assertions)] + impl $crate::__rust_embed::RustEmbed for $name { + fn get( + file_path: &str, + ) -> ::core::option::Option<$crate::__rust_embed::EmbeddedFile> { + <$name>::get(file_path) + } + + fn iter( + ) -> impl ::core::iter::Iterator> + 'static + { + $crate::__fs_embed_iter( + $root_relative, + &[$($($include),*)?], + &[$($($exclude),*)?], + ) + } + } + }; +} + +/// A `rust_embed` asset source that embeds files in release builds. Dev builds +/// read from the checkout at runtime unless the `debug-embed` feature is enabled, +/// in which case they embed the files too. +/// +/// It expands to one of these arms: +/// * Release (`not(debug_assertions)`): `#[derive(RustEmbed)]` embedding +/// `crate_relative` at build time, with the given `include`/`exclude` globs. +/// * Dev with `debug-embed`: the same compile-time embedding as release. +/// * Dev without `debug-embed`: a runtime filesystem source rooted at +/// `root_relative`; edits appear on the next launch without a rebuild. +/// +/// Two paths are required because the arms resolve from different bases: the +/// derive reads `crate_relative` relative to the crate's `Cargo.toml`, while the +/// runtime dev arm resolves `root_relative` relative to the repository root via +/// [`dev_repo_root`]. Baking the build-time path into that arm would point at the +/// wrong checkout from another worktree and is rejected by corgi, whose sandbox +/// requires checkout-independent output. +/// +/// ```ignore +/// util::fs_embed! { +/// pub struct Assets, +/// crate_relative = "../../assets", +/// root_relative = "assets", +/// include = ["fonts/**/*", "themes/**/*", "*.md"], +/// exclude = ["themes/src/*", "*.DS_Store"], +/// } +/// ``` +#[macro_export] +macro_rules! fs_embed { + ($($tokens:tt)*) => { + $crate::__fs_embed!($($tokens)*); + }; +} + pub trait RangeExt { fn sorted(&self) -> Self; fn to_inclusive(&self) -> RangeInclusive; @@ -795,6 +1021,55 @@ impl From> for ConnectionResult { mod tests { use super::*; + #[test] + fn test_fs_embed_iter_and_get() { + let inherent_names: Vec<_> = FsEmbedTestAssets::iter().collect(); + assert!( + inherent_names.iter().any(|name| name == "util.rs"), + "iter should list files matching the include globs, got {inherent_names:?}" + ); + assert!( + !inherent_names.iter().any(|name| name.ends_with(".toml")), + "iter should filter files excluded by the globs, got {inherent_names:?}" + ); + + let trait_names: Vec<_> = + ::iter().collect(); + assert_eq!(inherent_names, trait_names); + + let file = FsEmbedTestAssets::get("util.rs").expect("util.rs should be readable"); + assert!( + std::str::from_utf8(&file.data) + .expect("util.rs should be utf-8") + .contains("fs_embed"), + "get should read the real file contents" + ); + assert!( + FsEmbedTestAssets::get("Cargo.toml").is_none(), + "get should filter files excluded by the globs" + ); + } + + #[cfg(not(feature = "debug-embed"))] + crate::fs_embed! { + struct FsEmbedTestAssets, + crate_relative = "src", + root_relative = "crates/util/src", + include = ["*.rs"], + exclude = ["test/**/*"], + } + + // A consuming workspace does not contain a git dependency's files at this + // repository-relative path, so `debug-embed` must not consult it. + #[cfg(feature = "debug-embed")] + crate::fs_embed! { + struct FsEmbedTestAssets, + crate_relative = "src", + root_relative = "this-path-must-not-be-read", + include = ["*.rs"], + exclude = ["test/**/*"], + } + #[test] fn test_parse_os_release() { let os_release = diff --git a/crates/zlog/Cargo.toml b/crates/zlog/Cargo.toml index 2799592..46f2cff 100644 --- a/crates/zlog/Cargo.toml +++ b/crates/zlog/Cargo.toml @@ -3,7 +3,7 @@ name = "zlog" version = "0.1.0" edition.workspace = true publish.workspace = true -license = "GPL-3.0-or-later" +license = "Apache-2.0" [lints] workspace = true diff --git a/crates/zlog/LICENSE-APACHE b/crates/zlog/LICENSE-APACHE new file mode 120000 index 0000000..1cd601d --- /dev/null +++ b/crates/zlog/LICENSE-APACHE @@ -0,0 +1 @@ +../../LICENSE-APACHE \ No newline at end of file diff --git a/crates/zlog/LICENSE-GPL b/crates/zlog/LICENSE-GPL deleted file mode 120000 index 89e542f..0000000 --- a/crates/zlog/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing/Cargo.toml b/crates/ztracing/Cargo.toml index 81d3fc0..982f382 100644 --- a/crates/ztracing/Cargo.toml +++ b/crates/ztracing/Cargo.toml @@ -3,7 +3,7 @@ name = "ztracing" version = "0.1.0" edition.workspace = true publish.workspace = true -license = "GPL-3.0-or-later" +license = "Apache-2.0" [lints] workspace = true @@ -16,13 +16,13 @@ web = ["dep:async-channel", "dep:js-sys", "dep:wasm-bindgen", "dep:web-sys"] zlog.workspace = true tracing.workspace = true -tracing-subscriber = "0.3.22" +tracing-subscriber = "0.3.23" ztracing_macro.workspace = true [target.'cfg(not(target_family = "wasm"))'.dependencies] tracing-tracy = { version = "0.11.4", optional = true, features = ["enable", "ondemand"] } -tracy-client = { version = "0.18.2", optional = true, features = ["enable", "ondemand"] } +tracy-client = { version = "0.18.5", optional = true, features = ["enable", "ondemand"] } [target.'cfg(target_family = "wasm")'.dependencies] async-channel = { workspace = true, optional = true } diff --git a/crates/ztracing/LICENSE-GPL b/crates/ztracing/LICENSE-GPL deleted file mode 120000 index 89e542f..0000000 --- a/crates/ztracing/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file diff --git a/crates/ztracing_macro/Cargo.toml b/crates/ztracing_macro/Cargo.toml index dbd7adc..5420bc6 100644 --- a/crates/ztracing_macro/Cargo.toml +++ b/crates/ztracing_macro/Cargo.toml @@ -3,7 +3,7 @@ name = "ztracing_macro" version = "0.1.0" edition.workspace = true publish.workspace = true -license = "GPL-3.0-or-later" +license = "Apache-2.0" [lib] proc-macro = true diff --git a/crates/ztracing_macro/LICENSE-GPL b/crates/ztracing_macro/LICENSE-GPL deleted file mode 120000 index 89e542f..0000000 --- a/crates/ztracing_macro/LICENSE-GPL +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE-GPL \ No newline at end of file